dsh-plugin-teamflow 0.1.5 → 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: [
@@ -185,6 +207,17 @@ function extractText(blocks) {
185
207
  if (!Array.isArray(blocks)) return "";
186
208
  return blocks.filter((b) => b && b.type === "text" && typeof b.text === "string").map((b) => b.text).join("\n");
187
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
+ }
188
221
  /**
189
222
  * ADR-0008 任务夹命名:<yyyyMMdd>-r<N>[-<slug>]。
190
223
  * - date 用本地时区(用户在东八区晚上建的需求不能落到"明天")
@@ -253,10 +286,11 @@ function sanitizeSnapOptions(o) {
253
286
  const SAFE_SIGNAL = {
254
287
  aborted: false,
255
288
  addEventListener: () => {},
256
- removeEventListener: () => {}
289
+ removeEventListener: () => {},
290
+ throwIfAborted: () => {}
257
291
  };
258
292
  function normalizeSignal(s) {
259
- 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;
260
294
  }
261
295
  /** 分支 slug 派生(ADR-2026-08-27):branchName > triageSlug > 需求中的英文标识词 > reqId 数字 > 'feature'。
262
296
  * 实锤 feat/feature:lite 显式时 triage 不跑(无 slug)+ 分支检查早于 reqId 生成 → fallback 'feature'。 */
@@ -305,6 +339,33 @@ function handoffBrief(text) {
305
339
  const m = String(text).match(/<!--\s*handoff\s*-->([\s\S]*?)(?:<!--\s*\/handoff\s*-->|$)/);
306
340
  return clip((m && m[1] ? m[1] : String(text)).trim(), 2e3);
307
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
+ }
308
369
  /**
309
370
  * 验收结论解析:只以显式「验收结论 / 整体结论」行为准(acceptancePrompt 强制 4 档固定话术),
310
371
  * 不做正文散文朴素子串匹配。历史误报实锤(run tf-msytlok5):验收报告 ✅ 通过,其记忆回写段一句
@@ -313,8 +374,11 @@ function handoffBrief(text) {
313
374
  * - 「📝 需求不适用」是验收负责人专用的强结论词,允许全文命中;
314
375
  * - 其余 reject 词(需求与实际不符/站不住/无效/无需改动等)仅在结论行且该行不含「通过/✅/⚠️」时才算;
315
376
  * - rework 词仅认结论行(且不与「✅ 通过」同现)。
377
+ * 反向护栏(漏报实锤 2026-09-03):模型写「❌ 不通过」但漏写「验收结论:」前缀 → accLine 为空 →
378
+ * 旧实现落回默认 accepted(最乐观默认值,质量门禁漏报=假交付)。现改为 **找不到结论行 → needs-human**
379
+ * (宁严勿松:误拦截=人工看一眼,误放行=假交付;📝 全文命中与架构红词仍优先于该默认)。
316
380
  * @param {unknown} text 验收报告全文
317
- * @returns {'accepted'|'rework'|'reject'}
381
+ * @returns {'accepted'|'rework'|'reject'|'needs-human'}
318
382
  */
319
383
  function parseAcceptanceVerdict(text) {
320
384
  const acc = String(text || "");
@@ -322,9 +386,14 @@ function parseAcceptanceVerdict(text) {
322
386
  const hasArchRedFlag = /重复实现|重复适配|偏离蓝图|未按蓝图|该拆未拆|该抽象未抽象|破坏既有结构|结构性.*问题|架构(打回|需重构)|需.*返工|返工.*项.*(存在|仍)|仍.*(返工|重构)/.test(acc);
323
387
  const archNegated = /无返工|无.*返工|不返工|无架构打回|无.*打回|非漂移|无.*重复|无.*偏离|无.*抽象.*问题|无.*蓝图.*问题|架构一致性.*(PASS|良好|达标|通过|无问题)|M3.*(PASS|通过|达标)|架构.*(达标|无问题|良好)/.test(acc);
324
388
  if (hasArchRedFlag && !archNegated) return "rework";
325
- if (/❌\s*不通过|需返工|未通过/.test(accLine) && !/✅\s*通过/.test(accLine)) return "rework";
326
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
+ }
327
394
  if (!/通过|✅|⚠️/.test(accLine) && /需求不适用|需求与实际不符|需求站不住|需求无效|无需改动|无需修改/.test(accLine)) return "reject";
395
+ if (!accLine) return "needs-human";
396
+ if (!/通过|✅|⚠️|❌|📝/.test(accLine)) return "needs-human";
328
397
  return "accepted";
329
398
  }
330
399
  const bdOpen = "<!-- blueprint -->";
@@ -834,6 +903,20 @@ function createSubtask(journal, title, spec) {
834
903
  const store = storeFor(journal.workspace || "default");
835
904
  const mainTask = journal.taskId ? store.find("task", journal.taskId) : null;
836
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
+ }
837
920
  const id = store.nextId("dev");
838
921
  const sub = {
839
922
  id,
@@ -841,7 +924,8 @@ function createSubtask(journal, title, spec) {
841
924
  parentId: journal.taskId,
842
925
  product: journal.workspace || "default",
843
926
  type: "subtask",
844
- title: `开发 · ${title}`,
927
+ title: fullTitle,
928
+ taskKey: title,
845
929
  spec: spec || "",
846
930
  status: "pending",
847
931
  devAssign: mainTask && mainTask.devAssign || null,
@@ -1170,16 +1254,59 @@ function runSanityCheck(path) {
1170
1254
  //#endregion
1171
1255
  //#region host/core/metering.ts
1172
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
+ /**
1173
1304
  * 累计子代理会话中所有 LLM 调用的真实 usage(官方三桶 + 调用数)。
1174
1305
  * 返回 null 表示拿不到 usage(会话未暴露 events / 无数据)。
1175
1306
  */
1176
1307
  function accumulateSessionUsage(run) {
1177
- const local = run && run.localAgent;
1178
- const session = local && local.session;
1179
- if (!session) return null;
1180
- const rawEvents = session.events;
1181
- const events = Array.isArray(rawEvents) ? rawEvents : typeof rawEvents === "function" ? rawEvents() : null;
1182
- if (!Array.isArray(events)) return null;
1308
+ const events = sessionEventsOf(run);
1309
+ if (events.length === 0) return null;
1183
1310
  const buckets = {
1184
1311
  input: 0,
1185
1312
  cacheRead: 0,
@@ -1193,7 +1320,7 @@ function accumulateSessionUsage(run) {
1193
1320
  if (!e || e.type !== "assistant/message") continue;
1194
1321
  const d = e.data || {};
1195
1322
  if (typeof d.turn === "number" && typeof d.step === "number") seen.add(`${d.turn}.${d.step}`);
1196
- const u = d.usage;
1323
+ const u = usageOfEvent(e);
1197
1324
  if (u) {
1198
1325
  buckets.input += u.inputTokens || 0;
1199
1326
  buckets.cacheRead += u.cacheReadTokens || 0;
@@ -1240,14 +1367,48 @@ function totalTokensOf(usage) {
1240
1367
  * 纯 read 循环(反复整读同一文件却无变更/无脚本执行)= 真退化。实锤 run tf-mte906e9:QA 重跑
1241
1368
  * 只读分析(不 edit)→ 旧判定「零变更进展」误杀,第 2 次 provider error 后 450k 熔断。 */
1242
1369
  const PROGRESS_TOOLS = /^(edit|write|create|apply_patch|patch|remove|delete|rm|mkdir|move|rename|append|bash|pwsh|shell|powershell)$/i;
1243
- /** 与 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())。 */
1244
1388
  function eventsOf(run) {
1245
- const local = run && run.localAgent;
1246
- const session = local && local.session;
1247
- if (!session) return [];
1248
- const raw = session.events;
1249
- const events = Array.isArray(raw) ? raw : typeof raw === "function" ? raw() : null;
1250
- 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
+ }
1251
1412
  }
1252
1413
  /** 规范化文本片段:小写 + 仅保留字母数字/CJK,供逐字重复比对。 */
1253
1414
  function normalizeFragment(s) {
@@ -1304,6 +1465,7 @@ function startStageGuard(opts) {
1304
1465
  const warnedScripts = /* @__PURE__ */ new Set();
1305
1466
  let lastMutationAt = 0;
1306
1467
  let repeatWarned = false;
1468
+ let busyWarned = false;
1307
1469
  function warnOnce(key, set, message, hint) {
1308
1470
  if (set.has(key)) return;
1309
1471
  set.add(key);
@@ -1322,6 +1484,34 @@ function startStageGuard(opts) {
1322
1484
  clearInterval(timer);
1323
1485
  stage.guardReason = reason;
1324
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) {}
1325
1515
  try {
1326
1516
  journal.logs.push({
1327
1517
  t: Date.now(),
@@ -1395,8 +1585,22 @@ function startStageGuard(opts) {
1395
1585
  lastEventCount = events.length;
1396
1586
  lastGrowthAt = Date.now();
1397
1587
  } else if (Date.now() - lastGrowthAt > 6e5) {
1398
- fire(`挂死(${Math.round(GUARD_SILENCE_MS / 6e4)} 分钟无任何新事件)`, "stalled");
1399
- 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
+ }
1400
1604
  }
1401
1605
  if (seenToolCall && Date.now() - lastToolSignalAt > 9e5) {
1402
1606
  fire(`空转(${Math.round(GUARD_NO_TOOL_MS / 6e4)} 分钟内无任何工具调用,但会话仍在产出)`, "stalled");
@@ -1498,7 +1702,7 @@ function resolveChildRoute(parent) {
1498
1702
  return out;
1499
1703
  }
1500
1704
  /** 运行单个阶段子代理:执行 + 产出实质校验 + token 双口径计量 + stage 状态流转。 */
1501
- async function runAgent(journal, parent, label, phase, prompt, signal) {
1705
+ async function runAgent(journal, parent, label, phase, prompt, signal, taskKey) {
1502
1706
  const maxSeq = journal.stages.length ? Math.max(...journal.stages.map((s) => s.seq)) : 0;
1503
1707
  let stageText = null;
1504
1708
  const stage = {
@@ -1507,6 +1711,7 @@ async function runAgent(journal, parent, label, phase, prompt, signal) {
1507
1711
  phase,
1508
1712
  status: "running",
1509
1713
  outcome: null,
1714
+ taskKey: taskKey || null,
1510
1715
  childId: null,
1511
1716
  startedAt: Date.now(),
1512
1717
  endedAt: null,
@@ -1575,6 +1780,7 @@ async function runAgent(journal, parent, label, phase, prompt, signal) {
1575
1780
  stage.status = "failed";
1576
1781
  stage.outcome = stage.guardOutcome || "degenerated";
1577
1782
  stage.summary = `进行中护栏中止(${stage.guardReason}),本次尝试无有效产出`;
1783
+ if (text) stage.output = clip(text, 4e3);
1578
1784
  journal.logs.push({
1579
1785
  t: Date.now(),
1580
1786
  level: "warn",
@@ -1586,12 +1792,23 @@ async function runAgent(journal, parent, label, phase, prompt, signal) {
1586
1792
  stage.outcome = stop === "completed" && text ? "insubstantial" : stop || "error";
1587
1793
  const errDetail = result && result.error;
1588
1794
  if (stage.outcome === "insubstantial") {
1589
- stage.summary = "产出未通过实质校验(含拒绝措辞或内容过短),视为未交付";
1590
- journal.logs.push({
1591
- t: Date.now(),
1592
- level: "warn",
1593
- message: `${label} 产出未通过实质校验(拒绝措辞/内容过短)`
1594
- });
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);
1595
1812
  } else {
1596
1813
  stage.summary = `未产出有效结果(stopReason=${stop || "unknown"}${errDetail ? `,error=${String(errDetail).slice(0, 200)}` : ""})`;
1597
1814
  journal.logs.push({
@@ -1599,6 +1816,7 @@ async function runAgent(journal, parent, label, phase, prompt, signal) {
1599
1816
  level: "error",
1600
1817
  message: `${label} ${stage.summary}`
1601
1818
  });
1819
+ if (text) stage.output = clip(text, 4e3);
1602
1820
  }
1603
1821
  return null;
1604
1822
  } catch (e) {
@@ -1628,23 +1846,29 @@ async function runAgent(journal, parent, label, phase, prompt, signal) {
1628
1846
  }
1629
1847
  }
1630
1848
  /** 单阶段重试 + token 熔断(官方口径:input+cacheRead+cacheWrite+output 累计)。 */
1631
- async function withRetry(journal, parent, label, phase, prompt, signal) {
1849
+ async function withRetry(journal, parent, label, phase, prompt, signal, taskKey) {
1632
1850
  let attempts = 0;
1633
1851
  let stageTokens = 0;
1852
+ let lastStage = null;
1634
1853
  for (let attempt = 1; attempt <= 2; attempt++) {
1635
1854
  attempts = attempt;
1636
- const result = await runAgent(journal, parent, attempt > 1 ? `${label}(第 ${attempt} 次重试)` : label, phase, prompt, signal);
1637
- 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;
1638
1860
  if (lastStage && lastStage.phase === phase) stageTokens += totalTokensOf(lastStage.usage);
1639
1861
  if (result) return {
1640
1862
  text: result,
1641
1863
  attempts,
1642
- stageTokens
1864
+ stageTokens,
1865
+ stage: lastStage
1643
1866
  };
1644
1867
  if (journal.cancelled) return {
1645
1868
  text: null,
1646
1869
  attempts,
1647
- stageTokens
1870
+ stageTokens,
1871
+ stage: lastStage
1648
1872
  };
1649
1873
  if (lastStage && isUnretryable(lastStage.outcome, lastStage.outcome)) {
1650
1874
  journal.logs.push({
@@ -1656,7 +1880,8 @@ async function withRetry(journal, parent, label, phase, prompt, signal) {
1656
1880
  return {
1657
1881
  text: null,
1658
1882
  attempts,
1659
- stageTokens
1883
+ stageTokens,
1884
+ stage: lastStage
1660
1885
  };
1661
1886
  }
1662
1887
  if (lastStage && lastStage.outcome === "aborted") {
@@ -1669,7 +1894,8 @@ async function withRetry(journal, parent, label, phase, prompt, signal) {
1669
1894
  return {
1670
1895
  text: null,
1671
1896
  attempts,
1672
- stageTokens
1897
+ stageTokens,
1898
+ stage: lastStage
1673
1899
  };
1674
1900
  }
1675
1901
  if (lastStage && lastStage.outcome === "degenerated") {
@@ -1682,7 +1908,22 @@ async function withRetry(journal, parent, label, phase, prompt, signal) {
1682
1908
  return {
1683
1909
  text: null,
1684
1910
  attempts,
1685
- 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
1686
1927
  };
1687
1928
  }
1688
1929
  if (stageTokens >= 6e4) {
@@ -1695,13 +1936,14 @@ async function withRetry(journal, parent, label, phase, prompt, signal) {
1695
1936
  return {
1696
1937
  text: null,
1697
1938
  attempts,
1698
- stageTokens
1939
+ stageTokens,
1940
+ stage: lastStage
1699
1941
  };
1700
1942
  }
1701
1943
  if (attempt < 2) journal.logs.push({
1702
1944
  t: Date.now(),
1703
1945
  level: "warn",
1704
- message: `${label} 第 ${attempt} 次尝试未成功,自动重试…`
1946
+ message: `${label} 第 ${attempt} 次尝试未成功(${lastStage ? lastStage.outcome || "unknown" : "unknown"}),自动重试(重试 prompt 已附上一轮失败诊断)…`
1705
1947
  });
1706
1948
  else {
1707
1949
  journal.logs.push({
@@ -1715,7 +1957,8 @@ async function withRetry(journal, parent, label, phase, prompt, signal) {
1715
1957
  return {
1716
1958
  text: null,
1717
1959
  attempts,
1718
- stageTokens
1960
+ stageTokens,
1961
+ stage: lastStage
1719
1962
  };
1720
1963
  }
1721
1964
  //#endregion
@@ -1897,6 +2140,17 @@ const STATE_BLOCK_INSTRUCTION = `\n\n[STATE BLOCK · mandatory at the end] Appen
1897
2140
  * - 回归基线:新 PRD 头部「基线依赖:<其他任务夹>」声明;跨代变更用「取代:<夹>#AC-n」;
1898
2141
  * 硬保障在项目 verify-* 可执行套件。
1899
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 内容用英文逗号),不混用中文标点。
1900
2154
  */
1901
2155
  /** 产品层文档根(memory.md 等跨任务资产;任务产物在其中的任务夹内)。 */
1902
2156
  const TF_DOCS = "docs/teamflow";
@@ -1984,8 +2238,8 @@ function productCtx(root) {
1984
2238
  Before starting: read ${base}/AGENTS.md (team rules & doc index — read the summary first, then details on demand; no aimless full reads).
1985
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/.
1986
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.
1987
- [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>/.
1988
- [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.
1989
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.
1990
2244
  `;
1991
2245
  }
@@ -1995,7 +2249,7 @@ function headTailClip(text, head, tail) {
1995
2249
  if (s.length <= head + tail) return s;
1996
2250
  return s.slice(0, head) + "\n...\n[CHANGED SECTION]\n" + s.slice(-tail);
1997
2251
  }
1998
- 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:
1999
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.
2000
2254
  - [No duplicate reads] Same file: read ≤1 times. To verify a change, grep the change point instead of re-reading the whole file.
2001
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.
@@ -2007,7 +2261,7 @@ const TOKEN_HYGIENE = (runId) => `[TOKEN HYGIENE · hard constraint] Context is
2007
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.
2008
2262
  `;
2009
2263
  /** 一次成型纪律:目标文档 write ≤1 次 + read ≤2 次,严禁 read→edit→read 循环。 */
2010
- 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):
2011
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).
2012
2266
  - **No read→edit→read loops**: never reopen the same document to "tweak"; never re-read the whole file just to confirm a change.
2013
2267
  - Use grep + limited segments for details; never whole-file read big documents.
@@ -2110,7 +2364,7 @@ ${clip(prd, 12e3)}
2110
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.
2111
2365
  ${productCtx(root)}${stateSliceFor(state, "dev")}${TOKEN_HYGIENE(runId)}[CONTEXT PACK]
2112
2366
  [TASK TITLE] ${task.title}
2113
- ${task.files && task.files.length ? `[TASK TARGET FILES] ${task.files.join(",")}` : ""}
2367
+ ${task.files && task.files.length ? `[TASK TARGET FILES] ${task.files.join(", ")}` : ""}
2114
2368
  [TASK BRIEF] ${task.spec || "(see technical design)"}
2115
2369
  ${tech && String(tech).trim() ? `[TECH DESIGN SUMMARY (grep details on demand, don't full re-read)]
2116
2370
  ${clip(tech, 12e3)}` : ""}
@@ -2121,9 +2375,14 @@ ${clip(tech, 12e3)}` : ""}
2121
2375
  3. If spec contradicts reality, explain with evidence in the summary instead of claiming completion or expanding scope on your own.
2122
2376
  4. Actually write/modify code (grep + segmented reads to locate; no repeated whole-file reads), then run relevant build/verification to ensure green.
2123
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.
2124
- 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.
2125
2379
  6. [Log discipline] Redirect command output to logs/teamflow/${runId || "<runId>"}/.
2126
- 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.)
2127
2386
  8. [State] End with a state block (phase="dev"), touched = array of changed files, summary = implementation conclusion.${STATE_BLOCK_INSTRUCTION}`;
2128
2387
  /** 视觉验证能力条款(ADR-2026-08-27,解锁 browser-use 视觉验证):
2129
2388
  * 按当前模型多模态能力动态生成——vision=true 允许截图看图(人眼类项),精确值仍走 DOM 计算断言;
@@ -2153,11 +2412,11 @@ ${clip(devSummary, 15e3)}
2153
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.
2154
2413
  3. Read AGENTS.md §4 engineering conventions (verify commands) and the code changes first, then actually run those sandbox-legal verifications.
2155
2414
  4. [Log discipline] Redirect command output to logs/teamflow/${runId || "<runId>"}/ (e.g. qa-out.log); no scatter at project root.
2156
- 5. Output the test report (body ≤150 lines, verdict first): scope & environment, cases & results (pass/fail/blocked), conclusion (whether acceptance-ready).
2157
- 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:
2158
2417
  | 编号 | 严重级(P0/P1/P2/P3) | 功能模块 | 复现步骤 | 期望行为 | 实际行为 | 关联验收项 |
2159
2418
  If no defects: explicitly output 「未发现缺陷」.
2160
- 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}/.
2161
2420
  8. [State] End with a state block (phase="qa"), summary = test conclusion / blocked items, extra = { "verifyScripts": [...] }.${STATE_BLOCK_INSTRUCTION}`;
2162
2421
  /** QA 打回后的开发修复 prompt:确认缺陷是否属实 → 修复 → 复验交接(QA→dev 打回闭环用)。 */
2163
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.
@@ -2174,7 +2433,12 @@ ${tech && String(tech).trim() ? clip(tech, 12e3) : ""}
2174
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).
2175
2434
  2. Touch ONLY defect-related files (grep to locate; no whole-file reads of irrelevant big files); respect existing architecture & code style.
2176
2435
  3. After fixing, run relevant verification to ensure green (regression floor: existing verify suites pass untouched); redirect output to logs/teamflow/${runId || "<runId>"}/.
2177
- 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.)
2178
2442
  5. [State] End with a state block (phase="dev"), touched = changed files array, summary = fix conclusion.${STATE_BLOCK_INSTRUCTION}`;
2179
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.
2180
2444
  ${productCtx(root)}${stateSliceFor(state, "acceptance")}${TOKEN_HYGIENE(runId)}
@@ -2191,9 +2455,9 @@ ${vision ? "[Visual re-check] If QA saved screenshots under the task folder, spo
2191
2455
  - Any obvious **duplicated implementation / adapter drift / broken existing structure** (this is a code-quality floor, not optional).
2192
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".
2193
2457
  1. Verify each PRD acceptance criterion one by one.
2194
- 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.
2195
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".
2196
- 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.
2197
2461
  5. Chinese Markdown.
2198
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}`;
2199
2463
  /** 需求分诊模型 prompt(模型驱动 triage;供 core/triage.runTriage 使用)。 */
@@ -2580,10 +2844,10 @@ function buildResumeProducts(journal) {
2580
2844
  const products = {};
2581
2845
  for (const s of journal.stages) {
2582
2846
  if (s.status !== "done" || !s.output) continue;
2583
- const key = PHASE_KEY_BY_NAME[s.phase];
2847
+ const key = phaseKeyOf(s.phase);
2584
2848
  if (!key) continue;
2585
- if (key === "dev") products.dev = journal.stages.filter((x) => x.phase === "开发" && x.status === "done" && x.output).map((x) => ({
2586
- 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(/^开发 · /, ""),
2587
2851
  failed: false,
2588
2852
  output: x.output
2589
2853
  }));
@@ -2591,6 +2855,19 @@ function buildResumeProducts(journal) {
2591
2855
  }
2592
2856
  return products;
2593
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
+ }
2594
2871
  /**
2595
2872
  * 断点续跑起点:第一个「没有任意 done 尝试」的阶段。
2596
2873
  * ⚠️ 按阶段而非尝试判断(实锤 tf-mtcomxpq):PRD 第 1 次尝试 failed(护栏退化)但第 2 次重试 done——
@@ -2598,13 +2875,37 @@ function buildResumeProducts(journal) {
2598
2875
  * 全部完成仍被中断(理论极端)→ 从产品验收继续。
2599
2876
  */
2600
2877
  function interruptedPhaseOf(journal) {
2601
- if (hasOpenBlockingBugs(journal)) return "QA 测试";
2878
+ if (hasOpenBlockingBugs(journal)) return "qa";
2602
2879
  for (const phase of PHASE_ORDER) {
2603
- const phaseStages = (journal.stages || []).filter((s) => s.phase === phase);
2880
+ const phaseStages = (journal.stages || []).filter((s) => phaseKeyOf(s.phase) === phase);
2604
2881
  if (phaseStages.length === 0) continue;
2605
- 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;
2885
+ }
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);
2606
2907
  }
2607
- return "产品验收";
2908
+ return m;
2608
2909
  }
2609
2910
  /** 开发任务定义(单一来源):架构蓝图自动拆 > 调用方显式 tasks > 整体开发兜底。
2610
2911
  * resume 补跑与正常执行共用(defByTitle 按 title 匹配失败子卡)。 */
@@ -2698,18 +2999,18 @@ async function executePipeline(journal, parent, requirement, options, signal, re
2698
2999
  });
2699
3000
  /** 阶段失败错误:带真实尝试次数/末次结果/累计消耗与熔断语义(取代千篇一律的「重试 N 次后仍无产出」)。 */
2700
3001
  const stageFailError = (label, r) => {
2701
- const last = [...journal.stages || []].reverse().find((s) => s.phase === label);
3002
+ const last = [...journal.stages || []].reverse().find((s) => phaseKeyOf(s.phase) === label);
2702
3003
  const attempts = r && r.attempts ? r.attempts : 2;
2703
3004
  const burnt = Math.round((r && r.stageTokens || 0) / 1e3);
2704
3005
  const breaker = (r && r.stageTokens || 0) >= 6e4 ? ",超出阶段预算熔断" : "";
2705
3006
  const detail = last ? `末次 ${last.outcome || "unknown"}${last.summary ? `(${last.summary})` : ""}` : "无阶段记录";
2706
- 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},需人工介入`);
2707
3008
  };
2708
3009
  try {
2709
3010
  if (resume) journal.logs.push({
2710
3011
  t: Date.now(),
2711
3012
  level: "info",
2712
- message: `断点续跑:复用 backlog(req=${journal.reqId}),从「${resume.phase}」继续`
3013
+ message: `断点续跑:复用 backlog(req=${journal.reqId}),从「${PHASE_KEY_OF[resume.phase] || resume.phase}」继续`
2713
3014
  });
2714
3015
  else {
2715
3016
  const init = initPipelineBacklog(journal, requirement, options);
@@ -2872,11 +3173,25 @@ async function executePipeline(journal, parent, requirement, options, signal, re
2872
3173
  const block = extractStateBlock(output);
2873
3174
  if (block) mergeStateBlock(journal.workspace || "default", block, phaseKey);
2874
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
+ };
2875
3190
  let prd = null;
2876
- if (resumed("PRD 产品需求")) {
3191
+ if (resumed("prd")) {
2877
3192
  prd = resume.products.prd;
2878
3193
  timeline.prd = prd;
2879
- logSkip("PRD 产品需求");
3194
+ logSkip(PHASE_KEY_OF.prd);
2880
3195
  } else {
2881
3196
  journal.logs.push({
2882
3197
  t: Date.now(),
@@ -2893,8 +3208,8 @@ async function executePipeline(journal, parent, requirement, options, signal, re
2893
3208
  label: "产品经理 · 梳理 PRD",
2894
3209
  fn: prdPrompt
2895
3210
  };
2896
- const prdR = await withRetry(journal, parent, pForm.label, "PRD 产品需求", pForm.fn(requirement, root, journal.id, state), signal);
2897
- 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);
2898
3213
  prd = prdR.text;
2899
3214
  timeline.prd = prd;
2900
3215
  mergeStageState("prd", prd);
@@ -2903,18 +3218,18 @@ async function executePipeline(journal, parent, requirement, options, signal, re
2903
3218
  }
2904
3219
  let design = null;
2905
3220
  if (enabled("design")) {
2906
- if (resumed("UI/UX 设计")) {
3221
+ if (resumed("design")) {
2907
3222
  design = resume.products.design;
2908
3223
  timeline.design = design;
2909
- logSkip("UI/UX 设计");
3224
+ logSkip(PHASE_KEY_OF.design);
2910
3225
  } else {
2911
3226
  journal.logs.push({
2912
3227
  t: Date.now(),
2913
3228
  level: "phase",
2914
3229
  message: "进入阶段:UI/UX 设计"
2915
3230
  });
2916
- const designR = await withRetry(journal, parent, "UI/UX 设计师 · 设计说明", "UI/UX 设计", designPrompt(prd, root, journal.id, state), signal);
2917
- 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);
2918
3233
  design = designR.text;
2919
3234
  timeline.design = design;
2920
3235
  mergeStageState("design", design);
@@ -2924,18 +3239,18 @@ async function executePipeline(journal, parent, requirement, options, signal, re
2924
3239
  }
2925
3240
  let scaffold = null;
2926
3241
  if (enabled("scaffold")) {
2927
- if (resumed("架构规划")) {
3242
+ if (resumed("scaffold")) {
2928
3243
  scaffold = resume.products.scaffold;
2929
3244
  timeline.scaffold = scaffold;
2930
- logSkip("架构规划");
3245
+ logSkip(PHASE_KEY_OF.scaffold);
2931
3246
  } else {
2932
3247
  journal.logs.push({
2933
3248
  t: Date.now(),
2934
3249
  level: "phase",
2935
3250
  message: "进入阶段:架构规划"
2936
3251
  });
2937
- const scR = await withRetry(journal, parent, "架构师 · 脚手架规划与落地", "架构规划", scaffoldPrompt(requirement, design, root, journal.id, state), signal);
2938
- 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);
2939
3254
  scaffold = scR.text;
2940
3255
  timeline.scaffold = scaffold;
2941
3256
  mergeStageState("scaffold", scaffold);
@@ -2945,10 +3260,10 @@ async function executePipeline(journal, parent, requirement, options, signal, re
2945
3260
  }
2946
3261
  let tech = null;
2947
3262
  if (enabled("tech")) {
2948
- if (resumed("技术方案")) {
3263
+ if (resumed("tech")) {
2949
3264
  tech = resume.products.tech;
2950
3265
  timeline.tech = tech;
2951
- logSkip("技术方案");
3266
+ logSkip(PHASE_KEY_OF.tech);
2952
3267
  } else {
2953
3268
  const isHeavy = !options.lite && options.mode !== "tech" && options.mode !== "patch";
2954
3269
  journal.logs.push({
@@ -2957,7 +3272,7 @@ async function executePipeline(journal, parent, requirement, options, signal, re
2957
3272
  message: isHeavy ? "进入阶段:技术方案" : "进入阶段:架构蓝图"
2958
3273
  });
2959
3274
  const label = isHeavy ? "高级全栈工程师 · 技术方案" : "架构师 · 架构蓝图";
2960
- const techR = await withRetry(journal, parent, label, "技术方案", isHeavy ? techPrompt(prd, design, scaffold, tasks, root, journal.id, state) : architectPrompt(prd, root, journal.id, state), signal);
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);
2961
3276
  if (!techR.text) throw stageFailError(label, techR);
2962
3277
  tech = techR.text;
2963
3278
  timeline.tech = tech;
@@ -2990,28 +3305,28 @@ async function executePipeline(journal, parent, requirement, options, signal, re
2990
3305
  }
2991
3306
  }
2992
3307
  let devResults = null;
2993
- if (resumed("开发")) {
3308
+ if (resume) {
2994
3309
  devResults = resume.products.dev || [];
2995
- const failedSubs = (storeFor(scopeKey).tasks || []).filter((t) => t.reqId === journal.reqId && t.parentId === journal.taskId && (t.status === "failed" || t.failed === true));
2996
- if (failedSubs.length > 0) {
2997
- const defs = buildDevTaskDefs(journal, tasks);
2998
- const defByTitle = new Map(defs.map((d) => [d.title, d]));
2999
- const rerunDefs = failedSubs.map((s) => {
3000
- const key = String(s.title || "").replace(/^开发 · /, "");
3001
- return defByTitle.get(key) || {
3002
- title: key,
3003
- spec: s.spec || "按技术方案实现该任务改动",
3004
- files: []
3005
- };
3006
- }).filter(Boolean);
3007
- 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));
3008
3320
  journal.logs.push({
3009
3321
  t: Date.now(),
3010
3322
  level: "warn",
3011
- message: `断点续跑开发:复用 ${reused.length} 个已完成任务,补跑 ${rerunDefs.length} 个失败任务`
3323
+ message: `断点续跑开发:复用 ${reused.length} 个已完成任务,补跑 ${todo.length} 个失败任务`
3012
3324
  });
3013
- const rerun = await runPool(rerunDefs, maxConcurrency, async (task) => {
3014
- 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);
3015
3330
  const ok = !!devR.text;
3016
3331
  return {
3017
3332
  title: task.title,
@@ -3020,14 +3335,11 @@ async function executePipeline(journal, parent, requirement, options, signal, re
3020
3335
  };
3021
3336
  });
3022
3337
  for (const t of rerun) {
3023
- const sub = failedSubs.find((s) => String(s.title || "").replace(/^开发 · /, "") === t.title);
3338
+ const sub = createSubtask(journal, t.title, t.spec || "");
3024
3339
  if (sub) completeSubtask(journal, sub.id, t.failed, t.output ? snippet(t.output, 1e3) : null, null);
3025
3340
  }
3026
3341
  devResults = [...reused, ...rerun];
3027
3342
  timeline.dev = devResults;
3028
- } else {
3029
- timeline.dev = devResults;
3030
- logSkip("开发");
3031
3343
  }
3032
3344
  } else {
3033
3345
  journal.logs.push({
@@ -3069,12 +3381,12 @@ async function executePipeline(journal, parent, requirement, options, signal, re
3069
3381
  persistJournal(journal);
3070
3382
  }
3071
3383
  }
3072
- 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);
3073
3386
  const ok = !!devR.text;
3074
3387
  if (sub) {
3075
3388
  completeSubtask(journal, sub.id, !ok, devR.text ? snippet(devR.text, 1e3) : null, null);
3076
- const devStage = journal.stages.filter((s) => s.phase === "开发").pop();
3077
- if (devStage) noteSubtaskUsage(journal, sub.id, devStage);
3389
+ if (devR.stage) noteSubtaskUsage(journal, sub.id, devR.stage);
3078
3390
  }
3079
3391
  return {
3080
3392
  title: task.title,
@@ -3085,7 +3397,7 @@ async function executePipeline(journal, parent, requirement, options, signal, re
3085
3397
  timeline.dev = devResults;
3086
3398
  for (const r of devResults) if (r && r.output) mergeStageState("dev", r.output);
3087
3399
  noteTaskStageUsage(journal);
3088
- 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(",") || "开发组");
3089
3401
  const failedCount = devResults.filter((r) => r && r.failed).length;
3090
3402
  if (failedCount > 0) {
3091
3403
  advanceTask(journal, "needs-human", null, "开发失败,需人工介入", { by: "dev" });
@@ -3120,10 +3432,10 @@ async function executePipeline(journal, parent, requirement, options, signal, re
3120
3432
  message: "当前档位阶段集不含独立 QA:跳过(单点修复,开发自测兜底)"
3121
3433
  });
3122
3434
  qa = "(独立 QA 跳过:当前档位由开发自测兜底)";
3123
- } else if (resumed("QA 测试") && !hasOpenBlockingBugs(journal)) {
3124
- qa = resume.products.qa;
3435
+ } else if (resumed("qa") && !hasOpenBlockingBugs(journal)) {
3436
+ qa = artifactText(journal, "QA-REPORT.md") || resume.products.qa;
3125
3437
  timeline.qa = qa;
3126
- logSkip("QA 测试");
3438
+ logSkip(PHASE_KEY_OF.qa);
3127
3439
  } else {
3128
3440
  journal.logs.push({
3129
3441
  t: Date.now(),
@@ -3132,7 +3444,7 @@ async function executePipeline(journal, parent, requirement, options, signal, re
3132
3444
  });
3133
3445
  advanceTask(journal, "testing", null, "QA 开始(待测试 → 测试中)", { by: "qa" });
3134
3446
  const store = storeFor(scopeKey);
3135
- 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(",") || "测试组";
3136
3448
  let round = 0;
3137
3449
  let qaClean = false;
3138
3450
  const devFixRounds = [];
@@ -3144,14 +3456,26 @@ async function executePipeline(journal, parent, requirement, options, signal, re
3144
3456
  do {
3145
3457
  round += 1;
3146
3458
  const isReverify = round > 1;
3147
- 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);
3148
3460
  if (!qaR.text) {
3149
3461
  advanceTask(journal, "needs-human", null, isReverify ? `QA 复验失败(第 ${round - 1} 轮修复后)` : "QA 失败", { by: "qa" });
3150
- 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
+ });
3151
3477
  }
3152
- qa = qaR.text;
3153
3478
  timeline.qa = qa;
3154
- mergeStageState("qa", qa);
3155
3479
  noteTaskStageUsage(journal);
3156
3480
  noteTaskAssign(journal, "qa", qaStageChildren());
3157
3481
  defects = parseDefects(qa);
@@ -3189,7 +3513,8 @@ async function executePipeline(journal, parent, requirement, options, signal, re
3189
3513
  message: `QA 发现 ${blocking.length} 个阻断缺陷(第 ${round} 轮),打回开发确认修复后复验`
3190
3514
  });
3191
3515
  advanceTask(journal, "rework", snippet(qa, 3e3), `QA 打回开发修复(第 ${round}/3 轮)`, { by: "qa" });
3192
- 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);
3193
3518
  if (!fixR.text) {
3194
3519
  advanceTask(journal, "needs-human", null, "QA 打回后开发修复失败", { by: "qa" });
3195
3520
  throw stageFailError("开发(QA 打回修复)", fixR);
@@ -3239,18 +3564,47 @@ async function executePipeline(journal, parent, requirement, options, signal, re
3239
3564
  const curTask = storeFor(scopeKey).find("task", journal.taskId);
3240
3565
  if (curTask && curTask.status !== "pending-acceptance" && curTask.status !== "needs-human" && curTask.status !== "rework") advanceTask(journal, "pending-acceptance", null, "进入验收(待验收)", { by: "pm" });
3241
3566
  }
3242
- 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);
3243
3568
  if (!accR.text) {
3244
3569
  advanceTask(journal, "needs-human", null, "验收失败", { by: "pm" });
3245
- 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
+ });
3246
3585
  }
3247
- const acceptance = accR.text;
3248
3586
  timeline.acceptance = acceptance;
3249
3587
  noteTaskStageUsage(journal);
3250
- const accStage = journal.stages.find((s) => s.phase === "产品验收" && s.childId);
3588
+ const accStage = journal.stages.find((s) => phaseKeyOf(s.phase) === "acceptance" && s.childId);
3251
3589
  noteTaskAssign(journal, "accept", accStage ? String(accStage.childId).slice(0, 8) : "验收组");
3252
- mergeStageState("acceptance", acceptance);
3253
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
+ }
3254
3608
  if (accVerdict === "reject") {
3255
3609
  advanceTask(journal, "needs-human", snippet(acceptance, 3e3), "需求与现状不符(无需改动),需人工决定调整或取消需求", { by: "pm" });
3256
3610
  const store = storeFor(scopeKey);
@@ -4295,8 +4649,7 @@ function tryFlushPendingInjections(sessionId) {
4295
4649
  const agent = runtime.agents ? runtime.agents.get(sessionId) : void 0;
4296
4650
  if (!agent || typeof agent.inject !== "function") return;
4297
4651
  try {
4298
- agent.inject({
4299
- type: "user",
4652
+ agent.inject(createUserMessage({
4300
4653
  content: [{
4301
4654
  type: "text",
4302
4655
  text: teamflowContextText(pending.teamIcon, pending.teamName, pending.teamId)
@@ -4304,9 +4657,9 @@ function tryFlushPendingInjections(sessionId) {
4304
4657
  source: {
4305
4658
  kind: "plugin",
4306
4659
  plugin: "dsh-plugin-teamflow",
4307
- form: "context"
4660
+ form: "instructions"
4308
4661
  }
4309
- });
4662
+ }));
4310
4663
  pendingInjections.delete(sessionId);
4311
4664
  } catch (e) {}
4312
4665
  }
@@ -4379,7 +4732,9 @@ var TeamflowService = class extends TypertRemoteService {
4379
4732
  const j = runs.get(latest.id);
4380
4733
  return j ? snapshotOf(j) : null;
4381
4734
  }
4382
- /** 阶段详情:卡片点击查看 —— 状态/耗时/官方 usage + 产物全文(超 24k 截断)。 */
4735
+ /** 阶段详情:卡片点击查看 —— 状态/耗时/官方 usage + 产物全文(超 24k 截断)。
4736
+ * 2026-09-06 状态机化:返回同任务全部尝试(attempts 聚合——按 stage.taskKey(旧数据 label 兜底),
4737
+ * 按 seq 排序)——client 弹窗单次渲染现状、多次渲染时间线。 */
4383
4738
  stageDetail(runId, seq, sessionId) {
4384
4739
  if (typeof runId !== "string" || !runId || seq === void 0 || seq === null) return null;
4385
4740
  const sc = sessionScope(sessionId);
@@ -4388,6 +4743,21 @@ var TeamflowService = class extends TypertRemoteService {
4388
4743
  if (j.workspace && sc.projectKey && j.workspace !== sc.projectKey && sc.projectKey !== "default") return null;
4389
4744
  const s = (j.stages || []).find((st) => Number(st.seq) === Number(seq));
4390
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;
4391
4761
  return {
4392
4762
  seq: s.seq,
4393
4763
  label: s.label,
@@ -4399,8 +4769,10 @@ var TeamflowService = class extends TypertRemoteService {
4399
4769
  endedAt: s.endedAt,
4400
4770
  ownerSession: j.ownerSession || null,
4401
4771
  usage: s.usage || null,
4772
+ verifyEvidence: s.verifyEvidence || null,
4402
4773
  summary: clip(s.summary || "", 3e3),
4403
- output: clip(toText(s.output) || toText(s.handoff) || "", 24e3)
4774
+ output: clip(toText(s.output) || toText(s.handoff) || "", 24e3),
4775
+ attempts
4404
4776
  };
4405
4777
  }
4406
4778
  /** Backlog 条目详情:卡片点击查看 —— 完整字段 + 流转时间线 + 关联(子卡/缺陷)+ 任务夹路径。 */
@@ -4625,8 +4997,7 @@ var TeamflowService = class extends TypertRemoteService {
4625
4997
  activeTeams.set(sid, tid);
4626
4998
  saveActiveTeams();
4627
4999
  const agent = runtime.agents && runtime.agents.get(sid);
4628
- const injectPayload = {
4629
- type: "user",
5000
+ const injectPayload = createUserMessage({
4630
5001
  content: [{
4631
5002
  type: "text",
4632
5003
  text: teamflowContextText(team.icon, team.name, tid)
@@ -4634,9 +5005,9 @@ var TeamflowService = class extends TypertRemoteService {
4634
5005
  source: {
4635
5006
  kind: "plugin",
4636
5007
  plugin: "dsh-plugin-teamflow",
4637
- form: "context"
5008
+ form: "instructions"
4638
5009
  }
4639
- };
5010
+ });
4640
5011
  if (agent && typeof agent.inject === "function") try {
4641
5012
  agent.inject(injectPayload);
4642
5013
  } catch (e) {}