duaer-spec 0.19.0 → 0.20.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.
@@ -1,5 +1,5 @@
1
1
  {
2
- "feature": "188-release-019",
3
- "spec": ".duaer/specs/188-release-019/spec.md",
4
- "updatedAt": "2026-09-19T00:00:00.000Z"
2
+ "feature": "192-release-020",
3
+ "spec": ".duaer/specs/192-release-020/spec.md",
4
+ "updatedAt": "2026-09-19T02:50:00.000Z"
5
5
  }
@@ -0,0 +1,18 @@
1
+ {
2
+ "status": "accepted",
3
+ "feature": "188-release-019",
4
+ "acceptedAt": "2026-09-19T00:00:00.000Z",
5
+ "summary": "0.19.0 shipped: develop+main pushed, tag v0.19.0, GitHub Release, npm publish success.",
6
+ "verification": {
7
+ "commands": [
8
+ "git push origin develop main v0.19.0",
9
+ "gh release create v0.19.0",
10
+ "gh run watch npm-publish"
11
+ ],
12
+ "results": "CI pass; Pages pass; npm publish success"
13
+ },
14
+ "preview": {
15
+ "url": "https://www.npmjs.com/package/duaer-spec/v/0.19.0",
16
+ "label": "npm 0.19.0"
17
+ }
18
+ }
@@ -1,6 +1,6 @@
1
1
  # Tasks — 188-release-019
2
2
 
3
3
  - [x] Brief
4
- - [ ] Bump 0.19.0 + CHANGELOG
5
- - [ ] Merge to develop; promote main; push; tag; gh release
6
- - [ ] Watch npm publish / stamp delivery
4
+ - [x] Bump 0.19.0 + CHANGELOG
5
+ - [x] Merge to develop; promote main; push; tag; gh release
6
+ - [x] Watch npm publish / stamp delivery
@@ -0,0 +1,10 @@
1
+ {
2
+ "status": "accepted",
3
+ "feature": "189-fde-portfolio",
4
+ "acceptedAt": "2026-09-19T02:30:00.000Z",
5
+ "verification": {
6
+ "L0": "node --check app.js delivery-status.mjs live-projects.mjs",
7
+ "L1": "node --test test/delivery-status.test.mjs test/live-projects.test.mjs (9 pass)",
8
+ "L3": "npm run test:live (pass)"
9
+ }
10
+ }
@@ -0,0 +1,29 @@
1
+ # 189 — FDE project portfolio status strip
2
+
3
+ ## Goal
4
+
5
+ Operators see each project’s delivery status at a glance in the Projects drawer,
6
+ open deliverables in one click, and get a clear next action in the Progress
7
+ column — without learning Duaer phases.
8
+
9
+ ## In scope
10
+
11
+ - Derive status: drafting / confirming / building / delivered / revising
12
+ - Enrich `GET /api/projects` project rows with status + nextAction + hasDeliverables
13
+ - Portfolio list: status chip + open-deliverables control
14
+ - Progress: stage strip + next-action line; deliverables CTA stays primary
15
+ - Docs (live-desk) + E2E + L0/L3
16
+
17
+ ## Out of scope
18
+
19
+ - Dedicated cloud / multi-user accounts
20
+ - Session export/import
21
+ - Changing BYOC deploy hosts
22
+
23
+ ## Acceptance
24
+
25
+ 1. `/api/projects` includes `deliveryStatus` and `nextAction` per project when a
26
+ session exists (or sensible defaults when empty).
27
+ 2. Portfolio row shows a status chip; deliverables openable when `hasDeliverables`.
28
+ 3. Progress column shows stage summary + next-action copy for the active desk.
29
+ 4. `npm test` (targeted) + `npm run test:live` pass.
@@ -0,0 +1,8 @@
1
+ # Tasks — 189-fde-portfolio
2
+
3
+ - [x] Brief + active-job
4
+ - [x] `delivery-status.mjs` derive status / nextAction / stages
5
+ - [x] Enrich `/api/projects` via live-projects / duaer-live
6
+ - [x] Portfolio UI chips + deliverables
7
+ - [x] Progress cockpit (stages + next action)
8
+ - [x] i18n, CSS, docs, E2E, verify, merge
@@ -0,0 +1,9 @@
1
+ {
2
+ "status": "accepted",
3
+ "feature": "190-live-open-no-model",
4
+ "acceptedAt": "2026-09-19T02:45:00.000Z",
5
+ "verification": {
6
+ "L1": "node --test test/live-open-no-model.test.mjs",
7
+ "L3": "npm run test:live"
8
+ }
9
+ }
@@ -0,0 +1,17 @@
1
+ # 190 — Live starts without model gate; open desk URL
2
+
3
+ ## Goal
4
+
5
+ `duaer live` must not require a configured model before serving. Always start
6
+ the desk, open `http://127.0.0.1:<port>` in the browser, and guide the operator
7
+ to Settings when the model is missing.
8
+
9
+ ## Acceptance
10
+
11
+ 1. `serve()` does not exit or block when `configReady` is false.
12
+ 2. After listen, the desk URL is opened (macOS `open` / Linux `xdg-open`).
13
+ 3. Console copy points to in-page Settings, not “must run live config first”.
14
+ 4. UI shows the desk even when not ready; Settings opens as guidance; Settings
15
+ can be closed and reopened via the header control.
16
+ 5. Chat/API that need a model still fail clearly until configured.
17
+ 6. L0 + targeted tests / live smoke markers pass.
@@ -0,0 +1,6 @@
1
+ # Tasks — 190-live-open-no-model
2
+
3
+ - [x] Brief
4
+ - [x] Open browser URL on serve; soft console hint
5
+ - [x] UI: show desk without model; allow closing Settings
6
+ - [x] Tests + docs + merge
@@ -0,0 +1,11 @@
1
+ {
2
+ "status": "accepted",
3
+ "acceptedAt": "2026-09-19T02:45:00.000Z",
4
+ "verification": {
5
+ "L0": "node --check bin/duaer-live.mjs; node --check web/live-dev/app.js",
6
+ "L1": "node --test test/live-repo-path.test.mjs test/live-projects.test.mjs",
7
+ "L3": "npm run test:live",
8
+ "result": "pass"
9
+ },
10
+ "summary": "产品父目录 uses native folder pick (选择…/清除); no path typing."
11
+ }
@@ -0,0 +1,14 @@
1
+ # 191 — Pick projects parent folder (no typing)
2
+
3
+ ## Goal
4
+
5
+ Operators choose the **产品父目录** via the system folder dialog, not by
6
+ typing a path. Selection saves immediately.
7
+
8
+ ## Acceptance
9
+
10
+ 1. Projects drawer shows parent path as read-only display (not a free-text field).
11
+ 2. A **选择…** control opens the native folder picker and saves `projectsRoot`.
12
+ 3. Cancel leaves the previous value unchanged.
13
+ 4. Optional clear control empties `projectsRoot`.
14
+ 5. L0 + smoke/unit markers + merge to develop.
@@ -0,0 +1,6 @@
1
+ # Tasks — 191-projects-root-pick
2
+
3
+ - [x] Brief
4
+ - [x] API pick-root + pickFolderNative prompt
5
+ - [x] UI: readonly path + 选择… / 清除
6
+ - [x] i18n, tests, docs, merge
@@ -0,0 +1,12 @@
1
+ # 192 — Release 0.20.0
2
+
3
+ ## Goal
4
+
5
+ Ship 0.20.0: bump version + CHANGELOG, promote develop → main, push,
6
+ GitHub Release `v0.20.0`, trigger npm Trusted Publishing.
7
+
8
+ ## Acceptance
9
+
10
+ 1. `package.json` is `0.20.0`; CHANGELOG has `0.20.0` section (Unreleased cleared).
11
+ 2. `develop` and `main` pushed with release commits.
12
+ 3. GitHub Release `v0.20.0` published; npm publish workflow run succeeds or documented.
@@ -0,0 +1,6 @@
1
+ # Tasks — 192-release-020
2
+
3
+ - [x] Brief
4
+ - [ ] Bump 0.20.0 + CHANGELOG
5
+ - [ ] Merge to develop; promote main; push; tag; gh release
6
+ - [ ] Watch npm publish / stamp delivery
package/CHANGELOG.md CHANGED
@@ -2,6 +2,23 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.20.0 — 2026-09-19
6
+
7
+ ### Feat: pick projects parent folder via system dialog
8
+
9
+ - 产品父目录 is read-only; **选择…** opens the native folder picker and saves
10
+ - **清除** empties `projectsRoot`; cancel leaves the previous path
11
+
12
+ ### Fix: `duaer live` opens without model gate
13
+
14
+ - Desk starts and opens the browser even when no model is configured
15
+ - Settings guides model setup instead of blocking launch
16
+
17
+ ### Feat: FDE portfolio status and delivery cockpit
18
+
19
+ - Project list shows delivery status chips and open-deliverables actions
20
+ - Progress column shows stage summary, next action, and deliverables CTA
21
+
5
22
  ## 0.19.0 — 2026-09-19
