zdashboard 2.0.0 → 2.1.1

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/dist/cli.js CHANGED
@@ -1,14 +1,138 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
- import path8 from "path";
4
+ import path10 from "path";
5
+ import fs11 from "fs";
5
6
  import { fileURLToPath as fileURLToPath2 } from "url";
6
7
  import { access, readdir } from "fs/promises";
7
8
 
8
9
  // src/server/detect.ts
10
+ import fs2 from "fs";
11
+ import path2 from "path";
12
+ import { execFile } from "child_process";
13
+
14
+ // src/server/bugs.ts
9
15
  import fs from "fs";
10
16
  import path from "path";
11
- import { execFile } from "child_process";
17
+
18
+ // src/server/api/fetch.ts
19
+ import ky from "ky";
20
+
21
+ // src/server/errors.ts
22
+ var HttpError = class extends Error {
23
+ constructor(status, message, body) {
24
+ super(message);
25
+ this.status = status;
26
+ this.body = body;
27
+ this.name = "HttpError";
28
+ }
29
+ status;
30
+ body;
31
+ };
32
+ var NetworkError = class extends Error {
33
+ constructor(message, cause) {
34
+ super(message);
35
+ this.cause = cause;
36
+ this.name = "NetworkError";
37
+ }
38
+ cause;
39
+ };
40
+
41
+ // src/server/api/fetch.ts
42
+ async function fetchJson(url, init) {
43
+ try {
44
+ const res = await ky(url, { ...init, timeout: 8e3, retry: 2 });
45
+ return await res.json();
46
+ } catch (e) {
47
+ if (e instanceof HttpError) throw e;
48
+ if (e instanceof Error && e.name === "TimeoutError") {
49
+ throw new NetworkError(`\u8BF7\u6C42\u8D85\u65F6: ${url}`);
50
+ }
51
+ if (e instanceof Error && e.name === "HTTPError") {
52
+ throw new HttpError(e.status ?? 500, e.message);
53
+ }
54
+ throw new NetworkError(`\u8BF7\u6C42\u5931\u8D25: ${url}`, e);
55
+ }
56
+ }
57
+
58
+ // src/server/bugs.ts
59
+ var BUGS_CONFIG_CANDIDATES = [".zdev/config.yaml", ".zgoal/config.yaml"];
60
+ function loadConfig(root) {
61
+ for (const rel of BUGS_CONFIG_CANDIDATES) {
62
+ const file = path.join(root, rel);
63
+ if (!fs.existsSync(file)) continue;
64
+ const kv = {};
65
+ for (const line of fs.readFileSync(file, "utf8").split("\n")) {
66
+ const m = line.match(/^\s*([A-Za-z_]\w*)\s*:\s*(.+?)\s*$/);
67
+ if (m && !m[2].startsWith("#")) kv[m[1]] = m[2].replace(/^["']|["']$/g, "");
68
+ }
69
+ const product = Number(kv.product);
70
+ if (!kv.url || !product) return null;
71
+ return {
72
+ url: kv.url.replace(/\/+$/, ""),
73
+ account: kv.account ?? "",
74
+ password: kv.password,
75
+ token: kv.token,
76
+ product
77
+ };
78
+ }
79
+ return null;
80
+ }
81
+ var tokenCache = null;
82
+ async function getToken(cfg) {
83
+ if (cfg.token) return cfg.token;
84
+ const key = `${cfg.url}|${cfg.account}|${cfg.password ?? ""}`;
85
+ if (tokenCache && tokenCache.key === key && Date.now() - tokenCache.at < 10 * 6e4) return tokenCache.token;
86
+ const json = await fetchJson(`${cfg.url}/api.php/v1/tokens`, {
87
+ method: "POST",
88
+ headers: { "Content-Type": "application/json" },
89
+ body: JSON.stringify({ account: cfg.account, password: cfg.password })
90
+ });
91
+ const token = typeof json.token === "string" ? json.token : "";
92
+ if (!token) throw new Error("token \u83B7\u53D6\u5931\u8D25:\u68C0\u67E5 account / password");
93
+ tokenCache = { key, token, at: Date.now() };
94
+ return token;
95
+ }
96
+ function normBug(b, account) {
97
+ const assigned = b.assignedTo;
98
+ const assignedTo = typeof assigned === "string" ? assigned : assigned && typeof assigned === "object" && "realname" in assigned ? String(assigned.realname ?? "") : "";
99
+ const assignedAccount = typeof assigned === "string" ? assigned : assigned && typeof assigned === "object" ? String(assigned.account ?? "") : "";
100
+ const mine = !!account && (assignedAccount === account || assignedTo === account);
101
+ return {
102
+ id: Number(b.id),
103
+ title: String(b.title ?? ""),
104
+ severity: b.severity ?? 4,
105
+ pri: b.pri ?? 3,
106
+ status: String(b.status ?? ""),
107
+ assignedTo,
108
+ openedBy: typeof b.openedBy === "string" ? b.openedBy : void 0,
109
+ mine
110
+ };
111
+ }
112
+ async function fetchBugs(root) {
113
+ const cfg = loadConfig(root);
114
+ if (!cfg) {
115
+ const anyExists = BUGS_CONFIG_CANDIDATES.some((rel) => fs.existsSync(path.join(root, rel)));
116
+ return {
117
+ ok: false,
118
+ error: anyExists ? "\u914D\u7F6E\u5B58\u5728\u4F46\u65E0\u6548(\u7F3A url \u6216 product \u5B57\u6BB5)\u2014\u2014\u68C0\u67E5 .zdev/config.yaml" : ".zdev/config.yaml \u7F3A\u5931(\u7531 zgoal skill \u521B\u5EFA;\u5B58\u91CF .zgoal/config.yaml \u4EA6\u53EF)"
119
+ };
120
+ }
121
+ try {
122
+ const token = await getToken(cfg);
123
+ const json = await fetchJson(
124
+ `${cfg.url}/api.php/v1/products/${cfg.product}/bugs?page=1&limit=100`,
125
+ { headers: { Token: token } }
126
+ );
127
+ const raw = Array.isArray(json.bugs) ? json.bugs : [];
128
+ return { ok: true, url: cfg.url, total: Number(json.total ?? raw.length), bugs: raw.map((b) => normBug(b, cfg.account)) };
129
+ } catch (e) {
130
+ const msg = e instanceof Error ? e.message : String(e);
131
+ return { ok: false, error: `\u7985\u9053\u8BF7\u6C42\u5931\u8D25(${msg})\u2014\u2014\u68C0\u67E5 url / \u51ED\u636E / \u662F\u5426\u5F00\u542F RESTful API v1` };
132
+ }
133
+ }
134
+
135
+ // src/server/detect.ts
12
136
  function justAvailable(cwd) {
13
137
  return new Promise((resolve) => {
14
138
  const child = execFile("just", ["--list", "--unsorted"], { cwd, timeout: 5e3 }, (err) => {
@@ -18,10 +142,10 @@ function justAvailable(cwd) {
18
142
  });
19
143
  }
20
144
  async function detect(root) {
21
- const hasOpenspec = fs.existsSync(path.join(root, "openspec"));
22
- const hasDocs = fs.existsSync(path.join(root, "docs"));
145
+ const hasOpenspec = fs2.existsSync(path2.join(root, "openspec"));
146
+ const hasDocs = fs2.existsSync(path2.join(root, "docs"));
23
147
  const hasJust = await justAvailable(root);
24
- const hasBugs = fs.existsSync(path.join(root, ".zgoal", "config.yaml"));
148
+ const hasBugs = BUGS_CONFIG_CANDIDATES.some((rel) => fs2.existsSync(path2.join(root, rel)));
25
149
  return { hasOpenspec, hasDocs, hasJust, hasBugs };
26
150
  }
27
151
 
@@ -30,16 +154,15 @@ import { Context } from "cordis";
30
154
 
31
155
  // src/core/server.ts
32
156
  import http from "http";
33
- import fs2 from "fs";
34
- import path2 from "path";
157
+ import fs4 from "fs";
158
+ import path4 from "path";
35
159
  import crypto from "crypto";
36
- import { exec } from "child_process";
37
160
  import { fileURLToPath } from "url";
38
161
 
39
162
  // package.json
40
163
  var package_default = {
41
164
  name: "zdashboard",
42
- version: "2.0.0",
165
+ version: "2.1.1",
43
166
  description: "ZCode skill dashboard platform \u2014 pluggable viewers for zdesign/zview/zreview/zgoal",
44
167
  type: "module",
45
168
  bin: {
@@ -123,8 +246,113 @@ var package_default = {
123
246
 
124
247
  // src/core/server.ts
125
248
  import { Service } from "cordis";
249
+
250
+ // src/core/instance.ts
251
+ import fs3 from "fs";
252
+ import path3 from "path";
253
+ var RECORD_FILE = ".zdev/dashboard.json";
254
+ var VERIFY_TIMEOUT_MS = 1500;
255
+ var STOP_POLL_MS = 2e3;
256
+ var STOP_POLL_INTERVAL_MS = 100;
257
+ var STOP_FINAL_WAIT_MS = 100;
258
+ function recordPath(root) {
259
+ return path3.join(root, RECORD_FILE);
260
+ }
261
+ function readRecord(root) {
262
+ try {
263
+ const fp = recordPath(root);
264
+ const raw = fs3.readFileSync(fp, "utf-8");
265
+ const rec = JSON.parse(raw);
266
+ if (typeof rec.pid === "number" && typeof rec.port === "number" && typeof rec.root === "string" && typeof rec.startedAt === "string") {
267
+ return rec;
268
+ }
269
+ return null;
270
+ } catch {
271
+ return null;
272
+ }
273
+ }
274
+ function writeRecord(root, port) {
275
+ fs3.mkdirSync(path3.dirname(recordPath(root)), { recursive: true });
276
+ const rec = {
277
+ pid: process.pid,
278
+ port,
279
+ root,
280
+ startedAt: (/* @__PURE__ */ new Date()).toISOString()
281
+ };
282
+ fs3.writeFileSync(recordPath(root), JSON.stringify(rec, null, 2) + "\n");
283
+ }
284
+ function clearRecord(root) {
285
+ try {
286
+ const rec = readRecord(root);
287
+ if (rec && rec.pid !== process.pid) return;
288
+ fs3.unlinkSync(recordPath(root));
289
+ } catch {
290
+ }
291
+ }
292
+ function isAlive(pid) {
293
+ try {
294
+ process.kill(pid, 0);
295
+ return true;
296
+ } catch (e) {
297
+ if (e.code === "ESRCH") return false;
298
+ return true;
299
+ }
300
+ }
301
+ async function fetchWithTimeout(url) {
302
+ const controller = new AbortController();
303
+ const timer = setTimeout(() => controller.abort(), VERIFY_TIMEOUT_MS);
304
+ try {
305
+ return await fetch(url, { signal: controller.signal });
306
+ } finally {
307
+ clearTimeout(timer);
308
+ }
309
+ }
310
+ async function verifyRoot(port, root) {
311
+ try {
312
+ const res = await fetchWithTimeout(`http://127.0.0.1:${port}/__config`);
313
+ if (res.status !== 200) return false;
314
+ const body = await res.json();
315
+ if (typeof body.root !== "string") return false;
316
+ return body.root === root;
317
+ } catch {
318
+ return false;
319
+ }
320
+ }
321
+ async function findReusable(root) {
322
+ const rec = readRecord(root);
323
+ if (!rec) return null;
324
+ if (!isAlive(rec.pid)) return null;
325
+ if (!await verifyRoot(rec.port, root)) return null;
326
+ return rec;
327
+ }
328
+ async function stopInstance(record) {
329
+ try {
330
+ process.kill(record.pid, "SIGTERM");
331
+ } catch {
332
+ return;
333
+ }
334
+ const deadline = Date.now() + STOP_POLL_MS;
335
+ while (Date.now() < deadline) {
336
+ if (!isAlive(record.pid)) return;
337
+ await new Promise((r) => setTimeout(r, STOP_POLL_INTERVAL_MS));
338
+ }
339
+ try {
340
+ process.kill(record.pid, "SIGKILL");
341
+ } catch {
342
+ }
343
+ await new Promise((r) => setTimeout(r, STOP_FINAL_WAIT_MS));
344
+ }
345
+
346
+ // src/core/open-url.ts
347
+ import { exec } from "child_process";
348
+ function openUrl(url) {
349
+ const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
350
+ exec(`${cmd} ${url}`);
351
+ }
352
+
353
+ // src/core/server.ts
126
354
  var VERSION = package_default.version;
127
- var __dirname = path2.dirname(fileURLToPath(import.meta.url));
355
+ var __dirname = path4.dirname(fileURLToPath(import.meta.url));
128
356
  var MIME = {
129
357
  ".html": "text/html; charset=utf-8",
130
358
  ".htm": "text/html; charset=utf-8",
@@ -182,10 +410,12 @@ var ServerService = class extends Service {
182
410
  open;
183
411
  page;
184
412
  det;
413
+ dataDir;
185
414
  routes = /* @__PURE__ */ new Map();
186
415
  sses = /* @__PURE__ */ new Map();
187
416
  prefixStatic = /* @__PURE__ */ new Map();
188
417
  server;
418
+ onListen;
189
419
  constructor(ctx, config) {
190
420
  super(ctx, "server");
191
421
  this.stopToken = crypto.randomBytes(12).toString("hex");
@@ -194,6 +424,8 @@ var ServerService = class extends Service {
194
424
  this.open = config.open;
195
425
  this.page = config.page;
196
426
  this.det = config.detect;
427
+ this.dataDir = config.dataDir;
428
+ this.onListen = config.onListen;
197
429
  ctx.effect(() => () => this.dispose());
198
430
  this.route("/__config", (_req, res) => {
199
431
  res.writeHead(200, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-cache" });
@@ -211,25 +443,25 @@ var ServerService = class extends Service {
211
443
  });
212
444
  this.start(config.port);
213
445
  }
214
- route(path9, handler) {
215
- this.routes.set(path9, handler);
216
- this.ctx.effect(() => () => this.routes.delete(path9));
446
+ route(path11, handler) {
447
+ this.routes.set(path11, handler);
448
+ this.ctx.effect(() => () => this.routes.delete(path11));
217
449
  }
218
- sse(path9, onConnect) {
219
- this.sses.set(path9, onConnect);
220
- this.ctx.effect(() => () => this.sses.delete(path9));
450
+ sse(path11, onConnect) {
451
+ this.sses.set(path11, onConnect);
452
+ this.ctx.effect(() => () => this.sses.delete(path11));
221
453
  }
222
454
  static(prefix, dir) {
223
455
  this.prefixStatic.set(prefix, dir);
224
456
  this.ctx.effect(() => () => this.prefixStatic.delete(prefix));
225
457
  }
226
458
  serveFile(filePath, res, injectHtml) {
227
- fs2.readFile(filePath, (err, data) => {
459
+ fs4.readFile(filePath, (err, data) => {
228
460
  if (err) {
229
461
  res.writeHead(404);
230
462
  return res.end("Not found");
231
463
  }
232
- const ext = path2.extname(filePath).toLowerCase();
464
+ const ext = path4.extname(filePath).toLowerCase();
233
465
  const ct = MIME[ext] ?? "application/octet-stream";
234
466
  let body = data;
235
467
  if (injectHtml && ext === ".html") {
@@ -244,9 +476,9 @@ var ServerService = class extends Service {
244
476
  for (const [prefix, dir] of this.prefixStatic) {
245
477
  if (url.indexOf(prefix) === 0) {
246
478
  let rel = this.safeDecode(url.slice(prefix.length));
247
- if (!rel || rel === "/" || !path2.extname(rel)) rel = path2.join(rel || "", "index.html");
248
- const fp = path2.join(dir, rel);
249
- if (fp.indexOf(dir + path2.sep) !== 0) {
479
+ if (!rel || rel === "/" || !path4.extname(rel)) rel = path4.join(rel || "", "index.html");
480
+ const fp = path4.join(dir, rel);
481
+ if (fp.indexOf(dir + path4.sep) !== 0) {
250
482
  res.writeHead(403);
251
483
  return res.end("Forbidden");
252
484
  }
@@ -285,17 +517,17 @@ var ServerService = class extends Service {
285
517
  if (this.servePrefix(url, res)) return;
286
518
  if (url === "/" || url.indexOf("/__app/") === 0 || url.indexOf("/assets/") === 0) {
287
519
  let fp2 = this.appDir;
288
- if (url !== "/") fp2 = path2.join(this.appDir, this.safeDecode(url));
289
- if (url.indexOf("/__app/") === 0) fp2 = path2.join(this.appDir, url.slice(7));
290
- if (fp2 !== this.appDir && fp2.indexOf(this.appDir + path2.sep) !== 0) {
520
+ if (url !== "/") fp2 = path4.join(this.appDir, this.safeDecode(url));
521
+ if (url.indexOf("/__app/") === 0) fp2 = path4.join(this.appDir, url.slice(7));
522
+ if (fp2 !== this.appDir && fp2.indexOf(this.appDir + path4.sep) !== 0) {
291
523
  res.writeHead(403);
292
524
  return res.end("Forbidden");
293
525
  }
294
- if (url === "/") fp2 = path2.join(this.appDir, "index.html");
526
+ if (url === "/") fp2 = path4.join(this.appDir, "index.html");
295
527
  return this.serveFile(fp2, res, false);
296
528
  }
297
- const fp = path2.join(this.root, this.safeDecode(url));
298
- if (fp !== this.root && fp.indexOf(this.root + path2.sep) !== 0) {
529
+ const fp = path4.join(this.root, this.safeDecode(url));
530
+ if (fp !== this.root && fp.indexOf(this.root + path4.sep) !== 0) {
299
531
  res.writeHead(403);
300
532
  return res.end("Forbidden");
301
533
  }
@@ -311,6 +543,8 @@ var ServerService = class extends Service {
311
543
  };
312
544
  start(port) {
313
545
  this.server = http.createServer(this.handler);
546
+ this.server.keepAliveTimeout = 65e3;
547
+ this.server.headersTimeout = 66e3;
314
548
  this.server.on("error", (err) => {
315
549
  if (err.code === "EADDRINUSE") {
316
550
  console.log(`[zdashboard] port ${port} busy, trying ${port + 1}`);
@@ -323,7 +557,9 @@ var ServerService = class extends Service {
323
557
  console.log(`[zdashboard] v${VERSION} dashboard -> ${u}`);
324
558
  console.log(`[zdashboard] project -> ${this.root}`);
325
559
  console.log(`[zdashboard] detect -> openspec:${this.det.hasOpenspec} docs:${this.det.hasDocs} just:${this.det.hasJust} bugs:${this.det.hasBugs}`);
326
- if (this.open) exec(process.platform === "darwin" ? `open ${target}` : `start ${target}`);
560
+ if (this.dataDir) console.log(`[zdashboard] data -> ${this.dataDir}`);
561
+ if (this.open) openUrl(target);
562
+ this.onListen?.(port);
327
563
  });
328
564
  }
329
565
  stop() {
@@ -333,6 +569,10 @@ var ServerService = class extends Service {
333
569
  } catch {
334
570
  }
335
571
  }
572
+ try {
573
+ clearRecord(this.root);
574
+ } catch {
575
+ }
336
576
  setTimeout(() => {
337
577
  try {
338
578
  this.ctx.root.fiber.dispose();
@@ -348,11 +588,15 @@ var ServerService = class extends Service {
348
588
  } catch {
349
589
  }
350
590
  }
591
+ try {
592
+ clearRecord(this.root);
593
+ } catch {
594
+ }
351
595
  }
352
596
  };
353
597
 
354
598
  // src/core/reload.ts
355
- import fs3 from "fs";
599
+ import fs5 from "fs";
356
600
  import { Service as Service2 } from "cordis";
357
601
  var WATCH_DEBOUNCE_MS = 150;
358
602
  var ReloadService = class extends Service2 {
@@ -368,7 +612,7 @@ var ReloadService = class extends Service2 {
368
612
  return () => this.clients.delete(res);
369
613
  });
370
614
  try {
371
- this.watcher = fs3.watch(config.root, { recursive: true }, () => {
615
+ this.watcher = fs5.watch(config.root, { recursive: true }, () => {
372
616
  if (this.timer) clearTimeout(this.timer);
373
617
  this.timer = setTimeout(() => {
374
618
  this.broadcast("reload");
@@ -411,13 +655,13 @@ data: ${JSON.stringify(data == null ? "" : data)}
411
655
  };
412
656
 
413
657
  // src/server/spec-scan.ts
414
- import fs4 from "fs";
415
- import path3 from "path";
658
+ import fs6 from "fs";
659
+ import path5 from "path";
416
660
  function walkFiles(absDir, relDir, depth = 0) {
417
661
  if (depth > 4) return [];
418
662
  let ents;
419
663
  try {
420
- ents = fs4.readdirSync(absDir, { withFileTypes: true });
664
+ ents = fs6.readdirSync(absDir, { withFileTypes: true });
421
665
  } catch {
422
666
  return [];
423
667
  }
@@ -426,7 +670,7 @@ function walkFiles(absDir, relDir, depth = 0) {
426
670
  if (ent.name.startsWith(".") || ent.name === "node_modules") continue;
427
671
  const rel = relDir ? `${relDir}/${ent.name}` : ent.name;
428
672
  if (ent.isDirectory()) {
429
- nodes.push({ name: ent.name, kind: "dir", children: walkFiles(path3.join(absDir, ent.name), rel, depth + 1) });
673
+ nodes.push({ name: ent.name, kind: "dir", children: walkFiles(path5.join(absDir, ent.name), rel, depth + 1) });
430
674
  } else {
431
675
  nodes.push({ name: ent.name, kind: "file", path: rel });
432
676
  }
@@ -436,41 +680,41 @@ function walkFiles(absDir, relDir, depth = 0) {
436
680
  }
437
681
  function scanTree(root, hasOpenspec, hasDocs) {
438
682
  const tree = [];
439
- if (hasOpenspec && fs4.existsSync(path3.join(root, "openspec", "changes"))) {
440
- const changesDir = path3.join(root, "openspec", "changes");
683
+ if (hasOpenspec && fs6.existsSync(path5.join(root, "openspec", "changes"))) {
684
+ const changesDir = path5.join(root, "openspec", "changes");
441
685
  const active = [];
442
686
  const archived = [];
443
- for (const ent of fs4.readdirSync(changesDir, { withFileTypes: true })) {
687
+ for (const ent of fs6.readdirSync(changesDir, { withFileTypes: true })) {
444
688
  if (!ent.isDirectory() || ent.name.startsWith(".") || ent.name === "archive") continue;
445
- active.push({ name: ent.name, kind: "dir", children: walkFiles(path3.join(changesDir, ent.name), `openspec/changes/${ent.name}`) });
689
+ active.push({ name: ent.name, kind: "dir", children: walkFiles(path5.join(changesDir, ent.name), `openspec/changes/${ent.name}`) });
446
690
  }
447
691
  active.sort((a, b) => a.name.localeCompare(b.name));
448
- const archiveDir = path3.join(changesDir, "archive");
449
- if (fs4.existsSync(archiveDir)) {
450
- for (const ent of fs4.readdirSync(archiveDir, { withFileTypes: true })) {
692
+ const archiveDir = path5.join(changesDir, "archive");
693
+ if (fs6.existsSync(archiveDir)) {
694
+ for (const ent of fs6.readdirSync(archiveDir, { withFileTypes: true })) {
451
695
  if (!ent.isDirectory() || ent.name.startsWith(".")) continue;
452
- archived.push({ name: ent.name, kind: "dir", children: walkFiles(path3.join(archiveDir, ent.name), `openspec/changes/archive/${ent.name}`) });
696
+ archived.push({ name: ent.name, kind: "dir", children: walkFiles(path5.join(archiveDir, ent.name), `openspec/changes/archive/${ent.name}`) });
453
697
  }
454
698
  archived.sort((a, b) => b.name.localeCompare(a.name));
455
699
  }
456
700
  if (active.length) tree.push({ name: `\u8FDB\u884C\u4E2D (${active.length})`, kind: "dir", children: active });
457
701
  if (archived.length) tree.push({ name: "archive", kind: "dir", defaultCollapsed: true, children: archived });
458
- const specsDir = path3.join(root, "openspec", "specs");
459
- if (fs4.existsSync(specsDir)) {
702
+ const specsDir = path5.join(root, "openspec", "specs");
703
+ if (fs6.existsSync(specsDir)) {
460
704
  const specs = walkFiles(specsDir, "openspec/specs");
461
705
  if (specs.length) tree.push({ name: "specs", kind: "dir", children: specs });
462
706
  }
463
707
  }
464
- if (hasDocs && fs4.existsSync(path3.join(root, "docs"))) {
465
- const docs = walkFiles(path3.join(root, "docs"), "docs");
708
+ if (hasDocs && fs6.existsSync(path5.join(root, "docs"))) {
709
+ const docs = walkFiles(path5.join(root, "docs"), "docs");
466
710
  if (docs.length) tree.push({ name: "docs", kind: "dir", children: docs });
467
711
  }
468
712
  const skip = /* @__PURE__ */ new Set(["openspec", "docs", "node_modules", ".git", "dist", "test-server"]);
469
713
  const etc = [];
470
714
  try {
471
- for (const ent of fs4.readdirSync(root, { withFileTypes: true })) {
715
+ for (const ent of fs6.readdirSync(root, { withFileTypes: true })) {
472
716
  if (ent.name.startsWith(".") || skip.has(ent.name)) continue;
473
- const ext = path3.extname(ent.name).toLowerCase();
717
+ const ext = path5.extname(ent.name).toLowerCase();
474
718
  if (ent.isFile() && (ext === ".md" || ext === ".markdown")) etc.push({ name: ent.name, kind: "file", path: ent.name });
475
719
  }
476
720
  } catch (e) {
@@ -540,13 +784,7 @@ import { spawn, execFile as execFile2 } from "child_process";
540
784
  var MAX_BUFFER = 1e3;
541
785
  var JustRunner = class {
542
786
  cwd;
543
- child = null;
544
- recipe = null;
545
- state = "idle";
546
- code = null;
547
- buffer = [];
548
- pending = "";
549
- // 行缓冲:块缓冲输出(如 maven)的 chunk 会在行中间断开,攒到 \n 才切行
787
+ tasks = /* @__PURE__ */ new Map();
550
788
  clients = /* @__PURE__ */ new Set();
551
789
  recipesCache = null;
552
790
  constructor(cwd) {
@@ -580,26 +818,25 @@ var JustRunner = class {
580
818
  }
581
819
  subscribe(fn) {
582
820
  this.clients.add(fn);
583
- for (const text of this.buffer) fn({ type: "log", text });
584
- fn({ type: "state", state: this.state, recipe: this.recipe, code: this.code });
821
+ for (const t of this.tasks.values()) {
822
+ for (const text of t.buffer) fn({ type: "log", recipe: t.recipe, text });
823
+ fn({ type: "state", recipe: t.recipe, state: t.state, code: t.code, startedAt: t.startedAt });
824
+ }
585
825
  return () => this.clients.delete(fn);
586
826
  }
587
827
  emit(ev) {
588
828
  for (const fn of this.clients) fn(ev);
589
829
  }
590
- info() {
591
- return { state: this.state, recipe: this.recipe, code: this.code };
830
+ list() {
831
+ return Array.from(this.tasks.values(), (t) => ({ recipe: t.recipe, state: t.state, code: t.code, startedAt: t.startedAt }));
592
832
  }
593
- /** 启动 recipe(调用方须先用 recipes() 校验名字);自动停旧进程 */
833
+ /** 启动 recipe:同名先停旧进程(重启语义),不影响其他任务;调用方须先用 recipes() 校验名字 */
594
834
  start(recipe) {
595
- this.killChild();
596
- this.recipe = recipe;
597
- this.code = null;
598
- this.state = "running";
599
- this.buffer = [];
600
- this.pending = "";
601
- this.emit({ type: "clear" });
602
- this.emit({ type: "state", state: "running", recipe, code: null });
835
+ this.killOne(recipe);
836
+ const task = { recipe, child: null, state: "running", code: null, startedAt: Date.now(), buffer: [], pending: "" };
837
+ this.tasks.set(recipe, task);
838
+ this.emit({ type: "clear", recipe });
839
+ this.emit({ type: "state", recipe, state: "running", code: null, startedAt: task.startedAt });
603
840
  const child = spawn("just", [recipe], {
604
841
  cwd: this.cwd,
605
842
  shell: true,
@@ -612,47 +849,62 @@ var JustRunner = class {
612
849
  CI: ""
613
850
  }
614
851
  });
615
- this.child = child;
852
+ task.child = child;
853
+ const isStale = () => this.tasks.get(recipe) !== task;
616
854
  const push = (d) => {
617
- this.pending += d.toString();
855
+ if (isStale()) return;
856
+ task.pending += d.toString();
618
857
  let idx;
619
- while ((idx = this.pending.indexOf("\n")) >= 0) {
620
- const line = this.pending.slice(0, idx + 1);
621
- this.pending = this.pending.slice(idx + 1);
622
- this.pushLine(line);
858
+ while ((idx = task.pending.indexOf("\n")) >= 0) {
859
+ const line = task.pending.slice(0, idx + 1);
860
+ task.pending = task.pending.slice(idx + 1);
861
+ this.pushLine(task, line);
623
862
  }
624
863
  };
625
864
  child.stdout?.on("data", push);
626
865
  child.stderr?.on("data", push);
627
866
  child.on("error", (err) => {
628
- this.pushLine(`[zdashboard] spawn error: ${err.message}
867
+ this.pushLine(task, `[zdashboard] spawn error: ${err.message}
629
868
  `);
630
869
  });
631
- child.on("exit", (code) => {
632
- if (this.pending) {
633
- this.pushLine(this.pending + "\n");
634
- this.pending = "";
870
+ child.on("exit", (code, signal) => {
871
+ if (task.pending && !isStale()) {
872
+ this.pushLine(task, task.pending + "\n");
635
873
  }
636
- this.child = null;
637
- this.state = "exited";
638
- this.code = code ?? 0;
639
- this.emit({ type: "state", state: "exited", recipe: this.recipe, code: this.code });
874
+ task.pending = "";
875
+ task.child = null;
876
+ task.state = "exited";
877
+ task.code = code ?? 0;
878
+ task.signal = signal ?? void 0;
879
+ if (isStale()) return;
880
+ this.emit({ type: "state", recipe, state: "exited", code: task.code, startedAt: task.startedAt, signal: task.signal });
640
881
  });
641
882
  }
642
- pushLine(line) {
643
- this.buffer.push(line);
644
- if (this.buffer.length > MAX_BUFFER) this.buffer.shift();
645
- this.emit({ type: "log", text: line });
883
+ pushLine(task, line) {
884
+ task.buffer.push(line);
885
+ if (task.buffer.length > MAX_BUFFER) task.buffer.shift();
886
+ this.emit({ type: "log", recipe: task.recipe, text: line });
646
887
  }
647
- stop() {
648
- this.killChild();
888
+ /** 停单个任务;不传 recipe 停全部 */
889
+ stop(recipe) {
890
+ if (recipe === void 0) {
891
+ for (const t of this.tasks.values()) this.killOne(t.recipe);
892
+ } else this.killOne(recipe);
649
893
  }
650
894
  restart(recipe) {
651
- const target = recipe ?? this.recipe;
652
- if (target) this.start(target);
895
+ if (this.tasks.has(recipe)) this.start(recipe);
896
+ }
897
+ /** 清空某任务的日志缓冲并广播(真源清除,重连重放不会复活) */
898
+ clear(recipe) {
899
+ const task = this.tasks.get(recipe);
900
+ if (!task) return;
901
+ task.buffer = [];
902
+ task.pending = "";
903
+ this.emit({ type: "clear", recipe });
653
904
  }
654
- killChild() {
655
- const child = this.child;
905
+ killOne(recipe) {
906
+ const task = this.tasks.get(recipe);
907
+ const child = task?.child;
656
908
  if (child?.pid) {
657
909
  try {
658
910
  if (process.platform === "win32") spawn("taskkill", ["/PID", String(child.pid), "/T", "/F"]);
@@ -660,7 +912,9 @@ var JustRunner = class {
660
912
  } catch {
661
913
  }
662
914
  }
663
- this.child = null;
915
+ if (task) {
916
+ task.child = null;
917
+ }
664
918
  }
665
919
  };
666
920
 
@@ -673,6 +927,7 @@ var apply2 = {
673
927
  if (!ctx.server?.route) return;
674
928
  const runner = new JustRunner(root);
675
929
  ctx.effect(() => () => runner.stop());
930
+ ctx.dashboard.register({ mode: "just", label: "Just Runner", icon: "\u{1F4DC}", description: "Just \u591A\u4EFB\u52A1\u5E76\u53D1\u6267\u884C\u4E0E\u65E5\u5FD7" });
676
931
  ctx.server.route("/__just/recipes", async (_req, res) => {
677
932
  res.writeHead(200, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-cache" });
678
933
  try {
@@ -690,45 +945,7 @@ var apply2 = {
690
945
  });
691
946
  return unsub;
692
947
  });
693
- ctx.server.route("/__just/start", async (req, res) => {
694
- if (req.headers["x-stop-token"] !== ctx.server.stopToken) {
695
- res.writeHead(403);
696
- res.end("forbidden");
697
- return;
698
- }
699
- const body = await readBody(req);
700
- let recipe;
701
- try {
702
- recipe = JSON.parse(body || "{}").recipe;
703
- } catch {
704
- }
705
- const target = recipe ?? runner.info().recipe;
706
- if (!target) {
707
- res.writeHead(400);
708
- res.end('{"error":"no recipe"}');
709
- return;
710
- }
711
- const recipes = await runner.recipes();
712
- if (!recipes.some((r) => r.name === target)) {
713
- res.writeHead(403);
714
- res.end('{"error":"unknown recipe"}');
715
- return;
716
- }
717
- runner.start(target);
718
- res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
719
- res.end(JSON.stringify(runner.info()));
720
- });
721
- ctx.server.route("/__just/stop", async (req, res) => {
722
- if (req.headers["x-stop-token"] !== ctx.server.stopToken) {
723
- res.writeHead(403);
724
- res.end("forbidden");
725
- return;
726
- }
727
- runner.stop();
728
- res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
729
- res.end(JSON.stringify(runner.info()));
730
- });
731
- ctx.server.route("/__just/restart", async (req, res) => {
948
+ const handleAction = async (req, res, act) => {
732
949
  if (req.headers["x-stop-token"] !== ctx.server.stopToken) {
733
950
  res.writeHead(403);
734
951
  res.end("forbidden");
@@ -740,23 +957,45 @@ var apply2 = {
740
957
  recipe = JSON.parse(body || "{}").recipe;
741
958
  } catch {
742
959
  }
743
- const target = recipe ?? runner.info().recipe;
744
- if (!target) {
745
- res.writeHead(400);
746
- res.end('{"error":"no recipe"}');
960
+ if (act === "clear") {
961
+ if (recipe) runner.clear(recipe);
962
+ res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
963
+ res.end('{"ok":true}');
747
964
  return;
748
965
  }
749
- const recipes = await runner.recipes();
750
- if (!recipes.some((r) => r.name === target)) {
751
- res.writeHead(403);
752
- res.end('{"error":"unknown recipe"}');
753
- return;
966
+ if (act !== "stop") {
967
+ if (!recipe) {
968
+ res.writeHead(400);
969
+ res.end('{"error":"no recipe"}');
970
+ return;
971
+ }
972
+ const recipes = await runner.recipes();
973
+ if (!recipes.some((r) => r.name === recipe)) {
974
+ res.writeHead(403);
975
+ res.end('{"error":"unknown recipe"}');
976
+ return;
977
+ }
978
+ runner.start(recipe);
979
+ } else {
980
+ runner.stop(recipe || void 0);
754
981
  }
755
- runner.restart(target);
756
982
  res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
757
- res.end(JSON.stringify(runner.info()));
758
- });
759
- ctx.dashboard.register({ mode: "just", label: "Just Runner", icon: "\u{1F4DC}", description: "Just \u4EFB\u52A1\u65E5\u5FD7\u4E0E\u6267\u884C" });
983
+ res.end(JSON.stringify(runner.list()));
984
+ };
985
+ const routeAction = (name) => {
986
+ ctx.server.route(`/__just/${name}`, (req, res) => {
987
+ handleAction(req, res, name).catch(() => {
988
+ if (!res.headersSent) {
989
+ res.writeHead(500);
990
+ res.end('{"error":"internal"}');
991
+ }
992
+ });
993
+ });
994
+ };
995
+ routeAction("start");
996
+ routeAction("stop");
997
+ routeAction("restart");
998
+ routeAction("clear");
760
999
  });
761
1000
  }
762
1001
  };
@@ -767,120 +1006,10 @@ async function readBody(req) {
767
1006
  data += c;
768
1007
  });
769
1008
  req.on("end", () => resolve(data));
1009
+ req.on("error", () => resolve(""));
770
1010
  });
771
1011
  }
772
1012
 
773
- // src/server/bugs.ts
774
- import fs5 from "fs";
775
- import path4 from "path";
776
-
777
- // src/server/api/fetch.ts
778
- import ky from "ky";
779
-
780
- // src/server/errors.ts
781
- var HttpError = class extends Error {
782
- constructor(status, message, body) {
783
- super(message);
784
- this.status = status;
785
- this.body = body;
786
- this.name = "HttpError";
787
- }
788
- status;
789
- body;
790
- };
791
- var NetworkError = class extends Error {
792
- constructor(message, cause) {
793
- super(message);
794
- this.cause = cause;
795
- this.name = "NetworkError";
796
- }
797
- cause;
798
- };
799
-
800
- // src/server/api/fetch.ts
801
- async function fetchJson(url, init) {
802
- try {
803
- const res = await ky(url, { ...init, timeout: 8e3, retry: 2 });
804
- return await res.json();
805
- } catch (e) {
806
- if (e instanceof HttpError) throw e;
807
- if (e instanceof Error && e.name === "TimeoutError") {
808
- throw new NetworkError(`\u8BF7\u6C42\u8D85\u65F6: ${url}`);
809
- }
810
- if (e instanceof Error && e.name === "HTTPError") {
811
- throw new HttpError(e.status ?? 500, e.message);
812
- }
813
- throw new NetworkError(`\u8BF7\u6C42\u5931\u8D25: ${url}`, e);
814
- }
815
- }
816
-
817
- // src/server/bugs.ts
818
- function loadZgoalConfig(root) {
819
- const file = path4.join(root, ".zgoal", "config.yaml");
820
- if (!fs5.existsSync(file)) return null;
821
- const kv = {};
822
- for (const line of fs5.readFileSync(file, "utf8").split("\n")) {
823
- const m = line.match(/^\s*([A-Za-z_]\w*)\s*:\s*(.+?)\s*$/);
824
- if (m && !m[2].startsWith("#")) kv[m[1]] = m[2].replace(/^["']|["']$/g, "");
825
- }
826
- const product = Number(kv.product);
827
- if (!kv.url || !product) return null;
828
- return {
829
- url: kv.url.replace(/\/+$/, ""),
830
- account: kv.account ?? "",
831
- password: kv.password,
832
- token: kv.token,
833
- product
834
- };
835
- }
836
- var tokenCache = null;
837
- async function getToken(cfg) {
838
- if (cfg.token) return cfg.token;
839
- const key = `${cfg.url}|${cfg.account}|${cfg.password ?? ""}`;
840
- if (tokenCache && tokenCache.key === key && Date.now() - tokenCache.at < 10 * 6e4) return tokenCache.token;
841
- const json = await fetchJson(`${cfg.url}/api.php/v1/tokens`, {
842
- method: "POST",
843
- headers: { "Content-Type": "application/json" },
844
- body: JSON.stringify({ account: cfg.account, password: cfg.password })
845
- });
846
- const token = typeof json.token === "string" ? json.token : "";
847
- if (!token) throw new Error("token \u83B7\u53D6\u5931\u8D25:\u68C0\u67E5 account / password");
848
- tokenCache = { key, token, at: Date.now() };
849
- return token;
850
- }
851
- function normBug(b, account) {
852
- const assigned = b.assignedTo;
853
- const assignedTo = typeof assigned === "string" ? assigned : assigned && typeof assigned === "object" && "realname" in assigned ? String(assigned.realname ?? "") : "";
854
- const assignedAccount = typeof assigned === "string" ? assigned : assigned && typeof assigned === "object" ? String(assigned.account ?? "") : "";
855
- const mine = !!account && (assignedAccount === account || assignedTo === account);
856
- return {
857
- id: Number(b.id),
858
- title: String(b.title ?? ""),
859
- severity: b.severity ?? 4,
860
- pri: b.pri ?? 3,
861
- status: String(b.status ?? ""),
862
- assignedTo,
863
- openedBy: typeof b.openedBy === "string" ? b.openedBy : void 0,
864
- mine
865
- };
866
- }
867
- async function fetchBugs(root) {
868
- const cfg = loadZgoalConfig(root);
869
- if (!cfg) return { ok: false, error: ".zgoal/config.yaml \u7F3A\u5931\u6216 url/product \u672A\u914D\u7F6E(\u7531 zgoal skill \u521B\u5EFA)" };
870
- try {
871
- const token = await getToken(cfg);
872
- const json = await fetchJson(
873
- `${cfg.url}/api.php/v1/products/${cfg.product}/bugs?page=1&limit=100`,
874
- { headers: { Token: token } }
875
- );
876
- const raw = Array.isArray(json.bugs) ? json.bugs : [];
877
- return { ok: true, url: cfg.url, total: Number(json.total ?? raw.length), bugs: raw.map((b) => normBug(b, cfg.account)) };
878
- } catch (e) {
879
- const msg = e instanceof Error ? e.message : String(e);
880
- return { ok: false, error: `\u7985\u9053\u8BF7\u6C42\u5931\u8D25(${msg})\u2014\u2014\u68C0\u67E5 url / \u51ED\u636E / \u662F\u5426\u5F00\u542F RESTful API v1` };
881
- }
882
- }
883
-
884
1013
  // src/plugins/bugs/index.ts
885
1014
  var apply3 = {
886
1015
  inject: ["server", "dashboard"],
@@ -903,25 +1032,26 @@ var apply3 = {
903
1032
  };
904
1033
 
905
1034
  // src/server/review-store.ts
906
- import fs6 from "fs";
907
- import path5 from "path";
1035
+ import fs7 from "fs";
1036
+ import path6 from "path";
908
1037
  import YAML from "yaml";
909
- var REVIEW_FILE = "review.yaml";
1038
+ var REVIEW_CANDIDATES = [".zdev/review.yaml", "review.yaml"];
910
1039
  var ReviewStore = class {
911
1040
  root;
912
1041
  file;
913
1042
  onChange;
914
1043
  constructor(root, onChange) {
915
1044
  this.root = root;
916
- this.file = path5.join(root, REVIEW_FILE);
917
1045
  this.onChange = onChange;
1046
+ const existing = REVIEW_CANDIDATES.find((rel) => fs7.existsSync(path6.join(root, rel)));
1047
+ this.file = path6.join(root, existing ?? REVIEW_CANDIDATES[0]);
918
1048
  }
919
1049
  exists() {
920
- return fs6.existsSync(this.file);
1050
+ return fs7.existsSync(this.file);
921
1051
  }
922
1052
  read() {
923
1053
  try {
924
- const parsed = YAML.parse(fs6.readFileSync(this.file, "utf8"));
1054
+ const parsed = YAML.parse(fs7.readFileSync(this.file, "utf8"));
925
1055
  if (!parsed || !Array.isArray(parsed.items)) return { status: "draft", items: [] };
926
1056
  return parsed;
927
1057
  } catch {
@@ -929,7 +1059,8 @@ var ReviewStore = class {
929
1059
  }
930
1060
  }
931
1061
  write(data) {
932
- fs6.writeFileSync(this.file, YAML.stringify(data), "utf8");
1062
+ fs7.mkdirSync(path6.dirname(this.file), { recursive: true });
1063
+ fs7.writeFileSync(this.file, YAML.stringify(data), "utf8");
933
1064
  this.onChange?.();
934
1065
  }
935
1066
  updateItem(id, patch) {
@@ -953,7 +1084,9 @@ var ReviewStore = class {
953
1084
  }
954
1085
  docs() {
955
1086
  try {
956
- return fs6.readdirSync(this.root).filter((f) => /\.(md|markdown)$/i.test(f) && fs6.statSync(path5.join(this.root, f)).isFile()).sort();
1087
+ const zdevDir = path6.join(this.root, ".zdev");
1088
+ if (!fs7.existsSync(zdevDir) || !fs7.statSync(zdevDir).isDirectory()) return [];
1089
+ return fs7.readdirSync(zdevDir).filter((f) => /\.(md|markdown)$/i.test(f) && fs7.statSync(path6.join(zdevDir, f)).isFile()).sort();
957
1090
  } catch {
958
1091
  return [];
959
1092
  }
@@ -1025,8 +1158,8 @@ async function readBody2(req) {
1025
1158
  }
1026
1159
 
1027
1160
  // src/plugins/apply/scan.ts
1028
- import fs7 from "fs";
1029
- import path6 from "path";
1161
+ import fs8 from "fs";
1162
+ import path7 from "path";
1030
1163
  function countTasks(md) {
1031
1164
  const all = (md.match(/^\s*-\s*\[[ xX]\]\s*/gm) || []).length;
1032
1165
  const done = (md.match(/^\s*-\s*\[[xX]\]\s*/gm) || []).length;
@@ -1034,38 +1167,85 @@ function countTasks(md) {
1034
1167
  }
1035
1168
  function readText(p) {
1036
1169
  try {
1037
- return fs7.readFileSync(p, "utf8");
1170
+ return fs8.readFileSync(p, "utf8");
1038
1171
  } catch {
1039
1172
  return "";
1040
1173
  }
1041
1174
  }
1175
+ function worktreeDir(root, name) {
1176
+ return path7.join(root, ".zworktree", name);
1177
+ }
1178
+ function changeDir(root, name) {
1179
+ return path7.join(root, "openspec", "changes", name);
1180
+ }
1181
+ function parseDependsOn(proposal) {
1182
+ const deps = [];
1183
+ const m = proposal.match(/^##\s+依赖\s*\n([\s\S]*?)(?:\n#{1,6}\s|\n---\s*\n|$)/i);
1184
+ if (!m) return deps;
1185
+ for (const line of m[1].split("\n")) {
1186
+ if (!/^-\s+/.test(line)) continue;
1187
+ const name = line.replace(/^-\s+/, "").trim();
1188
+ if (name) deps.push(name);
1189
+ }
1190
+ return deps;
1191
+ }
1042
1192
  function scanApplyChanges(root) {
1043
- const changesDir = path6.join(root, "openspec", "changes");
1044
- if (!fs7.existsSync(changesDir)) return [];
1193
+ const changesDir = path7.join(root, "openspec", "changes");
1194
+ if (!fs8.existsSync(changesDir)) return [];
1045
1195
  const out = [];
1046
- for (const ent of fs7.readdirSync(changesDir, { withFileTypes: true })) {
1196
+ for (const ent of fs8.readdirSync(changesDir, { withFileTypes: true })) {
1047
1197
  if (!ent.isDirectory() || ent.name.startsWith(".") || ent.name === "archive") continue;
1048
- const dir = path6.join(changesDir, ent.name);
1049
- const tasks = readText(path6.join(dir, "tasks.md"));
1050
- const { total, done } = countTasks(tasks);
1051
- out.push({
1052
- name: ent.name,
1053
- path: `openspec/changes/${ent.name}`,
1054
- total,
1055
- done,
1056
- hasProposal: fs7.existsSync(path6.join(dir, "proposal.md")),
1057
- hasDesign: fs7.existsSync(path6.join(dir, "design.md"))
1058
- });
1198
+ const wt = worktreeDir(root, ent.name);
1199
+ const tasks = readText(path7.join(wt, "openspec", "changes", ent.name, "tasks.md"));
1200
+ if (!tasks) {
1201
+ const fallback = readText(path7.join(changesDir, ent.name, "tasks.md"));
1202
+ if (fallback) {
1203
+ const { total, done } = countTasks(fallback);
1204
+ const wtChange = path7.join(wt, "openspec", "changes", ent.name);
1205
+ const mainChange = path7.join(changesDir, ent.name);
1206
+ out.push({
1207
+ name: ent.name,
1208
+ path: `openspec/changes/${ent.name}`,
1209
+ total,
1210
+ done,
1211
+ hasProposal: fs8.existsSync(path7.join(wtChange, "proposal.md")) || fs8.existsSync(path7.join(mainChange, "proposal.md")),
1212
+ hasDesign: fs8.existsSync(path7.join(wtChange, "design.md")) || fs8.existsSync(path7.join(mainChange, "design.md")),
1213
+ // worktree 目录存在即算执行中(tasks.md 可能尚未落盘)
1214
+ inWorktree: fs8.existsSync(wtChange)
1215
+ });
1216
+ }
1217
+ } else {
1218
+ const { total, done } = countTasks(tasks);
1219
+ const wtChange = path7.join(wt, "openspec", "changes", ent.name);
1220
+ const mainChange = path7.join(changesDir, ent.name);
1221
+ out.push({
1222
+ name: ent.name,
1223
+ path: `openspec/changes/${ent.name}`,
1224
+ total,
1225
+ done,
1226
+ // 任一侧存在即算(详情读取是 worktree 优先、主目录兜底,列表标记保持一致)
1227
+ hasProposal: fs8.existsSync(path7.join(wtChange, "proposal.md")) || fs8.existsSync(path7.join(mainChange, "proposal.md")),
1228
+ hasDesign: fs8.existsSync(path7.join(wtChange, "design.md")) || fs8.existsSync(path7.join(mainChange, "design.md")),
1229
+ inWorktree: true
1230
+ });
1231
+ }
1059
1232
  }
1060
1233
  out.sort((a, b) => a.name.localeCompare(b.name));
1061
1234
  return out;
1062
1235
  }
1063
1236
  function readApplyChange(root, name) {
1064
- const dir = path6.join(root, "openspec", "changes", name);
1065
- const proposal = readText(path6.join(dir, "proposal.md"));
1066
- const design = readText(path6.join(dir, "design.md"));
1067
- const tasks = readText(path6.join(dir, "tasks.md"));
1237
+ const wt = worktreeDir(root, name);
1238
+ const mainChangeDir = changeDir(root, name);
1239
+ const proposalFrom = fs8.existsSync(path7.join(wt, "openspec", "changes", name, "proposal.md")) ? path7.join(wt, "openspec", "changes", name, "proposal.md") : path7.join(mainChangeDir, "proposal.md");
1240
+ const designFrom = fs8.existsSync(path7.join(wt, "openspec", "changes", name, "design.md")) ? path7.join(wt, "openspec", "changes", name, "design.md") : path7.join(mainChangeDir, "design.md");
1241
+ const tasksFrom = fs8.existsSync(path7.join(wt, "openspec", "changes", name, "tasks.md")) ? path7.join(wt, "openspec", "changes", name, "tasks.md") : path7.join(mainChangeDir, "tasks.md");
1242
+ const proposal = readText(proposalFrom);
1243
+ const design = readText(designFrom);
1244
+ const tasks = readText(tasksFrom);
1068
1245
  const { total, done } = countTasks(tasks);
1246
+ const inWorktree = fs8.existsSync(path7.join(wt, "openspec", "changes", name));
1247
+ const dependsOn = parseDependsOn(proposal);
1248
+ const hasTestStrategy = /^##\s+测试策略\s*$/m.test(design);
1069
1249
  return {
1070
1250
  name,
1071
1251
  path: `openspec/changes/${name}`,
@@ -1073,13 +1253,63 @@ function readApplyChange(root, name) {
1073
1253
  done,
1074
1254
  hasProposal: !!proposal,
1075
1255
  hasDesign: !!design,
1256
+ inWorktree,
1076
1257
  proposal,
1077
1258
  design,
1078
- tasks
1259
+ tasks,
1260
+ dependsOn,
1261
+ hasTestStrategy
1079
1262
  };
1080
1263
  }
1081
1264
 
1082
1265
  // src/plugins/apply/index.ts
1266
+ import { execFile as execFile3 } from "child_process";
1267
+ var GIT_TIMEOUT_MS = 5e3;
1268
+ function gitWorktrees(root) {
1269
+ return new Promise((resolve) => {
1270
+ execFile3(
1271
+ "git",
1272
+ ["worktree", "list", "--porcelain"],
1273
+ { cwd: root, timeout: GIT_TIMEOUT_MS },
1274
+ (err, stdout) => {
1275
+ if (err) return resolve([]);
1276
+ const entries = [];
1277
+ const lines = stdout.split("\n");
1278
+ let current = {};
1279
+ for (const line of lines) {
1280
+ const m = line.match(/^(worktree|HEAD|branch|detached)\s+(.+)$/);
1281
+ if (m) {
1282
+ const [, key, val] = m;
1283
+ if (key === "worktree") {
1284
+ if (current.path && current.path.includes(".zworktree/")) {
1285
+ entries.push({
1286
+ path: current.path,
1287
+ name: current.path.split("/.zworktree/").pop() ?? "",
1288
+ branch: current.branch ?? "",
1289
+ head: current.head ?? ""
1290
+ });
1291
+ }
1292
+ current = { path: val, head: "", branch: "" };
1293
+ } else if (key === "HEAD") {
1294
+ current.head = val;
1295
+ } else if (key === "branch") {
1296
+ current.branch = val.replace(/^refs\/heads\//, "");
1297
+ }
1298
+ }
1299
+ }
1300
+ if (current.path && current.path.includes(".zworktree/")) {
1301
+ entries.push({
1302
+ path: current.path,
1303
+ name: current.path.split("/.zworktree/").pop() ?? "",
1304
+ branch: current.branch ?? "",
1305
+ head: current.head ?? ""
1306
+ });
1307
+ }
1308
+ resolve(entries);
1309
+ }
1310
+ );
1311
+ });
1312
+ }
1083
1313
  var apply5 = {
1084
1314
  inject: ["server", "dashboard"],
1085
1315
  apply(ctx, config) {
@@ -1113,13 +1343,23 @@ var apply5 = {
1113
1343
  res.end(JSON.stringify({ error: e instanceof Error ? e.message : "unknown error" }));
1114
1344
  }
1115
1345
  });
1346
+ ctx.server.route("/__worktrees", async (_req, res) => {
1347
+ try {
1348
+ const entries = await gitWorktrees(root);
1349
+ res.writeHead(200, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-cache" });
1350
+ res.end(JSON.stringify(entries));
1351
+ } catch {
1352
+ res.writeHead(200, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-cache" });
1353
+ res.end(JSON.stringify([]));
1354
+ }
1355
+ });
1116
1356
  });
1117
1357
  }
1118
1358
  };
1119
1359
 
1120
1360
  // src/server/design-assets.ts
1121
- import fs8 from "fs";
1122
- import path7 from "path";
1361
+ import fs9 from "fs";
1362
+ import path8 from "path";
1123
1363
  var PAGE_EXTS = [".html", ".htm"];
1124
1364
  var ICON_EXTS = [".svg", ".png", ".ico", ".jpg", ".jpeg", ".gif", ".webp"];
1125
1365
  var VIDEO_EXTS = [".mp4", ".webm", ".mov", ".ogg", ".ogv"];
@@ -1136,18 +1376,21 @@ function categorize(rel, ext) {
1136
1376
  if (FONT_EXTS.includes(ext)) return "font";
1137
1377
  if (ICON_EXTS.includes(ext)) return "icon";
1138
1378
  if (PAGE_EXTS.includes(ext)) return "page";
1139
- if (CODE_EXTS.includes(ext)) return TOKEN_RE.test(rel) && (ext === ".css" || ext === ".json") ? "token" : "code";
1379
+ if (CODE_EXTS.includes(ext)) {
1380
+ if (TOKEN_RE.test(rel) && (ext === ".css" || ext === ".json")) return "token";
1381
+ return null;
1382
+ }
1140
1383
  return "other";
1141
1384
  }
1142
1385
  function scanAssets(root) {
1143
1386
  const out = {};
1144
- const keys = ["page", "component", "icon", "token", "md", "video", "audio", "pdf", "code", "font", "other"];
1387
+ const keys = ["page", "component", "icon", "token", "md", "video", "audio", "pdf", "font", "other"];
1145
1388
  for (const k of keys) out[k] = [];
1146
1389
  const SKIP = /* @__PURE__ */ new Set(["node_modules", ".git", "dist", "build", "coverage", ".next", ".cache"]);
1147
1390
  function walk(dir, rel) {
1148
1391
  let ents;
1149
1392
  try {
1150
- ents = fs8.readdirSync(dir, { withFileTypes: true });
1393
+ ents = fs9.readdirSync(dir, { withFileTypes: true });
1151
1394
  } catch {
1152
1395
  return;
1153
1396
  }
@@ -1155,12 +1398,12 @@ function scanAssets(root) {
1155
1398
  if (ent.name.startsWith(".") || SKIP.has(ent.name)) continue;
1156
1399
  const r = rel ? `${rel}/${ent.name}` : ent.name;
1157
1400
  if (ent.isDirectory()) {
1158
- walk(path7.join(dir, ent.name), r);
1401
+ walk(path8.join(dir, ent.name), r);
1159
1402
  continue;
1160
1403
  }
1161
- const ext = path7.extname(ent.name).toLowerCase();
1404
+ const ext = path8.extname(ent.name).toLowerCase();
1162
1405
  const t = categorize(r, ext);
1163
- out[t].push({ path: r, name: ent.name, ext, type: t });
1406
+ if (t) out[t].push({ path: r, name: ent.name, ext, type: t });
1164
1407
  }
1165
1408
  }
1166
1409
  walk(root, "");
@@ -1191,11 +1434,91 @@ var apply7 = {
1191
1434
  }
1192
1435
  };
1193
1436
 
1437
+ // src/plugins/stats/index.ts
1438
+ import fs10 from "fs";
1439
+ import path9 from "path";
1440
+ var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", ".git", "dist", "build", ".next", ".cache"]);
1441
+ function scan(root) {
1442
+ const stats = {
1443
+ root: path9.basename(root),
1444
+ files: 0,
1445
+ dirs: 0,
1446
+ totalSize: 0,
1447
+ byExt: [],
1448
+ markdown: 0,
1449
+ openspec: { active: 0, archived: 0 },
1450
+ hasJust: fs10.existsSync(path9.join(root, "justfile"))
1451
+ };
1452
+ const extMap = /* @__PURE__ */ new Map();
1453
+ const walk = (dir) => {
1454
+ let ents;
1455
+ try {
1456
+ ents = fs10.readdirSync(dir, { withFileTypes: true });
1457
+ } catch {
1458
+ return;
1459
+ }
1460
+ for (const ent of ents) {
1461
+ if (ent.name.startsWith(".") || SKIP_DIRS.has(ent.name)) continue;
1462
+ const abs = path9.join(dir, ent.name);
1463
+ if (ent.isDirectory()) {
1464
+ stats.dirs++;
1465
+ walk(abs);
1466
+ continue;
1467
+ }
1468
+ stats.files++;
1469
+ try {
1470
+ stats.totalSize += fs10.statSync(abs).size;
1471
+ } catch {
1472
+ }
1473
+ const ext = path9.extname(ent.name).toLowerCase() || "(\u65E0\u6269\u5C55\u540D)";
1474
+ extMap.set(ext, (extMap.get(ext) ?? 0) + 1);
1475
+ if (ext === ".md" || ext === ".markdown") stats.markdown++;
1476
+ }
1477
+ };
1478
+ walk(root);
1479
+ const changesDir = path9.join(root, "openspec", "changes");
1480
+ if (fs10.existsSync(changesDir)) {
1481
+ for (const ent of fs10.readdirSync(changesDir, { withFileTypes: true })) {
1482
+ if (!ent.isDirectory() || ent.name.startsWith(".")) continue;
1483
+ if (ent.name === "archive") {
1484
+ try {
1485
+ stats.openspec.archived = fs10.readdirSync(path9.join(changesDir, "archive"), { withFileTypes: true }).filter((e) => e.isDirectory() && !e.name.startsWith(".")).length;
1486
+ } catch {
1487
+ }
1488
+ } else stats.openspec.active++;
1489
+ }
1490
+ }
1491
+ stats.byExt = Array.from(extMap.entries()).map(([ext, count]) => ({ ext, count })).sort((a, b) => b.count - a.count).slice(0, 10);
1492
+ return stats;
1493
+ }
1494
+ var apply8 = {
1495
+ inject: ["server", "dashboard"],
1496
+ apply(ctx, config) {
1497
+ ctx.inject(["server", "dashboard"], () => {
1498
+ const server = ctx.server;
1499
+ const dashboard = ctx.dashboard;
1500
+ if (!server?.route || !dashboard?.register) return;
1501
+ dashboard.register({
1502
+ mode: "stats",
1503
+ label: "\u9879\u76EE\u7EDF\u8BA1",
1504
+ icon: "\u{1F4CA}",
1505
+ description: "\u9879\u76EE\u6587\u4EF6\u7EDF\u8BA1 \xB7 \u626B\u63CF\u751F\u6210"
1506
+ });
1507
+ server.route("/__stats/data", (_req, res) => {
1508
+ res.writeHead(200, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-cache" });
1509
+ res.end(JSON.stringify(scan(config.root)));
1510
+ });
1511
+ });
1512
+ }
1513
+ };
1514
+
1194
1515
  // src/cli.ts
1195
- var __dirname2 = path8.dirname(fileURLToPath2(import.meta.url));
1516
+ var __dirname2 = path10.dirname(fileURLToPath2(import.meta.url));
1517
+ var DEFAULT_PORT = 4190;
1196
1518
  function parseArgs() {
1197
1519
  const args = process.argv.slice(2);
1198
1520
  const opts = {};
1521
+ let portExplicit = false;
1199
1522
  for (let i = 0; i < args.length; i++) {
1200
1523
  const a = args[i];
1201
1524
  if (a.startsWith("--")) {
@@ -1207,13 +1530,16 @@ function parseArgs() {
1207
1530
  } else {
1208
1531
  opts[key] = true;
1209
1532
  }
1533
+ if (key === "port") portExplicit = true;
1210
1534
  }
1211
1535
  }
1212
1536
  return {
1213
1537
  dir: typeof opts.dir === "string" ? opts.dir : process.cwd(),
1214
- port: typeof opts.port === "string" ? Number(opts.port) : 4190,
1538
+ port: typeof opts.port === "string" ? Number(opts.port) : DEFAULT_PORT,
1539
+ portExplicit,
1215
1540
  open: !!opts.open,
1216
1541
  page: typeof opts.page === "string" ? opts.page : null,
1542
+ restart: !!opts.restart,
1217
1543
  plugins: typeof opts.plugins === "string" ? opts.plugins : null
1218
1544
  };
1219
1545
  }
@@ -1240,7 +1566,7 @@ async function loadExternal(ctx, dir, root) {
1240
1566
  if (!ent.isDirectory()) continue;
1241
1567
  const candidates = tsxLoaded ? ["index.ts", "index.js", "index.mjs"] : ["index.js", "index.mjs"];
1242
1568
  for (const name of candidates) {
1243
- const p = path8.join(dir, ent.name, name);
1569
+ const p = path10.join(dir, ent.name, name);
1244
1570
  if (await pathExists(p)) {
1245
1571
  try {
1246
1572
  const mod = await import(p);
@@ -1255,8 +1581,8 @@ async function loadExternal(ctx, dir, root) {
1255
1581
  const m = ctx.dashboard.get(ent.name);
1256
1582
  if (m && m.mode === ent.name) {
1257
1583
  const patch = { external: true };
1258
- const webDir = path8.join(dir, ent.name, "web");
1259
- if (await pathExists(path8.join(webDir, "index.html"))) {
1584
+ const webDir = path10.join(dir, ent.name, "web");
1585
+ if (await pathExists(path10.join(webDir, "index.html"))) {
1260
1586
  ctx.server.static(`${PLUGIN_STATIC_PREFIX}${ent.name}/`, webDir);
1261
1587
  if (!m.viewerUrl) patch.viewerUrl = `${PLUGIN_STATIC_PREFIX}${ent.name}/`;
1262
1588
  }
@@ -1275,15 +1601,41 @@ async function loadExternal(ctx, dir, root) {
1275
1601
  }
1276
1602
  async function main() {
1277
1603
  const args = parseArgs();
1278
- const root = path8.resolve(args.dir);
1279
- const appDir = path8.resolve(__dirname2, "web");
1604
+ const root = path10.resolve(args.dir);
1605
+ const existing = await findReusable(root);
1606
+ if (existing && !args.restart) {
1607
+ const u = `http://localhost:${existing.port}` + (args.page ? `#${args.page}` : "");
1608
+ if (args.open) openUrl(u);
1609
+ console.log(`[zdashboard] \u5DF2\u590D\u7528\u5B9E\u4F8B ${u}\uFF08--restart \u53EF\u5F3A\u5236\u91CD\u5F00\uFF09`);
1610
+ await new Promise((resolve) => process.stdout.write("", () => resolve()));
1611
+ process.exit(0);
1612
+ }
1613
+ const oldRecord = existing && args.restart ? existing : null;
1614
+ if (oldRecord) {
1615
+ console.log(`[zdashboard] --restart\uFF1A\u505C\u6B62\u65E7\u5B9E\u4F8B pid=${oldRecord.pid}`);
1616
+ await stopInstance(oldRecord);
1617
+ }
1618
+ const appDir = path10.resolve(__dirname2, "web");
1619
+ const startPort = oldRecord && !args.portExplicit ? oldRecord.port : args.port;
1620
+ const zdevDir = path10.join(root, ".zdev");
1621
+ const dataDir = fs11.existsSync(zdevDir) ? ".zdev/" : "";
1280
1622
  const det = await detect(root);
1281
1623
  const ctx = new Context();
1282
- ctx.plugin(ServerService, { root, appDir, port: args.port, open: args.open, detect: det, page: args.page });
1624
+ ctx.plugin(ServerService, {
1625
+ root,
1626
+ appDir,
1627
+ port: startPort,
1628
+ open: args.open,
1629
+ detect: det,
1630
+ page: args.page,
1631
+ dataDir: dataDir || void 0,
1632
+ onListen: (port) => writeRecord(root, port)
1633
+ });
1283
1634
  ctx.plugin(ReloadService, { root });
1284
1635
  ctx.plugin(apply, { root });
1285
1636
  ctx.plugin(DashboardService);
1286
1637
  const plugins = [
1638
+ { name: "stats", apply: apply8 },
1287
1639
  { name: "just", apply: apply2 },
1288
1640
  { name: "bugs", apply: apply3 },
1289
1641
  { name: "review", apply: apply4 },
@@ -1299,8 +1651,17 @@ async function main() {
1299
1651
  }
1300
1652
  }
1301
1653
  if (args.plugins) {
1302
- await loadExternal(ctx, path8.resolve(args.plugins), root);
1654
+ await loadExternal(ctx, path10.resolve(args.plugins), root);
1303
1655
  }
1656
+ const shutdown = () => {
1657
+ try {
1658
+ clearRecord(root);
1659
+ } catch {
1660
+ }
1661
+ process.exit(0);
1662
+ };
1663
+ process.on("SIGTERM", shutdown);
1664
+ process.on("SIGINT", shutdown);
1304
1665
  return ctx;
1305
1666
  }
1306
1667
  await main().catch((e) => {