zdashboard 2.1.0 → 2.2.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/dist/cli.js CHANGED
@@ -2,13 +2,137 @@
2
2
 
3
3
  // src/cli.ts
4
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,15 +154,15 @@ import { Context } from "cordis";
30
154
 
31
155
  // src/core/server.ts
32
156
  import http from "http";
33
- import fs3 from "fs";
34
- import path3 from "path";
157
+ import fs4 from "fs";
158
+ import path4 from "path";
35
159
  import crypto from "crypto";
36
160
  import { fileURLToPath } from "url";
37
161
 
38
162
  // package.json
39
163
  var package_default = {
40
164
  name: "zdashboard",
41
- version: "2.1.0",
165
+ version: "2.2.0",
42
166
  description: "ZCode skill dashboard platform \u2014 pluggable viewers for zdesign/zview/zreview/zgoal",
43
167
  type: "module",
44
168
  bin: {
@@ -124,20 +248,20 @@ var package_default = {
124
248
  import { Service } from "cordis";
125
249
 
126
250
  // src/core/instance.ts
127
- import fs2 from "fs";
128
- import path2 from "path";
251
+ import fs3 from "fs";
252
+ import path3 from "path";
129
253
  var RECORD_FILE = ".zdev/dashboard.json";
130
254
  var VERIFY_TIMEOUT_MS = 1500;
131
255
  var STOP_POLL_MS = 2e3;
132
256
  var STOP_POLL_INTERVAL_MS = 100;
133
257
  var STOP_FINAL_WAIT_MS = 100;
134
258
  function recordPath(root) {
135
- return path2.join(root, RECORD_FILE);
259
+ return path3.join(root, RECORD_FILE);
136
260
  }
137
261
  function readRecord(root) {
138
262
  try {
139
263
  const fp = recordPath(root);
140
- const raw = fs2.readFileSync(fp, "utf-8");
264
+ const raw = fs3.readFileSync(fp, "utf-8");
141
265
  const rec = JSON.parse(raw);
142
266
  if (typeof rec.pid === "number" && typeof rec.port === "number" && typeof rec.root === "string" && typeof rec.startedAt === "string") {
143
267
  return rec;
@@ -148,20 +272,20 @@ function readRecord(root) {
148
272
  }
149
273
  }
150
274
  function writeRecord(root, port) {
151
- fs2.mkdirSync(path2.dirname(recordPath(root)), { recursive: true });
275
+ fs3.mkdirSync(path3.dirname(recordPath(root)), { recursive: true });
152
276
  const rec = {
153
277
  pid: process.pid,
154
278
  port,
155
279
  root,
156
280
  startedAt: (/* @__PURE__ */ new Date()).toISOString()
157
281
  };
158
- fs2.writeFileSync(recordPath(root), JSON.stringify(rec, null, 2) + "\n");
282
+ fs3.writeFileSync(recordPath(root), JSON.stringify(rec, null, 2) + "\n");
159
283
  }
160
284
  function clearRecord(root) {
161
285
  try {
162
286
  const rec = readRecord(root);
163
287
  if (rec && rec.pid !== process.pid) return;
164
- fs2.unlinkSync(recordPath(root));
288
+ fs3.unlinkSync(recordPath(root));
165
289
  } catch {
166
290
  }
167
291
  }
@@ -221,14 +345,14 @@ async function stopInstance(record) {
221
345
 
222
346
  // src/core/open-url.ts
223
347
  import { exec } from "child_process";
224
- function openUrl2(url) {
348
+ function openUrl(url) {
225
349
  const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
226
350
  exec(`${cmd} ${url}`);
227
351
  }
228
352
 
229
353
  // src/core/server.ts
230
354
  var VERSION = package_default.version;
231
- var __dirname = path3.dirname(fileURLToPath(import.meta.url));
355
+ var __dirname = path4.dirname(fileURLToPath(import.meta.url));
232
356
  var MIME = {
233
357
  ".html": "text/html; charset=utf-8",
234
358
  ".htm": "text/html; charset=utf-8",
@@ -286,6 +410,7 @@ var ServerService = class extends Service {
286
410
  open;
287
411
  page;
288
412
  det;
413
+ dataDir;
289
414
  routes = /* @__PURE__ */ new Map();
290
415
  sses = /* @__PURE__ */ new Map();
291
416
  prefixStatic = /* @__PURE__ */ new Map();
@@ -299,6 +424,7 @@ var ServerService = class extends Service {
299
424
  this.open = config.open;
300
425
  this.page = config.page;
301
426
  this.det = config.detect;
427
+ this.dataDir = config.dataDir;
302
428
  this.onListen = config.onListen;
303
429
  ctx.effect(() => () => this.dispose());
304
430
  this.route("/__config", (_req, res) => {
@@ -330,12 +456,12 @@ var ServerService = class extends Service {
330
456
  this.ctx.effect(() => () => this.prefixStatic.delete(prefix));
331
457
  }
332
458
  serveFile(filePath, res, injectHtml) {
333
- fs3.readFile(filePath, (err, data) => {
459
+ fs4.readFile(filePath, (err, data) => {
334
460
  if (err) {
335
461
  res.writeHead(404);
336
462
  return res.end("Not found");
337
463
  }
338
- const ext = path3.extname(filePath).toLowerCase();
464
+ const ext = path4.extname(filePath).toLowerCase();
339
465
  const ct = MIME[ext] ?? "application/octet-stream";
340
466
  let body = data;
341
467
  if (injectHtml && ext === ".html") {
@@ -350,9 +476,9 @@ var ServerService = class extends Service {
350
476
  for (const [prefix, dir] of this.prefixStatic) {
351
477
  if (url.indexOf(prefix) === 0) {
352
478
  let rel = this.safeDecode(url.slice(prefix.length));
353
- if (!rel || rel === "/" || !path3.extname(rel)) rel = path3.join(rel || "", "index.html");
354
- const fp = path3.join(dir, rel);
355
- if (fp.indexOf(dir + path3.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) {
356
482
  res.writeHead(403);
357
483
  return res.end("Forbidden");
358
484
  }
@@ -391,17 +517,17 @@ var ServerService = class extends Service {
391
517
  if (this.servePrefix(url, res)) return;
392
518
  if (url === "/" || url.indexOf("/__app/") === 0 || url.indexOf("/assets/") === 0) {
393
519
  let fp2 = this.appDir;
394
- if (url !== "/") fp2 = path3.join(this.appDir, this.safeDecode(url));
395
- if (url.indexOf("/__app/") === 0) fp2 = path3.join(this.appDir, url.slice(7));
396
- if (fp2 !== this.appDir && fp2.indexOf(this.appDir + path3.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) {
397
523
  res.writeHead(403);
398
524
  return res.end("Forbidden");
399
525
  }
400
- if (url === "/") fp2 = path3.join(this.appDir, "index.html");
526
+ if (url === "/") fp2 = path4.join(this.appDir, "index.html");
401
527
  return this.serveFile(fp2, res, false);
402
528
  }
403
- const fp = path3.join(this.root, this.safeDecode(url));
404
- if (fp !== this.root && fp.indexOf(this.root + path3.sep) !== 0) {
529
+ const fp = path4.join(this.root, this.safeDecode(url));
530
+ if (fp !== this.root && fp.indexOf(this.root + path4.sep) !== 0) {
405
531
  res.writeHead(403);
406
532
  return res.end("Forbidden");
407
533
  }
@@ -417,6 +543,8 @@ var ServerService = class extends Service {
417
543
  };
418
544
  start(port) {
419
545
  this.server = http.createServer(this.handler);
546
+ this.server.keepAliveTimeout = 65e3;
547
+ this.server.headersTimeout = 66e3;
420
548
  this.server.on("error", (err) => {
421
549
  if (err.code === "EADDRINUSE") {
422
550
  console.log(`[zdashboard] port ${port} busy, trying ${port + 1}`);
@@ -429,7 +557,8 @@ var ServerService = class extends Service {
429
557
  console.log(`[zdashboard] v${VERSION} dashboard -> ${u}`);
430
558
  console.log(`[zdashboard] project -> ${this.root}`);
431
559
  console.log(`[zdashboard] detect -> openspec:${this.det.hasOpenspec} docs:${this.det.hasDocs} just:${this.det.hasJust} bugs:${this.det.hasBugs}`);
432
- if (this.open) openUrl2(target);
560
+ if (this.dataDir) console.log(`[zdashboard] data -> ${this.dataDir}`);
561
+ if (this.open) openUrl(target);
433
562
  this.onListen?.(port);
434
563
  });
435
564
  }
@@ -467,7 +596,7 @@ var ServerService = class extends Service {
467
596
  };
468
597
 
469
598
  // src/core/reload.ts
470
- import fs4 from "fs";
599
+ import fs5 from "fs";
471
600
  import { Service as Service2 } from "cordis";
472
601
  var WATCH_DEBOUNCE_MS = 150;
473
602
  var ReloadService = class extends Service2 {
@@ -483,7 +612,7 @@ var ReloadService = class extends Service2 {
483
612
  return () => this.clients.delete(res);
484
613
  });
485
614
  try {
486
- this.watcher = fs4.watch(config.root, { recursive: true }, () => {
615
+ this.watcher = fs5.watch(config.root, { recursive: true }, () => {
487
616
  if (this.timer) clearTimeout(this.timer);
488
617
  this.timer = setTimeout(() => {
489
618
  this.broadcast("reload");
@@ -526,13 +655,13 @@ data: ${JSON.stringify(data == null ? "" : data)}
526
655
  };
527
656
 
528
657
  // src/server/spec-scan.ts
529
- import fs5 from "fs";
530
- import path4 from "path";
658
+ import fs6 from "fs";
659
+ import path5 from "path";
531
660
  function walkFiles(absDir, relDir, depth = 0) {
532
661
  if (depth > 4) return [];
533
662
  let ents;
534
663
  try {
535
- ents = fs5.readdirSync(absDir, { withFileTypes: true });
664
+ ents = fs6.readdirSync(absDir, { withFileTypes: true });
536
665
  } catch {
537
666
  return [];
538
667
  }
@@ -541,7 +670,7 @@ function walkFiles(absDir, relDir, depth = 0) {
541
670
  if (ent.name.startsWith(".") || ent.name === "node_modules") continue;
542
671
  const rel = relDir ? `${relDir}/${ent.name}` : ent.name;
543
672
  if (ent.isDirectory()) {
544
- nodes.push({ name: ent.name, kind: "dir", children: walkFiles(path4.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) });
545
674
  } else {
546
675
  nodes.push({ name: ent.name, kind: "file", path: rel });
547
676
  }
@@ -551,41 +680,41 @@ function walkFiles(absDir, relDir, depth = 0) {
551
680
  }
552
681
  function scanTree(root, hasOpenspec, hasDocs) {
553
682
  const tree = [];
554
- if (hasOpenspec && fs5.existsSync(path4.join(root, "openspec", "changes"))) {
555
- const changesDir = path4.join(root, "openspec", "changes");
683
+ if (hasOpenspec && fs6.existsSync(path5.join(root, "openspec", "changes"))) {
684
+ const changesDir = path5.join(root, "openspec", "changes");
556
685
  const active = [];
557
686
  const archived = [];
558
- for (const ent of fs5.readdirSync(changesDir, { withFileTypes: true })) {
687
+ for (const ent of fs6.readdirSync(changesDir, { withFileTypes: true })) {
559
688
  if (!ent.isDirectory() || ent.name.startsWith(".") || ent.name === "archive") continue;
560
- active.push({ name: ent.name, kind: "dir", children: walkFiles(path4.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}`) });
561
690
  }
562
691
  active.sort((a, b) => a.name.localeCompare(b.name));
563
- const archiveDir = path4.join(changesDir, "archive");
564
- if (fs5.existsSync(archiveDir)) {
565
- for (const ent of fs5.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 })) {
566
695
  if (!ent.isDirectory() || ent.name.startsWith(".")) continue;
567
- archived.push({ name: ent.name, kind: "dir", children: walkFiles(path4.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}`) });
568
697
  }
569
698
  archived.sort((a, b) => b.name.localeCompare(a.name));
570
699
  }
571
700
  if (active.length) tree.push({ name: `\u8FDB\u884C\u4E2D (${active.length})`, kind: "dir", children: active });
572
701
  if (archived.length) tree.push({ name: "archive", kind: "dir", defaultCollapsed: true, children: archived });
573
- const specsDir = path4.join(root, "openspec", "specs");
574
- if (fs5.existsSync(specsDir)) {
702
+ const specsDir = path5.join(root, "openspec", "specs");
703
+ if (fs6.existsSync(specsDir)) {
575
704
  const specs = walkFiles(specsDir, "openspec/specs");
576
705
  if (specs.length) tree.push({ name: "specs", kind: "dir", children: specs });
577
706
  }
578
707
  }
579
- if (hasDocs && fs5.existsSync(path4.join(root, "docs"))) {
580
- const docs = walkFiles(path4.join(root, "docs"), "docs");
708
+ if (hasDocs && fs6.existsSync(path5.join(root, "docs"))) {
709
+ const docs = walkFiles(path5.join(root, "docs"), "docs");
581
710
  if (docs.length) tree.push({ name: "docs", kind: "dir", children: docs });
582
711
  }
583
712
  const skip = /* @__PURE__ */ new Set(["openspec", "docs", "node_modules", ".git", "dist", "test-server"]);
584
713
  const etc = [];
585
714
  try {
586
- for (const ent of fs5.readdirSync(root, { withFileTypes: true })) {
715
+ for (const ent of fs6.readdirSync(root, { withFileTypes: true })) {
587
716
  if (ent.name.startsWith(".") || skip.has(ent.name)) continue;
588
- const ext = path4.extname(ent.name).toLowerCase();
717
+ const ext = path5.extname(ent.name).toLowerCase();
589
718
  if (ent.isFile() && (ext === ".md" || ext === ".markdown")) etc.push({ name: ent.name, kind: "file", path: ent.name });
590
719
  }
591
720
  } catch (e) {
@@ -625,6 +754,84 @@ var apply = {
625
754
  }
626
755
  };
627
756
 
757
+ // src/core/worktrees.ts
758
+ import { execFile as execFile2 } from "child_process";
759
+ var GIT_TIMEOUT_MS = 5e3;
760
+ var ZWORKTREE_SEGMENT = ".zworktree/";
761
+ function runGit(args, cwd) {
762
+ return new Promise((resolve) => {
763
+ execFile2("git", args, { cwd, timeout: GIT_TIMEOUT_MS }, (err, stdout) => {
764
+ if (err) return resolve("");
765
+ resolve(stdout);
766
+ });
767
+ });
768
+ }
769
+ async function listWorktrees(root) {
770
+ const raw = await runGit(["worktree", "list", "--porcelain"], root);
771
+ if (!raw) return [];
772
+ const entries = [];
773
+ const lines = raw.split("\n");
774
+ let current = {};
775
+ for (const line of lines) {
776
+ const m = line.match(/^(worktree|HEAD|branch|detached)\s+(.+)$/);
777
+ if (!m) continue;
778
+ const [, key, val] = m;
779
+ if (key === "worktree") {
780
+ if (current.path && current.path.includes(ZWORKTREE_SEGMENT)) {
781
+ entries.push({
782
+ path: current.path,
783
+ name: current.path.split(ZWORKTREE_SEGMENT).pop() ?? "",
784
+ branch: current.branch ?? "",
785
+ head: current.head ?? "",
786
+ dirty: false
787
+ });
788
+ }
789
+ current = { path: val, head: "", branch: "" };
790
+ } else if (key === "HEAD") {
791
+ current.head = val;
792
+ } else if (key === "branch") {
793
+ current.branch = val.replace(/^refs\/heads\//, "");
794
+ }
795
+ }
796
+ if (current.path && current.path.includes(ZWORKTREE_SEGMENT)) {
797
+ entries.push({
798
+ path: current.path,
799
+ name: current.path.split(ZWORKTREE_SEGMENT).pop() ?? "",
800
+ branch: current.branch ?? "",
801
+ head: current.head ?? "",
802
+ dirty: false
803
+ });
804
+ }
805
+ await Promise.all(
806
+ entries.map(async (entry) => {
807
+ try {
808
+ const statusOut = await runGit(["status", "--porcelain"], entry.path);
809
+ entry.dirty = statusOut.trim().length > 0;
810
+ } catch {
811
+ entry.dirty = false;
812
+ }
813
+ })
814
+ );
815
+ return entries;
816
+ }
817
+ var apply2 = {
818
+ inject: ["server"],
819
+ apply(ctx, config) {
820
+ const server = ctx.server;
821
+ if (!server?.route) return;
822
+ server.route("/__worktrees", async (_req, res) => {
823
+ try {
824
+ const entries = await listWorktrees(config.root);
825
+ res.writeHead(200, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-cache" });
826
+ res.end(JSON.stringify(entries));
827
+ } catch {
828
+ res.writeHead(200, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-cache" });
829
+ res.end(JSON.stringify([]));
830
+ }
831
+ });
832
+ }
833
+ };
834
+
628
835
  // src/core/manifest.ts
629
836
  import { Service as Service3 } from "cordis";
630
837
  var DashboardService = class extends Service3 {
@@ -651,17 +858,11 @@ var DashboardService = class extends Service3 {
651
858
  };
652
859
 
653
860
  // src/server/just-runner.ts
654
- import { spawn, execFile as execFile2 } from "child_process";
861
+ import { spawn, execFile as execFile3 } from "child_process";
655
862
  var MAX_BUFFER = 1e3;
656
863
  var JustRunner = class {
657
864
  cwd;
658
- child = null;
659
- recipe = null;
660
- state = "idle";
661
- code = null;
662
- buffer = [];
663
- pending = "";
664
- // 行缓冲:块缓冲输出(如 maven)的 chunk 会在行中间断开,攒到 \n 才切行
865
+ tasks = /* @__PURE__ */ new Map();
665
866
  clients = /* @__PURE__ */ new Set();
666
867
  recipesCache = null;
667
868
  constructor(cwd) {
@@ -670,7 +871,7 @@ var JustRunner = class {
670
871
  recipes() {
671
872
  if (this.recipesCache) return Promise.resolve(this.recipesCache);
672
873
  return new Promise((resolve) => {
673
- execFile2("just", ["--list", "--unsorted"], { cwd: this.cwd, maxBuffer: 1 << 20, timeout: 8e3 }, (err, stdout) => {
874
+ execFile3("just", ["--list", "--unsorted"], { cwd: this.cwd, maxBuffer: 1 << 20, timeout: 8e3 }, (err, stdout) => {
674
875
  if (err) {
675
876
  resolve([]);
676
877
  return;
@@ -695,26 +896,25 @@ var JustRunner = class {
695
896
  }
696
897
  subscribe(fn) {
697
898
  this.clients.add(fn);
698
- for (const text of this.buffer) fn({ type: "log", text });
699
- fn({ type: "state", state: this.state, recipe: this.recipe, code: this.code });
899
+ for (const t of this.tasks.values()) {
900
+ for (const text of t.buffer) fn({ type: "log", recipe: t.recipe, text });
901
+ fn({ type: "state", recipe: t.recipe, state: t.state, code: t.code, startedAt: t.startedAt });
902
+ }
700
903
  return () => this.clients.delete(fn);
701
904
  }
702
905
  emit(ev) {
703
906
  for (const fn of this.clients) fn(ev);
704
907
  }
705
- info() {
706
- return { state: this.state, recipe: this.recipe, code: this.code };
908
+ list() {
909
+ return Array.from(this.tasks.values(), (t) => ({ recipe: t.recipe, state: t.state, code: t.code, startedAt: t.startedAt }));
707
910
  }
708
- /** 启动 recipe(调用方须先用 recipes() 校验名字);自动停旧进程 */
911
+ /** 启动 recipe:同名先停旧进程(重启语义),不影响其他任务;调用方须先用 recipes() 校验名字 */
709
912
  start(recipe) {
710
- this.killChild();
711
- this.recipe = recipe;
712
- this.code = null;
713
- this.state = "running";
714
- this.buffer = [];
715
- this.pending = "";
716
- this.emit({ type: "clear" });
717
- this.emit({ type: "state", state: "running", recipe, code: null });
913
+ this.killOne(recipe);
914
+ const task = { recipe, child: null, state: "running", code: null, startedAt: Date.now(), buffer: [], pending: "" };
915
+ this.tasks.set(recipe, task);
916
+ this.emit({ type: "clear", recipe });
917
+ this.emit({ type: "state", recipe, state: "running", code: null, startedAt: task.startedAt });
718
918
  const child = spawn("just", [recipe], {
719
919
  cwd: this.cwd,
720
920
  shell: true,
@@ -727,47 +927,62 @@ var JustRunner = class {
727
927
  CI: ""
728
928
  }
729
929
  });
730
- this.child = child;
930
+ task.child = child;
931
+ const isStale = () => this.tasks.get(recipe) !== task;
731
932
  const push = (d) => {
732
- this.pending += d.toString();
933
+ if (isStale()) return;
934
+ task.pending += d.toString();
733
935
  let idx;
734
- while ((idx = this.pending.indexOf("\n")) >= 0) {
735
- const line = this.pending.slice(0, idx + 1);
736
- this.pending = this.pending.slice(idx + 1);
737
- this.pushLine(line);
936
+ while ((idx = task.pending.indexOf("\n")) >= 0) {
937
+ const line = task.pending.slice(0, idx + 1);
938
+ task.pending = task.pending.slice(idx + 1);
939
+ this.pushLine(task, line);
738
940
  }
739
941
  };
740
942
  child.stdout?.on("data", push);
741
943
  child.stderr?.on("data", push);
742
944
  child.on("error", (err) => {
743
- this.pushLine(`[zdashboard] spawn error: ${err.message}
945
+ this.pushLine(task, `[zdashboard] spawn error: ${err.message}
744
946
  `);
745
947
  });
746
- child.on("exit", (code) => {
747
- if (this.pending) {
748
- this.pushLine(this.pending + "\n");
749
- this.pending = "";
948
+ child.on("exit", (code, signal) => {
949
+ if (task.pending && !isStale()) {
950
+ this.pushLine(task, task.pending + "\n");
750
951
  }
751
- this.child = null;
752
- this.state = "exited";
753
- this.code = code ?? 0;
754
- this.emit({ type: "state", state: "exited", recipe: this.recipe, code: this.code });
952
+ task.pending = "";
953
+ task.child = null;
954
+ task.state = "exited";
955
+ task.code = code ?? 0;
956
+ task.signal = signal ?? void 0;
957
+ if (isStale()) return;
958
+ this.emit({ type: "state", recipe, state: "exited", code: task.code, startedAt: task.startedAt, signal: task.signal });
755
959
  });
756
960
  }
757
- pushLine(line) {
758
- this.buffer.push(line);
759
- if (this.buffer.length > MAX_BUFFER) this.buffer.shift();
760
- this.emit({ type: "log", text: line });
961
+ pushLine(task, line) {
962
+ task.buffer.push(line);
963
+ if (task.buffer.length > MAX_BUFFER) task.buffer.shift();
964
+ this.emit({ type: "log", recipe: task.recipe, text: line });
761
965
  }
762
- stop() {
763
- this.killChild();
966
+ /** 停单个任务;不传 recipe 停全部 */
967
+ stop(recipe) {
968
+ if (recipe === void 0) {
969
+ for (const t of this.tasks.values()) this.killOne(t.recipe);
970
+ } else this.killOne(recipe);
764
971
  }
765
972
  restart(recipe) {
766
- const target = recipe ?? this.recipe;
767
- if (target) this.start(target);
768
- }
769
- killChild() {
770
- const child = this.child;
973
+ if (this.tasks.has(recipe)) this.start(recipe);
974
+ }
975
+ /** 清空某任务的日志缓冲并广播(真源清除,重连重放不会复活) */
976
+ clear(recipe) {
977
+ const task = this.tasks.get(recipe);
978
+ if (!task) return;
979
+ task.buffer = [];
980
+ task.pending = "";
981
+ this.emit({ type: "clear", recipe });
982
+ }
983
+ killOne(recipe) {
984
+ const task = this.tasks.get(recipe);
985
+ const child = task?.child;
771
986
  if (child?.pid) {
772
987
  try {
773
988
  if (process.platform === "win32") spawn("taskkill", ["/PID", String(child.pid), "/T", "/F"]);
@@ -775,12 +990,14 @@ var JustRunner = class {
775
990
  } catch {
776
991
  }
777
992
  }
778
- this.child = null;
993
+ if (task) {
994
+ task.child = null;
995
+ }
779
996
  }
780
997
  };
781
998
 
782
999
  // src/plugins/just/index.ts
783
- var apply2 = {
1000
+ var apply3 = {
784
1001
  inject: ["server", "dashboard"],
785
1002
  apply(ctx, config) {
786
1003
  const root = config.root;
@@ -788,6 +1005,7 @@ var apply2 = {
788
1005
  if (!ctx.server?.route) return;
789
1006
  const runner = new JustRunner(root);
790
1007
  ctx.effect(() => () => runner.stop());
1008
+ ctx.dashboard.register({ mode: "just", label: "Just Runner", icon: "\u{1F4DC}", description: "Just \u591A\u4EFB\u52A1\u5E76\u53D1\u6267\u884C\u4E0E\u65E5\u5FD7" });
791
1009
  ctx.server.route("/__just/recipes", async (_req, res) => {
792
1010
  res.writeHead(200, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-cache" });
793
1011
  try {
@@ -805,7 +1023,7 @@ var apply2 = {
805
1023
  });
806
1024
  return unsub;
807
1025
  });
808
- ctx.server.route("/__just/start", async (req, res) => {
1026
+ const handleAction = async (req, res, act) => {
809
1027
  if (req.headers["x-stop-token"] !== ctx.server.stopToken) {
810
1028
  res.writeHead(403);
811
1029
  res.end("forbidden");
@@ -817,61 +1035,45 @@ var apply2 = {
817
1035
  recipe = JSON.parse(body || "{}").recipe;
818
1036
  } catch {
819
1037
  }
820
- const target = recipe ?? runner.info().recipe;
821
- if (!target) {
822
- res.writeHead(400);
823
- res.end('{"error":"no recipe"}');
824
- return;
825
- }
826
- const recipes = await runner.recipes();
827
- if (!recipes.some((r) => r.name === target)) {
828
- res.writeHead(403);
829
- res.end('{"error":"unknown recipe"}');
830
- return;
831
- }
832
- runner.start(target);
833
- res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
834
- res.end(JSON.stringify(runner.info()));
835
- });
836
- ctx.server.route("/__just/stop", async (req, res) => {
837
- if (req.headers["x-stop-token"] !== ctx.server.stopToken) {
838
- res.writeHead(403);
839
- res.end("forbidden");
840
- return;
841
- }
842
- runner.stop();
843
- res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
844
- res.end(JSON.stringify(runner.info()));
845
- });
846
- ctx.server.route("/__just/restart", async (req, res) => {
847
- if (req.headers["x-stop-token"] !== ctx.server.stopToken) {
848
- res.writeHead(403);
849
- res.end("forbidden");
850
- return;
851
- }
852
- const body = await readBody(req);
853
- let recipe;
854
- try {
855
- recipe = JSON.parse(body || "{}").recipe;
856
- } catch {
857
- }
858
- const target = recipe ?? runner.info().recipe;
859
- if (!target) {
860
- res.writeHead(400);
861
- res.end('{"error":"no recipe"}');
1038
+ if (act === "clear") {
1039
+ if (recipe) runner.clear(recipe);
1040
+ res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
1041
+ res.end('{"ok":true}');
862
1042
  return;
863
1043
  }
864
- const recipes = await runner.recipes();
865
- if (!recipes.some((r) => r.name === target)) {
866
- res.writeHead(403);
867
- res.end('{"error":"unknown recipe"}');
868
- return;
1044
+ if (act !== "stop") {
1045
+ if (!recipe) {
1046
+ res.writeHead(400);
1047
+ res.end('{"error":"no recipe"}');
1048
+ return;
1049
+ }
1050
+ const recipes = await runner.recipes();
1051
+ if (!recipes.some((r) => r.name === recipe)) {
1052
+ res.writeHead(403);
1053
+ res.end('{"error":"unknown recipe"}');
1054
+ return;
1055
+ }
1056
+ runner.start(recipe);
1057
+ } else {
1058
+ runner.stop(recipe || void 0);
869
1059
  }
870
- runner.restart(target);
871
1060
  res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
872
- res.end(JSON.stringify(runner.info()));
873
- });
874
- ctx.dashboard.register({ mode: "just", label: "Just Runner", icon: "\u{1F4DC}", description: "Just \u4EFB\u52A1\u65E5\u5FD7\u4E0E\u6267\u884C" });
1061
+ res.end(JSON.stringify(runner.list()));
1062
+ };
1063
+ const routeAction = (name) => {
1064
+ ctx.server.route(`/__just/${name}`, (req, res) => {
1065
+ handleAction(req, res, name).catch(() => {
1066
+ if (!res.headersSent) {
1067
+ res.writeHead(500);
1068
+ res.end('{"error":"internal"}');
1069
+ }
1070
+ });
1071
+ });
1072
+ };
1073
+ routeAction("start");
1074
+ routeAction("stop");
1075
+ routeAction("restart");
1076
+ routeAction("clear");
875
1077
  });
876
1078
  }
877
1079
  };
@@ -882,122 +1084,12 @@ async function readBody(req) {
882
1084
  data += c;
883
1085
  });
884
1086
  req.on("end", () => resolve(data));
1087
+ req.on("error", () => resolve(""));
885
1088
  });
886
1089
  }
887
1090
 
888
- // src/server/bugs.ts
889
- import fs6 from "fs";
890
- import path5 from "path";
891
-
892
- // src/server/api/fetch.ts
893
- import ky from "ky";
894
-
895
- // src/server/errors.ts
896
- var HttpError = class extends Error {
897
- constructor(status, message, body) {
898
- super(message);
899
- this.status = status;
900
- this.body = body;
901
- this.name = "HttpError";
902
- }
903
- status;
904
- body;
905
- };
906
- var NetworkError = class extends Error {
907
- constructor(message, cause) {
908
- super(message);
909
- this.cause = cause;
910
- this.name = "NetworkError";
911
- }
912
- cause;
913
- };
914
-
915
- // src/server/api/fetch.ts
916
- async function fetchJson(url, init) {
917
- try {
918
- const res = await ky(url, { ...init, timeout: 8e3, retry: 2 });
919
- return await res.json();
920
- } catch (e) {
921
- if (e instanceof HttpError) throw e;
922
- if (e instanceof Error && e.name === "TimeoutError") {
923
- throw new NetworkError(`\u8BF7\u6C42\u8D85\u65F6: ${url}`);
924
- }
925
- if (e instanceof Error && e.name === "HTTPError") {
926
- throw new HttpError(e.status ?? 500, e.message);
927
- }
928
- throw new NetworkError(`\u8BF7\u6C42\u5931\u8D25: ${url}`, e);
929
- }
930
- }
931
-
932
- // src/server/bugs.ts
933
- function loadZgoalConfig(root) {
934
- const file = path5.join(root, ".zgoal", "config.yaml");
935
- if (!fs6.existsSync(file)) return null;
936
- const kv = {};
937
- for (const line of fs6.readFileSync(file, "utf8").split("\n")) {
938
- const m = line.match(/^\s*([A-Za-z_]\w*)\s*:\s*(.+?)\s*$/);
939
- if (m && !m[2].startsWith("#")) kv[m[1]] = m[2].replace(/^["']|["']$/g, "");
940
- }
941
- const product = Number(kv.product);
942
- if (!kv.url || !product) return null;
943
- return {
944
- url: kv.url.replace(/\/+$/, ""),
945
- account: kv.account ?? "",
946
- password: kv.password,
947
- token: kv.token,
948
- product
949
- };
950
- }
951
- var tokenCache = null;
952
- async function getToken(cfg) {
953
- if (cfg.token) return cfg.token;
954
- const key = `${cfg.url}|${cfg.account}|${cfg.password ?? ""}`;
955
- if (tokenCache && tokenCache.key === key && Date.now() - tokenCache.at < 10 * 6e4) return tokenCache.token;
956
- const json = await fetchJson(`${cfg.url}/api.php/v1/tokens`, {
957
- method: "POST",
958
- headers: { "Content-Type": "application/json" },
959
- body: JSON.stringify({ account: cfg.account, password: cfg.password })
960
- });
961
- const token = typeof json.token === "string" ? json.token : "";
962
- if (!token) throw new Error("token \u83B7\u53D6\u5931\u8D25:\u68C0\u67E5 account / password");
963
- tokenCache = { key, token, at: Date.now() };
964
- return token;
965
- }
966
- function normBug(b, account) {
967
- const assigned = b.assignedTo;
968
- const assignedTo = typeof assigned === "string" ? assigned : assigned && typeof assigned === "object" && "realname" in assigned ? String(assigned.realname ?? "") : "";
969
- const assignedAccount = typeof assigned === "string" ? assigned : assigned && typeof assigned === "object" ? String(assigned.account ?? "") : "";
970
- const mine = !!account && (assignedAccount === account || assignedTo === account);
971
- return {
972
- id: Number(b.id),
973
- title: String(b.title ?? ""),
974
- severity: b.severity ?? 4,
975
- pri: b.pri ?? 3,
976
- status: String(b.status ?? ""),
977
- assignedTo,
978
- openedBy: typeof b.openedBy === "string" ? b.openedBy : void 0,
979
- mine
980
- };
981
- }
982
- async function fetchBugs(root) {
983
- const cfg = loadZgoalConfig(root);
984
- if (!cfg) return { ok: false, error: ".zgoal/config.yaml \u7F3A\u5931\u6216 url/product \u672A\u914D\u7F6E(\u7531 zgoal skill \u521B\u5EFA)" };
985
- try {
986
- const token = await getToken(cfg);
987
- const json = await fetchJson(
988
- `${cfg.url}/api.php/v1/products/${cfg.product}/bugs?page=1&limit=100`,
989
- { headers: { Token: token } }
990
- );
991
- const raw = Array.isArray(json.bugs) ? json.bugs : [];
992
- return { ok: true, url: cfg.url, total: Number(json.total ?? raw.length), bugs: raw.map((b) => normBug(b, cfg.account)) };
993
- } catch (e) {
994
- const msg = e instanceof Error ? e.message : String(e);
995
- 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` };
996
- }
997
- }
998
-
999
1091
  // src/plugins/bugs/index.ts
1000
- var apply3 = {
1092
+ var apply4 = {
1001
1093
  inject: ["server", "dashboard"],
1002
1094
  apply(ctx, config) {
1003
1095
  const root = config.root;
@@ -1021,15 +1113,16 @@ var apply3 = {
1021
1113
  import fs7 from "fs";
1022
1114
  import path6 from "path";
1023
1115
  import YAML from "yaml";
1024
- var REVIEW_FILE = "review.yaml";
1116
+ var REVIEW_CANDIDATES = [".zdev/review.yaml", "review.yaml"];
1025
1117
  var ReviewStore = class {
1026
1118
  root;
1027
1119
  file;
1028
1120
  onChange;
1029
1121
  constructor(root, onChange) {
1030
1122
  this.root = root;
1031
- this.file = path6.join(root, REVIEW_FILE);
1032
1123
  this.onChange = onChange;
1124
+ const existing = REVIEW_CANDIDATES.find((rel) => fs7.existsSync(path6.join(root, rel)));
1125
+ this.file = path6.join(root, existing ?? REVIEW_CANDIDATES[0]);
1033
1126
  }
1034
1127
  exists() {
1035
1128
  return fs7.existsSync(this.file);
@@ -1044,6 +1137,7 @@ var ReviewStore = class {
1044
1137
  }
1045
1138
  }
1046
1139
  write(data) {
1140
+ fs7.mkdirSync(path6.dirname(this.file), { recursive: true });
1047
1141
  fs7.writeFileSync(this.file, YAML.stringify(data), "utf8");
1048
1142
  this.onChange?.();
1049
1143
  }
@@ -1068,7 +1162,9 @@ var ReviewStore = class {
1068
1162
  }
1069
1163
  docs() {
1070
1164
  try {
1071
- return fs7.readdirSync(this.root).filter((f) => /\.(md|markdown)$/i.test(f) && fs7.statSync(path6.join(this.root, f)).isFile()).sort();
1165
+ const zdevDir = path6.join(this.root, ".zdev");
1166
+ if (!fs7.existsSync(zdevDir) || !fs7.statSync(zdevDir).isDirectory()) return [];
1167
+ return fs7.readdirSync(zdevDir).filter((f) => /\.(md|markdown)$/i.test(f) && fs7.statSync(path6.join(zdevDir, f)).isFile()).sort();
1072
1168
  } catch {
1073
1169
  return [];
1074
1170
  }
@@ -1076,7 +1172,7 @@ var ReviewStore = class {
1076
1172
  };
1077
1173
 
1078
1174
  // src/plugins/review/index.ts
1079
- var apply4 = {
1175
+ var apply5 = {
1080
1176
  inject: ["server", "dashboard", "reload"],
1081
1177
  apply(ctx, config) {
1082
1178
  const root = config.root;
@@ -1154,33 +1250,80 @@ function readText(p) {
1154
1250
  return "";
1155
1251
  }
1156
1252
  }
1253
+ function worktreeDir(root, name) {
1254
+ return path7.join(root, ".zworktree", name);
1255
+ }
1256
+ function changeDir(root, name) {
1257
+ return path7.join(root, "openspec", "changes", name);
1258
+ }
1259
+ function parseDependsOn(proposal) {
1260
+ const deps = [];
1261
+ const m = proposal.match(/^##\s+依赖\s*\n([\s\S]*?)(?:\n#{1,6}\s|\n---\s*\n|$)/i);
1262
+ if (!m) return deps;
1263
+ for (const line of m[1].split("\n")) {
1264
+ if (!/^-\s+/.test(line)) continue;
1265
+ const name = line.replace(/^-\s+/, "").trim();
1266
+ if (name) deps.push(name);
1267
+ }
1268
+ return deps;
1269
+ }
1157
1270
  function scanApplyChanges(root) {
1158
1271
  const changesDir = path7.join(root, "openspec", "changes");
1159
1272
  if (!fs8.existsSync(changesDir)) return [];
1160
1273
  const out = [];
1161
1274
  for (const ent of fs8.readdirSync(changesDir, { withFileTypes: true })) {
1162
1275
  if (!ent.isDirectory() || ent.name.startsWith(".") || ent.name === "archive") continue;
1163
- const dir = path7.join(changesDir, ent.name);
1164
- const tasks = readText(path7.join(dir, "tasks.md"));
1165
- const { total, done } = countTasks(tasks);
1166
- out.push({
1167
- name: ent.name,
1168
- path: `openspec/changes/${ent.name}`,
1169
- total,
1170
- done,
1171
- hasProposal: fs8.existsSync(path7.join(dir, "proposal.md")),
1172
- hasDesign: fs8.existsSync(path7.join(dir, "design.md"))
1173
- });
1276
+ const wt = worktreeDir(root, ent.name);
1277
+ const tasks = readText(path7.join(wt, "openspec", "changes", ent.name, "tasks.md"));
1278
+ if (!tasks) {
1279
+ const fallback = readText(path7.join(changesDir, ent.name, "tasks.md"));
1280
+ if (fallback) {
1281
+ const { total, done } = countTasks(fallback);
1282
+ const wtChange = path7.join(wt, "openspec", "changes", ent.name);
1283
+ const mainChange = path7.join(changesDir, ent.name);
1284
+ out.push({
1285
+ name: ent.name,
1286
+ path: `openspec/changes/${ent.name}`,
1287
+ total,
1288
+ done,
1289
+ hasProposal: fs8.existsSync(path7.join(wtChange, "proposal.md")) || fs8.existsSync(path7.join(mainChange, "proposal.md")),
1290
+ hasDesign: fs8.existsSync(path7.join(wtChange, "design.md")) || fs8.existsSync(path7.join(mainChange, "design.md")),
1291
+ // worktree 目录存在即算执行中(tasks.md 可能尚未落盘)
1292
+ inWorktree: fs8.existsSync(wtChange)
1293
+ });
1294
+ }
1295
+ } else {
1296
+ const { total, done } = countTasks(tasks);
1297
+ const wtChange = path7.join(wt, "openspec", "changes", ent.name);
1298
+ const mainChange = path7.join(changesDir, ent.name);
1299
+ out.push({
1300
+ name: ent.name,
1301
+ path: `openspec/changes/${ent.name}`,
1302
+ total,
1303
+ done,
1304
+ // 任一侧存在即算(详情读取是 worktree 优先、主目录兜底,列表标记保持一致)
1305
+ hasProposal: fs8.existsSync(path7.join(wtChange, "proposal.md")) || fs8.existsSync(path7.join(mainChange, "proposal.md")),
1306
+ hasDesign: fs8.existsSync(path7.join(wtChange, "design.md")) || fs8.existsSync(path7.join(mainChange, "design.md")),
1307
+ inWorktree: true
1308
+ });
1309
+ }
1174
1310
  }
1175
1311
  out.sort((a, b) => a.name.localeCompare(b.name));
1176
1312
  return out;
1177
1313
  }
1178
1314
  function readApplyChange(root, name) {
1179
- const dir = path7.join(root, "openspec", "changes", name);
1180
- const proposal = readText(path7.join(dir, "proposal.md"));
1181
- const design = readText(path7.join(dir, "design.md"));
1182
- const tasks = readText(path7.join(dir, "tasks.md"));
1315
+ const wt = worktreeDir(root, name);
1316
+ const mainChangeDir = changeDir(root, name);
1317
+ 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");
1318
+ 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");
1319
+ 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");
1320
+ const proposal = readText(proposalFrom);
1321
+ const design = readText(designFrom);
1322
+ const tasks = readText(tasksFrom);
1183
1323
  const { total, done } = countTasks(tasks);
1324
+ const inWorktree = fs8.existsSync(path7.join(wt, "openspec", "changes", name));
1325
+ const dependsOn = parseDependsOn(proposal);
1326
+ const hasTestStrategy = /^##\s+测试策略\s*$/m.test(design);
1184
1327
  return {
1185
1328
  name,
1186
1329
  path: `openspec/changes/${name}`,
@@ -1188,14 +1331,17 @@ function readApplyChange(root, name) {
1188
1331
  done,
1189
1332
  hasProposal: !!proposal,
1190
1333
  hasDesign: !!design,
1334
+ inWorktree,
1191
1335
  proposal,
1192
1336
  design,
1193
- tasks
1337
+ tasks,
1338
+ dependsOn,
1339
+ hasTestStrategy
1194
1340
  };
1195
1341
  }
1196
1342
 
1197
1343
  // src/plugins/apply/index.ts
1198
- var apply5 = {
1344
+ var apply6 = {
1199
1345
  inject: ["server", "dashboard"],
1200
1346
  apply(ctx, config) {
1201
1347
  const root = config.root;
@@ -1286,7 +1432,7 @@ function scanAssets(root) {
1286
1432
  }
1287
1433
 
1288
1434
  // src/plugins/design/index.ts
1289
- var apply6 = {
1435
+ var apply7 = {
1290
1436
  inject: ["server", "dashboard"],
1291
1437
  apply(ctx, config) {
1292
1438
  const root = config.root;
@@ -1302,7 +1448,7 @@ var apply6 = {
1302
1448
  };
1303
1449
 
1304
1450
  // src/plugins/view/index.ts
1305
- var apply7 = {
1451
+ var apply8 = {
1306
1452
  inject: ["dashboard"],
1307
1453
  apply(ctx) {
1308
1454
  ctx.dashboard.register({ mode: "view", label: "\u9879\u76EE\u6D4F\u89C8", icon: "\u{1F441}\uFE0F", description: "openspec / docs / \u6587\u6863\u9884\u89C8" });
@@ -1366,7 +1512,7 @@ function scan(root) {
1366
1512
  stats.byExt = Array.from(extMap.entries()).map(([ext, count]) => ({ ext, count })).sort((a, b) => b.count - a.count).slice(0, 10);
1367
1513
  return stats;
1368
1514
  }
1369
- var apply8 = {
1515
+ var apply9 = {
1370
1516
  inject: ["server", "dashboard"],
1371
1517
  apply(ctx, config) {
1372
1518
  ctx.inject(["server", "dashboard"], () => {
@@ -1389,9 +1535,11 @@ var apply8 = {
1389
1535
 
1390
1536
  // src/cli.ts
1391
1537
  var __dirname2 = path10.dirname(fileURLToPath2(import.meta.url));
1538
+ var DEFAULT_PORT = 4190;
1392
1539
  function parseArgs() {
1393
1540
  const args = process.argv.slice(2);
1394
1541
  const opts = {};
1542
+ let portExplicit = false;
1395
1543
  for (let i = 0; i < args.length; i++) {
1396
1544
  const a = args[i];
1397
1545
  if (a.startsWith("--")) {
@@ -1403,11 +1551,13 @@ function parseArgs() {
1403
1551
  } else {
1404
1552
  opts[key] = true;
1405
1553
  }
1554
+ if (key === "port") portExplicit = true;
1406
1555
  }
1407
1556
  }
1408
1557
  return {
1409
1558
  dir: typeof opts.dir === "string" ? opts.dir : process.cwd(),
1410
- port: typeof opts.port === "string" ? Number(opts.port) : 4190,
1559
+ port: typeof opts.port === "string" ? Number(opts.port) : DEFAULT_PORT,
1560
+ portExplicit,
1411
1561
  open: !!opts.open,
1412
1562
  page: typeof opts.page === "string" ? opts.page : null,
1413
1563
  restart: !!opts.restart,
@@ -1481,33 +1631,39 @@ async function main() {
1481
1631
  await new Promise((resolve) => process.stdout.write("", () => resolve()));
1482
1632
  process.exit(0);
1483
1633
  }
1484
- if (existing && args.restart) {
1485
- console.log(`[zdashboard] --restart\uFF1A\u505C\u6B62\u65E7\u5B9E\u4F8B pid=${existing.pid}`);
1486
- await stopInstance(existing);
1634
+ const oldRecord = existing && args.restart ? existing : null;
1635
+ if (oldRecord) {
1636
+ console.log(`[zdashboard] --restart\uFF1A\u505C\u6B62\u65E7\u5B9E\u4F8B pid=${oldRecord.pid}`);
1637
+ await stopInstance(oldRecord);
1487
1638
  }
1488
1639
  const appDir = path10.resolve(__dirname2, "web");
1640
+ const startPort = oldRecord && !args.portExplicit ? oldRecord.port : args.port;
1641
+ const zdevDir = path10.join(root, ".zdev");
1642
+ const dataDir = fs11.existsSync(zdevDir) ? ".zdev/" : "";
1489
1643
  const det = await detect(root);
1490
1644
  const ctx = new Context();
1491
1645
  ctx.plugin(ServerService, {
1492
1646
  root,
1493
1647
  appDir,
1494
- port: args.port,
1648
+ port: startPort,
1495
1649
  open: args.open,
1496
1650
  detect: det,
1497
1651
  page: args.page,
1652
+ dataDir: dataDir || void 0,
1498
1653
  onListen: (port) => writeRecord(root, port)
1499
1654
  });
1500
1655
  ctx.plugin(ReloadService, { root });
1501
1656
  ctx.plugin(apply, { root });
1657
+ ctx.plugin(apply2, { root });
1502
1658
  ctx.plugin(DashboardService);
1503
1659
  const plugins = [
1504
- { name: "stats", apply: apply8 },
1505
- { name: "just", apply: apply2 },
1506
- { name: "bugs", apply: apply3 },
1507
- { name: "review", apply: apply4 },
1508
- { name: "apply", apply: apply5 },
1509
- { name: "design", apply: apply6 },
1510
- { name: "view", apply: apply7 }
1660
+ { name: "stats", apply: apply9 },
1661
+ { name: "just", apply: apply3 },
1662
+ { name: "bugs", apply: apply4 },
1663
+ { name: "review", apply: apply5 },
1664
+ { name: "apply", apply: apply6 },
1665
+ { name: "design", apply: apply7 },
1666
+ { name: "view", apply: apply8 }
1511
1667
  ];
1512
1668
  for (const p of plugins) {
1513
1669
  try {