6
23
 
7
24
  ### Feat: modular `[模块]` structured requirement cards
package/README.md CHANGED
@@ -61,16 +61,18 @@ Isolated from product repos. Config and Briefs live under `~/.duaer/live/`.
61
61
  **Duaer-spec FDE** = Field Development Environment.
62
62
 
63
63
  ```bash
64
- # DeepSeek (recommended)
64
+ # Start anytime (opens the desk even if the model is not configured yet)
65
+ duaer live
66
+
67
+ # In the page Settings, or via CLI:
65
68
  duaer live config --provider deepseek --api-key sk-...
66
69
 
67
70
  # Remember a product repo for dispatch (optional; run inside that repo)
68
71
  duaer live repo add
69
-
70
- duaer live
71
72
  ```
72
73
 
73
- Open the printed URL (default `http://127.0.0.1:8787`). The desk is **full-width**
74
+ `duaer live` opens the browser to the desk URL (default `http://127.0.0.1:8787`).
75
+ If the model is missing, use **Settings** in the page. The desk is **full-width**
74
76
  with three columns: **chat** | **modules / confirm / architecture / kickoff /
75
77
  revise** | **task progress**. Detail:
76
78
  [`docs/agent/live-desk.md`](docs/agent/live-desk.md).
@@ -81,9 +83,10 @@ mock LLM — no paid API).
81
83
 
82
84
  **Flow**
83
85
 
84
- 1. Open or create a **project**, then chat. Messy multi-topic talk is fine: the
85
- desk evolves **`modules[]`** (tabs). Chat bubbles render **Markdown**
86
- (`**bold**`, code, links).
86
+ 1. Open or create a **project**, then chat. The **Projects** drawer shows each
87
+ project’s **delivery status**; open **Deliverables** when the session has
88
+ content. Messy multi-topic talk is fine: the desk evolves **`modules[]`**
89
+ (tabs). Chat bubbles render **Markdown** (`**bold**`, code, links).
87
90
  2. Per module: fill the confirm card (goal / out-of-scope / **checkable**
88
91
  acceptance / assumptions). Vague acceptance fails the gate; validate must
89
92
  pass before **Confirm**. Confirming a module **locks that card only** — it
@@ -104,11 +107,12 @@ mock LLM — no paid API).
104
107
  - To run **DeepSeek or other models** for coding, point Claude Code at that API — see [`docs/agent/worker-models.md`](docs/agent/worker-models.md). The FDE setup page guide covers the same.
105
108
  6. Edit the start command (must begin with `Duaer`) → kickoff creates
106
109
  `.worktree/feat-*` and opens **Terminal** for each worker lane.
