dsh-plugin-teamflow 0.1.4 → 0.1.6

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/lib/host.mjs CHANGED
@@ -1,11 +1,11 @@
1
1
  import { TEAMFLOW_DESCRIPTORS } from "./descriptors.mjs";
2
2
  import { fileFor, journalFile, loadJournals, persistJournal, readJson, readJsonAny, slugPath, teamflowRoot, writeJson } from "./store.mjs";
3
3
  import { TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
4
+ import { createUserMessage } from "@deepseek-ai/dsh-llm";
4
5
  import { parameterSchemaSpecToJsonSchema } from "@deepseek-ai/dsh-tools";
5
6
  import { dirname, join } from "node:path";
6
7
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
7
8
  import { execFileSync } from "node:child_process";
8
- import { createUserMessage } from "@deepseek-ai/dsh-llm";
9
9
  //#region host/constants.ts
10
10
  /** 单阶段 token 熔断预算(官方口径总消耗:input+cacheRead+cacheWrite+output 累计)。 */
11
11
  const STAGE_TOKEN_BUDGET = 6e4;
@@ -21,6 +21,7 @@ const REFUSAL_PATTERN = /(无法完成|不能完成|无法继续|抱歉|对不
21
21
  const STAGE_MIN_LENGTH = {
22
22
  prd: 400,
23
23
  design: 250,
24
+ scaffold: 250,
24
25
  arch: 250,
25
26
  tech: 350,
26
27
  dev: 60,
@@ -57,16 +58,30 @@ const STATUS = {
57
58
  "needs-human"
58
59
  ]
59
60
  };
60
- /** 流水线阶段顺序与 key 映射(resume/pipeline 用)。 */
61
+ /**
62
+ * 流水线阶段(2026-09-06 英文化改造):内部一律英文键(journal.stage.phase / 代码判断 / 状态机)。
63
+ * 中文阶段名只作为展示 label(client UI 映射,未来 i18n 与 dsh 中英对齐)。
64
+ */
61
65
  const PHASE_ORDER = [
62
- "PRD 产品需求",
63
- "UI/UX 设计",
64
- "架构规划",
65
- "技术方案",
66
- "开发",
67
- "QA 测试",
68
- "产品验收"
66
+ "prd",
67
+ "design",
68
+ "scaffold",
69
+ "tech",
70
+ "dev",
71
+ "qa",
72
+ "acceptance"
69
73
  ];
74
+ /** 英文键 → 中文展示名(仅 UI/label/日志文案使用,不得用于代码判断)。 */
75
+ const PHASE_KEY_OF = {
76
+ prd: "PRD 产品需求",
77
+ design: "UI/UX 设计",
78
+ scaffold: "架构规划",
79
+ tech: "技术方案",
80
+ dev: "开发",
81
+ qa: "QA 测试",
82
+ acceptance: "产品验收"
83
+ };
84
+ /** 中文阶段名 → 英文键(存量 journal/backlog 兼容映射;迁移脚本执行后仅防御性保留)。 */
70
85
  const PHASE_KEY_BY_NAME = {
71
86
  "PRD 产品需求": "prd",
72
87
  "UI/UX 设计": "design",
@@ -76,15 +91,22 @@ const PHASE_KEY_BY_NAME = {
76
91
  "QA 测试": "qa",
77
92
  "产品验收": "acceptance"
78
93
  };
79
- /** 阶段显示名角色键(任务卡 byRole 累计用;未知阶段归 'other')。 */
94
+ /** phase 归一:中文(存量)或英文(新数据)输入 英文键;未知回退原值小写化。 */
95
+ function phaseKeyOf(phase) {
96
+ const p = String(phase || "");
97
+ if (!p) return "";
98
+ if (PHASE_KEY_BY_NAME[p]) return PHASE_KEY_BY_NAME[p];
99
+ return p;
100
+ }
101
+ /** 阶段英文键 → 角色键(任务卡 byRole 累计用;未知阶段归 'other')。 */
80
102
  const PHASE_ROLE = {
81
- "PRD 产品需求": "pm",
82
- "UI/UX 设计": "design",
83
- "架构规划": "arch",
84
- "技术方案": "tech",
85
- "开发": "dev",
86
- "QA 测试": "qa",
87
- "产品验收": "acceptance"
103
+ prd: "pm",
104
+ design: "design",
105
+ scaffold: "arch",
106
+ tech: "tech",
107
+ dev: "dev",
108
+ qa: "qa",
109
+ acceptance: "acceptance"
88
110
  };
89
111
  const STAGE_POLICY = {
90
112
  full: [
@@ -157,9 +179,7 @@ const STAGE_POLICY = {
157
179
  key: "scaffold",
158
180
  when: (o) => !!o.needScaffold
159
181
  },
160
- { key: "tech" },
161
- { key: "dev" },
162
- { key: "acceptance" }
182
+ { key: "dev" }
163
183
  ]
164
184
  };
165
185
  /** 按档位 + 条件展开实际执行阶段集(纯函数;未知档回退 full)。 */
@@ -187,6 +207,17 @@ function extractText(blocks) {
187
207
  if (!Array.isArray(blocks)) return "";
188
208
  return blocks.filter((b) => b && b.type === "text" && typeof b.text === "string").map((b) => b.text).join("\n");
189
209
  }
210
+ /** 从 dev/qaFix 回复中提取「验证证据」块(`[Verification evidence]` 行起,到 state 块/结尾止)。
211
+ * dev 阶段无独立对抗校验(QA 有 QA-REPORT.md 结构化证据,dev 只有自述)——证据块是「可审计的
212
+ * 具体自述」:命令+退出码+断言计数+失败行引用,可对照 logs/teamflow/<runId>/ 命令输出日志核实;
213
+ * 模型仍可伪造,但具体细节难编造一致(具体性压力)且伪造可发现(审计轨迹)。
214
+ * 找不到块(契约未兑现)→ null,host 记 warn 不中断(policy 级)。 */
215
+ function extractVerificationEvidence(text) {
216
+ const m = toText(text).match(/\[Verification evidence\]([\s\S]*?)(?=<!--\s*state|$)/);
217
+ if (!m || !m[1]) return null;
218
+ const ev = m[1].trim();
219
+ return ev.length > 0 ? ev : null;
220
+ }
190
221
  /**
191
222
  * ADR-0008 任务夹命名:<yyyyMMdd>-r<N>[-<slug>]。
192
223
  * - date 用本地时区(用户在东八区晚上建的需求不能落到"明天")
@@ -255,10 +286,11 @@ function sanitizeSnapOptions(o) {
255
286
  const SAFE_SIGNAL = {
256
287
  aborted: false,
257
288
  addEventListener: () => {},
258
- removeEventListener: () => {}
289
+ removeEventListener: () => {},
290
+ throwIfAborted: () => {}
259
291
  };
260
292
  function normalizeSignal(s) {
261
- return s && typeof s === "object" && typeof s.addEventListener === "function" && typeof s.aborted === "boolean" ? s : SAFE_SIGNAL;
293
+ return s && typeof s === "object" && typeof s.addEventListener === "function" && typeof s.aborted === "boolean" && typeof s.throwIfAborted === "function" ? s : SAFE_SIGNAL;
262
294
  }
263
295
  /** 分支 slug 派生(ADR-2026-08-27):branchName > triageSlug > 需求中的英文标识词 > reqId 数字 > 'feature'。
264
296
  * 实锤 feat/feature:lite 显式时 triage 不跑(无 slug)+ 分支检查早于 reqId 生成 → fallback 'feature'。 */
@@ -266,7 +298,24 @@ function deriveBranchSlug(requirement, reqId, triageSlug, branchName) {
266
298
  if (branchName && /^[a-z0-9][a-z0-9-_]*$/i.test(branchName)) return String(branchName).replace(/[^a-z0-9-]/gi, "-").toLowerCase().slice(0, 40);
267
299
  if (triageSlug && /^[a-z0-9-]{3,24}$/i.test(triageSlug)) return triageSlug;
268
300
  const en = String(requirement || "").match(/[a-zA-Z][a-zA-Z0-9-]{2,23}/g);
269
- if (en && en.length) return en[0].toLowerCase().slice(0, 40);
301
+ if (en && en.length) {
302
+ const NOISE = /* @__PURE__ */ new Set([
303
+ "patch",
304
+ "lite",
305
+ "tech",
306
+ "full",
307
+ "medium",
308
+ "mode",
309
+ "the",
310
+ "and",
311
+ "for",
312
+ "with",
313
+ "use",
314
+ "using"
315
+ ]);
316
+ const hit = en.find((w) => !NOISE.has(w.toLowerCase()));
317
+ if (hit) return hit.toLowerCase().slice(0, 40);
318
+ }
270
319
  const num = String(reqId || "").match(/\d+/);
271
320
  if (num) return `r${num[0]}`;
272
321
  return "feature";
@@ -290,6 +339,33 @@ function handoffBrief(text) {
290
339
  const m = String(text).match(/<!--\s*handoff\s*-->([\s\S]*?)(?:<!--\s*\/handoff\s*-->|$)/);
291
340
  return clip((m && m[1] ? m[1] : String(text)).trim(), 2e3);
292
341
  }
342
+ /** 拒绝词命中点:返回命中的具体短语 + 原文上下文片段(供重试诊断回灌,比事后从截断尾巴重算可靠)。 */
343
+ function refusalHit(text) {
344
+ const s = String(text || "");
345
+ const m = REFUSAL_PATTERN.exec(s);
346
+ if (!m || m.index < 0) return null;
347
+ const start = Math.max(0, m.index - 40);
348
+ const end = Math.min(s.length, m.index + String(m[0]).length + 40);
349
+ return {
350
+ phrase: m[0],
351
+ context: s.slice(start, end).replace(/\s+/g, " ").trim()
352
+ };
353
+ }
354
+ /** 重试诊断包:上一轮失败详情回灌进重试 prompt(盲试 → 带因重试)。
355
+ * 失败分类/详情/护栏原因取自 stage;产出尾部截断 1000 字符供自查修正。 */
356
+ function buildRetryDiagnostic(attempt, stage) {
357
+ const lines = [];
358
+ lines.push(`[重试诊断 · 第 ${attempt} 次尝试] 上一轮尝试未成功。这不是新任务——请先阅读以下失败详情,再执行原任务并修正上一轮的问题。`);
359
+ lines.push(`- 失败分类:${stage.outcome || "unknown"}`);
360
+ if (stage.guardReason) lines.push(`- 护栏中止原因:${stage.guardReason}`);
361
+ if (stage.summary) lines.push(`- 详情:${stage.summary}`);
362
+ const out = String(stage.output || "");
363
+ if (out) {
364
+ const tail = out.length > 1e3 ? `…${out.slice(-1e3)}` : out;
365
+ lines.push(`- 上一轮产出末尾(节选,供自查修正):\n${tail}`);
366
+ }
367
+ return `\n\n${lines.join("\n")}\n[/重试诊断结束]`;
368
+ }
293
369
  /**
294
370
  * 验收结论解析:只以显式「验收结论 / 整体结论」行为准(acceptancePrompt 强制 4 档固定话术),
295
371
  * 不做正文散文朴素子串匹配。历史误报实锤(run tf-msytlok5):验收报告 ✅ 通过,其记忆回写段一句
@@ -298,8 +374,11 @@ function handoffBrief(text) {
298
374
  * - 「📝 需求不适用」是验收负责人专用的强结论词,允许全文命中;
299
375
  * - 其余 reject 词(需求与实际不符/站不住/无效/无需改动等)仅在结论行且该行不含「通过/✅/⚠️」时才算;
300
376
  * - rework 词仅认结论行(且不与「✅ 通过」同现)。
377
+ * 反向护栏(漏报实锤 2026-09-03):模型写「❌ 不通过」但漏写「验收结论:」前缀 → accLine 为空 →
378
+ * 旧实现落回默认 accepted(最乐观默认值,质量门禁漏报=假交付)。现改为 **找不到结论行 → needs-human**
379
+ * (宁严勿松:误拦截=人工看一眼,误放行=假交付;📝 全文命中与架构红词仍优先于该默认)。
301
380
  * @param {unknown} text 验收报告全文
302
- * @returns {'accepted'|'rework'|'reject'}
381
+ * @returns {'accepted'|'rework'|'reject'|'needs-human'}
303
382
  */
304
383
  function parseAcceptanceVerdict(text) {
305
384
  const acc = String(text || "");
@@ -307,9 +386,14 @@ function parseAcceptanceVerdict(text) {
307
386
  const hasArchRedFlag = /重复实现|重复适配|偏离蓝图|未按蓝图|该拆未拆|该抽象未抽象|破坏既有结构|结构性.*问题|架构(打回|需重构)|需.*返工|返工.*项.*(存在|仍)|仍.*(返工|重构)/.test(acc);
308
387
  const archNegated = /无返工|无.*返工|不返工|无架构打回|无.*打回|非漂移|无.*重复|无.*偏离|无.*抽象.*问题|无.*蓝图.*问题|架构一致性.*(PASS|良好|达标|通过|无问题)|M3.*(PASS|通过|达标)|架构.*(达标|无问题|良好)/.test(acc);
309
388
  if (hasArchRedFlag && !archNegated) return "rework";
310
- if (/❌\s*不通过|需返工|未通过/.test(accLine) && !/✅\s*通过/.test(accLine)) return "rework";
311
389
  if (/📝\s*需求不适用/.test(acc)) return "reject";
390
+ if (/不通过|需返工|未通过/.test(accLine) && !/无\s*不通过|未发现不通过|未出现不通过/.test(accLine)) {
391
+ if (/✅\s*通过/.test(accLine)) return "accepted";
392
+ return "rework";
393
+ }
312
394
  if (!/通过|✅|⚠️/.test(accLine) && /需求不适用|需求与实际不符|需求站不住|需求无效|无需改动|无需修改/.test(accLine)) return "reject";
395
+ if (!accLine) return "needs-human";
396
+ if (!/通过|✅|⚠️|❌|📝/.test(accLine)) return "needs-human";
313
397
  return "accepted";
314
398
  }
315
399
  const bdOpen = "<!-- blueprint -->";
@@ -819,6 +903,20 @@ function createSubtask(journal, title, spec) {
819
903
  const store = storeFor(journal.workspace || "default");
820
904
  const mainTask = journal.taskId ? store.find("task", journal.taskId) : null;
821
905
  if (!mainTask) return null;
906
+ const fullTitle = `开发 · ${title}`;
907
+ const existing = store.tasks.find((t) => t.reqId === journal.reqId && t.parentId === journal.taskId && (t.taskKey && t.taskKey === title || !t.taskKey && t.title === fullTitle));
908
+ if (existing) {
909
+ existing.status = "pending";
910
+ existing.failed = false;
911
+ existing.summary = null;
912
+ existing.endedAt = null;
913
+ existing.retries = (existing.retries || 0) + 1;
914
+ existing.taskKey = existing.taskKey || title;
915
+ existing.updatedAt = Date.now();
916
+ store.persist();
917
+ persistJournal(journal);
918
+ return existing;
919
+ }
822
920
  const id = store.nextId("dev");
823
921
  const sub = {
824
922
  id,
@@ -826,7 +924,8 @@ function createSubtask(journal, title, spec) {
826
924
  parentId: journal.taskId,
827
925
  product: journal.workspace || "default",
828
926
  type: "subtask",
829
- title: `开发 · ${title}`,
927
+ title: fullTitle,
928
+ taskKey: title,
830
929
  spec: spec || "",
831
930
  status: "pending",
832
931
  devAssign: mainTask && mainTask.devAssign || null,
@@ -1155,16 +1254,59 @@ function runSanityCheck(path) {
1155
1254
  //#endregion
1156
1255
  //#region host/core/metering.ts
1157
1256
  /**
1257
+ * 采集子代理会话事件(多源回退——2026-09-07 实锤 r38 usage 全空):
1258
+ * 宿主新版 Session(session v2)已无 `events` 属性/getter(仅私有 eventsSnapshot 缓存 +
1259
+ * 官方 snapshotEvents()/ownEvents() 方法),旧实现读 session.events = undefined → usage 全 null。
1260
+ * 回退链(与 guard.eventsOf 同款语义):events(老宿主快照,兼容)→ snapshotEvents()(官方完整日志)
1261
+ * → ownEvents()(fork 后本 agent 自己的事件)。取信息最多(含 usage 事件数最多)的源。
1262
+ */
1263
+ function sessionEventsOf(run) {
1264
+ try {
1265
+ const local = run && run.localAgent;
1266
+ const session = local && local.session;
1267
+ if (!session) return [];
1268
+ const candidates = [];
1269
+ try {
1270
+ const raw = session.events;
1271
+ if (Array.isArray(raw)) candidates.push(raw);
1272
+ else if (typeof raw === "function") candidates.push(raw());
1273
+ } catch (e) {}
1274
+ try {
1275
+ if (typeof session.snapshotEvents === "function") candidates.push(session.snapshotEvents());
1276
+ } catch (e) {}
1277
+ try {
1278
+ if (typeof session.ownEvents === "function") candidates.push(session.ownEvents());
1279
+ } catch (e) {}
1280
+ const valid = candidates.filter((c) => Array.isArray(c));
1281
+ if (valid.length === 0) return [];
1282
+ const countUsage = (arr) => arr.filter((ev) => {
1283
+ const e = ev;
1284
+ return e && e.type === "assistant/message" && e.data && typeof e.data.usage === "object" && e.data.usage !== null;
1285
+ }).length;
1286
+ valid.sort((a, b) => countUsage(b) - countUsage(a));
1287
+ return valid[0];
1288
+ } catch (e) {
1289
+ return [];
1290
+ }
1291
+ }
1292
+ /** 从单个 assistant/message 事件取 usage(宿主 usageOf 同款双路径:data.usage 优先,
1293
+ * 缺失时从 data.stream 的 usage chunk 取——v2 事件 usage 可能只在 stream 里)。 */
1294
+ function usageOfEvent(e) {
1295
+ if (!e || e.type !== "assistant/message") return void 0;
1296
+ const d = e.data || {};
1297
+ if (d.usage && typeof d.usage === "object") return d.usage;
1298
+ if (Array.isArray(d.stream)) for (const member of [...d.stream].reverse()) {
1299
+ const chunk = member?.chunk;
1300
+ if (chunk && chunk.type === "usage" && chunk.usage && typeof chunk.usage === "object") return chunk.usage;
1301
+ }
1302
+ }
1303
+ /**
1158
1304
  * 累计子代理会话中所有 LLM 调用的真实 usage(官方三桶 + 调用数)。
1159
1305
  * 返回 null 表示拿不到 usage(会话未暴露 events / 无数据)。
1160
1306
  */
1161
1307
  function accumulateSessionUsage(run) {
1162
- const local = run && run.localAgent;
1163
- const session = local && local.session;
1164
- if (!session) return null;
1165
- const rawEvents = session.events;
1166
- const events = Array.isArray(rawEvents) ? rawEvents : typeof rawEvents === "function" ? rawEvents() : null;
1167
- if (!Array.isArray(events)) return null;
1308
+ const events = sessionEventsOf(run);
1309
+ if (events.length === 0) return null;
1168
1310
  const buckets = {
1169
1311
  input: 0,
1170
1312
  cacheRead: 0,
@@ -1178,7 +1320,7 @@ function accumulateSessionUsage(run) {
1178
1320
  if (!e || e.type !== "assistant/message") continue;
1179
1321
  const d = e.data || {};
1180
1322
  if (typeof d.turn === "number" && typeof d.step === "number") seen.add(`${d.turn}.${d.step}`);
1181
- const u = d.usage;
1323
+ const u = usageOfEvent(e);
1182
1324
  if (u) {
1183
1325
  buckets.input += u.inputTokens || 0;
1184
1326
  buckets.cacheRead += u.cacheReadTokens || 0;
@@ -1225,14 +1367,48 @@ function totalTokensOf(usage) {
1225
1367
  * 纯 read 循环(反复整读同一文件却无变更/无脚本执行)= 真退化。实锤 run tf-mte906e9:QA 重跑
1226
1368
  * 只读分析(不 edit)→ 旧判定「零变更进展」误杀,第 2 次 provider error 后 450k 熔断。 */
1227
1369
  const PROGRESS_TOOLS = /^(edit|write|create|apply_patch|patch|remove|delete|rm|mkdir|move|rename|append|bash|pwsh|shell|powershell)$/i;
1228
- /** metering 同款事件访问器(session.events 可能是数组或返回数组的函数)。 */
1370
+ /** Agent 活动守卫(2026-09-06 实锤 r1):QA 子代理正常干活却被判「10 分钟无事件」——
1371
+ * 事件视图可能失明(session.events 缓存快照不增长)。若 agent 仍非 idle(phase 在跑)
1372
+ * 且本会话动过手(lastMutationAt>0)→ 不是挂死,跳过本次判定(不中止)。
1373
+ * 纯启动静默挂死(未动手)不受影响——照常 B 触发。 */
1374
+ function isAgentBusy(run) {
1375
+ try {
1376
+ const agent = run && run.localAgent;
1377
+ const kind = agent && agent.phase && agent.phase.kind;
1378
+ return !!kind && kind !== "idle";
1379
+ } catch (e) {
1380
+ return false;
1381
+ }
1382
+ }
1383
+ /** 与 metering 同款事件访问器(session.events 可能是数组或返回数组的函数)。
1384
+ * 2026-09-06 多源回退(实锤 json-parse r1:QA 子代理正常干活 254 事件 43 step 却被判「10 分钟
1385
+ * 无任何新事件」——session.events 缓存快照视图对某些子代理不增长)。回退链:
1386
+ * events(快照 getter)→ snapshotEvents()(宿主官方 API)→ ownEvents()(fork 后事件)——
1387
+ * 取信息最多(最长)的源;全部失效返回 [](stalled 触发前会记录诊断,见 fire())。 */
1229
1388
  function eventsOf(run) {
1230
- const local = run && run.localAgent;
1231
- const session = local && local.session;
1232
- if (!session) return [];
1233
- const raw = session.events;
1234
- const events = Array.isArray(raw) ? raw : typeof raw === "function" ? raw() : null;
1235
- return Array.isArray(events) ? events : [];
1389
+ try {
1390
+ const local = run && run.localAgent;
1391
+ const session = local && local.session;
1392
+ if (!session) return [];
1393
+ const candidates = [];
1394
+ try {
1395
+ const raw = session.events;
1396
+ if (Array.isArray(raw)) candidates.push(raw);
1397
+ else if (typeof raw === "function") candidates.push(raw());
1398
+ } catch (e) {}
1399
+ try {
1400
+ if (typeof session.snapshotEvents === "function") candidates.push(session.snapshotEvents());
1401
+ } catch (e) {}
1402
+ try {
1403
+ if (typeof session.ownEvents === "function") candidates.push(session.ownEvents());
1404
+ } catch (e) {}
1405
+ const valid = candidates.filter((c) => Array.isArray(c));
1406
+ if (valid.length === 0) return [];
1407
+ valid.sort((a, b) => b.length - a.length);
1408
+ return valid[0];
1409
+ } catch (e) {
1410
+ return [];
1411
+ }
1236
1412
  }
1237
1413
  /** 规范化文本片段:小写 + 仅保留字母数字/CJK,供逐字重复比对。 */
1238
1414
  function normalizeFragment(s) {
@@ -1289,6 +1465,7 @@ function startStageGuard(opts) {
1289
1465
  const warnedScripts = /* @__PURE__ */ new Set();
1290
1466
  let lastMutationAt = 0;
1291
1467
  let repeatWarned = false;
1468
+ let busyWarned = false;
1292
1469
  function warnOnce(key, set, message, hint) {
1293
1470
  if (set.has(key)) return;
1294
1471
  set.add(key);
@@ -1307,6 +1484,34 @@ function startStageGuard(opts) {
1307
1484
  clearInterval(timer);
1308
1485
  stage.guardReason = reason;
1309
1486
  stage.guardOutcome = outcome;
1487
+ if (outcome === "stalled") try {
1488
+ const local = run.localAgent;
1489
+ const session = local && local.session;
1490
+ const lens = [];
1491
+ if (session) {
1492
+ try {
1493
+ const r = session.events;
1494
+ lens.push(`events=${Array.isArray(r) ? r.length : typeof r === "function" ? r().length : "?"}`);
1495
+ } catch (e) {
1496
+ lens.push("events=err");
1497
+ }
1498
+ try {
1499
+ lens.push(`snap=${typeof session.snapshotEvents === "function" ? session.snapshotEvents().length : "-"}`);
1500
+ } catch (e) {
1501
+ lens.push("snap=err");
1502
+ }
1503
+ try {
1504
+ lens.push(`own=${typeof session.ownEvents === "function" ? session.ownEvents().length : "-"}`);
1505
+ } catch (e) {
1506
+ lens.push("own=err");
1507
+ }
1508
+ }
1509
+ journal.logs.push({
1510
+ t: Date.now(),
1511
+ level: "warn",
1512
+ message: `${label} 挂死诊断:${lens.join(" / ") || "session 不可访问"}`
1513
+ });
1514
+ } catch (e) {}
1310
1515
  try {
1311
1516
  journal.logs.push({
1312
1517
  t: Date.now(),
@@ -1380,8 +1585,22 @@ function startStageGuard(opts) {
1380
1585
  lastEventCount = events.length;
1381
1586
  lastGrowthAt = Date.now();
1382
1587
  } else if (Date.now() - lastGrowthAt > 6e5) {
1383
- fire(`挂死(${Math.round(GUARD_SILENCE_MS / 6e4)} 分钟无任何新事件)`, "stalled");
1384
- return;
1588
+ if (lastMutationAt > 0 && isAgentBusy(run)) {
1589
+ if (!busyWarned) {
1590
+ busyWarned = true;
1591
+ try {
1592
+ journal.logs.push({
1593
+ t: Date.now(),
1594
+ level: "warn",
1595
+ message: `${label} 事件视图零增长但 agent 仍活动(会话已动手)——视为视图失明而非挂死,继续观察(护栏诊断见 stall 分支)`
1596
+ });
1597
+ } catch (e) {}
1598
+ }
1599
+ lastGrowthAt = Date.now();
1600
+ } else {
1601
+ fire(`挂死(${Math.round(GUARD_SILENCE_MS / 6e4)} 分钟无任何新事件)`, "stalled");
1602
+ return;
1603
+ }
1385
1604
  }
1386
1605
  if (seenToolCall && Date.now() - lastToolSignalAt > 9e5) {
1387
1606
  fire(`空转(${Math.round(GUARD_NO_TOOL_MS / 6e4)} 分钟内无任何工具调用,但会话仍在产出)`, "stalled");
@@ -1483,7 +1702,7 @@ function resolveChildRoute(parent) {
1483
1702
  return out;
1484
1703
  }
1485
1704
  /** 运行单个阶段子代理:执行 + 产出实质校验 + token 双口径计量 + stage 状态流转。 */
1486
- async function runAgent(journal, parent, label, phase, prompt, signal) {
1705
+ async function runAgent(journal, parent, label, phase, prompt, signal, taskKey) {
1487
1706
  const maxSeq = journal.stages.length ? Math.max(...journal.stages.map((s) => s.seq)) : 0;
1488
1707
  let stageText = null;
1489
1708
  const stage = {
@@ -1492,6 +1711,7 @@ async function runAgent(journal, parent, label, phase, prompt, signal) {
1492
1711
  phase,
1493
1712
  status: "running",
1494
1713
  outcome: null,
1714
+ taskKey: taskKey || null,
1495
1715
  childId: null,
1496
1716
  startedAt: Date.now(),
1497
1717
  endedAt: null,
@@ -1560,6 +1780,7 @@ async function runAgent(journal, parent, label, phase, prompt, signal) {
1560
1780
  stage.status = "failed";
1561
1781
  stage.outcome = stage.guardOutcome || "degenerated";
1562
1782
  stage.summary = `进行中护栏中止(${stage.guardReason}),本次尝试无有效产出`;
1783
+ if (text) stage.output = clip(text, 4e3);
1563
1784
  journal.logs.push({
1564
1785
  t: Date.now(),
1565
1786
  level: "warn",
@@ -1569,20 +1790,33 @@ async function runAgent(journal, parent, label, phase, prompt, signal) {
1569
1790
  }
1570
1791
  stage.status = "failed";
1571
1792
  stage.outcome = stop === "completed" && text ? "insubstantial" : stop || "error";
1793
+ const errDetail = result && result.error;
1572
1794
  if (stage.outcome === "insubstantial") {
1573
- stage.summary = "产出未通过实质校验(含拒绝措辞或内容过短),视为未交付";
1574
- journal.logs.push({
1575
- t: Date.now(),
1576
- level: "warn",
1577
- message: `${label} 产出未通过实质校验(拒绝措辞/内容过短)`
1578
- });
1795
+ const hit = refusalHit(text);
1796
+ if (hit) {
1797
+ stage.summary = `产出未通过实质校验:命中拒绝词「${hit.phrase}」(原文:${hit.context}),视为未交付`;
1798
+ journal.logs.push({
1799
+ t: Date.now(),
1800
+ level: "warn",
1801
+ message: `${label} 产出命中拒绝词「${hit.phrase}」`
1802
+ });
1803
+ } else {
1804
+ stage.summary = `产出未通过实质校验:内容过短(${text.trim().length} 字符 < ${STAGE_MIN_LENGTH[phase] ?? 100} 下限),视为未交付`;
1805
+ journal.logs.push({
1806
+ t: Date.now(),
1807
+ level: "warn",
1808
+ message: `${label} 产出过短(${text.trim().length} 字符),未通过实质校验`
1809
+ });
1810
+ }
1811
+ if (text) stage.output = clip(text, 4e3);
1579
1812
  } else {
1580
- stage.summary = `未产出有效结果(stopReason=${stop || "unknown"})`;
1813
+ stage.summary = `未产出有效结果(stopReason=${stop || "unknown"}${errDetail ? `,error=${String(errDetail).slice(0, 200)}` : ""})`;
1581
1814
  journal.logs.push({
1582
1815
  t: Date.now(),
1583
1816
  level: "error",
1584
- message: `${label} 未产出有效结果(stopReason=${stop || "unknown"})`
1817
+ message: `${label} ${stage.summary}`
1585
1818
  });
1819
+ if (text) stage.output = clip(text, 4e3);
1586
1820
  }
1587
1821
  return null;
1588
1822
  } catch (e) {
@@ -1612,23 +1846,29 @@ async function runAgent(journal, parent, label, phase, prompt, signal) {
1612
1846
  }
1613
1847
  }
1614
1848
  /** 单阶段重试 + token 熔断(官方口径:input+cacheRead+cacheWrite+output 累计)。 */
1615
- async function withRetry(journal, parent, label, phase, prompt, signal) {
1849
+ async function withRetry(journal, parent, label, phase, prompt, signal, taskKey) {
1616
1850
  let attempts = 0;
1617
1851
  let stageTokens = 0;
1852
+ let lastStage = null;
1618
1853
  for (let attempt = 1; attempt <= 2; attempt++) {
1619
1854
  attempts = attempt;
1620
- const result = await runAgent(journal, parent, attempt > 1 ? `${label}(第 ${attempt} 次重试)` : label, phase, prompt, signal);
1621
- const lastStage = journal.stages[journal.stages.length - 1];
1855
+ const labelNow = attempt > 1 ? `${label}(第 ${attempt} 次重试)` : label;
1856
+ const promptNow = attempt > 1 && lastStage ? prompt + buildRetryDiagnostic(attempt, lastStage) : prompt;
1857
+ const beforeLen = journal.stages.length;
1858
+ const result = await runAgent(journal, parent, labelNow, phase, promptNow, signal, taskKey);
1859
+ lastStage = journal.stages[beforeLen] || null;
1622
1860
  if (lastStage && lastStage.phase === phase) stageTokens += totalTokensOf(lastStage.usage);
1623
1861
  if (result) return {
1624
1862
  text: result,
1625
1863
  attempts,
1626
- stageTokens
1864
+ stageTokens,
1865
+ stage: lastStage
1627
1866
  };
1628
1867
  if (journal.cancelled) return {
1629
1868
  text: null,
1630
1869
  attempts,
1631
- stageTokens
1870
+ stageTokens,
1871
+ stage: lastStage
1632
1872
  };
1633
1873
  if (lastStage && isUnretryable(lastStage.outcome, lastStage.outcome)) {
1634
1874
  journal.logs.push({
@@ -1640,7 +1880,22 @@ async function withRetry(journal, parent, label, phase, prompt, signal) {
1640
1880
  return {
1641
1881
  text: null,
1642
1882
  attempts,
1643
- stageTokens
1883
+ stageTokens,
1884
+ stage: lastStage
1885
+ };
1886
+ }
1887
+ if (lastStage && lastStage.outcome === "aborted") {
1888
+ journal.logs.push({
1889
+ t: Date.now(),
1890
+ level: "warn",
1891
+ message: `${label} 被外部中止(aborted),未正常产出——非预算问题;可 teamflow_resume 续跑(补跑失败任务,已完成任务复用)`
1892
+ });
1893
+ journal.humanIntervention = true;
1894
+ return {
1895
+ text: null,
1896
+ attempts,
1897
+ stageTokens,
1898
+ stage: lastStage
1644
1899
  };
1645
1900
  }
1646
1901
  if (lastStage && lastStage.outcome === "degenerated") {
@@ -1653,7 +1908,22 @@ async function withRetry(journal, parent, label, phase, prompt, signal) {
1653
1908
  return {
1654
1909
  text: null,
1655
1910
  attempts,
1656
- stageTokens
1911
+ stageTokens,
1912
+ stage: lastStage
1913
+ };
1914
+ }
1915
+ if (lastStage && lastStage.outcome === "stalled") {
1916
+ journal.logs.push({
1917
+ t: Date.now(),
1918
+ level: "warn",
1919
+ message: `${label} 进行中护栏中止(挂死/空转),不再自动重试(会话已无有效产出);可 teamflow_resume 以全新会话续跑`
1920
+ });
1921
+ journal.humanIntervention = true;
1922
+ return {
1923
+ text: null,
1924
+ attempts,
1925
+ stageTokens,
1926
+ stage: lastStage
1657
1927
  };
1658
1928
  }
1659
1929
  if (stageTokens >= 6e4) {
@@ -1666,13 +1936,14 @@ async function withRetry(journal, parent, label, phase, prompt, signal) {
1666
1936
  return {
1667
1937
  text: null,
1668
1938
  attempts,
1669
- stageTokens
1939
+ stageTokens,
1940
+ stage: lastStage
1670
1941
  };
1671
1942
  }
1672
1943
  if (attempt < 2) journal.logs.push({
1673
1944
  t: Date.now(),
1674
1945
  level: "warn",
1675
- message: `${label} 第 ${attempt} 次尝试未成功,自动重试…`
1946
+ message: `${label} 第 ${attempt} 次尝试未成功(${lastStage ? lastStage.outcome || "unknown" : "unknown"}),自动重试(重试 prompt 已附上一轮失败诊断)…`
1676
1947
  });
1677
1948
  else {
1678
1949
  journal.logs.push({
@@ -1686,7 +1957,8 @@ async function withRetry(journal, parent, label, phase, prompt, signal) {
1686
1957
  return {
1687
1958
  text: null,
1688
1959
  attempts,
1689
- stageTokens
1960
+ stageTokens,
1961
+ stage: lastStage
1690
1962
  };
1691
1963
  }
1692
1964
  //#endregion
@@ -1868,6 +2140,17 @@ const STATE_BLOCK_INSTRUCTION = `\n\n[STATE BLOCK · mandatory at the end] Appen
1868
2140
  * - 回归基线:新 PRD 头部「基线依赖:<其他任务夹>」声明;跨代变更用「取代:<夹>#AC-n」;
1869
2141
  * 硬保障在项目 verify-* 可执行套件。
1870
2142
  * - 命令输出日志照旧收口 logs/teamflow/<runId>/。
2143
+ *
2144
+ * 【约束分级约定】(2026-09-03,防 high-signal 词脱敏)
2145
+ * - [HOST-ENFORCED]:host 有真实校验/解析/硬失败后果(如单轨产物文件缺失→needs-human 停线、
2146
+ * 验收结论行缺失→需人工确认)。标注后**必须**在同一句内描述真实后果(缺失=停线),
2147
+ * 不得只堆措辞。新增此类约束 = 先加 host 代码再标词。
2148
+ * - [policy]:无 host 强制,靠模型自律 + guard 观测注入(warn + 轻提醒,从不中断)。
2149
+ * 标注时描述真实机制(warn-only / cache 重放费),不声称「hard constraint」。
2150
+ * - 禁止:prompt 内自称 hard constraint——措辞层面「hard」与 enforcement 脱节会训练模型
2151
+ * 对 high-signal 词脱敏(实证:17 条 warn 零削减,guard 注入闭环后才见效)。
2152
+ * 【中英混排纪律】约束句/标签用英文(模型对英文指令注意力高)、被约束对象/内容用中文;
2153
+ * 列表分隔符随内容语言(文件路径等 ASCII 内容用英文逗号),不混用中文标点。
1871
2154
  */
1872
2155
  /** 产品层文档根(memory.md 等跨任务资产;任务产物在其中的任务夹内)。 */
1873
2156
  const TF_DOCS = "docs/teamflow";
@@ -1955,8 +2238,8 @@ function productCtx(root) {
1955
2238
  Before starting: read ${base}/AGENTS.md (team rules & doc index — read the summary first, then details on demand; no aimless full reads).
1956
2239
  [Task-folder docs · ADR-0008] Each requirement gets a self-contained task folder docs/teamflow/<yyyyMMdd-rN-slug>/ (folder path given per stage below); ALL artifacts of this requirement (PRD/DESIGN/TECHNICAL/QA-REPORT/ACCEPTANCE) live inside it. The folder is immutable after creation — **no archiving, no versioning** — retries/resumes write to the same folder. Cross-requirement product docs only: ${TF_DOCS}/memory.md (conventions/todos) and architecture/.
1957
2240
  [Baseline] New PRD declares "基线依赖:<prior task folder>" at top; cross-generation behavior changes are explicitly marked "取代:<folder>#AC-n" — historical folders are never modified.
1958
- [Doc boundary · hard] TeamFlow contract docs are written ONLY under ${base}/${TF_DOCS}/ (create dirs if missing); **never write host docs/<role>/ and never scatter log files at project root**; command output logs go to logs/teamflow/<runId>/.
1959
- [AGENTS.md boundary · hard] AGENTS.md is team property (injected unconditionally — consensus/index/managed zone only): **do NOT append changelog-style sections during iterations (product memory / todos / change log)** — such data belongs in ${TF_DOCS}/memory.md and task folders; besides the <!-- teamflow:begin/end --> managed zone, no stage may rewrite, reorder, or overwrite any other part of AGENTS.md.
2241
+ [Doc boundary · policy] TeamFlow contract docs are written ONLY under ${base}/${TF_DOCS}/ (create dirs if missing); **never write host docs/<role>/ and never scatter log files at project root**; command output logs go to logs/teamflow/<runId>/.
2242
+ [AGENTS.md boundary · policy] AGENTS.md is team property (injected unconditionally — consensus/index/managed zone only): **do NOT append changelog-style sections during iterations (product memory / todos / change log)** — such data belongs in ${TF_DOCS}/memory.md and task folders; besides the <!-- teamflow:begin/end --> managed zone, no stage may rewrite, reorder, or overwrite any other part of AGENTS.md.
1960
2243
  Backlog (req/task/bug) source of truth is the persisted mirror $DSH_HOME/teamflow/<workspace>/ under ${base}/backlog/: single rotating task card model (待办→开发中→待测试→测试中→待验收→已验收); devAssign/qaAssign live on the task card.
1961
2244
  `;
1962
2245
  }
@@ -1966,7 +2249,7 @@ function headTailClip(text, head, tail) {
1966
2249
  if (s.length <= head + tail) return s;
1967
2250
  return s.slice(0, head) + "\n...\n[CHANGED SECTION]\n" + s.slice(-tail);
1968
2251
  }
1969
- const TOKEN_HYGIENE = (runId) => `[TOKEN HYGIENE · hard constraint] Context is expensive. Budget discipline below (violations only log a warning, never interrupt):
2252
+ const TOKEN_HYGIENE = (runId) => `[TOKEN HYGIENE · policy] Context is expensive. Budget discipline below host enforcement is warn + live reminder only (never interrupts), follow it as self-discipline:
1970
2253
  - [File scope] Whole-file read is allowed ONLY for target files explicitly listed in the task spec. To understand other files' interfaces, use grep for keywords (do not read whole files). Never whole-file read source files outside the task scope.
1971
2254
  - [No duplicate reads] Same file: read ≤1 times. To verify a change, grep the change point instead of re-reading the whole file.
1972
2255
  - [grep first] Before writing code, locate with one comprehensive grep pass, then batch-read in segments; avoid repeated small read/grep passes on the same file.
@@ -1978,7 +2261,7 @@ const TOKEN_HYGIENE = (runId) => `[TOKEN HYGIENE · hard constraint] Context is
1978
2261
  - The contract/AC for this iteration is in the context/handoff below or in this task folder's PRD: do NOT whole-file re-read PRD.md / DESIGN.md / TECHNICAL.md from the task folder; grep/read only the code you need.
1979
2262
  `;
1980
2263
  /** 一次成型纪律:目标文档 write ≤1 次 + read ≤2 次,严禁 read→edit→read 循环。 */
1981
- const ONCE_DISCIPLINE = `[ONE-SHOT WRITE · hard constraint] The most important efficiency rule; violating it burns tokens:
2264
+ const ONCE_DISCIPLINE = `[ONE-SHOT WRITE · policy] The most important efficiency rule; repeated write/read cycles pay cache replay fees (warn + reminder at 3rd read, never interrupt):
1982
2265
  - The target delivery doc (PRD/DESIGN/TECHNICAL/QA-REPORT/ACCEPTANCE/memory) allows only **1 write of the complete new version** + **at most 2 reads** (1 to confirm structure before writing, ≤1 to verify format after).
1983
2266
  - **No read→edit→read loops**: never reopen the same document to "tweak"; never re-read the whole file just to confirm a change.
1984
2267
  - Use grep + limited segments for details; never whole-file read big documents.
@@ -2081,7 +2364,7 @@ ${clip(prd, 12e3)}
2081
2364
  const devPrompt = (task, tech, prd, root, runId, state) => `You are a senior full-stack engineer (implementation executor). The current workspace IS the target project — actually implement the following task.
2082
2365
  ${productCtx(root)}${stateSliceFor(state, "dev")}${TOKEN_HYGIENE(runId)}[CONTEXT PACK]
2083
2366
  [TASK TITLE] ${task.title}
2084
- ${task.files && task.files.length ? `[TASK TARGET FILES] ${task.files.join("")}` : ""}
2367
+ ${task.files && task.files.length ? `[TASK TARGET FILES] ${task.files.join(", ")}` : ""}
2085
2368
  [TASK BRIEF] ${task.spec || "(see technical design)"}
2086
2369
  ${tech && String(tech).trim() ? `[TECH DESIGN SUMMARY (grep details on demand, don't full re-read)]
2087
2370
  ${clip(tech, 12e3)}` : ""}
@@ -2092,9 +2375,14 @@ ${clip(tech, 12e3)}` : ""}
2092
2375
  3. If spec contradicts reality, explain with evidence in the summary instead of claiming completion or expanding scope on your own.
2093
2376
  4. Actually write/modify code (grep + segmented reads to locate; no repeated whole-file reads), then run relevant build/verification to ensure green.
2094
2377
  5. [Engineering action execution] If task spec or PRD 工程约束 includes git actions (e.g. new branch): **execute the action BEFORE writing code** (e.g. git checkout -b <branch>); if the workspace carries unrelated uncommitted changes, do NOT commit/clean them — state the situation in the summary.
2095
- 5b. [Git discipline · hard (ADR-2026-08-27, 统一收口提交)] Work ONLY on the current branch: **never** git checkout main / merge / rebase / delete-branch / commit — main-branch actions and the final commit are performed by the host after acceptance (one commit per run: code + task-folder docs together). Just write/modify files; leave everything uncommitted. If a task asks for "merge back to main" or "commit", treat it as "prepare the delivery" (files ready + summary of what was done), do NOT commit or merge.
2378
+ 5b. [Git discipline · policy (ADR-2026-08-27, 统一收口提交)] Work ONLY on the current branch: **never** git checkout main / merge / rebase / delete-branch / commit — main-branch actions and the final commit are performed by the host after acceptance (one commit per run: code + task-folder docs together). Just write/modify files; leave everything uncommitted. If a task asks for "merge back to main" or "commit", treat it as "prepare the delivery" (files ready + summary of what was done), do NOT commit or merge.
2096
2379
  6. [Log discipline] Redirect command output to logs/teamflow/${runId || "<runId>"}/.
2097
- 7. Output an implementation summary (≤40 lines): changed files, key implementation points, how verified, leftovers. No big code pastes.
2380
+ 7. Output an implementation summary (≤40 lines): changed files, key implementation points, leftovers. No big code pastes.
2381
+ 7b. [Verification evidence · policy] **Mandatory block at the end of the reply (before the state block)** — host stores it verbatim for audit, cross-checkable against your command output in logs/teamflow/${runId || "<runId>"}/; missing block = contract not honored (warn only, never interrupts):
2382
+ [Verification evidence]
2383
+ - cmd: <exact command> → exit <code>, <passed>/<failed> asserts (<file>:<line> for failures)
2384
+ - ...(one line per verification run)
2385
+ - N/A: <explicit reason>(when nothing runnable — pure config/docs change, no test suite, etc.)
2098
2386
  8. [State] End with a state block (phase="dev"), touched = array of changed files, summary = implementation conclusion.${STATE_BLOCK_INSTRUCTION}`;
2099
2387
  /** 视觉验证能力条款(ADR-2026-08-27,解锁 browser-use 视觉验证):
2100
2388
  * 按当前模型多模态能力动态生成——vision=true 允许截图看图(人眼类项),精确值仍走 DOM 计算断言;
@@ -2124,11 +2412,11 @@ ${clip(devSummary, 15e3)}
2124
2412
  2. [人工补测清单] Items that cannot be auto-verified (audio output / real-device: 100dvh dynamic toolbar, safe-area, multi-touch / FPS performance / screen-reader): do NOT fail them — instead list each in the report's「人工补测清单」section (acceptance criteria + method + tool), note「环境限制,非交付缺陷」, for human review.
2125
2413
  3. Read AGENTS.md §4 engineering conventions (verify commands) and the code changes first, then actually run those sandbox-legal verifications.
2126
2414
  4. [Log discipline] Redirect command output to logs/teamflow/${runId || "<runId>"}/ (e.g. qa-out.log); no scatter at project root.
2127
- 5. Output the test report (body 150 lines, verdict first): scope & environment, cases & results (pass/fail/blocked), conclusion (whether acceptance-ready).
2128
- 6. [Defect format] Report found defects as the structured table below (for direct import by the defect tracker):
2415
+ 5. [Reply = brief summary only · HOST-ENFORCED] Output a short reply (≤12 lines, Chinese): verdict one-liner (whether acceptance-ready) + the QA report path docs/teamflow/.../QA-REPORT.md. **Do NOT repeat the report body in the reply** — the host imports QA-REPORT.md as the single source of truth; missing file = hard failure (needs-human, pipeline stops).
2416
+ 6. [Defect format · HOST-ENFORCED] Report found defects as the structured table below (for direct import by the defect tracker) — the table must be in QA-REPORT.md:
2129
2417
  | 编号 | 严重级(P0/P1/P2/P3) | 功能模块 | 复现步骤 | 期望行为 | 实际行为 | 关联验收项 |
2130
2418
  If no defects: explicitly output 「未发现缺陷」.
2131
- 7. Chinese Markdown, concrete & executable; write the report to ${RUN(state)}/QA-REPORT.md (write once, tight body). [Boundary] only under ${TF_DOCS}/.
2419
+ 7. Chinese Markdown, concrete & executable; write the **complete** report to ${RUN(state)}/QA-REPORT.md (write once, tight body) — **this file IS the deliverable**: scope & environment, cases & results (pass/fail/blocked), 人工补测清单, defect table (if any), conclusion (whether acceptance-ready). [Boundary] only under ${TF_DOCS}/.
2132
2420
  8. [State] End with a state block (phase="qa"), summary = test conclusion / blocked items, extra = { "verifyScripts": [...] }.${STATE_BLOCK_INSTRUCTION}`;
2133
2421
  /** QA 打回后的开发修复 prompt:确认缺陷是否属实 → 修复 → 复验交接(QA→dev 打回闭环用)。 */
2134
2422
  const qaFixPrompt = (defects, qa, tech, prd, root, runId, state) => `You are a senior full-stack engineer. The QA report points out several defects — **confirm each one** and fix them, then hand back for QA re-verification.
@@ -2145,7 +2433,12 @@ ${tech && String(tech).trim() ? clip(tech, 12e3) : ""}
2145
2433
  — confirmed → fix it directly; QA false positive / contradicts reality → state evidence explicit in the summary (no fabricated changes, and no ignoring real defects either).
2146
2434
  2. Touch ONLY defect-related files (grep to locate; no whole-file reads of irrelevant big files); respect existing architecture & code style.
2147
2435
  3. After fixing, run relevant verification to ensure green (regression floor: existing verify suites pass untouched); redirect output to logs/teamflow/${runId || "<runId>"}/.
2148
- 4. Output a fix summary (≤40 lines, Chinese): per defect —「truth judgment + fix」or「false-positive evidence」, changed files, how verified, leftovers. No big code pastes.
2436
+ 4. Output a fix summary (≤40 lines, Chinese): per defect —「truth judgment + fix」or「false-positive evidence」, changed files, leftovers. No big code pastes.
2437
+ 4b. [Verification evidence · policy] **Mandatory block at the end of the reply (before the state block)** — host stores it verbatim for audit, cross-checkable against your command output in logs/teamflow/${runId || "<runId>"}/; missing block = contract not honored (warn only, never interrupts):
2438
+ [Verification evidence]
2439
+ - cmd: <exact command> → exit <code>, <passed>/<failed> asserts (<file>:<line> for failures)
2440
+ - ...(one line per verification run; the re-verified defect cases must be listed)
2441
+ - N/A: <explicit reason>(when nothing runnable — pure config/docs change, no test suite, etc.)
2149
2442
  5. [State] End with a state block (phase="dev"), touched = changed files array, summary = fix conclusion.${STATE_BLOCK_INSTRUCTION}`;
2150
2443
  const acceptancePrompt = (prd, qa, devSummary, root, runId, state, vision) => `You are the product manager (acceptance lead). Do a final acceptance of this delivery against the PRD acceptance criteria.
2151
2444
  ${productCtx(root)}${stateSliceFor(state, "acceptance")}${TOKEN_HYGIENE(runId)}
@@ -2162,14 +2455,14 @@ ${vision ? "[Visual re-check] If QA saved screenshots under the task folder, spo
2162
2455
  - Any obvious **duplicated implementation / adapter drift / broken existing structure** (this is a code-quality floor, not optional).
2163
2456
  - **Verdict impact**: only functionally green but with 「deviates from blueprint / duplicated impl / should-have-extracted」 → verdict should be **⚠️ 有条件通过** (architecture rework items listed, re-accept after rework); **significant deviation / broken structure → ❌ 不通过**. Never treat "verify all green" as the sole evidence of "no rework needed".
2164
2457
  1. Verify each PRD acceptance criterion one by one.
2165
- 2. Output the acceptance verdict (body 80 lines): 通过 / ⚠️ 有条件通过 / ❌ 不通过 / 📝 需求不适用, with a per-criterion check table, opinions & leftovers.
2458
+ 2. [Reply = brief summary only · HOST-ENFORCED] Output a short reply (≤10 lines, Chinese): **verdict line — verbatim: 验收结论:✅ 通过 ⚠️ 有条件通过 ❌ 不通过 📝 需求不适用**(pick one)+ the acceptance report path docs/teamflow/.../ACCEPTANCE.md. **Do NOT repeat the report body in the reply** — the host imports ACCEPTANCE.md as the single source of truth.
2166
2459
  3. [Not-applicable judgment] If the PRD/tech-change/confirm doc already states「需求与现状不符」, or the dev result is explicitly「无需改动」, the verdict must be **「📝 需求不适用」** with reasons — do NOT mark ✅ 通过 just for "no defects".
2167
- 4. [Acceptance report] Write to ${RUN(state)}/ACCEPTANCE.md (write once, matching the body). [Memory write-back · convention changes ONLY] Update docs/teamflow/memory.md only if this requirement introduces new conventions/tech-stack decisions, or the 已知待办 list changes (same-topic line replace, idempotent, no changelog appending); otherwise don't touch memory. [Boundary] only under ${TF_DOCS}/; never modify AGENTS.md beyond the <!-- teamflow --> managed zone.
2460
+ 4. [Acceptance report · HOST-ENFORCED] Write the **complete** report to ${RUN(state)}/ACCEPTANCE.md (write once) — **this file IS the deliverable**: verdict line, per-criterion check table, opinions & leftovers. **The verdict line MUST be the LAST line of the file, verbatim one of: 验收结论:✅ 通过 / 验收结论:⚠️ 有条件通过 / 验收结论:❌ 不通过 / 验收结论:📝 需求不适用** — the host parses ONLY this line; missing it = contract violation → the run stops for human review; missing file = hard failure (needs-human, pipeline stops). [Memory write-back · convention changes ONLY] Update docs/teamflow/memory.md only if this requirement introduces new conventions/tech-stack decisions, or the 已知待办 list changes (same-topic line replace, idempotent, no changelog appending); otherwise don't touch memory. [Boundary] only under ${TF_DOCS}/; never modify AGENTS.md beyond the <!-- teamflow --> managed zone.
2168
2461
  5. Chinese Markdown.
2169
2462
  6. [State] End with a state block (phase="acceptance"), summary = acceptance conclusion, verdict = "accepted/rework/reject/needs-human", extra.done = confirmation of this delivery.${STATE_BLOCK_INSTRUCTION}`;
2170
2463
  /** 需求分诊模型 prompt(模型驱动 triage;供 core/triage.runTriage 使用)。 */
2171
- const TRIAGE_PROMPT = (requirement, opts, pre) => `You are a senior research-dev triage analyst. Do ONE thing: analyze which pipeline mode this dev requirement fits, then give the conclusion. No code, no scope speculation.
2172
- [RAW REQUIREMENT]
2464
+ const TRIAGE_PROMPT = (requirement, opts, pre, retryHint) => `You are a senior research-dev triage analyst. Do ONE thing: analyze which pipeline mode this dev requirement fits, then give the conclusion. No code, no scope speculation.
2465
+ ${retryHint ? `[RETRY — YOUR LAST REPLY FAILED]\n${retryHint}\n` : ""}[RAW REQUIREMENT]
2173
2466
  ${requirement}
2174
2467
  ${pre.rationale.length ? `\n[REGEX PRE-FILTER SIGNALS (reference only; judge semantically, don't blindly follow)]\n${pre.rationale.join("\n")}` : ""}
2175
2468
  \n[OPTIONAL SIGNAL] UI work needed: ${opts && opts.needDesign ? "yes" : "not flagged"}
@@ -2183,12 +2476,12 @@ ${pre.rationale.length ? `\n[REGEX PRE-FILTER SIGNALS (reference only; judge sem
2183
2476
 
2184
2477
  [JUDGMENT POINTS]
2185
2478
  1. Distinguish "user-visible functional change" vs "internal tech change": refactors/optimizations, even large code volume, usually go tech, not full.
2186
- 2. UI/visual/interaction/buttons/pages involved → at least medium (excludes patch/tech).
2479
+ 2. **UI feature work** (new screens/components/interactions/pages) → at least medium (excludes patch/tech). **UI micro-adjustments** — moving a button, relocating a control, changing copy/labels, spacing/padding, color tweaks, "move the button", "switch the button position" — are patch-or-lite material, NOT medium: no new interaction logic, no design phase worth the token cost. The line: does it change behavior/interaction logic (medium) or just placement/appearance of existing elements (patch/lite)?
2187
2480
  3. hotfix/single-point/pure numeric/pure docs → patch; clear "add feature X" → pick lite/medium/full by size.
2188
2481
  4. Focused change (even with tests/regression) → lite/tech by nature; not necessarily full.
2189
2482
  5. [M1 ARCHITECTURE CRITERION (important)] **Architecture-level changes** — persistence/localStorage/database/standalone module/abstraction/cross-many-files without an existing reusable wrapper (like a localStorage wrapper, storage layer, state management) — even if they look like "small features", go **at least medium** (must pass the architecture stage and produce a blueprint, avoiding scattered local implementations by dev); such changes collapse under a light "micro feature" tier. Tech-driven rework (refactor/optimize/arch upgrade) is itself tech (tech also runs the lightweight blueprint now).
2190
2483
 
2191
- [OUTPUT] one JSON object only (no other text):
2484
+ [OUTPUT] JSON object ONLY no commentary, no preface, no closing text. The FIRST character of your reply must be '{'. Do NOT say anything like "here is the JSON" or "Let me output the JSON" — output the object itself:
2192
2485
  { "mode": "patch|lite|tech|medium|full", "slug": "<topic words> (3-24 lowercase letters/digits/hyphens, e.g. wallkick-toggle, 7bag-random; used to name the task folder)", "kind": "one-word nature", "needDesign": true|false, "complexity": "small|medium|large", "rationale": ["key argument 1","key argument 2"], "confidence": "high|medium|low" }`;
2193
2486
  /** tech 档 PRD:技术变更单(无功能 AC,重范围/目标/改动面/回归)。 */
2194
2487
  const techChangePrompt = (requirement, root, runId, state) => `You are the senior tech lead. The current workspace IS the target project. This is a **tech-driven rework** requirement (refactor/optimize/upgrade/architecture/dependencies/tech debt) — the product doesn't need a full feature PRD, but needs a **技术变更单** (tech change sheet) as the contract for dev/QA/acceptance and memory write-back.
@@ -2268,140 +2561,72 @@ const MODE_REGISTRY = {
2268
2561
  desc: "单行/常量/版本号/hotfix:单 agent 直改+自测即交付(无独立 QA)"
2269
2562
  }
2270
2563
  };
2271
- /** 关键词信号集(命中 加重某档倾向)。 */
2272
- const SIGNALS = [
2273
- {
2274
- mode: "patch",
2275
- words: [
2276
- "hotfix",
2277
- "热修",
2278
- "修复 bug",
2279
- "修 bug",
2280
- "改个 bug",
2281
- "版本号",
2282
- "常量",
2283
- "一行",
2284
- "bump",
2285
- "回滚",
2286
- "卫生",
2287
- "笔误",
2288
- "拼写"
2289
- ]
2290
- },
2291
- {
2292
- mode: "tech",
2293
- words: [
2294
- "重构",
2295
- "优化",
2296
- "升级",
2297
- "架构",
2298
- "性能",
2299
- "依赖",
2300
- "脚手架",
2301
- "缓存",
2302
- "清理",
2303
- "遗留债",
2304
- "工程",
2305
- "内部改造",
2306
- "技术债"
2307
- ]
2308
- },
2309
- {
2310
- mode: "lite",
2311
- words: [
2312
- "微功能",
2313
- "小功能",
2314
- "小增强",
2315
- "加个",
2316
- "补一个",
2317
- "轻微",
2318
- "顺手"
2319
- ]
2320
- },
2321
- {
2322
- mode: "medium",
2323
- words: [
2324
- "界面",
2325
- "UI",
2326
- "视觉",
2327
- "页面",
2328
- "按钮",
2329
- "样式",
2330
- "交互",
2331
- "布局",
2332
- "组件",
2333
- "持久化",
2334
- "localStorage",
2335
- "本地存储",
2336
- "存储",
2337
- "保存",
2338
- "恢复",
2339
- "存档",
2340
- "独立模块",
2341
- "抽象",
2342
- "存储层",
2343
- "sessionStorage",
2344
- "IndexedDB",
2345
- "跨模块",
2346
- "数据层"
2347
- ]
2348
- },
2349
- {
2350
- mode: "full",
2351
- words: [
2352
- "跨模块",
2353
- "完整",
2354
- "大型",
2355
- "对接",
2356
- "集成",
2357
- "模块化",
2358
- "重构为",
2359
- "二期",
2360
- "平台"
2361
- ]
2362
- }
2564
+ /** 确定性护栏关键词(双语,仅 fallback 兜底用;主路由是模型——TRIAGE_PROMPT 语义判断)。
2565
+ * 架构信号:持久化/存储/独立模块/抽象/跨模块——防「轻档位局部实现塌方」(M1 架构护栏)。
2566
+ * UI 信号:UI 相关需求不得落 patch/tech(无设计/QA 的档位)——最低 lite。 */
2567
+ const ARCH_SIGNALS = [
2568
+ "持久化",
2569
+ "存储",
2570
+ "保存",
2571
+ "恢复",
2572
+ "存档",
2573
+ "独立模块",
2574
+ "抽象",
2575
+ "存储层",
2576
+ "localStorage",
2577
+ "sessionStorage",
2578
+ "IndexedDB",
2579
+ "跨模块",
2580
+ "数据层",
2581
+ "persistence",
2582
+ "storage",
2583
+ "database",
2584
+ "数据库",
2585
+ "standalone module",
2586
+ "abstraction"
2587
+ ];
2588
+ const UI_SIGNALS = [
2589
+ "界面",
2590
+ "UI",
2591
+ "视觉",
2592
+ "页面",
2593
+ "按钮",
2594
+ "样式",
2595
+ "交互",
2596
+ "布局",
2597
+ "组件",
2598
+ "page",
2599
+ "button",
2600
+ "style",
2601
+ "layout",
2602
+ "component",
2603
+ "visual",
2604
+ "interaction"
2363
2605
  ];
2364
- /** 对原始需求做启发式分诊。返回建议 mode + 判定理由 + 置信。 */
2606
+ /** 对原始需求做启发式分诊(兜底路径专用)。返回建议 mode + 判定理由 + 置信。
2607
+ * 只做确定性护栏(架构强升/UI 禁轻档/needDesign 升档)——不再逐词匹配五档信号:
2608
+ * 主路由是模型(TRIAGE_PROMPT 语义判断,天然双语),正则兜底在模型不可用时宁重勿漏(默认 full)。 */
2365
2609
  function suggestMode(requirement, opts) {
2366
2610
  const text = String(requirement || "");
2367
- const hits = [];
2368
- for (const sig of SIGNALS) for (const w of sig.words) if (text.includes(w)) hits.push({
2369
- mode: sig.mode,
2370
- word: w
2371
- });
2372
- const rationale = hits.map((h) => `命中「${h.word}」→ ${h.mode}`);
2373
- const countOf = (m) => hits.filter((h) => h.mode === m).length;
2611
+ const rationale = [];
2374
2612
  let mode = "full";
2375
- if (countOf("patch") > 0) mode = "patch";
2376
- else if (countOf("tech") > 0 && countOf("medium") === 0 && countOf("full") === 0) mode = "tech";
2377
- else if (countOf("lite") > 0 && countOf("medium") === 0) mode = "lite";
2378
- else if (countOf("medium") > 0 || opts && opts.needDesign) mode = "medium";
2379
- else if (hits.length === 0) mode = "full";
2380
- if ((mode === "patch" || mode === "tech") && opts && opts.needDesign) mode = "medium";
2381
- if (mode === "patch" && (countOf("tech") > 0 || text.includes("新增") || text.includes("功能"))) mode = "lite";
2382
- const architectureSignals = [
2383
- "持久化",
2384
- "localStorage",
2385
- "本地存储",
2386
- "存储",
2387
- "保存",
2388
- "恢复",
2389
- "存档",
2390
- "独立模块",
2391
- "抽象",
2392
- "存储层",
2393
- "sessionStorage",
2394
- "IndexedDB",
2395
- "跨模块",
2396
- "数据层"
2397
- ];
2398
- if ((mode === "lite" || mode === "tech" || mode === "patch") && architectureSignals.some((w) => text.includes(w))) {
2613
+ const archHit = ARCH_SIGNALS.find((w) => text.includes(w));
2614
+ if (archHit) {
2615
+ mode = "medium";
2616
+ rationale.push(`架构护栏:需求含「${archHit}」→ 强升 medium(需架构阶段产蓝图,防塌)`);
2617
+ }
2618
+ const uiHit = !archHit ? UI_SIGNALS.find((w) => text.includes(w)) : void 0;
2619
+ if (uiHit) {
2620
+ mode = mode === "full" ? "lite" : mode;
2621
+ rationale.push(`UI 护栏:需求含「${uiHit}」→ 不低于 lite(UI 改动需 QA/验收)`);
2622
+ }
2623
+ if (opts && opts.needDesign && mode !== "medium") {
2399
2624
  mode = "medium";
2400
- rationale.push(`架构护栏:需求含「${architectureSignals.find((w) => text.includes(w))}」→ 强升 medium(需架构阶段)`);
2625
+ rationale.push("needDesign=true 强升 medium(显式要求设计阶段)");
2401
2626
  }
2402
- const kind = mode === "patch" ? "热修/单点" : mode === "tech" ? "技术驱动改造" : mode === "lite" ? "微功能" : mode === "medium" ? "标准功能(含UI)" : "完整需求";
2403
- const confidence = mode === "patch" ? "high" : hits.length >= 2 ? "medium" : "low";
2404
- if (rationale.length === 0) rationale.push("无强信号,默认 full(护栏优先)");
2627
+ if (rationale.length === 0) rationale.push("无强护栏信号,默认 full(模型不可用时宁重勿漏)");
2628
+ const kind = mode === "medium" ? "标准功能(含UI)" : mode === "lite" ? "微功能" : "完整需求";
2629
+ const confidence = mode === "medium" && !opts?.needDesign ? "medium" : "low";
2405
2630
  return {
2406
2631
  mode,
2407
2632
  kind,
@@ -2459,29 +2684,36 @@ function fallbackVerdict(requirement, opts) {
2459
2684
  source: "fallback"
2460
2685
  };
2461
2686
  }
2462
- /** 模型驱动分诊:spawn「分诊分析师」子代理思考一轮;子代理不可用/超时/解析失败 → 正则兜底。 */
2687
+ /** 模型驱动分诊:spawn「分诊分析师」子代理思考一轮;子代理不可用/超时/解析失败 → 正则兜底。
2688
+ * 解析失败先带纠错提示重试一次(实锤 run tf-mtfo8exi:模型输出「Let me output the JSON.」开场白
2689
+ * 后无 JSON——首轮输出预算被思考耗尽/模型停早;重试提示直接输出 JSON 对象本身)。 */
2463
2690
  async function runTriage(requirement, opts, parent, signal) {
2464
2691
  const subagents = runtime.subagents;
2465
2692
  if (!subagents || typeof subagents.start !== "function") return fallbackVerdict(requirement, opts);
2466
2693
  const pre = suggestMode(requirement, opts);
2467
- let run = null;
2468
- try {
2469
- run = await subagents.start(providerName(), {
2470
- label: "需求分诊",
2471
- prompt: [{
2472
- type: "text",
2473
- text: TRIAGE_PROMPT(requirement, opts, pre)
2474
- }],
2475
- parent,
2476
- signal
2477
- });
2478
- const result = await Promise.race([run.result, new Promise((_, rej) => setTimeout(() => rej(/* @__PURE__ */ new Error("triage 超时(90s)")), 9e4))]);
2479
- const parsed = parseVerdictText(extractText(result && result.output));
2480
- if (parsed) return parsed;
2481
- } catch (e) {} finally {
2482
- if (run && run.dispose) try {
2483
- await run.dispose();
2484
- } catch (e) {}
2694
+ for (let attempt = 1; attempt <= 2; attempt++) {
2695
+ let run = null;
2696
+ try {
2697
+ const hint = attempt > 1 ? "Your previous reply contained only a preface (e.g. \"Let me output the JSON.\") with NO JSON object — that is a failed reply. Reply now with the JSON object ITSELF as the first and only content, starting with {." : "";
2698
+ run = await subagents.start(providerName(), {
2699
+ label: "需求分诊",
2700
+ prompt: [{
2701
+ type: "text",
2702
+ text: TRIAGE_PROMPT(requirement, opts, pre, hint)
2703
+ }],
2704
+ parent,
2705
+ signal
2706
+ });
2707
+ const result = await Promise.race([run.result, new Promise((_, rej) => setTimeout(() => rej(/* @__PURE__ */ new Error("triage 超时(90s)")), 9e4))]);
2708
+ const parsed = parseVerdictText(extractText(result && result.output));
2709
+ if (parsed) return parsed;
2710
+ } catch (e) {
2711
+ if (attempt === 2) {}
2712
+ } finally {
2713
+ if (run && run.dispose) try {
2714
+ await run.dispose();
2715
+ } catch (e) {}
2716
+ }
2485
2717
  }
2486
2718
  return fallbackVerdict(requirement, opts);
2487
2719
  }
@@ -2612,10 +2844,10 @@ function buildResumeProducts(journal) {
2612
2844
  const products = {};
2613
2845
  for (const s of journal.stages) {
2614
2846
  if (s.status !== "done" || !s.output) continue;
2615
- const key = PHASE_KEY_BY_NAME[s.phase];
2847
+ const key = phaseKeyOf(s.phase);
2616
2848
  if (!key) continue;
2617
- if (key === "dev") products.dev = journal.stages.filter((x) => x.phase === "开发" && x.status === "done" && x.output).map((x) => ({
2618
- title: x.label.replace(/^开发 · /, ""),
2849
+ if (key === "dev") products.dev = journal.stages.filter((x) => phaseKeyOf(x.phase) === "dev" && x.status === "done" && x.output).map((x) => ({
2850
+ title: x.taskKey || x.label.replace(/^开发 · /, ""),
2619
2851
  failed: false,
2620
2852
  output: x.output
2621
2853
  }));
@@ -2623,6 +2855,19 @@ function buildResumeProducts(journal) {
2623
2855
  }
2624
2856
  return products;
2625
2857
  }
2858
+ /** 任务夹产物读取(单轨契约:文件即产物——QA/验收 host 只读文件,回复仅摘要)。
2859
+ * 缺失/空/读取异常返回 null(调用方决定硬失败或 journal 兜底)。 */
2860
+ function artifactText(journal, fileName) {
2861
+ const path = journal && journal.workspacePath && journal.runDocs ? `${journal.workspacePath}/${journal.runDocs}/${fileName}` : null;
2862
+ if (!path) return null;
2863
+ try {
2864
+ if (!existsSync(path)) return null;
2865
+ const t = readFileSync(path, "utf8").trim();
2866
+ return t ? t : null;
2867
+ } catch (e) {
2868
+ return null;
2869
+ }
2870
+ }
2626
2871
  /**
2627
2872
  * 断点续跑起点:第一个「没有任意 done 尝试」的阶段。
2628
2873
  * ⚠️ 按阶段而非尝试判断(实锤 tf-mtcomxpq):PRD 第 1 次尝试 failed(护栏退化)但第 2 次重试 done——
@@ -2630,13 +2875,37 @@ function buildResumeProducts(journal) {
2630
2875
  * 全部完成仍被中断(理论极端)→ 从产品验收继续。
2631
2876
  */
2632
2877
  function interruptedPhaseOf(journal) {
2633
- if (hasOpenBlockingBugs(journal)) return "QA 测试";
2878
+ if (hasOpenBlockingBugs(journal)) return "qa";
2634
2879
  for (const phase of PHASE_ORDER) {
2635
- const phaseStages = (journal.stages || []).filter((s) => s.phase === phase);
2880
+ const phaseStages = (journal.stages || []).filter((s) => phaseKeyOf(s.phase) === phase);
2636
2881
  if (phaseStages.length === 0) continue;
2637
- if (!phaseStages.some((s) => s.status === "done")) return phase;
2882
+ if (phase === "dev") {
2883
+ if ([...devTaskStatuses(phaseStages).values()].some((st) => !st.done)) return phase;
2884
+ } else if (!phaseStages.some((s) => s.status === "done")) return phase;
2638
2885
  }
2639
- return "产品验收";
2886
+ return "acceptance";
2887
+ }
2888
+ /** 任务级聚合(journal 驱动,2026-09-06 状态机化):按 stage.taskKey(旧数据 label 兜底)分组——
2889
+ * 有 done stage = 任务已成功(历史失败尝试不算失败)。
2890
+ * resume 补跑判定/阶段完成判定共用;不读 backlog(两块业务线解耦——残留失败卡污染判定实锤 json-parse r1)。 */
2891
+ function devTaskStatuses(stages) {
2892
+ const m = /* @__PURE__ */ new Map();
2893
+ for (const s of stages || []) {
2894
+ const title = String(s.taskKey || String(s.label || "").replace(/^开发 · /, "").replace(/((?:第 \d+ 次重试|补跑))$/, "").trim());
2895
+ if (!title) continue;
2896
+ const cur = m.get(title) || {
2897
+ done: false,
2898
+ lastStatus: null,
2899
+ lastSeq: -1
2900
+ };
2901
+ if ((s.seq || 0) > cur.lastSeq) {
2902
+ cur.lastSeq = s.seq || 0;
2903
+ cur.lastStatus = s.status || null;
2904
+ }
2905
+ if (s.status === "done") cur.done = true;
2906
+ m.set(title, cur);
2907
+ }
2908
+ return m;
2640
2909
  }
2641
2910
  /** 开发任务定义(单一来源):架构蓝图自动拆 > 调用方显式 tasks > 整体开发兜底。
2642
2911
  * resume 补跑与正常执行共用(defByTitle 按 title 匹配失败子卡)。 */
@@ -2730,18 +2999,18 @@ async function executePipeline(journal, parent, requirement, options, signal, re
2730
2999
  });
2731
3000
  /** 阶段失败错误:带真实尝试次数/末次结果/累计消耗与熔断语义(取代千篇一律的「重试 N 次后仍无产出」)。 */
2732
3001
  const stageFailError = (label, r) => {
2733
- const last = [...journal.stages || []].reverse().find((s) => s.phase === label);
3002
+ const last = [...journal.stages || []].reverse().find((s) => phaseKeyOf(s.phase) === label);
2734
3003
  const attempts = r && r.attempts ? r.attempts : 2;
2735
3004
  const burnt = Math.round((r && r.stageTokens || 0) / 1e3);
2736
3005
  const breaker = (r && r.stageTokens || 0) >= 6e4 ? ",超出阶段预算熔断" : "";
2737
3006
  const detail = last ? `末次 ${last.outcome || "unknown"}${last.summary ? `(${last.summary})` : ""}` : "无阶段记录";
2738
- return /* @__PURE__ */ new Error(`${label} 阶段失败:${attempts} 次尝试未交付,${detail},累计消耗 ${burnt}k token${breaker},需人工介入`);
3007
+ return /* @__PURE__ */ new Error(`${PHASE_KEY_OF[label] || label} 阶段失败:${attempts} 次尝试未交付,${detail},累计消耗 ${burnt}k token${breaker},需人工介入`);
2739
3008
  };
2740
3009
  try {
2741
3010
  if (resume) journal.logs.push({
2742
3011
  t: Date.now(),
2743
3012
  level: "info",
2744
- message: `断点续跑:复用 backlog(req=${journal.reqId}),从「${resume.phase}」继续`
3013
+ message: `断点续跑:复用 backlog(req=${journal.reqId}),从「${PHASE_KEY_OF[resume.phase] || resume.phase}」继续`
2745
3014
  });
2746
3015
  else {
2747
3016
  const init = initPipelineBacklog(journal, requirement, options);
@@ -2904,11 +3173,25 @@ async function executePipeline(journal, parent, requirement, options, signal, re
2904
3173
  const block = extractStateBlock(output);
2905
3174
  if (block) mergeStateBlock(journal.workspace || "default", block, phaseKey);
2906
3175
  };
3176
+ const noteVerifyEvidence = (stage, output) => {
3177
+ try {
3178
+ const ev = extractVerificationEvidence(output);
3179
+ if (!ev) {
3180
+ journal.logs.push({
3181
+ t: Date.now(),
3182
+ level: "warn",
3183
+ message: `${stage ? PHASE_KEY_OF[phaseKeyOf(stage.phase)] || stage.phase : "开发"} 回复缺少 [Verification evidence] 块(契约未兑现,已记录不中断)`
3184
+ });
3185
+ return;
3186
+ }
3187
+ if (stage) stage.verifyEvidence = ev;
3188
+ } catch (e) {}
3189
+ };
2907
3190
  let prd = null;
2908
- if (resumed("PRD 产品需求")) {
3191
+ if (resumed("prd")) {
2909
3192
  prd = resume.products.prd;
2910
3193
  timeline.prd = prd;
2911
- logSkip("PRD 产品需求");
3194
+ logSkip(PHASE_KEY_OF.prd);
2912
3195
  } else {
2913
3196
  journal.logs.push({
2914
3197
  t: Date.now(),
@@ -2925,8 +3208,8 @@ async function executePipeline(journal, parent, requirement, options, signal, re
2925
3208
  label: "产品经理 · 梳理 PRD",
2926
3209
  fn: prdPrompt
2927
3210
  };
2928
- const prdR = await withRetry(journal, parent, pForm.label, "PRD 产品需求", pForm.fn(requirement, root, journal.id, state), signal);
2929
- if (!prdR.text) throw stageFailError("PRD 产品需求", prdR);
3211
+ const prdR = await withRetry(journal, parent, pForm.label, "prd", pForm.fn(requirement, root, journal.id, state), signal);
3212
+ if (!prdR.text) throw stageFailError("prd", prdR);
2930
3213
  prd = prdR.text;
2931
3214
  timeline.prd = prd;
2932
3215
  mergeStageState("prd", prd);
@@ -2935,18 +3218,18 @@ async function executePipeline(journal, parent, requirement, options, signal, re
2935
3218
  }
2936
3219
  let design = null;
2937
3220
  if (enabled("design")) {
2938
- if (resumed("UI/UX 设计")) {
3221
+ if (resumed("design")) {
2939
3222
  design = resume.products.design;
2940
3223
  timeline.design = design;
2941
- logSkip("UI/UX 设计");
3224
+ logSkip(PHASE_KEY_OF.design);
2942
3225
  } else {
2943
3226
  journal.logs.push({
2944
3227
  t: Date.now(),
2945
3228
  level: "phase",
2946
3229
  message: "进入阶段:UI/UX 设计"
2947
3230
  });
2948
- const designR = await withRetry(journal, parent, "UI/UX 设计师 · 设计说明", "UI/UX 设计", designPrompt(prd, root, journal.id, state), signal);
2949
- if (!designR.text) throw stageFailError("UI/UX 设计", designR);
3231
+ const designR = await withRetry(journal, parent, "UI/UX 设计师 · 设计说明", "design", designPrompt(prd, root, journal.id, state), signal);
3232
+ if (!designR.text) throw stageFailError("design", designR);
2950
3233
  design = designR.text;
2951
3234
  timeline.design = design;
2952
3235
  mergeStageState("design", design);
@@ -2956,18 +3239,18 @@ async function executePipeline(journal, parent, requirement, options, signal, re
2956
3239
  }
2957
3240
  let scaffold = null;
2958
3241
  if (enabled("scaffold")) {
2959
- if (resumed("架构规划")) {
3242
+ if (resumed("scaffold")) {
2960
3243
  scaffold = resume.products.scaffold;
2961
3244
  timeline.scaffold = scaffold;
2962
- logSkip("架构规划");
3245
+ logSkip(PHASE_KEY_OF.scaffold);
2963
3246
  } else {
2964
3247
  journal.logs.push({
2965
3248
  t: Date.now(),
2966
3249
  level: "phase",
2967
3250
  message: "进入阶段:架构规划"
2968
3251
  });
2969
- const scR = await withRetry(journal, parent, "架构师 · 脚手架规划与落地", "架构规划", scaffoldPrompt(requirement, design, root, journal.id, state), signal);
2970
- if (!scR.text) throw stageFailError("架构规划", scR);
3252
+ const scR = await withRetry(journal, parent, "架构师 · 脚手架规划与落地", "scaffold", scaffoldPrompt(requirement, design, root, journal.id, state), signal);
3253
+ if (!scR.text) throw stageFailError("scaffold", scR);
2971
3254
  scaffold = scR.text;
2972
3255
  timeline.scaffold = scaffold;
2973
3256
  mergeStageState("scaffold", scaffold);
@@ -2976,72 +3259,74 @@ async function executePipeline(journal, parent, requirement, options, signal, re
2976
3259
  }
2977
3260
  }
2978
3261
  let tech = null;
2979
- if (resumed("技术方案")) {
2980
- tech = resume.products.tech;
2981
- timeline.tech = tech;
2982
- logSkip("技术方案");
2983
- } else {
2984
- const isHeavy = !options.lite && options.mode !== "tech" && options.mode !== "patch";
2985
- journal.logs.push({
2986
- t: Date.now(),
2987
- level: "phase",
2988
- message: isHeavy ? "进入阶段:技术方案" : "进入阶段:架构蓝图"
2989
- });
2990
- const label = isHeavy ? "高级全栈工程师 · 技术方案" : "架构师 · 架构蓝图";
2991
- const techR = await withRetry(journal, parent, label, "技术方案", isHeavy ? techPrompt(prd, design, scaffold, tasks, root, journal.id, state) : architectPrompt(prd, root, journal.id, state), signal);
2992
- if (!techR.text) throw stageFailError(label, techR);
2993
- tech = techR.text;
2994
- timeline.tech = tech;
2995
- mergeStageState("tech", tech);
2996
- let bd = extractBlueprint(tech);
2997
- if (!bd || bd.summary === void 0) try {
2998
- const techFile = journal.runDocs && journal.workspacePath ? `${journal.workspacePath}/${journal.runDocs}/TECHNICAL.md` : null;
2999
- if (techFile && existsSync(techFile)) bd = extractBlueprint(readFileSync(techFile, "utf8"));
3000
- if (bd) journal.logs.push({
3262
+ if (enabled("tech")) {
3263
+ if (resumed("tech")) {
3264
+ tech = resume.products.tech;
3265
+ timeline.tech = tech;
3266
+ logSkip(PHASE_KEY_OF.tech);
3267
+ } else {
3268
+ const isHeavy = !options.lite && options.mode !== "tech" && options.mode !== "patch";
3269
+ journal.logs.push({
3001
3270
  t: Date.now(),
3002
- level: "info",
3003
- message: "蓝图从任务夹 TECHNICAL.md 提取(模型把蓝图写进了文档而非回复输出)"
3271
+ level: "phase",
3272
+ message: isHeavy ? "进入阶段:技术方案" : "进入阶段:架构蓝图"
3004
3273
  });
3005
- } catch (e) {}
3006
- if (bd && bd.summary !== void 0) try {
3007
- state.__runCtx = state.__runCtx || {};
3008
- state.__runCtx.blueprint = bd.render;
3009
- journal.blueprint = {
3010
- modules: bd.modules,
3011
- tasks: bd.tasks
3012
- };
3013
- } catch (e) {}
3014
- else if (/<!-- blueprint -->/.test(String(tech))) journal.logs.push({
3015
- t: Date.now(),
3016
- level: "warn",
3017
- message: "技术方案蓝图块解析失败(JSON 畸形),开发任务将回退整体开发——TECHNICAL.md 蓝图块需人工检查"
3018
- });
3019
- noteTaskStageUsage(journal);
3020
- if (journal.cancelled) return;
3274
+ const label = isHeavy ? "高级全栈工程师 · 技术方案" : "架构师 · 架构蓝图";
3275
+ const techR = await withRetry(journal, parent, label, "tech", isHeavy ? techPrompt(prd, design, scaffold, tasks, root, journal.id, state) : architectPrompt(prd, root, journal.id, state), signal);
3276
+ if (!techR.text) throw stageFailError(label, techR);
3277
+ tech = techR.text;
3278
+ timeline.tech = tech;
3279
+ mergeStageState("tech", tech);
3280
+ let bd = extractBlueprint(tech);
3281
+ if (!bd || bd.summary === void 0) try {
3282
+ const techFile = journal.runDocs && journal.workspacePath ? `${journal.workspacePath}/${journal.runDocs}/TECHNICAL.md` : null;
3283
+ if (techFile && existsSync(techFile)) bd = extractBlueprint(readFileSync(techFile, "utf8"));
3284
+ if (bd) journal.logs.push({
3285
+ t: Date.now(),
3286
+ level: "info",
3287
+ message: "蓝图从任务夹 TECHNICAL.md 提取(模型把蓝图写进了文档而非回复输出)"
3288
+ });
3289
+ } catch (e) {}
3290
+ if (bd && bd.summary !== void 0) try {
3291
+ state.__runCtx = state.__runCtx || {};
3292
+ state.__runCtx.blueprint = bd.render;
3293
+ journal.blueprint = {
3294
+ modules: bd.modules,
3295
+ tasks: bd.tasks
3296
+ };
3297
+ } catch (e) {}
3298
+ else if (/<!-- blueprint -->/.test(String(tech))) journal.logs.push({
3299
+ t: Date.now(),
3300
+ level: "warn",
3301
+ message: "技术方案蓝图块解析失败(JSON 畸形),开发任务将回退整体开发——TECHNICAL.md 蓝图块需人工检查"
3302
+ });
3303
+ noteTaskStageUsage(journal);
3304
+ if (journal.cancelled) return;
3305
+ }
3021
3306
  }
3022
3307
  let devResults = null;
3023
- if (resumed("开发")) {
3308
+ if (resume) {
3024
3309
  devResults = resume.products.dev || [];
3025
- const failedSubs = (storeFor(scopeKey).tasks || []).filter((t) => t.reqId === journal.reqId && t.parentId === journal.taskId && (t.status === "failed" || t.failed === true));
3026
- if (failedSubs.length > 0) {
3027
- const defs = buildDevTaskDefs(journal, tasks);
3028
- const defByTitle = new Map(defs.map((d) => [d.title, d]));
3029
- const rerunDefs = failedSubs.map((s) => {
3030
- const key = String(s.title || "").replace(/^开发 · /, "");
3031
- return defByTitle.get(key) || {
3032
- title: key,
3033
- spec: s.spec || "按技术方案实现该任务改动",
3034
- files: []
3035
- };
3036
- }).filter(Boolean);
3037
- const reused = devResults.filter((r) => r && !rerunDefs.some((d) => d.title === r.title));
3310
+ const taskStatuses = devTaskStatuses(journal.stages || []);
3311
+ const todo = buildDevTaskDefs(journal, tasks).filter((d) => {
3312
+ const st = taskStatuses.get(String(d.title || "").trim());
3313
+ return !st || !st.done;
3314
+ });
3315
+ if (todo.length === 0) {
3316
+ timeline.dev = devResults;
3317
+ logSkip("开发");
3318
+ } else {
3319
+ const reused = devResults.filter((r) => r && !todo.some((d) => d.title === r.title));
3038
3320
  journal.logs.push({
3039
3321
  t: Date.now(),
3040
3322
  level: "warn",
3041
- message: `断点续跑开发:复用 ${reused.length} 个已完成任务,补跑 ${rerunDefs.length} 个失败任务`
3323
+ message: `断点续跑开发:复用 ${reused.length} 个已完成任务,补跑 ${todo.length} 个失败任务`
3042
3324
  });
3043
- const rerun = await runPool(rerunDefs, maxConcurrency, async (task) => {
3044
- const devR = await withRetry(journal, parent, `开发 · ${task.title}(补跑)`, "开发", devPrompt(task, tech, prd, root, journal.id, state), signal);
3325
+ const rerun = await runPool(todo, maxConcurrency, async (task) => {
3326
+ const prevStage = [...journal.stages].reverse().find((s) => phaseKeyOf(s.phase) === "dev" && s.status !== "done" && (s.taskKey && s.taskKey === String(task.title || "") || !s.taskKey && (s.label || "").includes(String(task.title || ""))));
3327
+ const resumePrompt = devPrompt(task, tech, prd, root, journal.id, state) + (prevStage ? buildRetryDiagnostic(2, prevStage) : "");
3328
+ const devR = await withRetry(journal, parent, `开发 · ${task.title}(补跑)`, "dev", resumePrompt, signal, task.title);
3329
+ noteVerifyEvidence(devR.stage, devR.text);
3045
3330
  const ok = !!devR.text;
3046
3331
  return {
3047
3332
  title: task.title,
@@ -3050,14 +3335,11 @@ async function executePipeline(journal, parent, requirement, options, signal, re
3050
3335
  };
3051
3336
  });
3052
3337
  for (const t of rerun) {
3053
- const sub = failedSubs.find((s) => String(s.title || "").replace(/^开发 · /, "") === t.title);
3338
+ const sub = createSubtask(journal, t.title, t.spec || "");
3054
3339
  if (sub) completeSubtask(journal, sub.id, t.failed, t.output ? snippet(t.output, 1e3) : null, null);
3055
3340
  }
3056
3341
  devResults = [...reused, ...rerun];
3057
3342
  timeline.dev = devResults;
3058
- } else {
3059
- timeline.dev = devResults;
3060
- logSkip("开发");
3061
3343
  }
3062
3344
  } else {
3063
3345
  journal.logs.push({
@@ -3099,12 +3381,12 @@ async function executePipeline(journal, parent, requirement, options, signal, re
3099
3381
  persistJournal(journal);
3100
3382
  }
3101
3383
  }
3102
- const devR = await withRetry(journal, parent, `开发 · ${task.title}`, "开发", devPrompt(task, tech, prd, root, journal.id, state), signal);
3384
+ const devR = await withRetry(journal, parent, `开发 · ${task.title}`, "dev", devPrompt(task, tech, prd, root, journal.id, state), signal, task.title);
3385
+ noteVerifyEvidence(devR.stage, devR.text);
3103
3386
  const ok = !!devR.text;
3104
3387
  if (sub) {
3105
3388
  completeSubtask(journal, sub.id, !ok, devR.text ? snippet(devR.text, 1e3) : null, null);
3106
- const devStage = journal.stages.filter((s) => s.phase === "开发").pop();
3107
- if (devStage) noteSubtaskUsage(journal, sub.id, devStage);
3389
+ if (devR.stage) noteSubtaskUsage(journal, sub.id, devR.stage);
3108
3390
  }
3109
3391
  return {
3110
3392
  title: task.title,
@@ -3115,7 +3397,7 @@ async function executePipeline(journal, parent, requirement, options, signal, re
3115
3397
  timeline.dev = devResults;
3116
3398
  for (const r of devResults) if (r && r.output) mergeStageState("dev", r.output);
3117
3399
  noteTaskStageUsage(journal);
3118
- noteTaskAssign(journal, "dev", journal.stages.filter((s) => s.phase === "开发").map((s) => (s.childId || "").slice(0, 8)).filter(Boolean).join(",") || "开发组");
3400
+ noteTaskAssign(journal, "dev", journal.stages.filter((s) => phaseKeyOf(s.phase) === "dev").map((s) => (s.childId || "").slice(0, 8)).filter(Boolean).join(",") || "开发组");
3119
3401
  const failedCount = devResults.filter((r) => r && r.failed).length;
3120
3402
  if (failedCount > 0) {
3121
3403
  advanceTask(journal, "needs-human", null, "开发失败,需人工介入", { by: "dev" });
@@ -3150,10 +3432,10 @@ async function executePipeline(journal, parent, requirement, options, signal, re
3150
3432
  message: "当前档位阶段集不含独立 QA:跳过(单点修复,开发自测兜底)"
3151
3433
  });
3152
3434
  qa = "(独立 QA 跳过:当前档位由开发自测兜底)";
3153
- } else if (resumed("QA 测试") && !hasOpenBlockingBugs(journal)) {
3154
- qa = resume.products.qa;
3435
+ } else if (resumed("qa") && !hasOpenBlockingBugs(journal)) {
3436
+ qa = artifactText(journal, "QA-REPORT.md") || resume.products.qa;
3155
3437
  timeline.qa = qa;
3156
- logSkip("QA 测试");
3438
+ logSkip(PHASE_KEY_OF.qa);
3157
3439
  } else {
3158
3440
  journal.logs.push({
3159
3441
  t: Date.now(),
@@ -3162,7 +3444,7 @@ async function executePipeline(journal, parent, requirement, options, signal, re
3162
3444
  });
3163
3445
  advanceTask(journal, "testing", null, "QA 开始(待测试 → 测试中)", { by: "qa" });
3164
3446
  const store = storeFor(scopeKey);
3165
- const qaStageChildren = () => journal.stages.filter((s) => s.phase === "QA 测试").map((s) => (s.childId || "").slice(0, 8)).filter(Boolean).join(",") || "测试组";
3447
+ const qaStageChildren = () => journal.stages.filter((s) => phaseKeyOf(s.phase) === "qa").map((s) => (s.childId || "").slice(0, 8)).filter(Boolean).join(",") || "测试组";
3166
3448
  let round = 0;
3167
3449
  let qaClean = false;
3168
3450
  const devFixRounds = [];
@@ -3174,14 +3456,26 @@ async function executePipeline(journal, parent, requirement, options, signal, re
3174
3456
  do {
3175
3457
  round += 1;
3176
3458
  const isReverify = round > 1;
3177
- const qaR = await withRetry(journal, parent, isReverify ? `QA 复验 · 第${round - 1}轮修复后` : "QA 测试工程师 · 功能测试", "QA 测试", qaPrompt(prd, qaDevSummary(), root, journal.id, state, await currentModelSupportsVision(resolveChildRoute(parent).provider, resolveChildRoute(parent).model)), signal);
3459
+ const qaR = await withRetry(journal, parent, isReverify ? `QA 复验 · 第${round - 1}轮修复后` : "QA 测试工程师 · 功能测试", "qa", qaPrompt(prd, qaDevSummary(), root, journal.id, state, await currentModelSupportsVision(resolveChildRoute(parent).provider, resolveChildRoute(parent).model)), signal);
3178
3460
  if (!qaR.text) {
3179
3461
  advanceTask(journal, "needs-human", null, isReverify ? `QA 复验失败(第 ${round - 1} 轮修复后)` : "QA 失败", { by: "qa" });
3180
- throw stageFailError(isReverify ? "QA 测试(复验)" : "QA 测试", qaR);
3462
+ throw stageFailError("qa", qaR);
3463
+ }
3464
+ mergeStageState("qa", qaR.text);
3465
+ qa = artifactText(journal, "QA-REPORT.md");
3466
+ if (!qa) {
3467
+ journal.logs.push({
3468
+ t: Date.now(),
3469
+ level: "error",
3470
+ message: `QA 子代理回复成功但 ${journal.runDocs ? journal.runDocs + "/" : ""}QA-REPORT.md 未落盘/为空——单轨契约(文件即产物)未兑现,需人工介入`
3471
+ });
3472
+ advanceTask(journal, "needs-human", null, "QA-REPORT.md 未落盘(单轨契约未兑现)", { by: "qa" });
3473
+ throw stageFailError("qa", {
3474
+ attempts: qaR.attempts,
3475
+ stageTokens: qaR.stageTokens
3476
+ });
3181
3477
  }
3182
- qa = qaR.text;
3183
3478
  timeline.qa = qa;
3184
- mergeStageState("qa", qa);
3185
3479
  noteTaskStageUsage(journal);
3186
3480
  noteTaskAssign(journal, "qa", qaStageChildren());
3187
3481
  defects = parseDefects(qa);
@@ -3219,7 +3513,8 @@ async function executePipeline(journal, parent, requirement, options, signal, re
3219
3513
  message: `QA 发现 ${blocking.length} 个阻断缺陷(第 ${round} 轮),打回开发确认修复后复验`
3220
3514
  });
3221
3515
  advanceTask(journal, "rework", snippet(qa, 3e3), `QA 打回开发修复(第 ${round}/3 轮)`, { by: "qa" });
3222
- const fixR = await withRetry(journal, parent, `开发 · QA 缺陷修复(第 ${round} 轮)`, "开发", qaFixPrompt(blocking, qa, tech, prd, root, journal.id, state), signal);
3516
+ const fixR = await withRetry(journal, parent, `开发 · QA 缺陷修复(第 ${round} 轮)`, "dev", qaFixPrompt(blocking, qa, tech, prd, root, journal.id, state), signal, null);
3517
+ noteVerifyEvidence(fixR.stage, fixR.text);
3223
3518
  if (!fixR.text) {
3224
3519
  advanceTask(journal, "needs-human", null, "QA 打回后开发修复失败", { by: "qa" });
3225
3520
  throw stageFailError("开发(QA 打回修复)", fixR);
@@ -3244,7 +3539,22 @@ async function executePipeline(journal, parent, requirement, options, signal, re
3244
3539
  if (journal.cancelled) return;
3245
3540
  }
3246
3541
  persistJournal(journal);
3247
- if (!qaBlocked) {
3542
+ if (!enabled("acceptance")) {
3543
+ journal.logs.push({
3544
+ t: Date.now(),
3545
+ level: "info",
3546
+ message: "patch 档交付完成:单 agent 直改 + 自测即收口(无独立 QA/验收)"
3547
+ });
3548
+ advanceTask(journal, "accepted", null, "patch 直改交付(自测通过)", { by: "dev" });
3549
+ const store = storeFor(scopeKey);
3550
+ const req = store.find("req", journal.reqId);
3551
+ if (req && req.status !== "accepted") {
3552
+ store.pushEvent(req, req.status, "accepted", "patch 交付通过(自测)");
3553
+ req.status = "accepted";
3554
+ store.persist();
3555
+ }
3556
+ journal.status = "completed";
3557
+ } else if (!qaBlocked) {
3248
3558
  journal.logs.push({
3249
3559
  t: Date.now(),
3250
3560
  level: "phase",
@@ -3254,18 +3564,47 @@ async function executePipeline(journal, parent, requirement, options, signal, re
3254
3564
  const curTask = storeFor(scopeKey).find("task", journal.taskId);
3255
3565
  if (curTask && curTask.status !== "pending-acceptance" && curTask.status !== "needs-human" && curTask.status !== "rework") advanceTask(journal, "pending-acceptance", null, "进入验收(待验收)", { by: "pm" });
3256
3566
  }
3257
- const accR = await withRetry(journal, parent, "产品经理 · 最终验收", "产品验收", acceptancePrompt(prd, qa, JSON.stringify(timeline.dev), root, journal.id, state, await currentModelSupportsVision(resolveChildRoute(parent).provider, resolveChildRoute(parent).model)), signal);
3567
+ const accR = await withRetry(journal, parent, "产品经理 · 最终验收", "acceptance", acceptancePrompt(prd, qa, JSON.stringify(timeline.dev), root, journal.id, state, await currentModelSupportsVision(resolveChildRoute(parent).provider, resolveChildRoute(parent).model)), signal);
3258
3568
  if (!accR.text) {
3259
3569
  advanceTask(journal, "needs-human", null, "验收失败", { by: "pm" });
3260
- throw stageFailError("产品验收", accR);
3570
+ throw stageFailError("acceptance", accR);
3571
+ }
3572
+ mergeStageState("acceptance", accR.text);
3573
+ const acceptance = artifactText(journal, "ACCEPTANCE.md");
3574
+ if (!acceptance) {
3575
+ journal.logs.push({
3576
+ t: Date.now(),
3577
+ level: "error",
3578
+ message: `验收子代理回复成功但 ${journal.runDocs ? journal.runDocs + "/" : ""}ACCEPTANCE.md 未落盘/为空——单轨契约(文件即产物)未兑现,需人工介入`
3579
+ });
3580
+ advanceTask(journal, "needs-human", null, "ACCEPTANCE.md 未落盘(单轨契约未兑现)", { by: "pm" });
3581
+ throw stageFailError("acceptance", {
3582
+ attempts: accR.attempts,
3583
+ stageTokens: accR.stageTokens
3584
+ });
3261
3585
  }
3262
- const acceptance = accR.text;
3263
3586
  timeline.acceptance = acceptance;
3264
3587
  noteTaskStageUsage(journal);
3265
- const accStage = journal.stages.find((s) => s.phase === "产品验收" && s.childId);
3588
+ const accStage = journal.stages.find((s) => phaseKeyOf(s.phase) === "acceptance" && s.childId);
3266
3589
  noteTaskAssign(journal, "accept", accStage ? String(accStage.childId).slice(0, 8) : "验收组");
3267
- mergeStageState("acceptance", acceptance);
3268
3590
  const accVerdict = parseAcceptanceVerdict(acceptance);
3591
+ if (accVerdict === "needs-human") {
3592
+ journal.logs.push({
3593
+ t: Date.now(),
3594
+ level: "error",
3595
+ message: "ACCEPTANCE.md 缺少「验收结论:」行(字面量模板未兑现)——不自动判通过,需人工确认结论"
3596
+ });
3597
+ advanceTask(journal, "needs-human", snippet(acceptance, 3e3), "ACCEPTANCE.md 缺少验收结论行(契约未兑现),需人工确认", { by: "pm" });
3598
+ const store = storeFor(scopeKey);
3599
+ const req = store.find("req", journal.reqId);
3600
+ if (req) {
3601
+ req.humanIntervention = true;
3602
+ store.pushEvent(req, req.status, "needs-human", "验收结论行缺失,需人工确认");
3603
+ }
3604
+ journal.humanIntervention = true;
3605
+ persistJournal(journal);
3606
+ throw new Error("ACCEPTANCE.md 缺少验收结论行,需人工确认结论");
3607
+ }
3269
3608
  if (accVerdict === "reject") {
3270
3609
  advanceTask(journal, "needs-human", snippet(acceptance, 3e3), "需求与现状不符(无需改动),需人工决定调整或取消需求", { by: "pm" });
3271
3610
  const store = storeFor(scopeKey);
@@ -4310,8 +4649,7 @@ function tryFlushPendingInjections(sessionId) {
4310
4649
  const agent = runtime.agents ? runtime.agents.get(sessionId) : void 0;
4311
4650
  if (!agent || typeof agent.inject !== "function") return;
4312
4651
  try {
4313
- agent.inject({
4314
- type: "user",
4652
+ agent.inject(createUserMessage({
4315
4653
  content: [{
4316
4654
  type: "text",
4317
4655
  text: teamflowContextText(pending.teamIcon, pending.teamName, pending.teamId)
@@ -4319,9 +4657,9 @@ function tryFlushPendingInjections(sessionId) {
4319
4657
  source: {
4320
4658
  kind: "plugin",
4321
4659
  plugin: "dsh-plugin-teamflow",
4322
- form: "context"
4660
+ form: "instructions"
4323
4661
  }
4324
- });
4662
+ }));
4325
4663
  pendingInjections.delete(sessionId);
4326
4664
  } catch (e) {}
4327
4665
  }
@@ -4394,7 +4732,9 @@ var TeamflowService = class extends TypertRemoteService {
4394
4732
  const j = runs.get(latest.id);
4395
4733
  return j ? snapshotOf(j) : null;
4396
4734
  }
4397
- /** 阶段详情:卡片点击查看 —— 状态/耗时/官方 usage + 产物全文(超 24k 截断)。 */
4735
+ /** 阶段详情:卡片点击查看 —— 状态/耗时/官方 usage + 产物全文(超 24k 截断)。
4736
+ * 2026-09-06 状态机化:返回同任务全部尝试(attempts 聚合——按 stage.taskKey(旧数据 label 兜底),
4737
+ * 按 seq 排序)——client 弹窗单次渲染现状、多次渲染时间线。 */
4398
4738
  stageDetail(runId, seq, sessionId) {
4399
4739
  if (typeof runId !== "string" || !runId || seq === void 0 || seq === null) return null;
4400
4740
  const sc = sessionScope(sessionId);
@@ -4403,6 +4743,21 @@ var TeamflowService = class extends TypertRemoteService {
4403
4743
  if (j.workspace && sc.projectKey && j.workspace !== sc.projectKey && sc.projectKey !== "default") return null;
4404
4744
  const s = (j.stages || []).find((st) => Number(st.seq) === Number(seq));
4405
4745
  if (!s) return null;
4746
+ const taskKeyOf = (x) => String(x.taskKey || String(x.label || "").replace(/^开发 · /, "").replace(/((?:第 \d+ 次重试|补跑))$/, "").trim());
4747
+ const taskKey = taskKeyOf(s);
4748
+ const attempts = taskKey ? (j.stages || []).filter((x) => phaseKeyOf(x.phase) === phaseKeyOf(s.phase) && taskKeyOf(x) === taskKey).sort((a, b) => Number(a.seq) - Number(b.seq)).map((x) => ({
4749
+ seq: x.seq,
4750
+ label: x.label,
4751
+ status: x.status,
4752
+ outcome: x.outcome || null,
4753
+ summary: clip(x.summary || "", 1500),
4754
+ output: clip(toText(x.output) || toText(x.handoff) || "", 12e3),
4755
+ usage: x.usage || null,
4756
+ verifyEvidence: x.verifyEvidence || null,
4757
+ childId: x.childId || null,
4758
+ startedAt: x.startedAt,
4759
+ endedAt: x.endedAt
4760
+ })) : null;
4406
4761
  return {
4407
4762
  seq: s.seq,
4408
4763
  label: s.label,
@@ -4414,8 +4769,10 @@ var TeamflowService = class extends TypertRemoteService {
4414
4769
  endedAt: s.endedAt,
4415
4770
  ownerSession: j.ownerSession || null,
4416
4771
  usage: s.usage || null,
4772
+ verifyEvidence: s.verifyEvidence || null,
4417
4773
  summary: clip(s.summary || "", 3e3),
4418
- output: clip(toText(s.output) || toText(s.handoff) || "", 24e3)
4774
+ output: clip(toText(s.output) || toText(s.handoff) || "", 24e3),
4775
+ attempts
4419
4776
  };
4420
4777
  }
4421
4778
  /** Backlog 条目详情:卡片点击查看 —— 完整字段 + 流转时间线 + 关联(子卡/缺陷)+ 任务夹路径。 */
@@ -4640,8 +4997,7 @@ var TeamflowService = class extends TypertRemoteService {
4640
4997
  activeTeams.set(sid, tid);
4641
4998
  saveActiveTeams();
4642
4999
  const agent = runtime.agents && runtime.agents.get(sid);
4643
- const injectPayload = {
4644
- type: "user",
5000
+ const injectPayload = createUserMessage({
4645
5001
  content: [{
4646
5002
  type: "text",
4647
5003
  text: teamflowContextText(team.icon, team.name, tid)
@@ -4649,9 +5005,9 @@ var TeamflowService = class extends TypertRemoteService {
4649
5005
  source: {
4650
5006
  kind: "plugin",
4651
5007
  plugin: "dsh-plugin-teamflow",
4652
- form: "context"
5008
+ form: "instructions"
4653
5009
  }
4654
- };
5010
+ });
4655
5011
  if (agent && typeof agent.inject === "function") try {
4656
5012
  agent.inject(injectPayload);
4657
5013
  } catch (e) {}