dsh-card-dispatch 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,41 @@
1
+ # 更新日志
2
+
3
+ 发布在 npm:<https://www.npmjs.com/package/dsh-card-dispatch>(0.1.0 之前还发布过旧名
4
+ [`dsh-unattended-dev`](https://www.npmjs.com/package/dsh-unattended-dev))。日期用 npm 记录的 UTC 时间。
5
+
6
+ ## 0.1.1 — 2026-10-25
7
+
8
+ ### 修复
9
+
10
+ - **预算 / 并发闸真的会拦了**:运行态由页面从看板 `/state` 汇总后随 `/split` 传入;拿不到快照时回
11
+ `E_STATE_UNKNOWN` 拒绝开跑,不再用 `0` 冒充「今天还没跑过、也没人在跑」——以前闸门永远放行就是因为它。
12
+ - **打回上限(R3)刷新不再归零**:打回次数按 `rootId` 落到 `<dataDir>/rework.json`;写盘失败返回 500
13
+ 且**不给**重跑信封(失败关闭,不能假装限流生效)。
14
+ - **父卡 prompt 不再自相矛盾**:父卡自己干活时不再说「父卡不产出报告」;没有验收卡时不再提「让它执行」。
15
+ - **`/split` 不再接受请求体里的 `permission` / `agentPreset`**:权限只认配置;`permission: null` 表示
16
+ 「不指定,沿用看板默认」。
17
+
18
+ ### 变更
19
+
20
+ - **验收卡随父卡自动运行**:交付门槛开着就生成,它是父卡的子卡,运行父卡会一起带起来。父卡始终以 Lead
21
+ 会话运行,验收卡是同一会话里的 teammate(智能体团队没勾也只算 1 条会话)。删除 `pending[]` 与页面上的
22
+ 「运行验收卡」;返工后由页面单独重排验收卡。
23
+ - **拆卡幂等**:卡 id 与 `requestId` 由输入 hash 决定,同一份输入 10 分钟内重试不会多建一棵树;换了提示词
24
+ 或超过窗口才是一批新卡。
25
+ - 判据条数、单条长度、重复编号在服务端收敛。
26
+ - HTTP 栅栏:回环地址 + `sec-fetch-site` + `Origin`/`Host` 一致 + `content-type: application/json` + `nosniff`。
27
+ - README / HANDOFF 校正到当前实现;新增本更新日志。
28
+
29
+ ### 测试
30
+
31
+ - 196/196(`node test\run-all.mjs`,17 个文件);新增 `http.test.js`(同源 / JSON / nosniff 栅栏)与
32
+ `model-call.test.js`(调用超时与重试边界)。
33
+
34
+ ## 0.1.0 — 2026-10-24
35
+
36
+ 首次公开发布(`dsh-card-dispatch`,旧名 `dsh-unattended-dev@0.1.0` 同日发布):需求解析 / 按判据拆分 /
37
+ 看板 action 信封 + 人工通道。
38
+
39
+ ## 0.0.0-stage — 2026-10-24
40
+
41
+ 占位预发布,用于验证 npm 发布链路。
package/README.md CHANGED
@@ -8,6 +8,8 @@ DSH Host 插件:**把需求拆成任务看板卡片**。就这一件事。
8
8
  npm i dsh-card-dispatch # 装进 DSH profile 的 node_modules
9
9
  ```
10
10
 
11
+ 更新日志:[CHANGELOG.md](CHANGELOG.md);当前发布版本见上面的 npm 徽章。
12
+
11
13
  - 拆分:`src/split.js`(纯函数,判据自动补 `C-01`,按判据拆父卡 + 并行子卡,prompt 自包含)
12
14
  - 填写:控制台页面直接调看板的 `POST /api/task-board/action`
13
15
  - 执行:**不归本插件**。开会话、钉工作区、预设/权限、跑、结算,全部是看板的事
@@ -84,7 +86,7 @@ npm i "dsh-card-dispatch@link:<仓库路径>/plugins/card-dispatch"
84
86
  5. 点「写入看板」(右下)建卡;勾了「写入后立即运行」才会真的开会话(**这一步才计费**)。
85
87
  6. 验收驳回后回来点「按结论处理」:读 `out/<根卡 id>/verdict.json`,按打回上限给出 pass / rework / escalate。
86
88
 
87
- ## 3. 路由(十一条)
89
+ ## 3. 路由(十二条)
88
90
 
89
91
  都以 `/api/card-dispatch` 为前缀,`kind: prefix`,页面每次请求从磁盘读(改 HTML 不用重启,改路由要)。
90
92
 
@@ -93,12 +95,12 @@ npm i "dsh-card-dispatch@link:<仓库路径>/plugins/card-dispatch"
93
95
  | `GET /` | 控制台页面(`src/console.html`) |
94
96
  | `GET /view` | 页面要的状态快照:能力清单、看板基址、当前配置 |
95
97
  | `GET /models` | 宿主设置里已添加的模型(只读扫描 `cordis.patch.yml`),给下拉用 |
96
- | `POST /split` | 需求 → 卡片计划(`cards[]` + `envelopes[]` + `warnings[]`),**不写任何东西** |
98
+ | `POST /split` | 需求 → 卡片计划(`cards[]` + `envelopes[]` + `warnings[]`),**不写任何东西**;同一份输入 10 分钟内幂等 |
97
99
  | `POST /plan` | 解析 + 算拆几张卡 + 给每张卡写提示词(两次模型调用);回 `intros` 给页面,写卡时原样带回 `/split` |
98
100
  | `POST /parse` | 整段需求 → 标题 / 描述 / 判据(只整理字段,不拆分) |
99
101
  | `POST /criteria` | 判据草稿:模型优先,失败退回规则提炼 |
100
102
  | `POST /import` | 文档正文 → 标题 / 描述 / 判据(正文由浏览器读成文本发来,插件不碰磁盘路径) |
101
- | `POST /rework` | 读 `verdict.json`,按 `gates.maxReworkRounds` 给 pass / rework / escalate |
103
+ | `POST /rework` | 读 `verdict.json`,按 `gates.maxReworkRounds` 给 pass / rework / escalate;打回次数由服务端落盘 |
102
104
  | `GET /config` | 声明表 + 当前值(页面据此回填控件) |
103
105
  | `POST /config` | 归一化后落盘成完整配置;不认识的键回 `rejected`,不会被悄悄丢掉 |
104
106
  | `GET /status` | 配置、开关、体检结论 |
@@ -106,6 +108,14 @@ npm i "dsh-card-dispatch@link:<仓库路径>/plugins/card-dispatch"
106
108
  `POST` 一律要求人标记:请求头 `x-card-dispatch-human: true` 或 body 里 `human: true`。
107
109
  没有这个标记就是 400——**这条路只能人走**,模型和脚本不该替人按「开跑」。
108
110
 
111
+ 除人标记外还有三道同源栅栏(人标记不是鉴权,别的页面照样能带):
112
+ 来源必须是回环地址;`sec-fetch-site` 不能是 `cross-site` / `same-site`;带了 `Origin` 就必须与 `Host` 一致;
113
+ `POST` 必须声明 `content-type: application/json`(`text/plain` 的简单请求不触发预检,正是跨站伪造最常用的形状)。
114
+ 命令行调用要同时带标记与 JSON 头。
115
+
116
+ `/split` 的幂等窗口是 10 分钟:同一份输入(标题、描述、判据、跑法、提示词)重复提交会被当成重试,
117
+ 卡 id 与 requestId 都不变,看板不会建出第二批;要故意新建一批,改一下标题/判据,或等窗口过去。
118
+
109
119
  ## 4. 配置
110
120
 
111
121
  配置只有一份真源:`src/config.js` 的声明表。面板、`/config` 路由、`cordis.patch.yml` 都按它收敛;
@@ -114,10 +124,10 @@ npm i "dsh-card-dispatch@link:<仓库路径>/plugins/card-dispatch"
114
124
  | 键 | 缺省 | 作用 | 插件能真拦吗 |
115
125
  |---|---|---|---|
116
126
  | `enabled` | `false` | 总开关;关掉只拆卡、不返回运行信封 | 能 |
117
- | `permission` | `workspace-write` | 卡片权限预设(写进每个 create 信封) | 能 |
127
+ | `permission` | `workspace-write` | 卡片权限预设(写进每个 create 信封;`null` = 不指定,沿用看板默认;请求体不能覆盖它) | 能 |
118
128
  | `dataDir` | `.card-dispatch` | 插件的数据目录(配置、结论文件) | — |
119
129
  | `gates.requirePlanApproval` | `true` | 没批准的拆分不给运行信封 | 能(「批准」= 人点「写入并运行」) |
120
- | `gates.requireEvidence` | `true` | 没有 commit / 报告不算完成(团队开着时验收卡也在同一条会话里) | 能 |
130
+ | `gates.requireEvidence` | `true` | 没有 commit / 报告不算完成;验收卡随父卡自动运行(团队开着时在同一条会话里) | 能 |
121
131
  | `gates.budgetPerDay` | `200` | 每日运行次数上限 | 能,需调用方给 `runsToday` |
122
132
  | `gates.maxParallelRuns` | `3` | 同时在跑的会话上限 | 能,需调用方给 `runningSessions` |
123
133
  | `gates.maxReworkRounds` | `2` | 打回上限,第 3 次升级给人(基线 R3) | 能 |
@@ -125,7 +135,7 @@ npm i "dsh-card-dispatch@link:<仓库路径>/plugins/card-dispatch"
125
135
  | `models.draftRoute` | 空 | 全局模型:解析并拆分、判据草稿、写卡提示词都用它(右栏下拉写的就是这一项),留空 = 宿主默认 | 是 |
126
136
  | `org.departments` | `dev/verify/integrate` | 部门名(写进卡片描述,供人读) | 只记录 |
127
137
  | `execution.driver` | `task-board` | 执行层驱动(只有看板一条路) | 枚举校验 |
128
- | `execution.teamRun` | `true` | 智能体团队:开了才按判据拆子卡(父卡带 `teamRun: true`,看板只起一个 Lead 会话,子卡由它派 teammate);关掉不拆子卡、每张卡各一条会话 | 能(决定拆不拆 + 写进 create 信封) |
138
+ | `execution.teamRun` | `true` | 智能体团队:开了才按判据拆子卡(父卡带 `teamRun: true`,看板只起一个 Lead 会话,子卡由它派 teammate);关掉不拆子卡;交付门槛开着时父卡仍以 Lead 会话运行(只带验收卡) | 能(决定拆不拆 + 写进 create 信封) |
129
139
  | `audit.orgChart` | `true` | 拓扑稽查:事后核对有没有人绕开主管 | 只检查,不拦 |
130
140
  | `org.depthRequired` | `2` | 主管带人所需的委派深度 | **不能**,只体检提示 |
131
141
 
@@ -153,17 +163,20 @@ npm i "dsh-card-dispatch@link:<仓库路径>/plugins/card-dispatch"
153
163
  |---|---|---|
154
164
  | 判据 0 条 | 1 张父卡 + 警告「执行者不知道做到什么算完」 | 1 |
155
165
  | 判据 1 条 | 1 张父卡(不拆——拆多了全是计费) | 1 |
156
- | **没开智能体团队**(`execution.teamRun: false`) | 不拆子卡,判据全写进一张卡 | 每张卡各 1 条 |
166
+ | **没开智能体团队、也没开交付门槛** | 不拆子卡,判据全写进一张卡 | 1 |
157
167
  | 判据 ≥2 条 + 开了团队 + 勾了并行 | 父卡 + 每判据组 1 张子卡(子卡由父卡的 Lead 在**同一个**会话里派成 teammate) | **1** |
158
168
  | 判据 ≥2 条但没勾并行 | 1 张父卡,全部判据写进去 | 1 |
159
- | 交付门槛开着(`gates.requireEvidence`) | 上面任一种 + **1 张验收卡** | 团队开:仍是 1;团队关:+1 |
169
+ | 交付门槛开着(`gates.requireEvidence`) | 上面任一种 + **1 张验收卡** | **1**(父卡以 Lead 会话运行,验收卡是同一会话里的 teammate;团队没勾也不额外开会话) |
160
170
 
161
- **卡数 ≠ 会话数**。团队开着时 5 张卡(父卡 + 3 子卡 + 验收卡)都在同一个 Lead 会话里跑,
162
- 只算 1 条会话;关掉团队时每张卡各开一条,所以「1 张开发卡 + 1 张验收卡」是 2 条。
163
- 预览里的警告会把这次到底是 1 条还是 N 条直接写出来。
171
+ **卡数 ≠ 会话数**。只要走 Lead 模式(开了团队,或团队没开但开了交付门槛),父卡 + 子卡 + 验收卡
172
+ 都在同一个 Lead 会话里跑,只算 1 条会话;只有团队和门槛都关、只有一张父卡时,才是单卡单会话。
173
+ 验收卡可能比开发卡先启动,它会在会话里等开发报告,再给出 PASS/REJECT。
174
+ 预览里的警告会把这次到底开几条会话直接写出来。
164
175
 
165
176
  拆不拆子卡只看**跑法**:团队开着才拆。页面跟着联动——团队没勾时
166
177
  「按判据拆成并行子卡」直接禁用;勾一下就存盘并重算预览,不用先点「保存」才发现会话条数变了。
178
+ 其余开关(总开关、计划闸、交付门槛、拓扑稽查、预算 / 并发 / 打回)也一样:改完立刻存盘;
179
+ 已经出过预览的会就地重算,不必再点一次「保存」。
167
180
 
168
181
  **子卡是并行的**:看板没有「卡片间依赖」字段。所以每张子卡的 prompt 自带它那组判据
169
182
  和「怎么算完」,不靠顺序;要严格串行就别勾并行。
@@ -172,9 +185,17 @@ npm i "dsh-card-dispatch@link:<仓库路径>/plugins/card-dispatch"
172
185
  不算验收。所以门槛开着时,插件会把交付要求写进每张开发卡(报告路径写死成
173
186
  `out/<该卡 id>/report.md`,附 commit sha),再生成一张只读证据的验收卡:
174
187
  结论只能是 PASS 或 REJECT,REJECT 必须带 `{criteriaId, expected, actual, evidencePath}`。
175
- 顺序(先开发、后验收、打回最多两次)写在父卡 prompt 里——teamRun 模式下父卡就是 Lead,
176
- 由它守;平台排不出这个顺序,因为看板没有依赖字段。
177
- 验收卡和团队开关无关:门槛开着就有;代价只是没开团队时它多占一条会话,预览会写明。
188
+ 打回上限按 `gates.maxReworkRounds` 写进验收 prompt;走 Lead 模式时父卡就是 Lead,负责收尾。
189
+ 平台排不出「先开发、后验收」的顺序(看板没有依赖字段),所以顺序靠验收卡自己等报告:
190
+ 父卡自己就是干活的那张时,prompt 不会再写「父卡不产出报告」,也不会提一张并不存在的验收卡。
191
+ 验收卡和团队开关无关:门槛开着就有,并且**随父卡一起运行**——它是父卡的子卡,运行父卡会把子卡
192
+ 一起带起来,所以不再需要手动点「运行验收卡」。代价是它可能比开发卡先启动,因此验收 prompt 里
193
+ 写死了等待规则:开始先等 30 秒;报告缺失或比该卡最近一次执行开始时间还旧时,每 60 秒再看一次;
194
+ 开发卡已结束但仍缺本轮报告 → 立即 REJECT;最多等 120 分钟。开发卡的 prompt 同时要求开工前
195
+ 删掉上一轮的 `report.md`,所以复验不会把旧报告当新证据。返工只重跑单张开发卡、不会带起验收卡,
196
+ 页面会在重跑信封之后再单独排一次验收卡。
197
+ 走 Lead 模式时它只是 Lead 会话里的一个 teammate,不额外开一条会话——所以团队没勾、门槛开着时,
198
+ 父卡 + 验收卡也只算 1 条会话。
178
199
 
179
200
  ## 7. 已验证 / 未验证
180
201
 
@@ -196,10 +217,12 @@ npm i "dsh-card-dispatch@link:<仓库路径>/plugins/card-dispatch"
196
217
  **未验证**:`run` 的会话侧效果(真的开出一个会话并跑完)——那会真开会话计费,
197
218
  没有替你跑。第一次点运行时请留意看板是否解析出工作区与预设。
198
219
  `teamRun` 写进 create 信封后看板是否真的只起一个 Lead、把子卡派成 teammate,同样没跑过;
220
+ 交付门槛开着、团队没勾时「父卡当 Lead + 验收卡做 teammate」这条路径也没跑过;
199
221
  看板自己的 `teamRunAvailable` 与「Agent Teams 服务缺失就拒绝运行」两道检查仍在它那边。
200
222
 
201
223
  **交付门槛现在到哪一步**:证据要求已写进每张开发卡的 prompt,验收卡独立存在、只给
202
224
  PASS/REJECT 并把结论落到 `out/<根卡 id>/verdict.json`——「证据从哪来、谁来验、结论放哪」都解决了。
225
+ 验收卡现在随父卡自动启动,不再需要人点「运行验收卡」;「等报告」是 prompt 级约束,未真机跑过。
203
226
 
204
227
  「打回之后谁重跑」现在有一个**人触发**的答案:`POST /rework` 读那个文件,按
205
228
  `gates.maxReworkRounds` 给出三种结局——`pass` 收尾、`rework` 返回重跑被拒卡片的 run 信封、
@@ -209,20 +232,23 @@ PASS/REJECT 并把结论落到 `out/<根卡 id>/verdict.json`——「证据从
209
232
  插件进程没有那张 cookie,**实测 403**;而且 `ExecutionRecord.result` 只记「执行成没成」,
210
233
  不记「验收判 PASS 还是 REJECT」。落盘的文件三方都能读,也才符合基线 P3。
211
234
 
212
- **还没做**:预算真正计数(需要调用方给 `runsToday` / `runningSessions`)、worktree 隔离、
213
- 逐手台账、`skills/card-dispatch-org/SKILL.md` 的安装。
235
+ **还没做**:worktree 隔离、模型调用缓存、`skills/card-dispatch-org/SKILL.md` 的安装。
236
+ 模型调用已加一次重试(只重试非超时失败)。预算与并发闸已经接上真实运行态:页面从看板 `/state` 的 executions 算「今天跑了几次、几个会话在跑」,
237
+ 随 `/split` 传给服务端;拿不到快照就回 `E_STATE_UNKNOWN` 拒绝放行,不再拿 0 冒充。
238
+ 注意:看板每张卡只保留最近 20 条 executions,单卡一天跑超过 20 次时预算数会少算。
214
239
 
215
240
  ## 8. 测试
216
241
 
217
242
  ```powershell
218
- node test\run-all.mjs # 155 个用例,全绿
243
+ node test\run-all.mjs # 196 个用例,全绿
219
244
  ```
220
245
 
221
- 十五个文件:`config`(收敛/兼容/合并)、`gates`(三个闸的判定顺序与理由)、
222
- `selfcheck`(体检判据)、`split`(拆分、信封、智能体团队、提示词覆盖)、`status`(所有路由)、
246
+ 十七个测试文件:`config`(收敛/兼容/合并)、`gates`(四个闸的判定顺序与理由)、
247
+ `selfcheck`(体检判据)、`split`(拆分、信封、智能体团队、提示词覆盖、幂等)、`status`(所有路由 + 打回次数落盘)、
223
248
  `verdict`(结论文件契约)、`import`(文档解析)、`criteria`(规则提炼)、`models`(模型清单扫描)、
224
- `criteria-llm`(判据草稿的模型通道)、`parse-llm`(整段需求解析)、`prompts-llm`(按需求写每张卡的提示词)、
225
- `human-channel`(路由 + 两道门 + 依赖透传 + 闸门降级)、`console`(页面 id / 看板基址 / 旧机制痕迹清零)、
249
+ `model-call`(调用超时 / 重试边界)、`criteria-llm`(判据草稿的模型通道)、`parse-llm`(整段需求解析)、
250
+ `prompts-llm`(按需求写每张卡的提示词)、`human-channel`(路由 + 两道门 + 依赖透传 + 闸门降级)、
251
+ `console`(页面 id / 看板基址 / 旧机制痕迹清零)、`http`(同源 / JSON / nosniff 栅栏)、
226
252
  `index`(导出形状 / 旧配置兼容 / apply 只挂路由 / 端到端打一次 /models)。
227
253
 
228
254
  模型相关的用例全部用假的 `llm` 服务:**不在测试里花真钱**。真模型的验证靠人点一次、
@@ -233,9 +259,10 @@ node test\run-all.mjs # 155 个用例,全绿
233
259
  | 文件 | 行数 | 作用 |
234
260
  |---|---|---|
235
261
  | `index.js` | ~90 | cordis 入口:`apply` 只注册路由,注入 `models` 依赖 |
236
- | `src/human-channel.js` | ~800 | 十一条路由、两道门(人标记 + 配置闸)、依赖透传 |
237
- | `src/split.js` | ~390 | 需求 → 卡片计划(唯一有「业务判断」的地方,纯函数) |
238
- | `src/gates.js` | ~120 | 三个闸:计划、预算、并发、打回次数 |
262
+ | `src/human-channel.js` | ~830 | 十二条路由、两道门(人标记 + 同源/JSON)、闸门与配置 |
263
+ | `src/split.js` | ~470 | 需求 → 卡片计划(唯一有「业务判断」的地方,纯函数;同输入幂等) |
264
+ | `src/gates.js` | ~110 | 四个闸:计划、运行态、预算、并发(+ 打回次数判定) |
265
+ | `src/rework-store.js` | ~70 | 打回次数按 `rootId` 落盘(`<dataDir>/rework.json`) |
239
266
  | `src/config.js` | ~230 | 声明表 + 归一化 + 兼容旧键 |
240
267
  | `src/config-store.js` | ~90 | 配置读写(`<dataDir>/config.json` 优先) |
241
268
  | `src/selfcheck.js` | ~200 | 体检:看板在不在、模型清单、权限、委派深度 |
@@ -247,4 +274,4 @@ node test\run-all.mjs # 155 个用例,全绿
247
274
  | `src/prompts-llm.js` | ~140 | 按需求给每张卡写提示词开头(结构不归它管) |
248
275
  | `src/import.js` | ~150 | `.md` / `.txt` / `.json` → 标题 / 描述 / 判据 |
249
276
  | `src/verdict.js` | ~120 | `verdict.json` 的读写与形状校验 |
250
- | `src/console.html` | ~950 | 控制台页面(单文件,无构建) |
277
+ | `src/console.html` | ~1010 | 控制台页面(单文件,无构建) |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-card-dispatch",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "type": "module",
5
5
  "description": "派卡台开发的上游:把需求拆成任务看板卡片(执行交给 dsh-client-ui-task-board)",
6
6
  "keywords": [
@@ -37,7 +37,8 @@
37
37
  "cordis.patch.yml",
38
38
  "locale/*.json",
39
39
  "icon.svg",
40
- "README.md"
40
+ "README.md",
41
+ "CHANGELOG.md"
41
42
  ],
42
43
  "scripts": {
43
44
  "test": "node test/run-all.mjs",
package/src/console.html CHANGED
@@ -191,6 +191,7 @@
191
191
  <label class="sw"><input type="checkbox" id="cfg-enabled"> <span><b>总开关</b> 关掉只拆卡、不开会话</span></label>
192
192
  <label class="sw"><input type="checkbox" id="cfg-plan"> <span><b>计划闸</b> 没批准不许开跑</span></label>
193
193
  <label class="sw"><input type="checkbox" id="cfg-evidence"> <span><b>交付门槛</b> 没有证据不算完成</span></label>
194
+ <div class="hint" id="cfg-evidence-hint" style="padding-left:24px;margin:-4px 0 6px"></div>
194
195
  <label class="sw"><input type="checkbox" id="cfg-teamrun"> <span><b>智能体团队</b> 父卡起 Lead、子卡起 teammate(关掉则不拆子卡,每张卡各开一条会话)</span></label>
195
196
  <label class="sw"><input type="checkbox" id="cfg-audit"> <span><b>拓扑稽查</b> 事后核对有没有人绕开主管</span></label>
196
197
 
@@ -319,14 +320,14 @@ function boardPost(envelope) {
319
320
  故意不做成自动的:没有轮询、没有定时器,人点一次才判一次。 */
320
321
 
321
322
  var lastPlan = null; /* 最近一次真正写进看板的拆分结果 */
322
- var rejectedCount = 0; /* 这一批已经打回几次(服务端也记,这里只是喂给它) */
323
+ var boardStats = null; /* 看板运行态:{runsToday, runningSessions};拿不到就是 null */
324
+ var boardFacts = null; /* 看板探测结果:{reachable, teamRun};回传给 /status 体检 */
323
325
  var WORKSPACE_PATHS = {}; /* 工作区 id → 根目录,读 verdict.json 要用绝对路径 */
324
326
  var hostHasRework = false;/* 宿主有没有 /rework:旧宿主上这个按钮点了只会 404 */
325
327
  var hostHasPlan = true;/* 宿主有没有 /plan:没有就退回只整理字段 */
326
328
 
327
329
  function rememberPlan(plan) {
328
330
  lastPlan = plan;
329
- rejectedCount = 0;
330
331
  el('btn-rework').disabled = !hostHasRework;
331
332
  el('rework-state').className = 'pill' + (hostHasRework ? '' : ' bad');
332
333
  el('rework-state').textContent = hostHasRework ? '等验收' : '宿主是旧代码';
@@ -385,26 +386,37 @@ function checkRework() {
385
386
  el('rework-state').className = 'pill';
386
387
  el('rework-state').textContent = '检查中…';
387
388
 
388
- var base = { rootId: lastPlan.rootId, workspaceRoot: workspaceRoot, rejectedCount: rejectedCount };
389
+ var base = { rootId: lastPlan.rootId, workspaceRoot: workspaceRoot };
389
390
  request('rework', base)
390
391
  .then(function (first) {
391
392
  var decision = first.decision || {};
392
393
  if (decision.action !== 'rework') { showRework(first); return first; }
393
- // 要返工:先找出被拒判据对应的卡,再要 run 信封(同一次请求里带 taskIds)
394
+ // 要返工:先找出被拒判据对应的卡,再要 run 信封(同一次请求里带 taskIds)。
395
+ // 打回次数由服务端落盘,页面不再传——刷新页面也不会把上限洗掉。
394
396
  var ids = failedCardIds(lastPlan, first.verdict && first.verdict.items);
395
397
  return request('rework', Object.assign({}, base, { taskIds: ids })).then(function (second) {
396
398
  var chain = Promise.resolve();
397
399
  (second.runs || []).forEach(function (run) {
398
400
  chain = chain.then(function () { return boardPost(run); });
399
401
  });
402
+ /* 验收卡也是父卡的子卡:重跑单张开发卡不会把它带起来,所以这里单独再排一次。
403
+ 它会先等 30 秒、并且只认比「该卡最近一次执行开始时间」更新的报告,
404
+ 不会把上一轮的旧报告当成本轮证据。 */
405
+ if (lastPlan && lastPlan.verifyId) {
406
+ chain = chain.then(function () {
407
+ return boardPost({
408
+ requestId: 'card-dispatch-verify-' + Date.now() + '-' + lastPlan.rootId,
409
+ action: { kind: 'run', taskId: lastPlan.verifyId },
410
+ });
411
+ });
412
+ }
400
413
  return chain.then(function () { showRework(second); return second; });
401
414
  });
402
415
  })
403
416
  .then(function (result) {
404
417
  var action = (result.decision || {}).action;
405
418
  if (action === 'rework') {
406
- rejectedCount = result.decision.rejectedCount;
407
- toast('已退回开发:' + (result.runs || []).length + ' 张卡重跑中', 'ok');
419
+ toast('已退回开发:' + (result.runs || []).length + ' 张卡重跑中(第 ' + result.attempt + ' 次打回)', 'ok');
408
420
  } else if (action === 'pass') {
409
421
  toast('验收通过', 'ok');
410
422
  } else {
@@ -454,21 +466,48 @@ function loadWorkspaces() {
454
466
  });
455
467
  }
456
468
 
469
+ /* 从看板 state 里算运行态:预算闸要「今天跑了几次」,并发闸要「几个会话在跑」。
470
+ 看板的 task.executions 带 startedAt / endedAt(ms epoch),正好够算;
471
+ 拿不到就返回 null,让服务端拒绝放行,而不是补 0。 */
472
+ function boardStatsOf(state) {
473
+ var tasks = (state && Array.isArray(state.tasks)) ? state.tasks : null;
474
+ if (!tasks) return null;
475
+ var midnight = new Date();
476
+ midnight.setHours(0, 0, 0, 0);
477
+ var since = midnight.getTime();
478
+ var running = 0;
479
+ var today = 0;
480
+ tasks.forEach(function (task) {
481
+ var executions = (task && Array.isArray(task.executions)) ? task.executions : [];
482
+ if (executions.some(function (execution) { return execution && execution.endedAt === undefined; })) running += 1;
483
+ executions.forEach(function (execution) {
484
+ if (execution && typeof execution.startedAt === 'number' && execution.startedAt >= since) today += 1;
485
+ });
486
+ });
487
+ return { runsToday: today, runningSessions: running };
488
+ }
489
+
457
490
  function checkBoard() {
458
491
  return fetch(BOARD + '/state', { credentials: 'same-origin' })
459
492
  .then(function (response) {
460
493
  if (!response.ok) {
494
+ boardStats = null;
495
+ boardFacts = { reachable: false, teamRun: null };
461
496
  el('board-state').className = 'pill bad';
462
497
  el('board-state').textContent = '看板不可用(HTTP ' + response.status + ')';
463
498
  return null;
464
499
  }
465
500
  return response.json().then(function (state) {
501
+ boardStats = boardStatsOf(state);
502
+ boardFacts = { reachable: true, teamRun: typeof state.teamRunAvailable === 'boolean' ? state.teamRunAvailable : null };
466
503
  el('board-state').className = 'pill ok';
467
504
  el('board-state').textContent = '看板可用 · 默认权限 ' + (state.sessionDefaultPermission || '?');
468
505
  return state;
469
506
  });
470
507
  })
471
508
  .catch(function () {
509
+ boardStats = null;
510
+ boardFacts = { reachable: false, teamRun: null };
472
511
  el('board-state').className = 'pill bad';
473
512
  el('board-state').textContent = '看板未安装或未启动';
474
513
  return null;
@@ -491,7 +530,9 @@ function splitPayload() {
491
530
  intros: aiIntros,
492
531
  /* 人点「写入并运行」这一下,就是计划闸要的那次批准。只写卡不运行时不给批准,
493
532
  免得预演也把闸门算成已过。 */
494
- planApproved: el('brd-run').checked
533
+ planApproved: el('brd-run').checked,
534
+ /* 运行态:预算/并发闸要真实数字。拿不到就是 null,服务端会拒绝放行。 */
535
+ state: boardStats
495
536
  };
496
537
  }
497
538
 
@@ -530,47 +571,52 @@ function clearPreview(hint) {
530
571
  }
531
572
 
532
573
  function previewSplit() {
533
- var payload = splitPayload();
534
- if (!payload.title) { toast('先写需求标题', 'err'); return; }
535
- request('split', payload).then(renderPlan).catch(function (error) {
536
- toast('拆分失败:' + error.message, 'err');
574
+ if (!el('brd-title').value.trim()) { toast('先写需求标题', 'err'); return; }
575
+ // 先刷一次看板运行态:预算/并发闸要用最新数字,不能拿页面打开那一刻的旧值。
576
+ checkBoard().then(function () {
577
+ request('split', splitPayload()).then(renderPlan).catch(function (error) {
578
+ toast('拆分失败:' + error.message, 'err');
579
+ });
537
580
  });
538
581
  }
539
582
 
540
583
  function pushToBoard() {
541
- var payload = splitPayload();
542
- if (!payload.title) { toast('先写需求标题', 'err'); return; }
543
- var runNow = el('brd-run').checked;
584
+ if (!el('brd-title').value.trim()) { toast('先写需求标题', 'err'); return; }
544
585
  var button = el('btn-push');
545
586
  button.disabled = true;
546
- request('split', payload).then(function (plan) {
547
- // 闸门不允许时服务端不返回运行信封:这里必须同步降级,否则会把「没批准」当「已排队」。
548
- if (runNow && !plan.run) {
549
- runNow = false;
550
- el('brd-run').checked = false;
551
- toast('只写卡,不运行:' + (plan.gate ? plan.gate.message : '闸门未放行'), 'err');
552
- }
553
- el('brd-preview').className = 'preview-body';
554
- el('brd-preview').textContent = '正在写入看板:' + plan.cards.length + ' 张卡…';
555
- el('brd-preview-hint').textContent = '写入中…';
556
- var chain = Promise.resolve();
557
- plan.envelopes.forEach(function (envelope) {
558
- chain = chain.then(function () { return boardPost(envelope); });
559
- });
560
- if (runNow) chain = chain.then(function () { return boardPost(plan.run); });
561
- return chain.then(function () {
562
- renderPlan(plan);
563
- rememberPlan(plan);
564
- el('brd-preview').textContent += '\n\n已写入 ' + plan.cards.length + ' 张卡'
565
- + (runNow ? ',并已排队运行。' : '。去侧边栏「任务看板」点「运行」开始执行。');
566
- el('brd-preview-hint').textContent = '已写入看板';
567
- toast('已写入任务看板:' + plan.cards.length + ' 张卡' + (runNow ? '(已排队运行)' : ''), 'ok');
568
- el('brd-title').value = ''; el('brd-desc').value = ''; el('brd-criteria').value = '';
569
- saveDraft();
587
+ checkBoard().then(function () {
588
+ var payload = splitPayload();
589
+ var runNow = el('brd-run').checked;
590
+ return request('split', payload).then(function (plan) {
591
+ // 闸门不允许时服务端不返回运行信封:这里必须同步降级,否则会把「没批准」当「已排队」。
592
+ if (runNow && !plan.run) {
593
+ runNow = false;
594
+ el('brd-run').checked = false;
595
+ toast('只写卡,不运行:' + (plan.gate ? plan.gate.message : '闸门未放行'), 'err');
596
+ }
597
+ el('brd-preview').className = 'preview-body';
598
+ el('brd-preview').textContent = '正在写入看板:' + plan.cards.length + ' 张卡…';
599
+ el('brd-preview-hint').textContent = '写入中…';
600
+ var chain = Promise.resolve();
601
+ plan.envelopes.forEach(function (envelope) {
602
+ chain = chain.then(function () { return boardPost(envelope); });
603
+ });
604
+ if (runNow) chain = chain.then(function () { return boardPost(plan.run); });
605
+ return chain.then(function () {
606
+ renderPlan(plan);
607
+ rememberPlan(plan);
608
+ el('brd-preview').textContent += '\n\n已写入 ' + plan.cards.length + ' 张卡'
609
+ + (runNow ? ',并已排队运行。' : '。去侧边栏「任务看板」点「运行」开始执行。')
610
+ + (plan.verifyId ? '\n验收卡是父卡的子卡:运行父卡时它会一起启动,等报告齐全后自动验收。' : '');
611
+ el('brd-preview-hint').textContent = '已写入看板';
612
+ toast('已写入任务看板:' + plan.cards.length + ' 张卡' + (runNow ? '(已排队运行)' : ''), 'ok');
613
+ el('brd-title').value = ''; el('brd-desc').value = ''; el('brd-criteria').value = '';
614
+ saveDraft();
615
+ });
570
616
  });
571
617
  }).catch(function (error) {
572
618
  clearPreview('写入失败');
573
- toast('写入看板失败:' + error.message, 'err');
619
+ toast('写入看板失败:' + error.message + '(已建的卡会保留;再点一次是同一批 id,看板会跳过)', 'err');
574
620
  }).then(function () {
575
621
  button.disabled = false;
576
622
  });
@@ -599,6 +645,25 @@ function applyConfig(snapshot) {
599
645
  ? '开关保存在 ' + snapshot.configFile + '(它优先于部署配置;删掉该文件即回到部署值)'
600
646
  : '';
601
647
  syncTeamRunUi();
648
+ syncEvidenceUi();
649
+ }
650
+
651
+ /* 交付门槛与团队开关是两件事,但经常一起被问:
652
+ - 门槛管「要不要独立验收」,关掉就退化成「执行者自己说完成」;
653
+ - 团队管「验收卡由谁跑」,没开团队时验收卡不自动排队,需要人点一下。
654
+ 把关系写在开关下面,比把两个开关绑成一个更难误操作。 */
655
+ function syncEvidenceUi() {
656
+ var hint = el('cfg-evidence-hint');
657
+ if (!hint) return;
658
+ var evidence = el('cfg-evidence').checked;
659
+ var team = el('cfg-teamrun').checked;
660
+ if (!evidence) {
661
+ hint.textContent = '关掉后不生成验收卡,也不要求报告与 commit;单卡自己说「做完了」就算完,不建议。';
662
+ } else if (team) {
663
+ hint.textContent = '验收卡是同一会话里的 teammate:和子卡一起启动,等报告齐全后自动验收,不额外开会话。';
664
+ } else {
665
+ hint.textContent = '会多一张验收卡:父卡以 Lead 会话运行,验收卡是同一会话里的 teammate,等报告出现后自动验收,只算 1 条会话。';
666
+ }
602
667
  }
603
668
 
604
669
  /* 拆不拆子卡由「智能体团队」决定:没开团队,每张子卡都是独立会话,所以「按判据拆」
@@ -665,8 +730,19 @@ function saveConfig() {
665
730
  }
666
731
 
667
732
  /* 体检结论按严重度直接摊开:fatal 会挡住开工,warn 是「能跑但难受」,info 是知道了就行。 */
733
+ /* 插件进程看不见看板(看板有信任栅栏),页面看得见:把探测结果带给 /status,
734
+ 体检才能给出「看板可达 / 支持 teamRun」而不是一片未知。 */
735
+ function statusQuery() {
736
+ var parts = [];
737
+ if (boardFacts && boardFacts.reachable !== null) parts.push('boardReachable=' + boardFacts.reachable);
738
+ if (boardFacts && boardFacts.teamRun !== null && boardFacts.teamRun !== undefined) {
739
+ parts.push('boardTeamRun=' + boardFacts.teamRun);
740
+ }
741
+ return parts.length ? '?' + parts.join('&') : '';
742
+ }
743
+
668
744
  function runStatus() {
669
- return fetch(BASE + 'status', { credentials: 'same-origin' })
745
+ return fetch(BASE + 'status' + statusQuery(), { credentials: 'same-origin' })
670
746
  .then(function (response) { return response.json().catch(function () { return {}; }); })
671
747
  .then(function (snapshot) {
672
748
  var check = snapshot.selfCheck || { findings: [] };
@@ -898,10 +974,25 @@ el('brd-model').addEventListener('change', function () {
898
974
  });
899
975
  /* 团队开关立刻生效:它决定拆不拆子卡、也就是这次会开几条会话,
900
976
  让人勾完再点一次「保存」才知道预览变了,属于会让人算错账的那种设计。 */
977
+ function saveAndRecompute() {
978
+ return saveConfig().then(function () {
979
+ if (el('brd-preview').textContent.trim() !== '') previewSplit();
980
+ });
981
+ }
982
+
901
983
  el('cfg-teamrun').addEventListener('change', function () {
902
984
  syncTeamRunUi();
903
- saveConfig().then(function () {
904
- if (el('brd-preview').textContent.trim() !== '') previewSplit();
985
+ syncEvidenceUi();
986
+ saveAndRecompute();
987
+ });
988
+
989
+ /* 其余开关与数字也一样:改完立刻保存并重算预览。
990
+ 以前只有「智能体团队」接了 change,于是勾「交付门槛」后预览不动,
991
+ 人会以为这个开关没生效——其实要再点一次「保存」才重算。 */
992
+ ['cfg-enabled', 'cfg-plan', 'cfg-evidence', 'cfg-audit', 'cfg-budget', 'cfg-parallel', 'cfg-rework'].forEach(function (id) {
993
+ el(id).addEventListener('change', function () {
994
+ syncEvidenceUi();
995
+ saveAndRecompute();
905
996
  });
906
997
  });
907
998
 
@@ -949,7 +1040,8 @@ function syncHost() {
949
1040
  if (!hostSynced) {
950
1041
  hostSynced = true;
951
1042
  loadConfig();
952
- runStatus();
1043
+ // 先探一次看板,再体检:否则第一次体检会把 boardReachable 报成未知
1044
+ checkBoard().then(function () { runStatus(); });
953
1045
  }
954
1046
  // 宿主是新的,但这个 iframe 里的文档是旧的(改了 HTML 没刷新)→ 整页重载
955
1047
  if (snapshot.pageVersion && snapshot.pageVersion !== PAGE_VERSION) location.reload();
package/src/criteria.js CHANGED
@@ -1,11 +1,10 @@
1
1
  /**
2
- * criteria.js:从标题与描述里提炼**判据草稿**。
2
+ * criteria.js:从标题与描述里提炼**判据草稿**(无模型时的退路)。
3
3
  *
4
- * ## 为什么是规则,不是模型
4
+ * ## 为什么这里不调模型
5
5
  *
6
- * 宿主暴露给插件的服务里没有模型接口(只有 workspaces / sessions / tools /
7
- * taskBoard / settings 这些)。要真让模型写判据,只能开一个会话——那是花钱且
8
- * 要人看着的事,不该藏在「点一下自动生成」后面。
6
+ * 模型通道在 `criteria-llm.js`(`ctx.get('llm')`)。这一层刻意只用规则:
7
+ * 模型不可用、开关关着、或者一次点击不值得多花一次调用时,仍然要能给出可编辑的草稿。
9
8
  *
10
9
  * 所以这里只做**提炼**:把描述里本来就写成要求的话捞出来,变成一行一条。
11
10
  * 提炼不出来就说提炼不出来,并告诉人怎么写才好提炼——不编造判据。
package/src/gates.js CHANGED
@@ -19,6 +19,7 @@
19
19
  export const GATE_CODES = Object.freeze({
20
20
  DISABLED: 'E_DISABLED',
21
21
  PLAN_NOT_APPROVED: 'E_PLAN_NOT_APPROVED',
22
+ STATE_UNKNOWN: 'E_STATE_UNKNOWN',
22
23
  BUDGET: 'E_BUDGET_EXCEEDED',
23
24
  PARALLEL: 'E_PARALLEL_LIMIT',
24
25
  EVIDENCE: 'E_EVIDENCE_MISSING',
@@ -27,17 +28,18 @@ export const GATE_CODES = Object.freeze({
27
28
  /**
28
29
  * 能不能开一轮(= 能不能花钱)。
29
30
  *
30
- * 顺序是刻意的:先看总开关,再看计划闸,最后才是预算和并发。
31
+ * 顺序是刻意的:先看总开关,再看计划闸,然后运行态、预算和并发。
31
32
  * 因为「没打开」和「没批准」是配置/流程问题,人应该先看到它们;
32
- * 预算和并发是运行态问题,排在后面。
33
+ * 运行态与预算是运行态问题,排在后面;运行态拿不到就直接拒绝,不拿 0 冒充。
33
34
  *
34
35
  * @param {object} input
35
36
  * @param {object} input.config 归一化后的配置
36
37
  * @param {object} [input.state] 运行态快照 `{runsToday, runningSessions}`
37
38
  * @param {object} [input.request] 本次请求 `{planApproved}`
39
+ * @param {boolean} [input.stateKnown] 调用方是否真的拿到了运行态;false = 不许拿 0 冒充
38
40
  * @returns {{allow:boolean, code:string|null, message:string, checks:Array}}
39
41
  */
40
- export function evaluateStart({ config, state = {}, request = {} } = {}) {
42
+ export function evaluateStart({ config, state = {}, request = {}, stateKnown } = {}) {
41
43
  const checks = []
42
44
  const push = (id, ok, detail) => {
43
45
  checks.push({ id, ok, detail })
@@ -56,11 +58,20 @@ export function evaluateStart({ config, state = {}, request = {} } = {}) {
56
58
  }
57
59
 
58
60
  const runsToday = Number(state.runsToday ?? 0)
61
+ const running = Number(state.runningSessions ?? 0)
62
+ // 调用方没明说时:给了非空 state 就当知道,空对象/没给就是「不知道」。
63
+ const known = stateKnown ?? (state !== null && typeof state === 'object' && Object.keys(state).length > 0)
64
+
65
+ // 运行态缺失时**不许按 0 放行**:预算/并发闸的全部价值就是「拿真实数字做决定」。
66
+ // 以前调用方传 state:{},这里永远是 0,两个闸形同虚设;现在缺数据就明说。
67
+ if (!push('state', known, known ? `运行态:今天 ${runsToday} 次 / 在跑 ${running} 个` : '没有运行态快照')) {
68
+ return deny(GATE_CODES.STATE_UNKNOWN, '预算/并发闸拿不到运行态(今天跑了几次、几个会话在跑):先刷新看板状态再开跑,不要拿 0 冒充')
69
+ }
70
+
59
71
  if (!push('budget', runsToday < config.gates.budgetPerDay, `今天已跑 ${runsToday} / ${config.gates.budgetPerDay}`)) {
60
72
  return deny(GATE_CODES.BUDGET, `预算闸:今天已经跑了 ${runsToday} 次,达到上限 ${config.gates.budgetPerDay};清零后才能继续`)
61
73
  }
62
74
 
63
- const running = Number(state.runningSessions ?? 0)
64
75
  if (!push('parallel', running < config.gates.maxParallelRuns, `当前在跑 ${running} / ${config.gates.maxParallelRuns}`)) {
65
76
  return deny(GATE_CODES.PARALLEL, `并发闸:已有 ${running} 个会话在跑,上限 ${config.gates.maxParallelRuns}`)
66
77
  }