107
- 7. Watch progress in the right column (`tasks.md`). **View deliverables** (above
108
- progress) opens a generated HTML page of stage artifacts (requirements doc
109
- timeline, confirmation, architecture, task pool, delivery). On delivery,
110
- workers update the product **README** before `delivery.json` is `accepted`.
111
- Then open **Results** (**View result** when a page exists, otherwise **Open
110
+ 7. Progress shows a **next-action** stage strip, then `tasks.md` polling.
111
+ **View deliverables** (above progress) opens a generated HTML page of stage
112
+ artifacts (requirements doc timeline, confirmation, architecture, task pool,
113
+ delivery). On delivery, workers update the product **README** before
114
+ `delivery.json` is `accepted`. Then open **Results** (**View result** when a
115
+ page exists, otherwise **Open
112
116
  project folder**).
113
117
  8. If the result is not right: **Continue improving (left chat)** → confirm the
114
118
  revise card (same validate gate) → re-confirm architecture (keep or redesign)
package/README.zh-CN.md CHANGED
@@ -56,7 +56,10 @@ npx duaer-spec update
56
56
  **Duaer-spec FDE** = Field Development Environment(现场开发)。
57
57
 
58
58
  ```bash
59
- # DeepSeek(推荐)
59
+ # 启动(任意目录都行,不会写入当前项目;未配模型也会打开台面)
60
+ duaer live
61
+
62
+ # 在打开的页面「设置」里填模型,或用 CLI:
60
63
  duaer live config --provider deepseek --api-key sk-...
61
64
 
62
65
  # 在产品仓登记,派工时点选(可选)
@@ -64,12 +67,10 @@ duaer live repo add
64
67
 
65
68
  # 或任意 OpenAI 兼容接口
66
69
  duaer live config --base-url https://api.openai.com/v1 --api-key sk-... --model gpt-4o-mini
67
-
68
- # 启动(任意目录都行,不会写入当前项目)
69
- duaer live
70
70
  ```
71
71
 
72
- 浏览器打开提示地址(默认 `http://127.0.0.1:8787`)。台面**全屏宽**,三列:
72
+ 启动后会自动打开浏览器(默认 `http://127.0.0.1:8787`)。未配置模型时请在页面
73
+ **设置**里填写并保存。台面**全屏宽**,三列:
73
74
  **对话** | **模块 / 确认 / 架构 / 开工 / 改进** | **任务进度**。细则见
74
75
  [`docs/agent/live-desk.md`](docs/agent/live-desk.md)。
75
76
 
@@ -79,7 +80,7 @@ duaer live
79
80
 
80
81
  **流程**
81
82
 
82
- 1. 打开或新建**项目**,再聊天。话题可以乱跳:台面会演进 **`modules[]`**(模块页签)。气泡支持 **Markdown**(加粗、代码、链接)。
83
+ 1. 打开或新建**项目**,再聊天。右上角「项目」列表会显示每个项目的**交付状态**;有内容时可直接打开**交付物**。话题可以乱跳:台面会演进 **`modules[]`**(模块页签)。气泡支持 **Markdown**(加粗、代码、链接)。
83
84
  2. **按模块**填确认卡(目标 / 不做 / **可检查验收** / 假设)。空泛验收过不了门禁;须校验通过才能点确认。确认只**锁住该模块卡**,**不会**立刻写 Brief 或开工。模块之间可随时切换。
84
85
  3. 关心的模块都确认完 → 审 / 确认**架构**(Archify)→ **开工(kickoff)**。开工才写 Brief,建带 **`dependsOn` 的任务池**,并分派。默认 **1** 个数字员工;可选 **N** 个同 CLI 并行。共享任务给 1 号;模块任务轮询。每个 worker 独占一条 **Terminal 队列泳道**(`live-terminal` / `live-terminal/w2`…)。
85
86
  4. 浏览 / 扫描 / 点选产品仓,或粘贴路径 / 项目名。路径不存在会自动创建;可先设**产品父目录**,再填短名在其下新建。非 git 目录会 `git init -b develop`;没有 develop/main/master 会建本地 `develop`。
@@ -88,7 +89,7 @@ duaer live
88
89
  - Claude Code:`npm install -g @anthropic-ai/claude-code`(或 `curl -fsSL https://claude.ai/install.sh | bash`)
89
90
  - 要用 **DeepSeek 等其它模型**写代码:把 Claude Code 接到对应 API,见 [`docs/agent/worker-models.zh-CN.md`](docs/agent/worker-models.zh-CN.md)。FDE「配置模型」页下方也有同样说明。
90
91
  6. 编辑「启动命令」(须以 `Duaer` 开头)→ 开工建 `.worktree/feat-*`,并为每条 worker 泳道打开 **Terminal**。
91
- 7. 右侧进度列轮询 `tasks.md`。进度上方 **查看交付物** 会打开生成的 HTML 页(需求文档时间线、确认书、架构、任务池、交付等)。交付时数字员工先更新产品仓 **README**,再把 `delivery.json` 标为 `accepted`。然后出现 **结果**(有页面时 **查看结果**;否则 **打开项目目录**)。
92
+ 7. 右侧进度列先提示**下一步**(阶段条),并轮询 `tasks.md`。进度上方 **查看交付物** 会打开生成的 HTML 页(需求文档时间线、确认书、架构、任务池、交付等)。交付时数字员工先更新产品仓 **README**,再把 `delivery.json` 标为 `accepted`。然后出现 **结果**(有页面时 **查看结果**;否则 **打开项目目录**)。
92
93
  8. 成品不满意:点 **继续改进(左侧对话)** → 确认改进卡(同样须校验)→ 再确认架构(保留或重画)→ 同 worktree 续派(先清残留 Agent 再入队)。进度跟本轮 `R{n}-*`。启动失败会回滚本轮 Brief Revision;状态行按泳道显示 Terminal 忙闲/队列。
93
94
  9. 若需要上线 / 公网:在台面选 **计划托管平台**(Cloudflare / 阿里云 / AWS / GitHub Pages),说明见 [`docs/agent/deploy-targets.md`](docs/agent/deploy-targets.md)。未指定时默认 **GitHub CLI(`gh`)+ Actions**([`docs/agent/deploy-github.md`](docs/agent/deploy-github.md))
94
95
 
@@ -50,6 +50,7 @@ import {
50
50
  } from "./live-repo-path.mjs";
51
51
  import {
52
52
  buildProjectList,
53
+ enrichProjectsWithDeliveryStatus,
53
54
  normalizeProjectKey,
54
55
  } from "./live-projects.mjs";
55
56
  import {
@@ -1572,11 +1573,12 @@ function discoverRepos({ max = 40 } = {}) {
1572
1573
  );
1573
1574
  }
1574
1575
 
1575
- function pickFolderNative() {
1576
+ function pickFolderNative(promptText = "选择产品仓库") {
1577
+ const prompt = String(promptText || "选择产品仓库").trim() || "选择产品仓库";
1576
1578
  if (process.platform === "darwin") {
1577
1579
  const r = spawnSync(
1578
1580
  "osascript",
1579
- ["-e", 'POSIX path of (choose folder with prompt "选择产品仓库")'],
1581
+ ["-e", `POSIX path of (choose folder with prompt ${JSON.stringify(prompt)})`],
1580
1582
  { encoding: "utf8", timeout: 300000 },
1581
1583
  );
1582
1584
  const err = String(r.stderr || "").trim();
@@ -1602,7 +1604,7 @@ function pickFolderNative() {
1602
1604
  if (process.platform === "linux") {
1603
1605
  const r = spawnSync(
1604
1606
  "zenity",
1605
- ["--file-selection", "--directory", "--title=选择产品仓库"],
1607
+ ["--file-selection", "--directory", `--title=${prompt}`],
1606
1608
  { encoding: "utf8", timeout: 300000 },
1607
1609
  );
1608
1610
  if (r.status !== 0) {
@@ -2035,6 +2037,9 @@ function listProjectsPayload() {
2035
2037
  jobs,
2036
2038
  activeProjectPath: cfg.activeProjectPath,
2037
2039
  });
2040
+ enrichProjectsWithDeliveryStatus(bag, (projectPath) =>
2041
+ readProjectChat(liveRoot(), projectPath),
2042
+ );
2038
2043
  return {
2039
2044
  ...bag,
2040
2045
  projectsRoot: cfg.projectsRoot || "",
@@ -6425,6 +6430,31 @@ async function handleApi(req, res) {
6425
6430
  return;
6426
6431
  }
6427
6432
 
6433
+ if (req.method === "POST" && url.pathname === "/api/projects/pick-root") {
6434
+ try {
6435
+ const chosen = pickFolderNative("选择产品父目录");
6436
+ const abs = path.resolve(String(chosen || "").trim()).replace(/[\\/]+$/, "");
6437
+ if (!abs || !fs.existsSync(abs) || !fs.statSync(abs).isDirectory()) {
6438
+ throw new Error("请选择一个有效的文件夹");
6439
+ }
6440
+ const next = writeConfig({ projectsRoot: abs });
6441
+ send(res, 200, {
6442
+ ok: true,
6443
+ projectsRoot: abs,
6444
+ ...publicConfig(next),
6445
+ ...listProjectsPayload(),
6446
+ });
6447
+ } catch (err) {
6448
+ const code = err && err.code;
6449
+ send(res, 400, {
6450
+ error: err instanceof Error ? err.message : "pick root failed",
6451
+ cancelled: code === "CANCELLED",
6452
+ code: code || undefined,
6453
+ });
6454
+ }
6455
+ return;
6456
+ }
6457
+
6428
6458
  if (req.method === "GET" && url.pathname === "/api/projects/chat") {
6429
6459
  try {
6430
6460
  const projectPath = String(url.searchParams.get("path") || "").trim();
@@ -6659,6 +6689,25 @@ function cmdConfig(opts) {
6659
6689
  }
6660
6690
  }
6661
6691
 
6692
+ function openDeskInBrowser(url) {
6693
+ const target = String(url || "").trim();
6694
+ if (!target) return;
6695
+ try {
6696
+ if (process.platform === "darwin") {
6697
+ spawn("open", [target], { detached: true, stdio: "ignore" }).unref();
6698
+ } else if (process.platform === "win32") {
6699
+ spawn("cmd", ["/c", "start", "", target], {
6700
+ detached: true,
6701
+ stdio: "ignore",
6702
+ }).unref();
6703
+ } else {
6704
+ spawn("xdg-open", [target], { detached: true, stdio: "ignore" }).unref();
6705
+ }
6706
+ } catch {
6707
+ /* ignore — operator can open the printed URL */
6708
+ }
6709
+ }
6710
+
6662
6711
  function serve(port) {
6663
6712
  if (!fs.existsSync(WEB_ROOT)) {
6664
6713
  console.error("Missing web/live-dev");
@@ -6678,19 +6727,19 @@ function serve(port) {
6678
6727
  });
6679
6728
 
6680
6729
  server.listen(port, "127.0.0.1", () => {
6730
+ const deskUrl = `http://127.0.0.1:${port}`;
6681
6731
  const cfg = publicConfig();
6682
- console.log(`Duaer-spec FDE http://127.0.0.1:${port}`);
6732
+ console.log(`Duaer-spec FDE ${deskUrl}`);
6683
6733
  console.log(`隔离目录 ${liveRoot()}`);
6684
6734
  console.log(`Brief 写入 ${jobsRoot()} (不会写入你当前业务仓库)`);
6685
6735
  if (!cfg.ready) {
6686
- console.log(`
6687
- 模型未配置。请先:
6688
- duaer live config --provider deepseek --api-key <key>
6689
- 或页面里点「DeepSeek」再填 Key。
6690
- `);
6736
+ console.log(
6737
+ `模型尚未配置 — 已打开台面,请在页面「设置」里填写模型后保存。`,
6738
+ );
6691
6739
  } else {
6692
6740
  console.log(`模型 ${cfg.model} @ ${cfg.baseUrl}`);
6693
6741
  }
6742
+ openDeskInBrowser(deskUrl);
6694
6743
  scheduleUpdateHint();
6695
6744
  void refreshUpdateInfo({ force: false });
6696
6745
  // Background: every 10 days check/upgrade Cursor Agent / Claude Code CLIs
@@ -3,6 +3,7 @@
3
3
  */
4
4
 
5
5
  import { basename } from "node:path";
6
+ import { deriveProjectDeliveryStatus } from "../web/live-dev/delivery-status.mjs";
6
7
 
7
8
  /**
8
9
  * @param {string | null | undefined} p
@@ -21,6 +22,34 @@ export function normalizeProjectKey(p) {
21
22
  }
22
23
  }
23
24
 
25
+ /**
26
+ * Attach deliveryStatus / nextAction / hasDeliverables from a session reader.
27
+ * @param {ReturnType<typeof buildProjectList>} bag
28
+ * @param {(projectPath: string) => object|null|undefined} readSession
29
+ */
30
+ export function enrichProjectsWithDeliveryStatus(bag, readSession) {
31
+ const projects = Array.isArray(bag?.projects) ? bag.projects : [];
32
+ for (const p of projects) {
33
+ let session = null;
34
+ try {
35
+ session = typeof readSession === "function" ? readSession(p.path) : null;
36
+ } catch {
37
+ session = null;
38
+ }
39
+ const latestJob = Array.isArray(p.jobs) && p.jobs.length ? p.jobs[0] : null;
40
+ const derived = deriveProjectDeliveryStatus(session, {
41
+ jobStatus: latestJob?.status || null,
42
+ deliveryAccepted:
43
+ String(latestJob?.status || "").toLowerCase() === "accepted",
44
+ });
45
+ p.deliveryStatus = derived.status;
46
+ p.nextAction = derived.nextAction;
47
+ p.hasDeliverables = derived.hasDeliverables;
48
+ p.stageSummary = derived.stages;
49
+ }
50
+ return bag;
51
+ }
52
+
24
53
  /**
25
54
  * Build project rows from remembered repos + jobs.
26
55
  * @param {{
@@ -34,7 +63,7 @@ export function buildProjectList(input = {}) {
34
63
  const jobs = Array.isArray(input.jobs) ? input.jobs : [];
35
64
  const active = normalizeProjectKey(input.activeProjectPath);
36
65
 
37
- /** @type {Map<string, { path: string, name: string, title: string, description: string, lastUsedAt: string|null, jobs: typeof jobs }>} */
66
+ /** @type {Map<string, object>} */
38
67
  const map = new Map();
39
68
 
40
69
  function ensure(pathRaw, meta = {}) {
@@ -52,6 +81,10 @@ export function buildProjectList(input = {}) {
52
81
  description: descHint,
53
82
  lastUsedAt: lastUsedAt || null,
54
83
  jobs: [],
84
+ deliveryStatus: "drafting",
85
+ nextAction: "chat",
86
+ hasDeliverables: false,
87
+ stageSummary: [],
55
88
  };
56
89
  map.set(key, row);
57
90
  } else {
@@ -131,10 +131,13 @@ the table for their app.
131
131
  | E2E-149 | 初版 · 需求卡 accordion body uses structured lists/paragraphs (`req-list` / `req-para`); goal prose does not shatter on `、` | `npm test` + `npm run test:live` |
132
132
  | E2E-150 | Requirements click-to-edit then blur without changes keeps field text (no wipe) | `npm run test:live` + manual |
133
133
  | E2E-151 | Modular `[模块]` card fields render section titles + numbered acceptance lists | `npm test` + `npm run test:live` |
134
+ | E2E-152 | Project portfolio shows delivery status chips + open deliverables; Progress shows stage next-action | `npm test` + `npm run test:live` |
135
+ | E2E-153 | `duaer live` starts without model config, opens desk URL, Settings guides setup | `npm test` + `npm run test:live` |
136
+ | E2E-154 | 产品父目录 is read-only; 选择… opens native folder pick and saves; 清除 empties; no path typing | `npm test` + manual |
134
137
  | E2E-093 | Header top actions share one button chrome; GitHub control links to duaer-spec and shows live stars via `/api/github` | `npm run test:live` + manual |
135
138
  | E2E-063 | Duaer-spec FDE brand: header/title show Duaer-spec FDE; zh secondary mark is 现场开发; en mark is Field Development Environment; setup hint / desk-model guide name Duaer | `npm test` + manual |
136
139
  | E2E-078 | Claude Terminal launch includes `--permission-mode bypassPermissions` and stamps `hasTrustDialogAccepted` for the worktree | `npm test` + manual |
137
- | E2E-077 | Missing product path auto-created; projectsRoot saves parent; relative name creates under parent | `npm test` + manual |
140
+ | E2E-077 | Missing product path auto-created; projectsRoot via native folder pick (no typing); relative name creates under parent | `npm test` + manual |
138
141
  | E2E-076 | Worker CLIs are Cursor Agent + Claude Code only; no deepseek-tui worker; tutorial docs/agent/worker-models(.zh-CN).md | `npm test` + manual |
139
142
  | E2E-075 | DeepSeek Terminal launch uses `deepseek -w <worktree> --yolo` (not `--workspace`); job exits 0 when CLI present | `npm test` + manual |
140
143
  | E2E-074 | Missing git: auto-install then init; worker CLIs versioned; 10-day auto-upgrade stamp under `~/.duaer/cli-tools-check.json` (opt-out `DUAER_NO_CLI_UPGRADE=1`) | `npm test` + manual |
@@ -307,3 +310,6 @@ the table for their app.
307
310
  | E2E-149 | `185-req-card-structure` | structured 初版需求卡 accordion |
308
311
  | E2E-150 | `186-req-edit-wipe` | click-edit blur must not wipe fields |
309
312
  | E2E-151 | `187-req-card-modules` | modular `[模块]` structured sections |
313
+ | E2E-152 | `189-fde-portfolio` | portfolio status + delivery cockpit |
314
+ | E2E-153 | `190-live-open-no-model` | live opens desk without model gate |
315
+ | E2E-154 | `191-projects-root-pick` | projectsRoot via native folder pick |
@@ -4,6 +4,10 @@ Field Development Environment for digital employees. Config and Briefs live unde
4
4
  `~/.duaer/live/`. Product work runs in isolated worktrees inside the chosen
5
5
  product repo.
6
6
 
7
+ `duaer live` always starts the desk and opens the browser URL. A missing model
8
+ does not block startup — the Settings drawer opens so the operator can save
9
+ credentials, then chat.
10
+
7
11
  Also see root [`README.md`](../../README.md) (EN) / [`README.zh-CN.md`](../../README.zh-CN.md).
8
12
 
9
13
  ## Layout
@@ -14,7 +18,24 @@ Three columns:
14
18
  |---|---|
15
19
  | Chat | Project dialogue; Markdown bubbles; messy multi-topic talk |
16
20
  | Center | Module tabs + confirm cards → architecture → kickoff / revise |
17
- | Progress | **View deliverables** (opens generated HTML) · `tasks.md` polling · result bar |
21
+ | Progress | Delivery cockpit (stages + next action) · **View deliverables** · `tasks.md` polling · result bar |
22
+
23
+ ## Project portfolio
24
+
25
+ The **Projects** drawer lists remembered product folders. Each row shows a
26
+ **delivery status** chip (`drafting` / `confirming` / `building` / `delivered` /
27
+ `revising`) derived from the per-project session under
28
+ `~/.duaer/live/project-chats/`. When the session has anything to show, a short
29
+ **Deliverables** control opens the dossier without hunting through progress.
30
+
31
+ `GET /api/projects` includes `deliveryStatus`, `nextAction`, and
32
+ `hasDeliverables` on each project row.
33
+
34
+ ## Delivery cockpit (Progress)
35
+
36
+ With an active project, Progress shows a stage strip (requirements →
37
+ architecture → kickoff → delivery → revise) and one **next action** line in
38
+ plain language. **View deliverables** stays the primary CTA above the strip.
18
39
 
19
40
  ## Stage deliverables page
20
41
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "duaer-spec",
3
- "version": "0.19.0",
3
+ "version": "0.20.0",
4
4
  "description": "Norms → satisfactory delivery: digital employees for Cursor, Claude, Codex, Copilot, Windsurf, Cline, Continue, Gemini, Aider",
5
5
  "type": "module",
6
6
  "bin": {
@@ -11,6 +11,10 @@ import {
11
11
  applyDomI18n,
12
12
  } from "./i18n.js";
13
13
  import { structuredHtml, escapeHtml, reqEditModel, serializeReqEdit } from "./structured-html.mjs";
14
+ import {
15
+ deriveProjectDeliveryStatus,
16
+ stageTone,
17
+ } from "./delivery-status.mjs";
14
18
  import { renderChatMarkdown } from "./chat-markdown.mjs";
15
19
  import { extractArchitectureIr } from "./architecture-ir.mjs";
16
20
  import {
@@ -211,7 +215,8 @@ const el = {
211
215
  repoList: document.getElementById("repoList"),
212
216
  repoPath: document.getElementById("repoPath"),
213
217
  projectsRoot: document.getElementById("projectsRoot"),
214
- saveProjectsRoot: document.getElementById("saveProjectsRoot"),
218
+ pickProjectsRoot: document.getElementById("pickProjectsRoot"),
219
+ clearProjectsRoot: document.getElementById("clearProjectsRoot"),
215
220
  projectFolder: document.getElementById("projectFolder"),
216
221
  projectTitle: document.getElementById("projectTitle"),
217
222
  projectDescription: document.getElementById("projectDescription"),
@@ -255,6 +260,9 @@ const el = {
255
260
  dispatchErr: document.getElementById("dispatchErr"),
256
261
  dispatchStatus: document.getElementById("dispatchStatus"),
257
262
  openDeliverables: document.getElementById("openDeliverables"),
263
+ deliveryCockpit: document.getElementById("deliveryCockpit"),
264
+ deliveryStages: document.getElementById("deliveryStages"),
265
+ deliveryNextAction: document.getElementById("deliveryNextAction"),
258
266
  progressCol: document.querySelector(".progress-col"),
259
267
  progressEmpty: document.getElementById("progressEmpty"),
260
268
  runTimeline: document.getElementById("runTimeline"),
@@ -589,6 +597,53 @@ function setActiveProject(path, name, description) {
589
597
  function syncDeliverablesEntry() {
590
598
  if (!el.openDeliverables) return;
591
599
  el.openDeliverables.hidden = !state.projectPath;
600
+ paintDeliveryCockpit();
601
+ }
602
+
603
+ function deskSessionSnapshot() {
604
+ return {
605
+ modules: state.modules || [],
606
+ architecture: state.architecture || {},
607
+ jobId: state.jobId || null,
608
+ reviseCards: state.reviseCards || [],
609
+ reviseLocked: Boolean(state.reviseLocked),
610
+ revisePlanConfirmed: Boolean(state.revisePlanConfirmed),
611
+ lastRevision: state.lastRevision || null,
612
+ originalCard: state.originalCard || null,
613
+ };
614
+ }
615
+
616
+ function paintDeliveryCockpit(statusHint = null) {
617
+ if (!el.deliveryCockpit) return;
618
+ if (!state.projectPath) {
619
+ el.deliveryCockpit.hidden = true;
620
+ return;
621
+ }
622
+ const hint =
623
+ statusHint && typeof statusHint === "object" ? statusHint : {};
624
+ const progress = hint.progress;
625
+ const derived = deriveProjectDeliveryStatus(deskSessionSnapshot(), {
626
+ jobStatus: hint.status || hint.jobStatus || null,
627
+ deliveryAccepted:
628
+ hint.delivery?.status === "accepted" ||
629
+ String(hint.status || "").toLowerCase() === "accepted",
630
+ progressDone: progress?.done,
631
+ progressTotal: progress?.total,
632
+ });
633
+ el.deliveryCockpit.hidden = false;
634
+ if (el.deliveryStages) {
635
+ el.deliveryStages.replaceChildren();
636
+ for (const st of stageTone(derived.stages)) {
637
+ const li = document.createElement("li");
638
+ li.className = `delivery-stage is-${st.tone}`;
639
+ li.dataset.stage = st.id;
640
+ li.textContent = t(`cockpit.stage.${st.id}`);
641
+ el.deliveryStages.appendChild(li);
642
+ }
643
+ }
644
+ if (el.deliveryNextAction) {
645
+ el.deliveryNextAction.textContent = t(`cockpit.next.${derived.nextAction}`);
646
+ }
592
647
  }
593
648
 
594
649
  function openDeliverablesPage() {
@@ -791,6 +846,7 @@ function renderModuleTabs() {
791
846
  total: String(list.length),
792
847
  });
793
848
  }
849
+ paintDeliveryCockpit();
794
850
  }
795
851
 
796
852
  function mergeModulesFromChatPayload(data) {
@@ -2661,10 +2717,6 @@ function syncDrawerBackdrop() {
2661
2717
  function setSettingsOpen(open) {
2662
2718
  if (!el.settingsPanel) return;
2663
2719
  const want = Boolean(open);
2664
- if (!want && !state.ready) {
2665
- // First-time config: keep drawer open until saved.
2666
- return;
2667
- }
2668
2720
  if (want && el.historyPanel && !el.historyPanel.hidden) {
2669
2721
  el.historyPanel.hidden = true;
2670
2722
  if (el.historyToggle) {
@@ -2676,7 +2728,7 @@ function setSettingsOpen(open) {
2676
2728
  el.cfgOpen.setAttribute("aria-expanded", want ? "true" : "false");
2677
2729
  }
2678
2730
  if (el.settingsClose) {
2679
- el.settingsClose.hidden = !state.ready;
2731
+ el.settingsClose.hidden = false;
2680
2732
  }
2681
2733
  syncDrawerBackdrop();
2682
2734
  }
@@ -2759,10 +2811,14 @@ function showSetup(cfg) {
2759
2811
  fillAwsFields(cfg);
2760
2812
  const id = cfg?.provider || "deepseek";
2761
2813
  applyProvider(id, { fillEmptyOnly: Boolean(cfg?.baseUrl || cfg?.model) });
2762
- if (!state.ready) {
2763
- if (el.desk) el.desk.hidden = true;
2764
- }
2814
+ // Always show the desk; open Settings so the operator can fill the model.
2815
+ if (el.desk) el.desk.hidden = false;
2816
+ state.ready = false;
2817
+ syncComposerEnabled();
2765
2818
  setSettingsOpen(true);
2819
+ if (el.meta) {
2820
+ el.meta.textContent = t("setup.hintOpenSettings");
2821
+ }
2766
2822
  }
2767
2823
 
2768
2824
  function showDesk(cfg) {
@@ -4035,6 +4091,7 @@ async function renderArchitectureFromIr(ir) {
4035
4091
  if (!state.architecture.fingerprint) state.architecture.fingerprint = fp;
4036
4092
  }
4037
4093
  syncArchitecturePanel();
4094
+ paintDeliveryCockpit();
4038
4095
  schedulePersistProjectDesk();
4039
4096
  return true;
4040
4097
  }
@@ -4933,36 +4990,65 @@ el.architectureRetry?.addEventListener("click", () => {
4933
4990
  retryArchitectureDesign();
4934
4991
  });
4935
4992
 
4936
- el.saveProjectsRoot?.addEventListener("click", async () => {
4993
+ el.pickProjectsRoot?.addEventListener("click", async () => {
4937
4994
  if (el.historyErr) el.historyErr.hidden = true;
4938
4995
  if (el.dispatchErr) el.dispatchErr.hidden = true;
4996
+ if (el.pickProjectsRoot) {
4997
+ el.pickProjectsRoot.disabled = true;
4998
+ el.pickProjectsRoot.textContent = t("dispatch.pickProjectsRootBusy");
4999
+ }
5000
+ try {
5001
+ const res = await fetch("/api/projects/pick-root", { method: "POST" });
5002
+ const data = await res.json().catch(() => ({}));
5003
+ if (!res.ok) {
5004
+ if (data.cancelled) return;
5005
+ throw new Error(data.error || t("dispatch.projectsRootPickFail"));
5006
+ }
5007
+ if (el.projectsRoot) el.projectsRoot.value = data.projectsRoot || "";
5008
+ state.lastCfg = { ...(state.lastCfg || {}), ...data };
5009
+ if (Array.isArray(data.projects)) {
5010
+ state.projects = data.projects;
5011
+ renderProjectList();
5012
+ }
5013
+ if (el.historyErr) {
5014
+ el.historyErr.hidden = false;
5015
+ el.historyErr.textContent = t("dispatch.projectsRootSaved");
5016
+ }
5017
+ } catch (err) {
5018
+ const msg = err instanceof Error ? err.message : String(err);
5019
+ if (el.historyErr) {
5020
+ el.historyErr.hidden = false;
5021
+ el.historyErr.textContent = msg;
5022
+ }
5023
+ } finally {
5024
+ if (el.pickProjectsRoot) {
5025
+ el.pickProjectsRoot.disabled = false;
5026
+ el.pickProjectsRoot.textContent = t("dispatch.pickProjectsRoot");
5027
+ }
5028
+ }
5029
+ });
5030
+
5031
+ el.clearProjectsRoot?.addEventListener("click", async () => {
5032
+ if (el.historyErr) el.historyErr.hidden = true;
4939
5033
  try {
4940
5034
  const res = await fetch("/api/config", {
4941
5035
  method: "POST",
4942
5036
  headers: { "content-type": "application/json" },
4943
- body: JSON.stringify({
4944
- projectsRoot: (el.projectsRoot?.value || "").trim(),
4945
- }),
5037
+ body: JSON.stringify({ projectsRoot: "" }),
4946
5038
  });
4947
5039
  const data = await res.json();
4948
5040
  if (!res.ok) throw new Error(data.error || t("dispatch.projectsRootFail"));
4949
- if (el.projectsRoot) el.projectsRoot.value = data.projectsRoot || "";
5041
+ if (el.projectsRoot) el.projectsRoot.value = "";
4950
5042
  state.lastCfg = { ...(state.lastCfg || {}), ...data };
4951
5043
  if (el.historyErr) {
4952
5044
  el.historyErr.hidden = false;
4953
- el.historyErr.textContent = t("dispatch.projectsRootSaved");
4954
- } else if (el.dispatchStatus) {
4955
- el.dispatchStatus.hidden = false;
4956
- el.dispatchStatus.textContent = t("dispatch.projectsRootSaved");
5045
+ el.historyErr.textContent = t("dispatch.projectsRootCleared");
4957
5046
  }
4958
5047
  } catch (err) {
4959
5048
  const msg = err instanceof Error ? err.message : String(err);
4960
5049
  if (el.historyErr) {
4961
5050
  el.historyErr.hidden = false;
4962
5051
  el.historyErr.textContent = msg;
4963
- } else if (el.dispatchErr) {
4964
- el.dispatchErr.hidden = false;
4965
- el.dispatchErr.textContent = msg;
4966
5052
  }
4967
5053
  }
4968
5054
  });
@@ -5492,6 +5578,7 @@ function fillRunProgress(block, data) {
5492
5578
 
5493
5579
  function renderProgress(data) {
5494
5580
  if (!el.runTimeline) return;
5581
+ paintDeliveryCockpit(data);
5495
5582
  const progress = data?.progress;
5496
5583
  if (!progress) {
5497
5584
  if (!state.activeRun) el.runTimeline.hidden = el.runTimeline.childElementCount === 0;
@@ -6865,20 +6952,22 @@ function renderProjectList() {
6865
6952
  }
6866
6953
  const active = normalizePathKey(state.projectPath);
6867
6954
  for (const proj of state.projects) {
6955
+ const row = document.createElement("div");
6956
+ row.className = "project-row";
6957
+ const pressed = normalizePathKey(proj.path) === active;
6958
+ if (pressed) row.classList.add("is-active");
6959
+
6868
6960
  const btn = document.createElement("button");
6869
6961
  btn.type = "button";
6870
6962
  btn.className = "history-item project-item";
6871
6963
  btn.dataset.path = proj.path;
6872
- const pressed = normalizePathKey(proj.path) === active;
6873
6964
  btn.setAttribute("aria-pressed", pressed ? "true" : "false");
6874
6965
  const goal = document.createElement("span");
6875
6966
  goal.className = "history-item-goal";
6876
6967
  goal.textContent = proj.title || proj.name || proj.path;
6877
6968
  const st = document.createElement("span");
6878
- st.className = "history-item-status";
6879
- st.textContent = t("project.jobCount", {
6880
- n: String((proj.jobs || []).length),
6881
- });
6969
+ st.className = `history-item-status project-status is-${proj.deliveryStatus || "drafting"}`;
6970
+ st.textContent = t(`project.status.${proj.deliveryStatus || "drafting"}`);
6882
6971
  const meta = document.createElement("span");
6883
6972
  meta.className = "history-item-meta";
6884
6973
  meta.textContent = proj.description
@@ -6894,7 +6983,33 @@ function renderProjectList() {
6894
6983
  requireMeta: false,
6895
6984
  });
6896
6985
  });
6897
- el.projectList.appendChild(btn);
6986
+ row.appendChild(btn);
6987
+
6988
+ if (proj.hasDeliverables) {
6989
+ const deliv = document.createElement("button");
6990
+ deliv.type = "button";
6991
+ deliv.className = "btn project-deliverables";
6992
+ deliv.textContent = t("deliverables.openShort");
6993
+ deliv.title = t("deliverables.openHint");
6994
+ deliv.addEventListener("click", (ev) => {
6995
+ ev.preventDefault();
6996
+ ev.stopPropagation();
6997
+ const open = async () => {
6998
+ if (normalizePathKey(state.projectPath) !== normalizePathKey(proj.path)) {
6999
+ await activateProjectPath(proj.path, {
7000
+ title: proj.title || proj.name,
7001
+ description: proj.description || "",
7002
+ requireMeta: false,
7003
+ });
7004
+ }
7005
+ openDeliverablesPage();
7006
+ };
7007
+ void open();
7008
+ });
7009
+ row.appendChild(deliv);
7010
+ }
7011
+
7012
+ el.projectList.appendChild(row);
6898
7013
  }
6899
7014
  }
6900
7015
 
@@ -7224,6 +7339,8 @@ el.projectBrowse?.addEventListener("click", async () => {
7224
7339
  onLocaleChange(() => {
7225
7340
  syncDynamicI18n();
7226
7341
  syncReviseCardChrome();
7342
+ paintDeliveryCockpit();
7343
+ renderProjectList();
7227
7344
  if (el.previewPanel && !el.previewPanel.hidden && state.lastStatus) {
7228
7345
  renderPreview(state.lastStatus);
7229
7346
  } else {
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Pure delivery status for FDE project portfolio + progress cockpit.
3
+ * Shared by live server (project list) and desk UI.
4
+ */
5
+
6
+ /**
7
+ * @param {unknown} modules
8
+ */
9
+ function modulesAllConfirmed(modules) {
10
+ const list = Array.isArray(modules) ? modules : [];
11
+ if (!list.length) return false;
12
+ return list.every((m) => m && m.status === "confirmed");
13
+ }
14
+
15
+ /**
16
+ * @typedef {'drafting'|'confirming'|'building'|'delivered'|'revising'} DeliveryStatus
17
+ * @typedef {'chat'|'confirm_module'|'confirm_arch'|'kickoff'|'wait_workers'|'view_deliverables'|'revise'} NextAction
18
+ */
19
+
20
+ /**
21
+ * Derive portfolio / cockpit status from a project chat session (+ optional job).
22
+ * @param {object|null|undefined} session
23
+ * @param {{
24
+ * jobStatus?: string|null,
25
+ * deliveryAccepted?: boolean,
26
+ * progressDone?: number,
27
+ * progressTotal?: number,
28
+ * }} [hint]
29
+ */
30
+ export function deriveProjectDeliveryStatus(session, hint = {}) {
31
+ const s = session && typeof session === "object" ? session : {};
32
+ const modules = Array.isArray(s.modules) ? s.modules : [];
33
+ const confirmed = modules.filter((m) => m && m.status === "confirmed");
34
+ const allReq = modulesAllConfirmed(modules);
35
+ const arch =
36
+ s.architecture && typeof s.architecture === "object" ? s.architecture : {};
37
+ const reviseCards = Array.isArray(s.reviseCards) ? s.reviseCards : [];
38
+ const hasJob = Boolean(s.jobId);
39
+ const jobStatus = String(hint.jobStatus || "").toLowerCase();
40
+ const deliveryAccepted =
41
+ Boolean(hint.deliveryAccepted) || jobStatus === "accepted";
42
+ const progressTotal = Number(hint.progressTotal) || 0;
43
+ const progressDone = Number(hint.progressDone) || 0;
44
+ const buildingIncomplete =
45
+ hasJob &&
46
+ !deliveryAccepted &&
47
+ progressTotal > 0 &&
48
+ progressDone < progressTotal;
49
+
50
+ /** @type {DeliveryStatus} */
51
+ let status = "drafting";
52
+ /** @type {NextAction} */
53
+ let nextAction = "chat";
54
+
55
+ if (deliveryAccepted && (reviseCards.length > 0 || s.reviseLocked)) {
56
+ status = "revising";
57
+ nextAction = s.revisePlanConfirmed ? "kickoff" : "revise";
58
+ } else if (deliveryAccepted) {
59
+ status = "delivered";
60
+ nextAction = "view_deliverables";
61
+ } else if (reviseCards.length > 0 || Number(s.lastRevision?.revision) > 0) {
62
+ status = "revising";
63
+ nextAction = "revise";
64
+ } else if (hasJob) {
65
+ status = "building";
66
+ nextAction = buildingIncomplete ? "wait_workers" : "view_deliverables";
67
+ } else if (allReq && arch.confirmed) {
68
+ status = "confirming";
69
+ nextAction = "kickoff";
70
+ } else if (allReq && !arch.confirmed) {
71
+ status = "confirming";
72
+ nextAction = "confirm_arch";
73
+ } else if (confirmed.length > 0 || modules.length > 0) {
74
+ status = "confirming";
75
+ nextAction = "confirm_module";
76
+ } else {
77
+ status = "drafting";
78
+ nextAction = "chat";
79
+ }
80
+
81
+ const stages = [
82
+ {
83
+ id: "requirements",
84
+ done: allReq,
85
+ partial: confirmed.length > 0 && !allReq,
86
+ },
87
+ {
88
+ id: "architecture",
89
+ done: Boolean(arch.confirmed),
90
+ partial: Boolean(arch.url) && !arch.confirmed,
91
+ },
92
+ {
93
+ id: "kickoff",
94
+ done: hasJob,
95
+ partial: false,
96
+ },
97
+ {
98
+ id: "delivery",
99
+ done: deliveryAccepted,
100
+ partial: hasJob && !deliveryAccepted,
101
+ },
102
+ {
103
+ id: "revise",
104
+ done: reviseCards.length > 0 && deliveryAccepted,
105
+ partial: reviseCards.length > 0 && !deliveryAccepted,
106
+ },
107
+ ];
108
+
109
+ const hasDeliverables =
110
+ confirmed.length > 0 ||
111
+ Boolean(arch.url) ||
112
+ hasJob ||
113
+ reviseCards.length > 0 ||
114
+ Boolean(s.originalCard?.goal);
115
+
116
+ return {
117
+ status,
118
+ nextAction,
119
+ hasDeliverables,
120
+ stages,
121
+ confirmedCount: confirmed.length,
122
+ moduleCount: modules.length,
123
+ };
124
+ }
125
+
126
+ /**
127
+ * Compact stage tokens for UI (done | partial | empty).
128
+ * @param {ReturnType<typeof deriveProjectDeliveryStatus>['stages']} stages
129
+ */
130
+ export function stageTone(stages) {
131
+ return (Array.isArray(stages) ? stages : []).map((st) => ({
132
+ id: st.id,
133
+ tone: st.done ? "done" : st.partial ? "partial" : "empty",
134
+ }));
135
+ }
@@ -59,11 +59,17 @@ const zhCN = {
59
59
  "project.needName": "请填写项目名或路径",
60
60
  "project.fail": "无法打开项目",
61
61
  "project.jobCount": "{n} 条对话",
62
+ "project.status.drafting": "起草中",
63
+ "project.status.confirming": "确认中",
64
+ "project.status.building": "实现中",
65
+ "project.status.delivered": "已交付",
66
+ "project.status.revising": "改进中",
62
67
  "project.unassigned": "未归入项目的对话(选项目后确认会挂上)",
63
68
  "bot.needProject": "请先点右上角「项目」选择或新建产品目录,再开始对话。",
64
69
  "setup.mark": "Duaer-spec FDE · model",
65
70
  "setup.title": "配置模型",
66
71
  "setup.hint": "先接好 Duaer 台面模型,再在台面说需求。写入 ~/.duaer/live/config.json",
72
+ "setup.hintOpenSettings": "请在「设置」里配置模型后保存,即可开始对话",
67
73
  "setup.provider": "服务商",
68
74
  "setup.providerAria": "服务商预设",
69
75
  "setup.save": "保存并开始",
@@ -254,9 +260,15 @@ const zhCN = {
254
260
  "arch.needConfirm": "请先确认架构再派工",
255
261
  "dispatch.projectsRoot": "产品父目录",
256
262
  "dispatch.projectsRootPh": "/Users/…/Projects",
263
+ "dispatch.projectsRootEmpty": "尚未选择",
264
+ "dispatch.pickProjectsRoot": "选择…",
265
+ "dispatch.clearProjectsRoot": "清除",
266
+ "dispatch.pickProjectsRootBusy": "选择中…",
257
267
  "dispatch.saveProjectsRoot": "保存父目录",
258
268
  "dispatch.projectsRootSaved": "父目录已保存",
269
+ "dispatch.projectsRootCleared": "已清除父目录",
259
270
  "dispatch.projectsRootFail": "父目录保存失败",
271
+ "dispatch.projectsRootPickFail": "无法选择父目录",
260
272
  "dispatch.agentLabel": "用哪个 CLI 数字员工启动",
261
273
  "dispatch.agentHint": "检测本机 PATH 上的 Cursor Agent / Claude Code",
262
274
  "dispatch.redetect": "重新检测",
@@ -396,9 +408,22 @@ const zhCN = {
396
408
  "progress.title": "任务进度",
397
409
  "progress.aria": "任务进度",
398
410
  "deliverables.open": "查看交付物",
411
+ "deliverables.openShort": "交付物",
399
412
  "deliverables.openHint": "新标签打开结构化交付物档案(需求文档 / 确认书 / 架构…)",
413
+ "cockpit.stage.requirements": "需求",
414
+ "cockpit.stage.architecture": "架构",
415
+ "cockpit.stage.kickoff": "开工",
416
+ "cockpit.stage.delivery": "交付",
417
+ "cockpit.stage.revise": "改进",
418
+ "cockpit.next.chat": "下一步:在对话里说清要做什么",
419
+ "cockpit.next.confirm_module": "下一步:确认当前模块需求卡",
420
+ "cockpit.next.confirm_arch": "下一步:确认系统架构",
421
+ "cockpit.next.kickoff": "下一步:开工派工",
422
+ "cockpit.next.wait_workers": "下一步:等待数字员工完成任务",
423
+ "cockpit.next.view_deliverables": "下一步:查看交付物或再改一版",
424
+ "cockpit.next.revise": "下一步:写清改进方案并派一版",
400
425
  "progress.emptyTitle": "还没有进度",
401
- "progress.empty": "派工后显示各轮任务与改动文件",
426
+ "progress.empty": "有项目后这里会提示下一步;派工后显示各轮任务",
402
427
  "run.mark": "Run",
403
428
  "run.dispatch": "派工进度",
404
429
  "run.dispatchWorkers": "派工进度 · {n} 名员工",
@@ -546,11 +571,17 @@ const en = {
546
571
  "project.needName": "Enter a project name or path",
547
572
  "project.fail": "Could not open project",
548
573
  "project.jobCount": "{n} chats",
574
+ "project.status.drafting": "Drafting",
575
+ "project.status.confirming": "Confirming",
576
+ "project.status.building": "Building",
577
+ "project.status.delivered": "Delivered",
578
+ "project.status.revising": "Revising",
549
579
  "project.unassigned": "Unassigned chats (confirm after picking a project to attach)",
550
580
  "bot.needProject": "Open Projects (top right) and select or create a product folder before chatting.",
551
581
  "setup.mark": "Duaer-spec FDE · model",
552
582
  "setup.title": "Configure model",
553
583
  "setup.hint": "Connect a Duaer desk model first, then state the job. Saved to ~/.duaer/live/config.json",
584
+ "setup.hintOpenSettings": "Open Settings, save a model, then start chatting",
554
585
  "setup.provider": "Provider",
555
586
  "setup.providerAria": "Provider presets",
556
587
  "setup.save": "Save and start",
@@ -715,9 +746,15 @@ const en = {
715
746
  "dispatch.deploy.github-pages": "GitHub Pages",
716
747
  "dispatch.projectsRoot": "Projects parent folder",
717
748
  "dispatch.projectsRootPh": "/Users/…/Projects",
749
+ "dispatch.projectsRootEmpty": "Not set",
750
+ "dispatch.pickProjectsRoot": "Choose…",
751
+ "dispatch.clearProjectsRoot": "Clear",
752
+ "dispatch.pickProjectsRootBusy": "Choosing…",
718
753
  "dispatch.saveProjectsRoot": "Save parent",
719
754
  "dispatch.projectsRootSaved": "Parent folder saved",
755
+ "dispatch.projectsRootCleared": "Parent folder cleared",
720
756
  "dispatch.projectsRootFail": "Could not save parent folder",
757
+ "dispatch.projectsRootPickFail": "Could not choose parent folder",
721
758
  "arch.title": "Architecture",
722
759
  "arch.titlePrevious": "Previous architecture",
723
760
  "arch.titleNew": "New architecture",
@@ -899,9 +936,22 @@ const en = {
899
936
  "progress.title": "Task progress",
900
937
  "progress.aria": "Task progress",
901
938
  "deliverables.open": "View deliverables",
939
+ "deliverables.openShort": "Deliverables",
902
940
  "deliverables.openHint": "Opens a structured deliverables dossier in a new tab",
941
+ "cockpit.stage.requirements": "Requirements",
942
+ "cockpit.stage.architecture": "Architecture",
943
+ "cockpit.stage.kickoff": "Kickoff",
944
+ "cockpit.stage.delivery": "Delivery",
945
+ "cockpit.stage.revise": "Revise",
946
+ "cockpit.next.chat": "Next: describe what to build in chat",
947
+ "cockpit.next.confirm_module": "Next: confirm the active module card",
948
+ "cockpit.next.confirm_arch": "Next: confirm architecture",
949
+ "cockpit.next.kickoff": "Next: kick off dispatch",
950
+ "cockpit.next.wait_workers": "Next: wait for digital employees",
951
+ "cockpit.next.view_deliverables": "Next: view deliverables or revise",
952
+ "cockpit.next.revise": "Next: write the revise plan and dispatch",
903
953
  "progress.emptyTitle": "No progress yet",
904
- "progress.empty": "Task steps and changed files appear here after dispatch",
954
+ "progress.empty": "With a project selected, the next step appears here; tasks show after dispatch",
905
955
  "run.mark": "Run",
906
956
  "run.dispatch": "Dispatch progress",
907
957
  "run.dispatchWorkers": "Dispatch progress · {n} employees",
@@ -302,7 +302,7 @@
302
302
  <h2 class="card-title" data-i18n="progress.title">任务进度</h2>
303
303
  <button
304
304
  type="button"
305
- class="btn deliverables-open"
305
+ class="btn deliverables-open primary"
306
306
  id="openDeliverables"
307
307
  data-i18n="deliverables.open"
308
308
  data-i18n-title="deliverables.openHint"
@@ -313,6 +313,10 @@
313
313
  </button>
314
314
  <p id="dispatchStatus" class="meta progress-status" hidden></p>
315
315
  </header>
316
+ <div id="deliveryCockpit" class="delivery-cockpit" hidden>
317
+ <ol class="delivery-stages" id="deliveryStages" aria-label="Delivery stages"></ol>
318
+ <p class="delivery-next" id="deliveryNextAction"></p>
319
+ </div>
316
320
  <div id="progressEmpty" class="progress-empty">
317
321
  <p class="progress-empty-title" data-i18n="progress.emptyTitle">还没有进度</p>
318
322
  <p class="progress-empty-hint" data-i18n="progress.empty">派工后显示各轮任务与改动文件</p>
@@ -337,10 +341,18 @@
337
341
  <p class="project-section-label" data-i18n="project.createTitle">新建 / 打开项目</p>
338
342
  <label class="field">
339
343
  <span data-i18n="dispatch.projectsRoot">产品父目录</span>
340
- <input id="projectsRoot" type="text" data-i18n-placeholder="dispatch.projectsRootPh" placeholder="/Users/…/Projects" autocomplete="off" />
344
+ <input
345
+ id="projectsRoot"
346
+ type="text"
347
+ readonly
348
+ data-i18n-placeholder="dispatch.projectsRootEmpty"
349
+ placeholder="尚未选择"
350
+ autocomplete="off"
351
+ />
341
352
  </label>
342
353
  <div class="repo-actions repo-root-actions">
343
- <button type="button" class="btn" id="saveProjectsRoot" data-i18n="dispatch.saveProjectsRoot">保存父目录</button>
354
+ <button type="button" class="btn primary" id="pickProjectsRoot" data-i18n="dispatch.pickProjectsRoot">选择…</button>
355
+ <button type="button" class="btn" id="clearProjectsRoot" data-i18n="dispatch.clearProjectsRoot">清除</button>
344
356
  </div>
345
357
  <label class="field">
346
358
  <span data-i18n="project.folderLabel">目录名或绝对路径</span>
@@ -558,6 +558,64 @@ body {
558
558
  border-color: var(--lock);
559
559
  }
560
560
 
561
+ .project-row {
562
+ display: flex;
563
+ flex-direction: column;
564
+ gap: 0.3rem;
565
+ }
566
+
567
+ .project-row.is-active .project-item {
568
+ border-color: var(--lock);
569
+ }
570
+
571
+ .project-row .project-item {
572
+ width: 100%;
573
+ }
574
+
575
+ .project-status {
576
+ display: inline-block;
577
+ margin-top: 0.15rem;
578
+ padding: 0.1rem 0.4rem;
579
+ border: 1px solid rgba(63, 86, 108, 0.65);
580
+ font-size: 0.68rem;
581
+ letter-spacing: 0.03em;
582
+ color: var(--mute);
583
+ }
584
+
585
+ .project-status.is-drafting {
586
+ color: #9fb0c0;
587
+ }
588
+
589
+ .project-status.is-confirming {
590
+ border-color: rgba(47, 158, 143, 0.55);
591
+ color: #9fe8dc;
592
+ }
593
+
594
+ .project-status.is-building {
595
+ border-color: rgba(212, 168, 75, 0.65);
596
+ color: #e8c87a;
597
+ }
598
+
599
+ .project-status.is-delivered {
600
+ border-color: rgba(47, 158, 143, 0.8);
601
+ background: rgba(47, 158, 143, 0.12);
602
+ color: #9fe8dc;
603
+ }
604
+
605
+ .project-status.is-revising {
606
+ border-color: rgba(127, 149, 168, 0.8);
607
+ color: #c5d4e0;
608
+ }
609
+
610
+ .project-deliverables {
611
+ align-self: flex-start;
612
+ font-size: 0.75rem;
613
+ padding: 0.25rem 0.55rem;
614
+ background: rgba(47, 158, 143, 0.1);
615
+ border-color: var(--lock);
616
+ color: #9fe8dc;
617
+ }
618
+
561
619
  .dispatch-project-summary {
562
620
  margin: 0 0 0.85rem;
563
621
  padding: 0.55rem 0.65rem;
@@ -1225,6 +1283,58 @@ a.btn[hidden] {
1225
1283
  display: none !important;
1226
1284
  }
1227
1285
 
1286
+ .deliverables-open.primary {
1287
+ font-weight: 650;
1288
+ }
1289
+
1290
+ .delivery-cockpit {
1291
+ flex-shrink: 0;
1292
+ margin: 0.55rem 0 0.35rem;
1293
+ padding: 0.5rem 0.55rem;
1294
+ border: 1px solid rgba(63, 86, 108, 0.55);
1295
+ background: rgba(8, 14, 20, 0.35);
1296
+ }
1297
+
1298
+ .delivery-cockpit[hidden] {
1299
+ display: none !important;
1300
+ }
1301
+
1302
+ .delivery-stages {
1303
+ list-style: none;
1304
+ margin: 0;
1305
+ padding: 0;
1306
+ display: flex;
1307
+ flex-wrap: wrap;
1308
+ gap: 0.3rem;
1309
+ }
1310
+
1311
+ .delivery-stage {
1312
+ margin: 0;
1313
+ padding: 0.15rem 0.4rem;
1314
+ border: 1px solid rgba(63, 86, 108, 0.55);
1315
+ font-size: 0.68rem;
1316
+ letter-spacing: 0.02em;
1317
+ color: var(--mute);
1318
+ }
1319
+
1320
+ .delivery-stage.is-partial {
1321
+ border-color: rgba(212, 168, 75, 0.65);
1322
+ color: #e8c87a;
1323
+ }
1324
+
1325
+ .delivery-stage.is-done {
1326
+ border-color: rgba(47, 158, 143, 0.75);
1327
+ background: rgba(47, 158, 143, 0.14);
1328
+ color: #9fe8dc;
1329
+ }
1330
+
1331
+ .delivery-next {
1332
+ margin: 0.45rem 0 0;
1333
+ font-size: 0.82rem;
1334
+ line-height: 1.4;
1335
+ color: var(--ink);
1336
+ }
1337
+
1228
1338
  .progress-col .card-title {
1229
1339
  font-size: 1.2rem;
1230
1340
  letter-spacing: 0.03em;