dsh-plugin-teamflow 0.1.5 → 0.1.7

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,14 +1,24 @@
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
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
7
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
8
+ import { fileAddressFor } from "@deepseek-ai/dsh-util-workspace-path";
7
9
  import { execFileSync } from "node:child_process";
8
- import { createUserMessage } from "@deepseek-ai/dsh-llm";
9
10
  //#region host/constants.ts
10
11
  /** 单阶段 token 熔断预算(官方口径总消耗:input+cacheRead+cacheWrite+output 累计)。 */
11
12
  const STAGE_TOKEN_BUDGET = 6e4;
13
+ /** 任务夹产物展示顺序(ADR-0008):工作台只列其中**真实存在**的文件,按此顺序出「一键右侧栏预览」按钮。 */
14
+ const TEAMFLOW_ARTIFACT_ORDER = [
15
+ "PRD.md",
16
+ "DESIGN.md",
17
+ "TECHNICAL.md",
18
+ "QA-REPORT.md",
19
+ "ACCEPTANCE.md",
20
+ "meta.json"
21
+ ];
12
22
  /** 护栏轮询间隔 ms。 */
13
23
  const GUARD_POLL_MS = 15e3;
14
24
  /** 挂死判定:连续这么久没有任何新会话事件(provider 挂起/静默死亡)→ stalled(走预算门转人工)。 */
@@ -21,6 +31,7 @@ const REFUSAL_PATTERN = /(无法完成|不能完成|无法继续|抱歉|对不
21
31
  const STAGE_MIN_LENGTH = {
22
32
  prd: 400,
23
33
  design: 250,
34
+ scaffold: 250,
24
35
  arch: 250,
25
36
  tech: 350,
26
37
  dev: 60,
@@ -57,16 +68,30 @@ const STATUS = {
57
68
  "needs-human"
58
69
  ]
59
70
  };
60
- /** 流水线阶段顺序与 key 映射(resume/pipeline 用)。 */
71
+ /**
72
+ * 流水线阶段(2026-09-06 英文化改造):内部一律英文键(journal.stage.phase / 代码判断 / 状态机)。
73
+ * 中文阶段名只作为展示 label(client UI 映射,未来 i18n 与 dsh 中英对齐)。
74
+ */
61
75
  const PHASE_ORDER = [
62
- "PRD 产品需求",
63
- "UI/UX 设计",
64
- "架构规划",
65
- "技术方案",
66
- "开发",
67
- "QA 测试",
68
- "产品验收"
76
+ "prd",
77
+ "design",
78
+ "scaffold",
79
+ "tech",
80
+ "dev",
81
+ "qa",
82
+ "acceptance"
69
83
  ];
84
+ /** 英文键 → 中文展示名(仅 UI/label/日志文案使用,不得用于代码判断)。 */
85
+ const PHASE_KEY_OF = {
86
+ prd: "PRD 产品需求",
87
+ design: "UI/UX 设计",
88
+ scaffold: "架构规划",
89
+ tech: "技术方案",
90
+ dev: "开发",
91
+ qa: "QA 测试",
92
+ acceptance: "产品验收"
93
+ };
94
+ /** 中文阶段名 → 英文键(存量 journal/backlog 兼容映射;迁移脚本执行后仅防御性保留)。 */
70
95
  const PHASE_KEY_BY_NAME = {
71
96
  "PRD 产品需求": "prd",
72
97
  "UI/UX 设计": "design",
@@ -76,15 +101,22 @@ const PHASE_KEY_BY_NAME = {
76
101
  "QA 测试": "qa",
77
102
  "产品验收": "acceptance"
78
103
  };
79
- /** 阶段显示名 → 角色键(任务卡 byRole 累计用;未知阶段归 'other')。 */
104
+ /** phase 归一:中文(存量)或英文(新数据)输入 → 英文键;未知回退原值小写化。 */
105
+ function phaseKeyOf(phase) {
106
+ const p = String(phase || "");
107
+ if (!p) return "";
108
+ if (PHASE_KEY_BY_NAME[p]) return PHASE_KEY_BY_NAME[p];
109
+ return p;
110
+ }
111
+ /** 阶段英文键 → 角色键(任务卡 byRole 累计用;未知阶段归 'other')。 */
80
112
  const PHASE_ROLE = {
81
- "PRD 产品需求": "pm",
82
- "UI/UX 设计": "design",
83
- "架构规划": "arch",
84
- "技术方案": "tech",
85
- "开发": "dev",
86
- "QA 测试": "qa",
87
- "产品验收": "acceptance"
113
+ prd: "pm",
114
+ design: "design",
115
+ scaffold: "arch",
116
+ tech: "tech",
117
+ dev: "dev",
118
+ qa: "qa",
119
+ acceptance: "acceptance"
88
120
  };
89
121
  const STAGE_POLICY = {
90
122
  full: [
@@ -185,6 +217,17 @@ function extractText(blocks) {
185
217
  if (!Array.isArray(blocks)) return "";
186
218
  return blocks.filter((b) => b && b.type === "text" && typeof b.text === "string").map((b) => b.text).join("\n");
187
219
  }
220
+ /** 从 dev/qaFix 回复中提取「验证证据」块(`[Verification evidence]` 行起,到 state 块/结尾止)。
221
+ * dev 阶段无独立对抗校验(QA 有 QA-REPORT.md 结构化证据,dev 只有自述)——证据块是「可审计的
222
+ * 具体自述」:命令+退出码+断言计数+失败行引用,可对照 logs/teamflow/<runId>/ 命令输出日志核实;
223
+ * 模型仍可伪造,但具体细节难编造一致(具体性压力)且伪造可发现(审计轨迹)。
224
+ * 找不到块(契约未兑现)→ null,host 记 warn 不中断(policy 级)。 */
225
+ function extractVerificationEvidence(text) {
226
+ const m = toText(text).match(/\[Verification evidence\]([\s\S]*?)(?=<!--\s*state|$)/);
227
+ if (!m || !m[1]) return null;
228
+ const ev = m[1].trim();
229
+ return ev.length > 0 ? ev : null;
230
+ }
188
231
  /**
189
232
  * ADR-0008 任务夹命名:<yyyyMMdd>-r<N>[-<slug>]。
190
233
  * - date 用本地时区(用户在东八区晚上建的需求不能落到"明天")
@@ -253,10 +296,11 @@ function sanitizeSnapOptions(o) {
253
296
  const SAFE_SIGNAL = {
254
297
  aborted: false,
255
298
  addEventListener: () => {},
256
- removeEventListener: () => {}
299
+ removeEventListener: () => {},
300
+ throwIfAborted: () => {}
257
301
  };
258
302
  function normalizeSignal(s) {
259
- return s && typeof s === "object" && typeof s.addEventListener === "function" && typeof s.aborted === "boolean" ? s : SAFE_SIGNAL;
303
+ return s && typeof s === "object" && typeof s.addEventListener === "function" && typeof s.aborted === "boolean" && typeof s.throwIfAborted === "function" ? s : SAFE_SIGNAL;
260
304
  }
261
305
  /** 分支 slug 派生(ADR-2026-08-27):branchName > triageSlug > 需求中的英文标识词 > reqId 数字 > 'feature'。
262
306
  * 实锤 feat/feature:lite 显式时 triage 不跑(无 slug)+ 分支检查早于 reqId 生成 → fallback 'feature'。 */
@@ -305,6 +349,33 @@ function handoffBrief(text) {
305
349
  const m = String(text).match(/<!--\s*handoff\s*-->([\s\S]*?)(?:<!--\s*\/handoff\s*-->|$)/);
306
350
  return clip((m && m[1] ? m[1] : String(text)).trim(), 2e3);
307
351
  }
352
+ /** 拒绝词命中点:返回命中的具体短语 + 原文上下文片段(供重试诊断回灌,比事后从截断尾巴重算可靠)。 */
353
+ function refusalHit(text) {
354
+ const s = String(text || "");
355
+ const m = REFUSAL_PATTERN.exec(s);
356
+ if (!m || m.index < 0) return null;
357
+ const start = Math.max(0, m.index - 40);
358
+ const end = Math.min(s.length, m.index + String(m[0]).length + 40);
359
+ return {
360
+ phrase: m[0],
361
+ context: s.slice(start, end).replace(/\s+/g, " ").trim()
362
+ };
363
+ }
364
+ /** 重试诊断包:上一轮失败详情回灌进重试 prompt(盲试 → 带因重试)。
365
+ * 失败分类/详情/护栏原因取自 stage;产出尾部截断 1000 字符供自查修正。 */
366
+ function buildRetryDiagnostic(attempt, stage) {
367
+ const lines = [];
368
+ lines.push(`[重试诊断 · 第 ${attempt} 次尝试] 上一轮尝试未成功。这不是新任务——请先阅读以下失败详情,再执行原任务并修正上一轮的问题。`);
369
+ lines.push(`- 失败分类:${stage.outcome || "unknown"}`);
370
+ if (stage.guardReason) lines.push(`- 护栏中止原因:${stage.guardReason}`);
371
+ if (stage.summary) lines.push(`- 详情:${stage.summary}`);
372
+ const out = String(stage.output || "");
373
+ if (out) {
374
+ const tail = out.length > 1e3 ? `…${out.slice(-1e3)}` : out;
375
+ lines.push(`- 上一轮产出末尾(节选,供自查修正):\n${tail}`);
376
+ }
377
+ return `\n\n${lines.join("\n")}\n[/重试诊断结束]`;
378
+ }
308
379
  /**
309
380
  * 验收结论解析:只以显式「验收结论 / 整体结论」行为准(acceptancePrompt 强制 4 档固定话术),
310
381
  * 不做正文散文朴素子串匹配。历史误报实锤(run tf-msytlok5):验收报告 ✅ 通过,其记忆回写段一句
@@ -313,8 +384,11 @@ function handoffBrief(text) {
313
384
  * - 「📝 需求不适用」是验收负责人专用的强结论词,允许全文命中;
314
385
  * - 其余 reject 词(需求与实际不符/站不住/无效/无需改动等)仅在结论行且该行不含「通过/✅/⚠️」时才算;
315
386
  * - rework 词仅认结论行(且不与「✅ 通过」同现)。
387
+ * 反向护栏(漏报实锤 2026-09-03):模型写「❌ 不通过」但漏写「验收结论:」前缀 → accLine 为空 →
388
+ * 旧实现落回默认 accepted(最乐观默认值,质量门禁漏报=假交付)。现改为 **找不到结论行 → needs-human**
389
+ * (宁严勿松:误拦截=人工看一眼,误放行=假交付;📝 全文命中与架构红词仍优先于该默认)。
316
390
  * @param {unknown} text 验收报告全文
317
- * @returns {'accepted'|'rework'|'reject'}
391
+ * @returns {'accepted'|'rework'|'reject'|'needs-human'}
318
392
  */
319
393
  function parseAcceptanceVerdict(text) {
320
394
  const acc = String(text || "");
@@ -322,9 +396,14 @@ function parseAcceptanceVerdict(text) {
322
396
  const hasArchRedFlag = /重复实现|重复适配|偏离蓝图|未按蓝图|该拆未拆|该抽象未抽象|破坏既有结构|结构性.*问题|架构(打回|需重构)|需.*返工|返工.*项.*(存在|仍)|仍.*(返工|重构)/.test(acc);
323
397
  const archNegated = /无返工|无.*返工|不返工|无架构打回|无.*打回|非漂移|无.*重复|无.*偏离|无.*抽象.*问题|无.*蓝图.*问题|架构一致性.*(PASS|良好|达标|通过|无问题)|M3.*(PASS|通过|达标)|架构.*(达标|无问题|良好)/.test(acc);
324
398
  if (hasArchRedFlag && !archNegated) return "rework";
325
- if (/❌\s*不通过|需返工|未通过/.test(accLine) && !/✅\s*通过/.test(accLine)) return "rework";
326
399
  if (/📝\s*需求不适用/.test(acc)) return "reject";
400
+ if (/不通过|需返工|未通过/.test(accLine) && !/无\s*不通过|未发现不通过|未出现不通过/.test(accLine)) {
401
+ if (/✅\s*通过/.test(accLine)) return "accepted";
402
+ return "rework";
403
+ }
327
404
  if (!/通过|✅|⚠️/.test(accLine) && /需求不适用|需求与实际不符|需求站不住|需求无效|无需改动|无需修改/.test(accLine)) return "reject";
405
+ if (!accLine) return "needs-human";
406
+ if (!/通过|✅|⚠️|❌|📝/.test(accLine)) return "needs-human";
328
407
  return "accepted";
329
408
  }
330
409
  const bdOpen = "<!-- blueprint -->";
@@ -402,20 +481,27 @@ function extractBlueprint(text) {
402
481
  //#region host/core/context.ts
403
482
  /**
404
483
  * dsh-plugin-teamflow core — 运行期共享状态(进程单例)。
405
- * - runtime(agents/subagents/tokenMeter/workspaceRegistry/agentDefaultModel):由 index=TeamflowService 的 static inject 注入(setRuntime)。
484
+ * - runtime(agents/subagents/workspaceRegistry/agentDefaultModel/llm):由 index=TeamflowService 的 static inject 注入(setRuntime)。
406
485
  * - runs/inFlight/activeProducts:流水线运行期 Map(跨 runner/pipeline/report/服务共享)。
407
486
  * 这是 ADR-0004「共享状态」在编排层的落点:共享对象集中、单向被 core 各模块 import(不反向)。
408
487
  */
409
488
  /** 子代理/计量等宿主能力(由 TeamflowService 装配时 setRuntime 注入)。字段为鸭子类型:消费方自行窄化。 */
410
489
  const runtime = {};
411
- function setRuntime(agents, subagents, tokenMeter, workspaceRegistry, agentDefaultModel, llm) {
490
+ function setRuntime(agents, subagents, workspaceRegistry, agentDefaultModel, llm) {
412
491
  runtime.agents = agents;
413
492
  runtime.subagents = subagents;
414
- runtime.tokenMeter = tokenMeter;
415
493
  runtime.workspaceRegistry = workspaceRegistry;
416
494
  runtime.agentDefaultModel = agentDefaultModel;
417
495
  runtime.llm = llm;
418
496
  }
497
+ /**
498
+ * 可选能力:官方 Session 投影注册表(ctx.sessionProjections,dsh-session-projection)。
499
+ * 单独 setter 而非并入 setRuntime——它是**可选**依赖:用 ctx.inject 在服务可用时注册,
500
+ * 未挂载(最小 profile)时计量自动回退事件扫描,插件照常加载。
501
+ */
502
+ function setSessionProjections(projections) {
503
+ runtime.sessionProjections = projections;
504
+ }
419
505
  /** 运行期 run 注册表(runId → Journal)。 */
420
506
  const runs = /* @__PURE__ */ new Map();
421
507
  /** 进行中的 stage 注册表(runId → { run, stage }),供取消/完成清理。 */
@@ -461,15 +547,19 @@ async function currentModelSupportsVision(provider, model) {
461
547
  /**
462
548
  * 从发起会话推导工作区作用域。
463
549
  *
464
- * 优先级:
465
- * 1. workspaceRegistry.resolveByPath(cwd) → 用 workspace.id(UUID,稳定)作 projectKey
466
- * 2. 回退到 session cwd 的 basename + 短 hash(兼容无 workspaceRegistry 的场景)
550
+ * 优先级(**实际生效的只有第 2 条**):
551
+ * 1. workspaceRegistry.resolveByPath(cwd) → 用 workspace.id(UUID)作 projectKey
552
+ * —— ⚠️ **当前不可达**:宿主 `resolveByPath` 是 `async`(返回 Promise,见
553
+ * `packages/workspace/workspace/src/index.ts`),本函数同步调用 → `ws.id` 恒为 undefined,
554
+ * 永远落到第 2 条。分支保留是为将来迁移(需 await + 存储 key 迁移,见 docs/TODO.md)。
555
+ * 2. session cwd 的 basename + 短 hash(`slugPath`)—— **当前实际使用的 key**
467
556
  * 3. 兜底 'default'
468
557
  *
469
558
  * projectKey 用于 $DSH_HOME/teamflow/<projectKey>/ 目录,要求:
470
- * - 同一 workspace 永远解析到同一个 key(UUID 天然满足)
471
- * - 不同 workspace 即使 basename 相同也不碰撞(UUID 天然满足)
559
+ * - 同一路径永远解析到同一个 key(sha1 派生,满足)
560
+ * - 不同 cwd 即使 basename 相同也不碰撞(hash 参与,满足)
472
561
  * - 目录名安全(只含 [a-zA-Z0-9_-])
562
+ * ⚠️ 代价:key 绑定**路径字符串**,同一工作区换个写法(盘符大小写/软链/尾斜杠)会得到不同 key。
473
563
  */
474
564
  function workspaceScopeOf(agent) {
475
565
  const session = agent?.session;
@@ -834,6 +924,20 @@ function createSubtask(journal, title, spec) {
834
924
  const store = storeFor(journal.workspace || "default");
835
925
  const mainTask = journal.taskId ? store.find("task", journal.taskId) : null;
836
926
  if (!mainTask) return null;
927
+ const fullTitle = `开发 · ${title}`;
928
+ const existing = store.tasks.find((t) => t.reqId === journal.reqId && t.parentId === journal.taskId && (t.taskKey && t.taskKey === title || !t.taskKey && t.title === fullTitle));
929
+ if (existing) {
930
+ existing.status = "pending";
931
+ existing.failed = false;
932
+ existing.summary = null;
933
+ existing.endedAt = null;
934
+ existing.retries = (existing.retries || 0) + 1;
935
+ existing.taskKey = existing.taskKey || title;
936
+ existing.updatedAt = Date.now();
937
+ store.persist();
938
+ persistJournal(journal);
939
+ return existing;
940
+ }
837
941
  const id = store.nextId("dev");
838
942
  const sub = {
839
943
  id,
@@ -841,7 +945,8 @@ function createSubtask(journal, title, spec) {
841
945
  parentId: journal.taskId,
842
946
  product: journal.workspace || "default",
843
947
  type: "subtask",
844
- title: `开发 · ${title}`,
948
+ title: fullTitle,
949
+ taskKey: title,
845
950
  spec: spec || "",
846
951
  status: "pending",
847
952
  devAssign: mainTask && mainTask.devAssign || null,
@@ -1170,16 +1275,122 @@ function runSanityCheck(path) {
1170
1275
  //#endregion
1171
1276
  //#region host/core/metering.ts
1172
1277
  /**
1278
+ * dsh-plugin-teamflow core — token 计量(官方口径)。
1279
+ * 依赖:types.ts、context.ts(runtime.sessionProjections)。
1280
+ *
1281
+ * 口径与模型 provider 账单一致(模型无关):
1282
+ * - input : 输入(缓存未命中)
1283
+ * - cacheRead : 输入(缓存命中)
1284
+ * - cacheWrite : 输入写入缓存
1285
+ * - output : 输出
1286
+ * billed input = input + cacheRead + cacheWrite。
1287
+ * 缓存命中率 = cacheRead / (input + cacheRead)。
1288
+ *
1289
+ * 来源优先级(2026-09-10 适配 dsh 0.1.5-rc.2):
1290
+ * 1) **官方 Session 投影**(首选):`ctx.sessionProjections.stateOf(session,'tokenUsage')` 取四桶 +
1291
+ * `'sessionStats'` 取调用数——零历史扫描,且与官方 token-meter 同一份 fold(不再自行复刻口径)。
1292
+ * 2) **事件扫描回退**(存量路径):宿主未挂载投影(最小 profile/未来移除)或投影无 provider usage 时,
1293
+ * 沿用 events → snapshotEvents() → ownEvents() 多源回退。宿主自 2026-09-09 起把这三个同步历史读取器
1294
+ * 标记为 deprecated(存量可留、新调用禁止),本路径仅为无投影宿主保底,不再扩展(见 docs/TODO.md)。
1295
+ */
1296
+ /** 投影字段读数(宽进严出:非法/非正一律 0,不虚报)。 */
1297
+ function countOf(value) {
1298
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0;
1299
+ }
1300
+ /**
1301
+ * 投影路径(官方口径首选):
1302
+ * - `tokenUsage`(dsh-token-meter 注册,stateVersion 2)→ totals 四桶:与官方同一份 fold,
1303
+ * `assistant/attempt` 内嵌 stream usage 同样计入、`llm/retry-started` 会先关掉被替换的重试槽位
1304
+ * ——比旧事件扫描(只认 assistant/message)更准,重试不重复计。
1305
+ * - `sessionStats`(dsh-session-stats 注册)steps → 调用数(一个 step = 一次模型请求;
1306
+ * 旧扫描按 assistant/message 的 turn.step 去重,语义等价)。
1307
+ * 返回 null = 投影不可用或该会话无 provider usage —— 交给事件扫描回退(不虚报 0)。
1308
+ */
1309
+ function projectedUsageOf(run) {
1310
+ try {
1311
+ const projections = runtime.sessionProjections;
1312
+ if (!projections || typeof projections.stateOf !== "function") return null;
1313
+ const session = run && run.localAgent ? run.localAgent.session : null;
1314
+ if (!session) return null;
1315
+ const usage = projections.stateOf(session, "tokenUsage");
1316
+ const totals = usage && usage.totals;
1317
+ if (!totals) return null;
1318
+ const buckets = {
1319
+ input: countOf(totals.uncachedInputTokens),
1320
+ cacheRead: countOf(totals.cacheReadTokens),
1321
+ cacheWrite: countOf(totals.cacheWriteTokens),
1322
+ output: countOf(totals.outputTokens),
1323
+ calls: 0
1324
+ };
1325
+ if (totalTokensOf(buckets) <= 0) return null;
1326
+ const stats = projections.stateOf(session, "sessionStats");
1327
+ buckets.calls = countOf(stats && stats.steps) || 1;
1328
+ return buckets;
1329
+ } catch (e) {
1330
+ return null;
1331
+ }
1332
+ }
1333
+ /**
1334
+ * 采集子代理会话事件(存量回退路径——2026-09-07 实锤 r38 usage 全空):
1335
+ * 宿主新版 Session(session v2)已无 `events` 属性/getter(仅私有 eventsSnapshot 缓存 +
1336
+ * 官方 snapshotEvents()/ownEvents() 方法),旧实现读 session.events = undefined → usage 全 null。
1337
+ * 回退链(与 guard.eventsOf 同款语义):events(老宿主快照,兼容)→ snapshotEvents()(官方完整日志)
1338
+ * → ownEvents()(fork 后本 agent 自己的事件)。取信息最多(含 usage 事件数最多)的源。
1339
+ */
1340
+ function sessionEventsOf(run) {
1341
+ try {
1342
+ const local = run && run.localAgent;
1343
+ const session = local && local.session;
1344
+ if (!session) return [];
1345
+ const candidates = [];
1346
+ try {
1347
+ const raw = session.events;
1348
+ if (Array.isArray(raw)) candidates.push(raw);
1349
+ else if (typeof raw === "function") candidates.push(raw());
1350
+ } catch (e) {}
1351
+ try {
1352
+ if (typeof session.snapshotEvents === "function") candidates.push(session.snapshotEvents());
1353
+ } catch (e) {}
1354
+ try {
1355
+ if (typeof session.ownEvents === "function") candidates.push(session.ownEvents());
1356
+ } catch (e) {}
1357
+ const valid = candidates.filter((c) => Array.isArray(c));
1358
+ if (valid.length === 0) return [];
1359
+ const countUsage = (arr) => arr.filter((ev) => {
1360
+ const e = ev;
1361
+ return e && e.type === "assistant/message" && e.data && typeof e.data.usage === "object" && e.data.usage !== null;
1362
+ }).length;
1363
+ valid.sort((a, b) => countUsage(b) - countUsage(a));
1364
+ return valid[0];
1365
+ } catch (e) {
1366
+ return [];
1367
+ }
1368
+ }
1369
+ /** 从单个 assistant/message 事件取 usage(宿主 usageOf 同款双路径:data.usage 优先,
1370
+ * 缺失时从 data.stream 的 usage chunk 取——v2 事件 usage 可能只在 stream 里)。 */
1371
+ function usageOfEvent(e) {
1372
+ if (!e || e.type !== "assistant/message") return void 0;
1373
+ const d = e.data || {};
1374
+ if (d.usage && typeof d.usage === "object") return d.usage;
1375
+ if (Array.isArray(d.stream)) for (const member of [...d.stream].reverse()) {
1376
+ const chunk = member?.chunk;
1377
+ if (chunk && chunk.type === "usage" && chunk.usage && typeof chunk.usage === "object") return chunk.usage;
1378
+ }
1379
+ }
1380
+ /**
1173
1381
  * 累计子代理会话中所有 LLM 调用的真实 usage(官方三桶 + 调用数)。
1174
- * 返回 null 表示拿不到 usage(会话未暴露 events / 无数据)。
1382
+ * 来源优先级:官方 Session 投影(首选,零历史扫描)→ 事件扫描(无投影宿主的存量回退)。
1383
+ * 返回 null 表示两条路径都拿不到 usage(会话未暴露投影与事件 / 无数据)。
1175
1384
  */
1176
1385
  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;
1386
+ const projected = projectedUsageOf(run);
1387
+ if (projected) return projected;
1388
+ return scannedUsageOf(run);
1389
+ }
1390
+ /** 事件扫描回退(投影未挂载/无数据时使用;沿用 2026-09-07 的多源回退语义,不改行为)。 */
1391
+ function scannedUsageOf(run) {
1392
+ const events = sessionEventsOf(run);
1393
+ if (events.length === 0) return null;
1183
1394
  const buckets = {
1184
1395
  input: 0,
1185
1396
  cacheRead: 0,
@@ -1193,7 +1404,7 @@ function accumulateSessionUsage(run) {
1193
1404
  if (!e || e.type !== "assistant/message") continue;
1194
1405
  const d = e.data || {};
1195
1406
  if (typeof d.turn === "number" && typeof d.step === "number") seen.add(`${d.turn}.${d.step}`);
1196
- const u = d.usage;
1407
+ const u = usageOfEvent(e);
1197
1408
  if (u) {
1198
1409
  buckets.input += u.inputTokens || 0;
1199
1410
  buckets.cacheRead += u.cacheReadTokens || 0;
@@ -1226,8 +1437,12 @@ function totalTokensOf(usage) {
1226
1437
  * ⚠️ 状态判定(实锤 run tf-mte906e9):大文件 read-edit 循环是正常模式——模型反复 read 同一大文件
1227
1438
  * (每次 edit 后内容已变,必须重读确认)、输出高度相似的「读后分析」,逐字片段在 400 条窗口内
1228
1439
  * 可累积 ≥12 次——伴随 edit/write 变更调用时只记录观察,不中止(否则大文件修改任务全被误杀)。
1229
- * B. 挂死检测:连续 GUARD_SILENCE_MS 一个新事件都没有(provider 层挂起/连接静默死亡)
1440
+ * B. 挂死检测:连续 GUARD_SILENCE_MS 没有**任何已提交事件**(provider 层挂起/连接静默死亡)
1230
1441
  * → outcome='stalled'(走正常预算门 → 熔断转人工,不自动重试烧钱)。
1442
+ * 2026-09-10:时间来源改为**官方 `subagentTiming` 投影**(`active.through` = 该投影 cut 上
1443
+ * 最新事件时间,由宿主在已提交事件上折叠)——不再依赖「三源取最长视图」的长度启发式
1444
+ * (r1 QA 误判的根因就是那个视图会失明);投影不可用时回退旧启发式。长工具静默执行
1445
+ * (跑 12 分钟测试无输出)仍由 agent 活动守卫豁免,不做误杀。
1231
1446
  * C. 空转检测:会话仍在产出事件,但连续 GUARD_NO_TOOL_MS 没有任何工具调用
1232
1447
  * (纯推理打转/改写式循环;正常 agent 每分钟都在调工具)→ outcome='stalled'。
1233
1448
  * 兜底关系:复读判定放宽后,edit 后陷入死循环的漏网场景由 C(长时间无工具调用)兜住。
@@ -1240,49 +1455,101 @@ function totalTokensOf(usage) {
1240
1455
  * 纯 read 循环(反复整读同一文件却无变更/无脚本执行)= 真退化。实锤 run tf-mte906e9:QA 重跑
1241
1456
  * 只读分析(不 edit)→ 旧判定「零变更进展」误杀,第 2 次 provider error 后 450k 熔断。 */
1242
1457
  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 可能是数组或返回数组的函数)。 */
1458
+ /** Agent 活动守卫(2026-09-06 实锤 r1):QA 子代理正常干活却被判「10 分钟无事件」——
1459
+ * 事件视图可能失明(session.events 缓存快照不增长)。若 agent 仍非 idle(phase 在跑)
1460
+ * 且本会话动过手(lastMutationAt>0)→ 不是挂死,跳过本次判定(不中止)。
1461
+ * 纯启动静默挂死(未动手)不受影响——照常 B 触发。 */
1462
+ function isAgentBusy(run) {
1463
+ try {
1464
+ const agent = run && run.localAgent;
1465
+ const kind = agent && agent.phase && agent.phase.kind;
1466
+ return !!kind && kind !== "idle";
1467
+ } catch (e) {
1468
+ return false;
1469
+ }
1470
+ }
1471
+ /** 与 metering 同款事件访问器(session.events 可能是数组或返回数组的函数)。
1472
+ * 2026-09-06 多源回退(实锤 json-parse r1:QA 子代理正常干活 254 事件 43 step 却被判「10 分钟
1473
+ * 无任何新事件」——session.events 缓存快照视图对某些子代理不增长)。回退链:
1474
+ * events(快照 getter)→ snapshotEvents()(宿主官方 API)→ ownEvents()(fork 后事件)——
1475
+ * 取信息最多(最长)的源;全部失效返回 [](stalled 触发前会记录诊断,见 fire())。 */
1244
1476
  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 : [];
1477
+ try {
1478
+ const local = run && run.localAgent;
1479
+ const session = local && local.session;
1480
+ if (!session) return [];
1481
+ const candidates = [];
1482
+ try {
1483
+ const raw = session.events;
1484
+ if (Array.isArray(raw)) candidates.push(raw);
1485
+ else if (typeof raw === "function") candidates.push(raw());
1486
+ } catch (e) {}
1487
+ try {
1488
+ if (typeof session.snapshotEvents === "function") candidates.push(session.snapshotEvents());
1489
+ } catch (e) {}
1490
+ try {
1491
+ if (typeof session.ownEvents === "function") candidates.push(session.ownEvents());
1492
+ } catch (e) {}
1493
+ const valid = candidates.filter((c) => Array.isArray(c));
1494
+ if (valid.length === 0) return [];
1495
+ valid.sort((a, b) => b.length - a.length);
1496
+ return valid[0];
1497
+ } catch (e) {
1498
+ return [];
1499
+ }
1251
1500
  }
1252
1501
  /** 规范化文本片段:小写 + 仅保留字母数字/CJK,供逐字重复比对。 */
1253
1502
  function normalizeFragment(s) {
1254
1503
  return String(s || "").toLowerCase().replace(/[^a-z0-9\u4e00-\u9fa5]+/g, "");
1255
1504
  }
1256
- /** 观测→执行闭环:向运行中的子代理注入轻提醒(不打断,下一轮 step 可见)。
1257
- * 通道:subagents.start 句柄无 inject——用 DSH 官方 session.append('user/message')(in-process driver 同款用法)。
1258
- * ⚠️ 安全窗口(实锤 tf-mtcnejqj):绝不能插在 assistant(tool_calls) → tool/result 之间——
1259
- * provider 校验「tool 消息必须响应前序 tool_calls」,插入 user 消息会 400 invalid_request_error。
1260
- * 因此只入队(pendingInjects),在观察到 step/end(该 step 的 tool/result 已写入)后统一 flush。 */
1261
- function injectReminder(run, text) {
1262
- const queue = run;
1505
+ /**
1506
+ * 官方 `subagentTiming` 投影读数(挂死检测首选源,2026-09-10 改)。
1507
+ * 形状 `{settledMs, active?:{since, through}}`——`through` 是该投影 cut 上**最新事件时间**,
1508
+ * 由宿主在已提交事件上折叠,不受 session.events 快照失明影响(r1 QA 误判根因)。
1509
+ * 返回 null = 投影不可用(未挂载 / 该子代理无 descriptor)→ 回退事件数增长启发式。
1510
+ */
1511
+ function timingOf(run) {
1263
1512
  try {
1264
- if (!Array.isArray(queue.__teamflowPending)) queue.__teamflowPending = [];
1265
- queue.__teamflowPending.push(text);
1266
- } catch (e) {}
1513
+ const projections = runtime.sessionProjections;
1514
+ if (!projections || typeof projections.stateOf !== "function") return null;
1515
+ const local = run && run.localAgent;
1516
+ const session = local && local.session;
1517
+ if (!session) return null;
1518
+ const timing = projections.stateOf(session, "subagentTiming");
1519
+ if (!timing || typeof timing !== "object") return null;
1520
+ const through = timing.active && timing.active.through;
1521
+ return { activeThrough: typeof through === "number" && Number.isFinite(through) ? through : void 0 };
1522
+ } catch (e) {
1523
+ return null;
1524
+ }
1267
1525
  }
1268
- function flushReminders(run) {
1526
+ /** 观测→执行闭环:向运行中的子代理注入轻提醒(不打断,下一 step 可见)。
1527
+ *
1528
+ * 通道(2026-09-10 改):`run.localAgent.inject()` —— 宿主官方 Agent 通道。next-step 队列由
1529
+ * agent loop 在 `preStep` 内、tool/result 之后整批认领,因此**不存在**「插进
1530
+ * assistant(tool_calls) → tool/result 之间触发 provider 400」的窗口;旧实现「先入队
1531
+ * `__teamflowPending`、观察到 step/end 再 session.append」的时序状态机整体删除。
1532
+ * (旧注释「subagents.start 句柄无 inject」是错的:`run.localAgent` 是活 Agent,
1533
+ * 有 `inject/steer/followup` —— `packages/core/agent/src/runtime-types.ts`。)
1534
+ *
1535
+ * 语义:`inject` 是 best-effort(可能晚一个 step),且不唤醒 idle driver——提醒只用于
1536
+ * 「仍在跑的 agent」;退化中止仍走 fire()/dispose(),不改为 steer 纠偏(后者是独立课题)。 */
1537
+ function injectReminder(run, text) {
1269
1538
  try {
1270
- const queue = run.__teamflowPending;
1271
- if (!queue || queue.length === 0) return;
1272
- const local = run.localAgent;
1273
- if (!local || typeof local.session?.append !== "function") return;
1274
- for (const text of queue.splice(0)) local.session.append("user/message", {
1275
- id: crypto.randomUUID(),
1276
- role: "user",
1539
+ const agent = run && run.localAgent;
1540
+ if (!agent || typeof agent.inject !== "function") return;
1541
+ agent.inject(createUserMessage({
1277
1542
  content: [{
1278
1543
  type: "text",
1279
1544
  text
1280
1545
  }],
1281
1546
  source: {
1282
1547
  kind: "plugin",
1283
- plugin: "teamflow"
1548
+ plugin: "dsh-plugin-teamflow",
1549
+ form: "notice",
1550
+ summary: "护栏轻提醒"
1284
1551
  }
1285
- }, { surfaceOp: "append" });
1552
+ }));
1286
1553
  } catch (e) {}
1287
1554
  }
1288
1555
  /**
@@ -1304,6 +1571,7 @@ function startStageGuard(opts) {
1304
1571
  const warnedScripts = /* @__PURE__ */ new Set();
1305
1572
  let lastMutationAt = 0;
1306
1573
  let repeatWarned = false;
1574
+ let busyWarned = false;
1307
1575
  function warnOnce(key, set, message, hint) {
1308
1576
  if (set.has(key)) return;
1309
1577
  set.add(key);
@@ -1322,6 +1590,40 @@ function startStageGuard(opts) {
1322
1590
  clearInterval(timer);
1323
1591
  stage.guardReason = reason;
1324
1592
  stage.guardOutcome = outcome;
1593
+ if (outcome === "stalled") try {
1594
+ const timing = timingOf(run);
1595
+ let detail;
1596
+ if (timing) detail = `subagentTiming.through=${timing.activeThrough === void 0 ? "(无 open turn)" : timing.activeThrough}`;
1597
+ else {
1598
+ const local = run.localAgent;
1599
+ const session = local && local.session;
1600
+ const lens = [];
1601
+ if (session) {
1602
+ try {
1603
+ const r = session.events;
1604
+ lens.push(`events=${Array.isArray(r) ? r.length : typeof r === "function" ? r().length : "?"}`);
1605
+ } catch (e) {
1606
+ lens.push("events=err");
1607
+ }
1608
+ try {
1609
+ lens.push(`snap=${typeof session.snapshotEvents === "function" ? session.snapshotEvents().length : "-"}`);
1610
+ } catch (e) {
1611
+ lens.push("snap=err");
1612
+ }
1613
+ try {
1614
+ lens.push(`own=${typeof session.ownEvents === "function" ? session.ownEvents().length : "-"}`);
1615
+ } catch (e) {
1616
+ lens.push("own=err");
1617
+ }
1618
+ }
1619
+ detail = `投影不可用,回退事件视图:${lens.join(" / ") || "session 不可访问"}`;
1620
+ }
1621
+ journal.logs.push({
1622
+ t: Date.now(),
1623
+ level: "warn",
1624
+ message: `${label} 挂死诊断:${detail}`
1625
+ });
1626
+ } catch (e) {}
1325
1627
  try {
1326
1628
  journal.logs.push({
1327
1629
  t: Date.now(),
@@ -1377,7 +1679,6 @@ function startStageGuard(opts) {
1377
1679
  }
1378
1680
  }
1379
1681
  observeToolCalls(newEvents);
1380
- if (newEvents.some((ev) => ev?.type === "step/end")) flushReminders(run);
1381
1682
  processed = events.length;
1382
1683
  }
1383
1684
  for (const ev of events.slice(-200)) {
@@ -1391,12 +1692,47 @@ function startStageGuard(opts) {
1391
1692
  break;
1392
1693
  }
1393
1694
  }
1394
- if (events.length !== lastEventCount) {
1695
+ const timing = timingOf(run);
1696
+ if (timing) {
1697
+ if (timing.activeThrough === void 0) lastGrowthAt = Date.now();
1698
+ else if (Date.now() - timing.activeThrough > 6e5) {
1699
+ if (lastMutationAt > 0 && isAgentBusy(run)) {
1700
+ if (!busyWarned) {
1701
+ busyWarned = true;
1702
+ try {
1703
+ journal.logs.push({
1704
+ t: Date.now(),
1705
+ level: "warn",
1706
+ message: `${label} 已提交事件静默(subagentTiming.through ${Math.round((Date.now() - timing.activeThrough) / 1e3)}s 未推进)但 agent 仍活动——视为长工具执行而非挂死,继续观察`
1707
+ });
1708
+ } catch (e) {}
1709
+ }
1710
+ lastGrowthAt = Date.now();
1711
+ } else {
1712
+ fire(`挂死(${Math.round(GUARD_SILENCE_MS / 6e4)} 分钟无任何已提交事件,来源:subagentTiming 投影)`, "stalled");
1713
+ return;
1714
+ }
1715
+ } else lastGrowthAt = Date.now();
1716
+ } else if (events.length !== lastEventCount) {
1395
1717
  lastEventCount = events.length;
1396
1718
  lastGrowthAt = Date.now();
1397
1719
  } else if (Date.now() - lastGrowthAt > 6e5) {
1398
- fire(`挂死(${Math.round(GUARD_SILENCE_MS / 6e4)} 分钟无任何新事件)`, "stalled");
1399
- return;
1720
+ if (lastMutationAt > 0 && isAgentBusy(run)) {
1721
+ if (!busyWarned) {
1722
+ busyWarned = true;
1723
+ try {
1724
+ journal.logs.push({
1725
+ t: Date.now(),
1726
+ level: "warn",
1727
+ message: `${label} 事件视图零增长但 agent 仍活动(会话已动手)——视为视图失明而非挂死,继续观察(护栏诊断见 stall 分支)`
1728
+ });
1729
+ } catch (e) {}
1730
+ }
1731
+ lastGrowthAt = Date.now();
1732
+ } else {
1733
+ fire(`挂死(${Math.round(GUARD_SILENCE_MS / 6e4)} 分钟无任何新事件)`, "stalled");
1734
+ return;
1735
+ }
1400
1736
  }
1401
1737
  if (seenToolCall && Date.now() - lastToolSignalAt > 9e5) {
1402
1738
  fire(`空转(${Math.round(GUARD_NO_TOOL_MS / 6e4)} 分钟内无任何工具调用,但会话仍在产出)`, "stalled");
@@ -1440,6 +1776,45 @@ function startStageGuard(opts) {
1440
1776
  * dsh-plugin-teamflow core — 子代理执行器(并发池 / 单阶段运行 / 重试与熔断)。
1441
1777
  * 依赖:util/constants/types + core(context/metering)。
1442
1778
  */
1779
+ /**
1780
+ * 推理强度能力探测(缓存,2026-09-11):只有宿主明确声明该路由支持某档位才下发。
1781
+ * 宿主对**不支持的值硬失败且不降级**(`UNSUPPORTED_REASONING_EFFORT`),所以宁可不下发。
1782
+ * 返回 null = 探测不可用(老宿主/未声明容量)→ 调用方一律不下发,保持宿主默认。
1783
+ */
1784
+ const effortSupportCache = /* @__PURE__ */ new Map();
1785
+ async function supportedEfforts(route) {
1786
+ if (!route.provider || !route.model) return null;
1787
+ const llm = runtime.llm;
1788
+ if (!llm || typeof llm.resolveModelInfo !== "function") return null;
1789
+ const key = `${route.provider}/${route.model || ""}`;
1790
+ if (effortSupportCache.has(key)) return effortSupportCache.get(key) || null;
1791
+ let out = null;
1792
+ try {
1793
+ const info = await llm.resolveModelInfo(route.provider, route.model);
1794
+ const efforts = info && info.reasoning && info.reasoning.efforts;
1795
+ if (Array.isArray(efforts)) out = efforts.map((e) => typeof e === "string" ? e : e && typeof e === "object" && typeof e.id === "string" ? e.id : null).filter((x) => typeof x === "string" && x.length > 0);
1796
+ } catch (e) {
1797
+ out = null;
1798
+ }
1799
+ effortSupportCache.set(key, out);
1800
+ return out;
1801
+ }
1802
+ /**
1803
+ * 解析本阶段要下发的推理强度:
1804
+ * - 阶段未要求降档(effortHint 空)→ 不传,宿主默认(DeepSeek high)
1805
+ * - 第 1 次尝试用 hint;**重试回升 'high'**(质量优先,ADR-0006)
1806
+ * - 只有探测到该路由支持该档位才返回,否则不传(防 `UNSUPPORTED_REASONING_EFFORT` 硬失败)
1807
+ * - 未下发时返回原因文本 → 调用方记 warn(这类静默失败必须可见,见 2026-09-11 实锤)
1808
+ */
1809
+ async function resolveStageEffort(route, attempt, effortHint) {
1810
+ const base = effortHint && String(effortHint).trim() ? String(effortHint).trim() : null;
1811
+ if (!base) return {};
1812
+ const wanted = attempt > 1 ? "high" : base;
1813
+ const supported = await supportedEfforts(route);
1814
+ if (!supported) return { skip: `路由 ${route.provider || "?"}/${route.model || "?"} 未声明 reasoning.efforts(或探测不可用)` };
1815
+ if (supported.indexOf(wanted) === -1) return { skip: `路由不支持 ${wanted}(可用:${supported.join("/") || "无"})` };
1816
+ return { effort: wanted };
1817
+ }
1443
1818
  /** 并发池:按 max 个 worker 消费 items,返回同序结果。 */
1444
1819
  async function runPool(items, max, fn) {
1445
1820
  const results = new Array(items.length);
@@ -1498,7 +1873,7 @@ function resolveChildRoute(parent) {
1498
1873
  return out;
1499
1874
  }
1500
1875
  /** 运行单个阶段子代理:执行 + 产出实质校验 + token 双口径计量 + stage 状态流转。 */
1501
- async function runAgent(journal, parent, label, phase, prompt, signal) {
1876
+ async function runAgent(journal, parent, label, phase, prompt, signal, taskKey, attempt = 1, effortHint) {
1502
1877
  const maxSeq = journal.stages.length ? Math.max(...journal.stages.map((s) => s.seq)) : 0;
1503
1878
  let stageText = null;
1504
1879
  const stage = {
@@ -1507,6 +1882,7 @@ async function runAgent(journal, parent, label, phase, prompt, signal) {
1507
1882
  phase,
1508
1883
  status: "running",
1509
1884
  outcome: null,
1885
+ taskKey: taskKey || null,
1510
1886
  childId: null,
1511
1887
  startedAt: Date.now(),
1512
1888
  endedAt: null,
@@ -1521,11 +1897,24 @@ async function runAgent(journal, parent, label, phase, prompt, signal) {
1521
1897
  let cancelGuard = null;
1522
1898
  try {
1523
1899
  const route = resolveChildRoute(parent);
1524
- const agentOptions = route.provider || route.model ? {
1900
+ const eff = await resolveStageEffort(route, attempt, effortHint);
1901
+ const effort = eff.effort;
1902
+ const agentOptions = route.provider || route.model || effort ? {
1525
1903
  ...route.provider ? { provider: route.provider } : {},
1526
1904
  ...route.model ? { model: route.model } : {},
1527
- ...route.maxTokens ? { maxTokens: route.maxTokens } : {}
1905
+ ...route.maxTokens ? { maxTokens: route.maxTokens } : {},
1906
+ ...effort ? { reasoningEffort: effort } : {}
1528
1907
  } : void 0;
1908
+ if (effort && !journal.cancelled) journal.logs.push({
1909
+ t: Date.now(),
1910
+ level: "info",
1911
+ message: `${label} 推理强度:${effort}${attempt > 1 ? "(重试回升)" : "(机械阶段降档)"}`
1912
+ });
1913
+ else if (eff.skip && !journal.cancelled) journal.logs.push({
1914
+ t: Date.now(),
1915
+ level: "warn",
1916
+ message: `${label} 推理强度未降档:${eff.skip}——保持宿主默认`
1917
+ });
1529
1918
  run = await runtime.subagents.start(providerName(), {
1530
1919
  label,
1531
1920
  prompt: [{
@@ -1575,6 +1964,7 @@ async function runAgent(journal, parent, label, phase, prompt, signal) {
1575
1964
  stage.status = "failed";
1576
1965
  stage.outcome = stage.guardOutcome || "degenerated";
1577
1966
  stage.summary = `进行中护栏中止(${stage.guardReason}),本次尝试无有效产出`;
1967
+ if (text) stage.output = clip(text, 4e3);
1578
1968
  journal.logs.push({
1579
1969
  t: Date.now(),
1580
1970
  level: "warn",
@@ -1586,12 +1976,23 @@ async function runAgent(journal, parent, label, phase, prompt, signal) {
1586
1976
  stage.outcome = stop === "completed" && text ? "insubstantial" : stop || "error";
1587
1977
  const errDetail = result && result.error;
1588
1978
  if (stage.outcome === "insubstantial") {
1589
- stage.summary = "产出未通过实质校验(含拒绝措辞或内容过短),视为未交付";
1590
- journal.logs.push({
1591
- t: Date.now(),
1592
- level: "warn",
1593
- message: `${label} 产出未通过实质校验(拒绝措辞/内容过短)`
1594
- });
1979
+ const hit = refusalHit(text);
1980
+ if (hit) {
1981
+ stage.summary = `产出未通过实质校验:命中拒绝词「${hit.phrase}」(原文:${hit.context}),视为未交付`;
1982
+ journal.logs.push({
1983
+ t: Date.now(),
1984
+ level: "warn",
1985
+ message: `${label} 产出命中拒绝词「${hit.phrase}」`
1986
+ });
1987
+ } else {
1988
+ stage.summary = `产出未通过实质校验:内容过短(${text.trim().length} 字符 < ${STAGE_MIN_LENGTH[phase] ?? 100} 下限),视为未交付`;
1989
+ journal.logs.push({
1990
+ t: Date.now(),
1991
+ level: "warn",
1992
+ message: `${label} 产出过短(${text.trim().length} 字符),未通过实质校验`
1993
+ });
1994
+ }
1995
+ if (text) stage.output = clip(text, 4e3);
1595
1996
  } else {
1596
1997
  stage.summary = `未产出有效结果(stopReason=${stop || "unknown"}${errDetail ? `,error=${String(errDetail).slice(0, 200)}` : ""})`;
1597
1998
  journal.logs.push({
@@ -1599,6 +2000,7 @@ async function runAgent(journal, parent, label, phase, prompt, signal) {
1599
2000
  level: "error",
1600
2001
  message: `${label} ${stage.summary}`
1601
2002
  });
2003
+ if (text) stage.output = clip(text, 4e3);
1602
2004
  }
1603
2005
  return null;
1604
2006
  } catch (e) {
@@ -1627,24 +2029,31 @@ async function runAgent(journal, parent, label, phase, prompt, signal) {
1627
2029
  } catch (e2) {}
1628
2030
  }
1629
2031
  }
1630
- /** 单阶段重试 + token 熔断(官方口径:input+cacheRead+cacheWrite+output 累计)。 */
1631
- async function withRetry(journal, parent, label, phase, prompt, signal) {
2032
+ /** 单阶段重试 + token 熔断(官方口径:input+cacheRead+cacheWrite+output 累计)。
2033
+ * `effortHint`:机械阶段的推理强度降档提示(第 1 次尝试生效,重试自动回升 high,见 resolveStageEffort)。 */
2034
+ async function withRetry(journal, parent, label, phase, prompt, signal, taskKey, effortHint) {
1632
2035
  let attempts = 0;
1633
2036
  let stageTokens = 0;
2037
+ let lastStage = null;
1634
2038
  for (let attempt = 1; attempt <= 2; attempt++) {
1635
2039
  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];
2040
+ const labelNow = attempt > 1 ? `${label}(第 ${attempt} 次重试)` : label;
2041
+ const promptNow = attempt > 1 && lastStage ? prompt + buildRetryDiagnostic(attempt, lastStage) : prompt;
2042
+ const beforeLen = journal.stages.length;
2043
+ const result = await runAgent(journal, parent, labelNow, phase, promptNow, signal, taskKey, attempt, effortHint);
2044
+ lastStage = journal.stages[beforeLen] || null;
1638
2045
  if (lastStage && lastStage.phase === phase) stageTokens += totalTokensOf(lastStage.usage);
1639
2046
  if (result) return {
1640
2047
  text: result,
1641
2048
  attempts,
1642
- stageTokens
2049
+ stageTokens,
2050
+ stage: lastStage
1643
2051
  };
1644
2052
  if (journal.cancelled) return {
1645
2053
  text: null,
1646
2054
  attempts,
1647
- stageTokens
2055
+ stageTokens,
2056
+ stage: lastStage
1648
2057
  };
1649
2058
  if (lastStage && isUnretryable(lastStage.outcome, lastStage.outcome)) {
1650
2059
  journal.logs.push({
@@ -1656,7 +2065,8 @@ async function withRetry(journal, parent, label, phase, prompt, signal) {
1656
2065
  return {
1657
2066
  text: null,
1658
2067
  attempts,
1659
- stageTokens
2068
+ stageTokens,
2069
+ stage: lastStage
1660
2070
  };
1661
2071
  }
1662
2072
  if (lastStage && lastStage.outcome === "aborted") {
@@ -1669,7 +2079,8 @@ async function withRetry(journal, parent, label, phase, prompt, signal) {
1669
2079
  return {
1670
2080
  text: null,
1671
2081
  attempts,
1672
- stageTokens
2082
+ stageTokens,
2083
+ stage: lastStage
1673
2084
  };
1674
2085
  }
1675
2086
  if (lastStage && lastStage.outcome === "degenerated") {
@@ -1682,7 +2093,22 @@ async function withRetry(journal, parent, label, phase, prompt, signal) {
1682
2093
  return {
1683
2094
  text: null,
1684
2095
  attempts,
1685
- stageTokens
2096
+ stageTokens,
2097
+ stage: lastStage
2098
+ };
2099
+ }
2100
+ if (lastStage && lastStage.outcome === "stalled") {
2101
+ journal.logs.push({
2102
+ t: Date.now(),
2103
+ level: "warn",
2104
+ message: `${label} 进行中护栏中止(挂死/空转),不再自动重试(会话已无有效产出);可 teamflow_resume 以全新会话续跑`
2105
+ });
2106
+ journal.humanIntervention = true;
2107
+ return {
2108
+ text: null,
2109
+ attempts,
2110
+ stageTokens,
2111
+ stage: lastStage
1686
2112
  };
1687
2113
  }
1688
2114
  if (stageTokens >= 6e4) {
@@ -1695,13 +2121,14 @@ async function withRetry(journal, parent, label, phase, prompt, signal) {
1695
2121
  return {
1696
2122
  text: null,
1697
2123
  attempts,
1698
- stageTokens
2124
+ stageTokens,
2125
+ stage: lastStage
1699
2126
  };
1700
2127
  }
1701
2128
  if (attempt < 2) journal.logs.push({
1702
2129
  t: Date.now(),
1703
2130
  level: "warn",
1704
- message: `${label} 第 ${attempt} 次尝试未成功,自动重试…`
2131
+ message: `${label} 第 ${attempt} 次尝试未成功(${lastStage ? lastStage.outcome || "unknown" : "unknown"}),自动重试(重试 prompt 已附上一轮失败诊断)…`
1705
2132
  });
1706
2133
  else {
1707
2134
  journal.logs.push({
@@ -1715,7 +2142,8 @@ async function withRetry(journal, parent, label, phase, prompt, signal) {
1715
2142
  return {
1716
2143
  text: null,
1717
2144
  attempts,
1718
- stageTokens
2145
+ stageTokens,
2146
+ stage: lastStage
1719
2147
  };
1720
2148
  }
1721
2149
  //#endregion
@@ -1897,6 +2325,17 @@ const STATE_BLOCK_INSTRUCTION = `\n\n[STATE BLOCK · mandatory at the end] Appen
1897
2325
  * - 回归基线:新 PRD 头部「基线依赖:<其他任务夹>」声明;跨代变更用「取代:<夹>#AC-n」;
1898
2326
  * 硬保障在项目 verify-* 可执行套件。
1899
2327
  * - 命令输出日志照旧收口 logs/teamflow/<runId>/。
2328
+ *
2329
+ * 【约束分级约定】(2026-09-03,防 high-signal 词脱敏)
2330
+ * - [HOST-ENFORCED]:host 有真实校验/解析/硬失败后果(如单轨产物文件缺失→needs-human 停线、
2331
+ * 验收结论行缺失→需人工确认)。标注后**必须**在同一句内描述真实后果(缺失=停线),
2332
+ * 不得只堆措辞。新增此类约束 = 先加 host 代码再标词。
2333
+ * - [policy]:无 host 强制,靠模型自律 + guard 观测注入(warn + 轻提醒,从不中断)。
2334
+ * 标注时描述真实机制(warn-only / cache 重放费),不声称「hard constraint」。
2335
+ * - 禁止:prompt 内自称 hard constraint——措辞层面「hard」与 enforcement 脱节会训练模型
2336
+ * 对 high-signal 词脱敏(实证:17 条 warn 零削减,guard 注入闭环后才见效)。
2337
+ * 【中英混排纪律】约束句/标签用英文(模型对英文指令注意力高)、被约束对象/内容用中文;
2338
+ * 列表分隔符随内容语言(文件路径等 ASCII 内容用英文逗号),不混用中文标点。
1900
2339
  */
1901
2340
  /** 产品层文档根(memory.md 等跨任务资产;任务产物在其中的任务夹内)。 */
1902
2341
  const TF_DOCS = "docs/teamflow";
@@ -1984,8 +2423,8 @@ function productCtx(root) {
1984
2423
  Before starting: read ${base}/AGENTS.md (team rules & doc index — read the summary first, then details on demand; no aimless full reads).
1985
2424
  [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
2425
  [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.
2426
+ [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>/.
2427
+ [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
2428
  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
2429
  `;
1991
2430
  }
@@ -1995,7 +2434,17 @@ function headTailClip(text, head, tail) {
1995
2434
  if (s.length <= head + tail) return s;
1996
2435
  return s.slice(0, head) + "\n...\n[CHANGED SECTION]\n" + s.slice(-tail);
1997
2436
  }
1998
- const TOKEN_HYGIENE = (runId) => `[TOKEN HYGIENE · hard constraint] Context is expensive. Budget discipline below (violations only log a warning, never interrupt):
2437
+ /**
2438
+ * 产物交付 · policy(2026-09-11):把任务夹产物交给官方的 `present` 工具,用户在该会话里得到
2439
+ * 「交付文件卡」(预览 / 默认程序打开 / 文件管理器定位)。
2440
+ * 诚实机制说明:present 是**模型工具**,host 不强制(没调用只是少一张卡,产物文件仍是唯一交付物);
2441
+ * 卡片渲染在**本子代理会话**的轮次尾部(宿主 ui-deliverables 挂 conversation.chat.turnTail),
2442
+ * 主会话不显示——所以它是增强项,工作台侧的「📄 产物」按钮才是主路径。
2443
+ */
2444
+ const ARTIFACT_DELIVERY = (runDocs) => `[Artifact delivery · policy] After the deliverable file exists (and before your final reply, before the state block), call the \`present\` tool so the user gets a delivery card (preview / open in default app / reveal in file manager):
2445
+ present({ files: [{ path: "${runDocs}/<your deliverable>.md", description: "<one-line what it is>" }] })
2446
+ Present ONLY user-facing deliverables (≤4 files, e.g. PRD.md / TECHNICAL.md / QA-REPORT.md / ACCEPTANCE.md) — never scratch files, temp scripts or command logs. This is additive: the file stays the single source of truth, and a missing file is still a hard failure.`;
2447
+ 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
2448
  - [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
2449
  - [No duplicate reads] Same file: read ≤1 times. To verify a change, grep the change point instead of re-reading the whole file.
2001
2450
  - [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 +2456,7 @@ const TOKEN_HYGIENE = (runId) => `[TOKEN HYGIENE · hard constraint] Context is
2007
2456
  - 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
2457
  `;
2009
2458
  /** 一次成型纪律:目标文档 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:
2459
+ 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
2460
  - 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
2461
  - **No read→edit→read loops**: never reopen the same document to "tweak"; never re-read the whole file just to confirm a change.
2013
2462
  - Use grep + limited segments for details; never whole-file read big documents.
@@ -2029,6 +2478,7 @@ ${requirement}
2029
2478
  4. Output the full PRD (Markdown): background & goals, user stories (each with testable acceptance criteria), scope & non-goals, interaction flow summary, priority (P0/P1/P2), dependencies & risks, milestone suggestions. ACs must be testable/quantifiable; prefer precision & brevity. **No revision table, no version fields like「版本:vX.Y / 状态:进行中」** (the folder IS the archive; its name carries the identity).
2030
2479
  5. [Memory write-back · convention changes ONLY] Update docs/teamflow/memory.md ONLY if this requirement introduces new team conventions / tech-stack decisions (replace the same-topic line, idempotent, no changelog-style appending); otherwise do not touch memory.
2031
2480
  6. [Engineering actions carried verbatim] Engineering instructions in the raw requirement (create/switch branch, commit, tag...) MUST be preserved verbatim into the "工程约束" section of the PRD: specify the action, timing, and baseline (e.g. "branch from latest main, then implement"). If the workspace already has uncommitted changes, note how to handle them. Never silently drop or reword engineering instructions.
2481
+ ${ARTIFACT_DELIVERY(RUN(state))}
2032
2482
  7. [State] End with a state block (phase="prd"): summary covers the AC highlights + one-sentence product semantics; extra contains { "acIndex": {...}, "summary": "<product one-liner>", "techStack": "..." }.${STATE_BLOCK_INSTRUCTION}`;
2033
2483
  const designPrompt = (prd, root, runId, state) => `You are a senior UI/UX designer. The current workspace IS the target project.
2034
2484
  ${productCtx(root)}${stateSliceFor(state, "design")}
@@ -2085,6 +2535,7 @@ ${JSON.stringify(tasks)}
2085
2535
  - modules: per touched file — responsibility + deps + assembly order + **architecture rationale (why)**.
2086
2536
  - tasks: parallelizable tasks split by file boundary (disjoint files → parallel); merge or sequence where dependencies/conflicts exist.
2087
2537
  - If you find duplication or a module that should be extracted (e.g. unified storage wrapper), add it to modules with the why.
2538
+ ${ARTIFACT_DELIVERY(RUN(state))}
2088
2539
  6. [State] End with a state block (phase="tech"), extra = { "verifyScripts": [...], "modules": {"/file": "contract or one-liner"} }, summary = key architecture/contract decisions.${STATE_BLOCK_INSTRUCTION}`;
2089
2540
  /**
2090
2541
  * 架构师 prompt(M1「认知前置 + 架构落地」):全模式启用,轻量版(lite/tech/patch)只产架构蓝图 JSON,
@@ -2110,7 +2561,7 @@ ${clip(prd, 12e3)}
2110
2561
  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
2562
  ${productCtx(root)}${stateSliceFor(state, "dev")}${TOKEN_HYGIENE(runId)}[CONTEXT PACK]
2112
2563
  [TASK TITLE] ${task.title}
2113
- ${task.files && task.files.length ? `[TASK TARGET FILES] ${task.files.join(",")}` : ""}
2564
+ ${task.files && task.files.length ? `[TASK TARGET FILES] ${task.files.join(", ")}` : ""}
2114
2565
  [TASK BRIEF] ${task.spec || "(see technical design)"}
2115
2566
  ${tech && String(tech).trim() ? `[TECH DESIGN SUMMARY (grep details on demand, don't full re-read)]
2116
2567
  ${clip(tech, 12e3)}` : ""}
@@ -2121,9 +2572,14 @@ ${clip(tech, 12e3)}` : ""}
2121
2572
  3. If spec contradicts reality, explain with evidence in the summary instead of claiming completion or expanding scope on your own.
2122
2573
  4. Actually write/modify code (grep + segmented reads to locate; no repeated whole-file reads), then run relevant build/verification to ensure green.
2123
2574
  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.
2575
+ 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
2576
  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.
2577
+ 7. Output an implementation summary (≤40 lines): changed files, key implementation points, leftovers. No big code pastes.
2578
+ 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):
2579
+ [Verification evidence]
2580
+ - cmd: <exact command> → exit <code>, <passed>/<failed> asserts (<file>:<line> for failures)
2581
+ - ...(one line per verification run)
2582
+ - N/A: <explicit reason>(when nothing runnable — pure config/docs change, no test suite, etc.)
2127
2583
  8. [State] End with a state block (phase="dev"), touched = array of changed files, summary = implementation conclusion.${STATE_BLOCK_INSTRUCTION}`;
2128
2584
  /** 视觉验证能力条款(ADR-2026-08-27,解锁 browser-use 视觉验证):
2129
2585
  * 按当前模型多模态能力动态生成——vision=true 允许截图看图(人眼类项),精确值仍走 DOM 计算断言;
@@ -2153,11 +2609,12 @@ ${clip(devSummary, 15e3)}
2153
2609
  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
2610
  3. Read AGENTS.md §4 engineering conventions (verify commands) and the code changes first, then actually run those sandbox-legal verifications.
2155
2611
  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):
2612
+ 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).
2613
+ 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
2614
  | 编号 | 严重级(P0/P1/P2/P3) | 功能模块 | 复现步骤 | 期望行为 | 实际行为 | 关联验收项 |
2159
2615
  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}/.
2616
+ 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}/.
2617
+ ${ARTIFACT_DELIVERY(RUN(state))}
2161
2618
  8. [State] End with a state block (phase="qa"), summary = test conclusion / blocked items, extra = { "verifyScripts": [...] }.${STATE_BLOCK_INSTRUCTION}`;
2162
2619
  /** QA 打回后的开发修复 prompt:确认缺陷是否属实 → 修复 → 复验交接(QA→dev 打回闭环用)。 */
2163
2620
  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 +2631,12 @@ ${tech && String(tech).trim() ? clip(tech, 12e3) : ""}
2174
2631
  — 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
2632
  2. Touch ONLY defect-related files (grep to locate; no whole-file reads of irrelevant big files); respect existing architecture & code style.
2176
2633
  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.
2634
+ 4. Output a fix summary (≤40 lines, Chinese): per defect —「truth judgment + fix」or「false-positive evidence」, changed files, leftovers. No big code pastes.
2635
+ 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):
2636
+ [Verification evidence]
2637
+ - cmd: <exact command> → exit <code>, <passed>/<failed> asserts (<file>:<line> for failures)
2638
+ - ...(one line per verification run; the re-verified defect cases must be listed)
2639
+ - N/A: <explicit reason>(when nothing runnable — pure config/docs change, no test suite, etc.)
2178
2640
  5. [State] End with a state block (phase="dev"), touched = changed files array, summary = fix conclusion.${STATE_BLOCK_INSTRUCTION}`;
2179
2641
  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
2642
  ${productCtx(root)}${stateSliceFor(state, "acceptance")}${TOKEN_HYGIENE(runId)}
@@ -2191,10 +2653,11 @@ ${vision ? "[Visual re-check] If QA saved screenshots under the task folder, spo
2191
2653
  - Any obvious **duplicated implementation / adapter drift / broken existing structure** (this is a code-quality floor, not optional).
2192
2654
  - **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
2655
  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.
2656
+ 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
2657
  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.
2658
+ 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
2659
  5. Chinese Markdown.
2660
+ ${ARTIFACT_DELIVERY(RUN(state))}
2198
2661
  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
2662
  /** 需求分诊模型 prompt(模型驱动 triage;供 core/triage.runTriage 使用)。 */
2200
2663
  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.
@@ -2580,10 +3043,10 @@ function buildResumeProducts(journal) {
2580
3043
  const products = {};
2581
3044
  for (const s of journal.stages) {
2582
3045
  if (s.status !== "done" || !s.output) continue;
2583
- const key = PHASE_KEY_BY_NAME[s.phase];
3046
+ const key = phaseKeyOf(s.phase);
2584
3047
  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(/^开发 · /, ""),
3048
+ if (key === "dev") products.dev = journal.stages.filter((x) => phaseKeyOf(x.phase) === "dev" && x.status === "done" && x.output).map((x) => ({
3049
+ title: x.taskKey || x.label.replace(/^开发 · /, ""),
2587
3050
  failed: false,
2588
3051
  output: x.output
2589
3052
  }));
@@ -2591,6 +3054,19 @@ function buildResumeProducts(journal) {
2591
3054
  }
2592
3055
  return products;
2593
3056
  }
3057
+ /** 任务夹产物读取(单轨契约:文件即产物——QA/验收 host 只读文件,回复仅摘要)。
3058
+ * 缺失/空/读取异常返回 null(调用方决定硬失败或 journal 兜底)。 */
3059
+ function artifactText(journal, fileName) {
3060
+ const path = journal && journal.workspacePath && journal.runDocs ? `${journal.workspacePath}/${journal.runDocs}/${fileName}` : null;
3061
+ if (!path) return null;
3062
+ try {
3063
+ if (!existsSync(path)) return null;
3064
+ const t = readFileSync(path, "utf8").trim();
3065
+ return t ? t : null;
3066
+ } catch (e) {
3067
+ return null;
3068
+ }
3069
+ }
2594
3070
  /**
2595
3071
  * 断点续跑起点:第一个「没有任意 done 尝试」的阶段。
2596
3072
  * ⚠️ 按阶段而非尝试判断(实锤 tf-mtcomxpq):PRD 第 1 次尝试 failed(护栏退化)但第 2 次重试 done——
@@ -2598,13 +3074,37 @@ function buildResumeProducts(journal) {
2598
3074
  * 全部完成仍被中断(理论极端)→ 从产品验收继续。
2599
3075
  */
2600
3076
  function interruptedPhaseOf(journal) {
2601
- if (hasOpenBlockingBugs(journal)) return "QA 测试";
3077
+ if (hasOpenBlockingBugs(journal)) return "qa";
2602
3078
  for (const phase of PHASE_ORDER) {
2603
- const phaseStages = (journal.stages || []).filter((s) => s.phase === phase);
3079
+ const phaseStages = (journal.stages || []).filter((s) => phaseKeyOf(s.phase) === phase);
2604
3080
  if (phaseStages.length === 0) continue;
2605
- if (!phaseStages.some((s) => s.status === "done")) return phase;
3081
+ if (phase === "dev") {
3082
+ if ([...devTaskStatuses(phaseStages).values()].some((st) => !st.done)) return phase;
3083
+ } else if (!phaseStages.some((s) => s.status === "done")) return phase;
2606
3084
  }
2607
- return "产品验收";
3085
+ return "acceptance";
3086
+ }
3087
+ /** 任务级聚合(journal 驱动,2026-09-06 状态机化):按 stage.taskKey(旧数据 label 兜底)分组——
3088
+ * 有 done stage = 任务已成功(历史失败尝试不算失败)。
3089
+ * resume 补跑判定/阶段完成判定共用;不读 backlog(两块业务线解耦——残留失败卡污染判定实锤 json-parse r1)。 */
3090
+ function devTaskStatuses(stages) {
3091
+ const m = /* @__PURE__ */ new Map();
3092
+ for (const s of stages || []) {
3093
+ const title = String(s.taskKey || String(s.label || "").replace(/^开发 · /, "").replace(/((?:第 \d+ 次重试|补跑))$/, "").trim());
3094
+ if (!title) continue;
3095
+ const cur = m.get(title) || {
3096
+ done: false,
3097
+ lastStatus: null,
3098
+ lastSeq: -1
3099
+ };
3100
+ if ((s.seq || 0) > cur.lastSeq) {
3101
+ cur.lastSeq = s.seq || 0;
3102
+ cur.lastStatus = s.status || null;
3103
+ }
3104
+ if (s.status === "done") cur.done = true;
3105
+ m.set(title, cur);
3106
+ }
3107
+ return m;
2608
3108
  }
2609
3109
  /** 开发任务定义(单一来源):架构蓝图自动拆 > 调用方显式 tasks > 整体开发兜底。
2610
3110
  * resume 补跑与正常执行共用(defByTitle 按 title 匹配失败子卡)。 */
@@ -2698,18 +3198,18 @@ async function executePipeline(journal, parent, requirement, options, signal, re
2698
3198
  });
2699
3199
  /** 阶段失败错误:带真实尝试次数/末次结果/累计消耗与熔断语义(取代千篇一律的「重试 N 次后仍无产出」)。 */
2700
3200
  const stageFailError = (label, r) => {
2701
- const last = [...journal.stages || []].reverse().find((s) => s.phase === label);
3201
+ const last = [...journal.stages || []].reverse().find((s) => phaseKeyOf(s.phase) === label);
2702
3202
  const attempts = r && r.attempts ? r.attempts : 2;
2703
3203
  const burnt = Math.round((r && r.stageTokens || 0) / 1e3);
2704
3204
  const breaker = (r && r.stageTokens || 0) >= 6e4 ? ",超出阶段预算熔断" : "";
2705
3205
  const detail = last ? `末次 ${last.outcome || "unknown"}${last.summary ? `(${last.summary})` : ""}` : "无阶段记录";
2706
- return /* @__PURE__ */ new Error(`${label} 阶段失败:${attempts} 次尝试未交付,${detail},累计消耗 ${burnt}k token${breaker},需人工介入`);
3206
+ return /* @__PURE__ */ new Error(`${PHASE_KEY_OF[label] || label} 阶段失败:${attempts} 次尝试未交付,${detail},累计消耗 ${burnt}k token${breaker},需人工介入`);
2707
3207
  };
2708
3208
  try {
2709
3209
  if (resume) journal.logs.push({
2710
3210
  t: Date.now(),
2711
3211
  level: "info",
2712
- message: `断点续跑:复用 backlog(req=${journal.reqId}),从「${resume.phase}」继续`
3212
+ message: `断点续跑:复用 backlog(req=${journal.reqId}),从「${PHASE_KEY_OF[resume.phase] || resume.phase}」继续`
2713
3213
  });
2714
3214
  else {
2715
3215
  const init = initPipelineBacklog(journal, requirement, options);
@@ -2872,11 +3372,25 @@ async function executePipeline(journal, parent, requirement, options, signal, re
2872
3372
  const block = extractStateBlock(output);
2873
3373
  if (block) mergeStateBlock(journal.workspace || "default", block, phaseKey);
2874
3374
  };
3375
+ const noteVerifyEvidence = (stage, output) => {
3376
+ try {
3377
+ const ev = extractVerificationEvidence(output);
3378
+ if (!ev) {
3379
+ journal.logs.push({
3380
+ t: Date.now(),
3381
+ level: "warn",
3382
+ message: `${stage ? PHASE_KEY_OF[phaseKeyOf(stage.phase)] || stage.phase : "开发"} 回复缺少 [Verification evidence] 块(契约未兑现,已记录不中断)`
3383
+ });
3384
+ return;
3385
+ }
3386
+ if (stage) stage.verifyEvidence = ev;
3387
+ } catch (e) {}
3388
+ };
2875
3389
  let prd = null;
2876
- if (resumed("PRD 产品需求")) {
3390
+ if (resumed("prd")) {
2877
3391
  prd = resume.products.prd;
2878
3392
  timeline.prd = prd;
2879
- logSkip("PRD 产品需求");
3393
+ logSkip(PHASE_KEY_OF.prd);
2880
3394
  } else {
2881
3395
  journal.logs.push({
2882
3396
  t: Date.now(),
@@ -2893,8 +3407,8 @@ async function executePipeline(journal, parent, requirement, options, signal, re
2893
3407
  label: "产品经理 · 梳理 PRD",
2894
3408
  fn: prdPrompt
2895
3409
  };
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);
3410
+ const prdR = await withRetry(journal, parent, pForm.label, "prd", pForm.fn(requirement, root, journal.id, state), signal, void 0, options.mode === "patch" ? "low" : null);
3411
+ if (!prdR.text) throw stageFailError("prd", prdR);
2898
3412
  prd = prdR.text;
2899
3413
  timeline.prd = prd;
2900
3414
  mergeStageState("prd", prd);
@@ -2903,18 +3417,18 @@ async function executePipeline(journal, parent, requirement, options, signal, re
2903
3417
  }
2904
3418
  let design = null;
2905
3419
  if (enabled("design")) {
2906
- if (resumed("UI/UX 设计")) {
3420
+ if (resumed("design")) {
2907
3421
  design = resume.products.design;
2908
3422
  timeline.design = design;
2909
- logSkip("UI/UX 设计");
3423
+ logSkip(PHASE_KEY_OF.design);
2910
3424
  } else {
2911
3425
  journal.logs.push({
2912
3426
  t: Date.now(),
2913
3427
  level: "phase",
2914
3428
  message: "进入阶段:UI/UX 设计"
2915
3429
  });
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);
3430
+ const designR = await withRetry(journal, parent, "UI/UX 设计师 · 设计说明", "design", designPrompt(prd, root, journal.id, state), signal);
3431
+ if (!designR.text) throw stageFailError("design", designR);
2918
3432
  design = designR.text;
2919
3433
  timeline.design = design;
2920
3434
  mergeStageState("design", design);
@@ -2924,18 +3438,18 @@ async function executePipeline(journal, parent, requirement, options, signal, re
2924
3438
  }
2925
3439
  let scaffold = null;
2926
3440
  if (enabled("scaffold")) {
2927
- if (resumed("架构规划")) {
3441
+ if (resumed("scaffold")) {
2928
3442
  scaffold = resume.products.scaffold;
2929
3443
  timeline.scaffold = scaffold;
2930
- logSkip("架构规划");
3444
+ logSkip(PHASE_KEY_OF.scaffold);
2931
3445
  } else {
2932
3446
  journal.logs.push({
2933
3447
  t: Date.now(),
2934
3448
  level: "phase",
2935
3449
  message: "进入阶段:架构规划"
2936
3450
  });
2937
- const scR = await withRetry(journal, parent, "架构师 · 脚手架规划与落地", "架构规划", scaffoldPrompt(requirement, design, root, journal.id, state), signal);
2938
- if (!scR.text) throw stageFailError("架构规划", scR);
3451
+ const scR = await withRetry(journal, parent, "架构师 · 脚手架规划与落地", "scaffold", scaffoldPrompt(requirement, design, root, journal.id, state), signal, void 0, "low");
3452
+ if (!scR.text) throw stageFailError("scaffold", scR);
2939
3453
  scaffold = scR.text;
2940
3454
  timeline.scaffold = scaffold;
2941
3455
  mergeStageState("scaffold", scaffold);
@@ -2945,10 +3459,10 @@ async function executePipeline(journal, parent, requirement, options, signal, re
2945
3459
  }
2946
3460
  let tech = null;
2947
3461
  if (enabled("tech")) {
2948
- if (resumed("技术方案")) {
3462
+ if (resumed("tech")) {
2949
3463
  tech = resume.products.tech;
2950
3464
  timeline.tech = tech;
2951
- logSkip("技术方案");
3465
+ logSkip(PHASE_KEY_OF.tech);
2952
3466
  } else {
2953
3467
  const isHeavy = !options.lite && options.mode !== "tech" && options.mode !== "patch";
2954
3468
  journal.logs.push({
@@ -2957,7 +3471,7 @@ async function executePipeline(journal, parent, requirement, options, signal, re
2957
3471
  message: isHeavy ? "进入阶段:技术方案" : "进入阶段:架构蓝图"
2958
3472
  });
2959
3473
  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);
3474
+ 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
3475
  if (!techR.text) throw stageFailError(label, techR);
2962
3476
  tech = techR.text;
2963
3477
  timeline.tech = tech;
@@ -2990,28 +3504,28 @@ async function executePipeline(journal, parent, requirement, options, signal, re
2990
3504
  }
2991
3505
  }
2992
3506
  let devResults = null;
2993
- if (resumed("开发")) {
3507
+ if (resume) {
2994
3508
  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));
3509
+ const taskStatuses = devTaskStatuses(journal.stages || []);
3510
+ const todo = buildDevTaskDefs(journal, tasks).filter((d) => {
3511
+ const st = taskStatuses.get(String(d.title || "").trim());
3512
+ return !st || !st.done;
3513
+ });
3514
+ if (todo.length === 0) {
3515
+ timeline.dev = devResults;
3516
+ logSkip("开发");
3517
+ } else {
3518
+ const reused = devResults.filter((r) => r && !todo.some((d) => d.title === r.title));
3008
3519
  journal.logs.push({
3009
3520
  t: Date.now(),
3010
3521
  level: "warn",
3011
- message: `断点续跑开发:复用 ${reused.length} 个已完成任务,补跑 ${rerunDefs.length} 个失败任务`
3522
+ message: `断点续跑开发:复用 ${reused.length} 个已完成任务,补跑 ${todo.length} 个失败任务`
3012
3523
  });
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);
3524
+ const rerun = await runPool(todo, maxConcurrency, async (task) => {
3525
+ 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 || ""))));
3526
+ const resumePrompt = devPrompt(task, tech, prd, root, journal.id, state) + (prevStage ? buildRetryDiagnostic(2, prevStage) : "");
3527
+ const devR = await withRetry(journal, parent, `开发 · ${task.title}(补跑)`, "dev", resumePrompt, signal, task.title);
3528
+ noteVerifyEvidence(devR.stage, devR.text);
3015
3529
  const ok = !!devR.text;
3016
3530
  return {
3017
3531
  title: task.title,
@@ -3020,14 +3534,11 @@ async function executePipeline(journal, parent, requirement, options, signal, re
3020
3534
  };
3021
3535
  });
3022
3536
  for (const t of rerun) {
3023
- const sub = failedSubs.find((s) => String(s.title || "").replace(/^开发 · /, "") === t.title);
3537
+ const sub = createSubtask(journal, t.title, t.spec || "");
3024
3538
  if (sub) completeSubtask(journal, sub.id, t.failed, t.output ? snippet(t.output, 1e3) : null, null);
3025
3539
  }
3026
3540
  devResults = [...reused, ...rerun];
3027
3541
  timeline.dev = devResults;
3028
- } else {
3029
- timeline.dev = devResults;
3030
- logSkip("开发");
3031
3542
  }
3032
3543
  } else {
3033
3544
  journal.logs.push({
@@ -3069,12 +3580,12 @@ async function executePipeline(journal, parent, requirement, options, signal, re
3069
3580
  persistJournal(journal);
3070
3581
  }
3071
3582
  }
3072
- const devR = await withRetry(journal, parent, `开发 · ${task.title}`, "开发", devPrompt(task, tech, prd, root, journal.id, state), signal);
3583
+ const devR = await withRetry(journal, parent, `开发 · ${task.title}`, "dev", devPrompt(task, tech, prd, root, journal.id, state), signal, task.title);
3584
+ noteVerifyEvidence(devR.stage, devR.text);
3073
3585
  const ok = !!devR.text;
3074
3586
  if (sub) {
3075
3587
  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);
3588
+ if (devR.stage) noteSubtaskUsage(journal, sub.id, devR.stage);
3078
3589
  }
3079
3590
  return {
3080
3591
  title: task.title,
@@ -3085,7 +3596,7 @@ async function executePipeline(journal, parent, requirement, options, signal, re
3085
3596
  timeline.dev = devResults;
3086
3597
  for (const r of devResults) if (r && r.output) mergeStageState("dev", r.output);
3087
3598
  noteTaskStageUsage(journal);
3088
- noteTaskAssign(journal, "dev", journal.stages.filter((s) => s.phase === "开发").map((s) => (s.childId || "").slice(0, 8)).filter(Boolean).join(",") || "开发组");
3599
+ noteTaskAssign(journal, "dev", journal.stages.filter((s) => phaseKeyOf(s.phase) === "dev").map((s) => (s.childId || "").slice(0, 8)).filter(Boolean).join(",") || "开发组");
3089
3600
  const failedCount = devResults.filter((r) => r && r.failed).length;
3090
3601
  if (failedCount > 0) {
3091
3602
  advanceTask(journal, "needs-human", null, "开发失败,需人工介入", { by: "dev" });
@@ -3120,10 +3631,10 @@ async function executePipeline(journal, parent, requirement, options, signal, re
3120
3631
  message: "当前档位阶段集不含独立 QA:跳过(单点修复,开发自测兜底)"
3121
3632
  });
3122
3633
  qa = "(独立 QA 跳过:当前档位由开发自测兜底)";
3123
- } else if (resumed("QA 测试") && !hasOpenBlockingBugs(journal)) {
3124
- qa = resume.products.qa;
3634
+ } else if (resumed("qa") && !hasOpenBlockingBugs(journal)) {
3635
+ qa = artifactText(journal, "QA-REPORT.md") || resume.products.qa;
3125
3636
  timeline.qa = qa;
3126
- logSkip("QA 测试");
3637
+ logSkip(PHASE_KEY_OF.qa);
3127
3638
  } else {
3128
3639
  journal.logs.push({
3129
3640
  t: Date.now(),
@@ -3132,7 +3643,7 @@ async function executePipeline(journal, parent, requirement, options, signal, re
3132
3643
  });
3133
3644
  advanceTask(journal, "testing", null, "QA 开始(待测试 → 测试中)", { by: "qa" });
3134
3645
  const store = storeFor(scopeKey);
3135
- const qaStageChildren = () => journal.stages.filter((s) => s.phase === "QA 测试").map((s) => (s.childId || "").slice(0, 8)).filter(Boolean).join(",") || "测试组";
3646
+ const qaStageChildren = () => journal.stages.filter((s) => phaseKeyOf(s.phase) === "qa").map((s) => (s.childId || "").slice(0, 8)).filter(Boolean).join(",") || "测试组";
3136
3647
  let round = 0;
3137
3648
  let qaClean = false;
3138
3649
  const devFixRounds = [];
@@ -3144,14 +3655,26 @@ async function executePipeline(journal, parent, requirement, options, signal, re
3144
3655
  do {
3145
3656
  round += 1;
3146
3657
  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);
3658
+ 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
3659
  if (!qaR.text) {
3149
3660
  advanceTask(journal, "needs-human", null, isReverify ? `QA 复验失败(第 ${round - 1} 轮修复后)` : "QA 失败", { by: "qa" });
3150
- throw stageFailError(isReverify ? "QA 测试(复验)" : "QA 测试", qaR);
3661
+ throw stageFailError("qa", qaR);
3662
+ }
3663
+ mergeStageState("qa", qaR.text);
3664
+ qa = artifactText(journal, "QA-REPORT.md");
3665
+ if (!qa) {
3666
+ journal.logs.push({
3667
+ t: Date.now(),
3668
+ level: "error",
3669
+ message: `QA 子代理回复成功但 ${journal.runDocs ? journal.runDocs + "/" : ""}QA-REPORT.md 未落盘/为空——单轨契约(文件即产物)未兑现,需人工介入`
3670
+ });
3671
+ advanceTask(journal, "needs-human", null, "QA-REPORT.md 未落盘(单轨契约未兑现)", { by: "qa" });
3672
+ throw stageFailError("qa", {
3673
+ attempts: qaR.attempts,
3674
+ stageTokens: qaR.stageTokens
3675
+ });
3151
3676
  }
3152
- qa = qaR.text;
3153
3677
  timeline.qa = qa;
3154
- mergeStageState("qa", qa);
3155
3678
  noteTaskStageUsage(journal);
3156
3679
  noteTaskAssign(journal, "qa", qaStageChildren());
3157
3680
  defects = parseDefects(qa);
@@ -3189,7 +3712,8 @@ async function executePipeline(journal, parent, requirement, options, signal, re
3189
3712
  message: `QA 发现 ${blocking.length} 个阻断缺陷(第 ${round} 轮),打回开发确认修复后复验`
3190
3713
  });
3191
3714
  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);
3715
+ const fixR = await withRetry(journal, parent, `开发 · QA 缺陷修复(第 ${round} 轮)`, "dev", qaFixPrompt(blocking, qa, tech, prd, root, journal.id, state), signal, null);
3716
+ noteVerifyEvidence(fixR.stage, fixR.text);
3193
3717
  if (!fixR.text) {
3194
3718
  advanceTask(journal, "needs-human", null, "QA 打回后开发修复失败", { by: "qa" });
3195
3719
  throw stageFailError("开发(QA 打回修复)", fixR);
@@ -3239,18 +3763,47 @@ async function executePipeline(journal, parent, requirement, options, signal, re
3239
3763
  const curTask = storeFor(scopeKey).find("task", journal.taskId);
3240
3764
  if (curTask && curTask.status !== "pending-acceptance" && curTask.status !== "needs-human" && curTask.status !== "rework") advanceTask(journal, "pending-acceptance", null, "进入验收(待验收)", { by: "pm" });
3241
3765
  }
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);
3766
+ 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
3767
  if (!accR.text) {
3244
3768
  advanceTask(journal, "needs-human", null, "验收失败", { by: "pm" });
3245
- throw stageFailError("产品验收", accR);
3769
+ throw stageFailError("acceptance", accR);
3770
+ }
3771
+ mergeStageState("acceptance", accR.text);
3772
+ const acceptance = artifactText(journal, "ACCEPTANCE.md");
3773
+ if (!acceptance) {
3774
+ journal.logs.push({
3775
+ t: Date.now(),
3776
+ level: "error",
3777
+ message: `验收子代理回复成功但 ${journal.runDocs ? journal.runDocs + "/" : ""}ACCEPTANCE.md 未落盘/为空——单轨契约(文件即产物)未兑现,需人工介入`
3778
+ });
3779
+ advanceTask(journal, "needs-human", null, "ACCEPTANCE.md 未落盘(单轨契约未兑现)", { by: "pm" });
3780
+ throw stageFailError("acceptance", {
3781
+ attempts: accR.attempts,
3782
+ stageTokens: accR.stageTokens
3783
+ });
3246
3784
  }
3247
- const acceptance = accR.text;
3248
3785
  timeline.acceptance = acceptance;
3249
3786
  noteTaskStageUsage(journal);
3250
- const accStage = journal.stages.find((s) => s.phase === "产品验收" && s.childId);
3787
+ const accStage = journal.stages.find((s) => phaseKeyOf(s.phase) === "acceptance" && s.childId);
3251
3788
  noteTaskAssign(journal, "accept", accStage ? String(accStage.childId).slice(0, 8) : "验收组");
3252
- mergeStageState("acceptance", acceptance);
3253
3789
  const accVerdict = parseAcceptanceVerdict(acceptance);
3790
+ if (accVerdict === "needs-human") {
3791
+ journal.logs.push({
3792
+ t: Date.now(),
3793
+ level: "error",
3794
+ message: "ACCEPTANCE.md 缺少「验收结论:」行(字面量模板未兑现)——不自动判通过,需人工确认结论"
3795
+ });
3796
+ advanceTask(journal, "needs-human", snippet(acceptance, 3e3), "ACCEPTANCE.md 缺少验收结论行(契约未兑现),需人工确认", { by: "pm" });
3797
+ const store = storeFor(scopeKey);
3798
+ const req = store.find("req", journal.reqId);
3799
+ if (req) {
3800
+ req.humanIntervention = true;
3801
+ store.pushEvent(req, req.status, "needs-human", "验收结论行缺失,需人工确认");
3802
+ }
3803
+ journal.humanIntervention = true;
3804
+ persistJournal(journal);
3805
+ throw new Error("ACCEPTANCE.md 缺少验收结论行,需人工确认结论");
3806
+ }
3254
3807
  if (accVerdict === "reject") {
3255
3808
  advanceTask(journal, "needs-human", snippet(acceptance, 3e3), "需求与现状不符(无需改动),需人工决定调整或取消需求", { by: "pm" });
3256
3809
  const store = storeFor(scopeKey);
@@ -4295,8 +4848,7 @@ function tryFlushPendingInjections(sessionId) {
4295
4848
  const agent = runtime.agents ? runtime.agents.get(sessionId) : void 0;
4296
4849
  if (!agent || typeof agent.inject !== "function") return;
4297
4850
  try {
4298
- agent.inject({
4299
- type: "user",
4851
+ agent.inject(createUserMessage({
4300
4852
  content: [{
4301
4853
  type: "text",
4302
4854
  text: teamflowContextText(pending.teamIcon, pending.teamName, pending.teamId)
@@ -4304,9 +4856,9 @@ function tryFlushPendingInjections(sessionId) {
4304
4856
  source: {
4305
4857
  kind: "plugin",
4306
4858
  plugin: "dsh-plugin-teamflow",
4307
- form: "context"
4859
+ form: "instructions"
4308
4860
  }
4309
- });
4861
+ }));
4310
4862
  pendingInjections.delete(sessionId);
4311
4863
  } catch (e) {}
4312
4864
  }
@@ -4314,14 +4866,16 @@ var TeamflowService = class extends TypertRemoteService {
4314
4866
  static inject = [
4315
4867
  "agents",
4316
4868
  "subagents",
4317
- "tokenMeter",
4318
4869
  "typert",
4319
4870
  "tools",
4320
4871
  "llm"
4321
4872
  ];
4322
4873
  constructor(ctx) {
4323
4874
  super(ctx, "teamflow");
4324
- setRuntime(ctx.get("agents"), ctx.get("subagents"), ctx.get("tokenMeter"), ctx.get("workspaceRegistry"), ctx.get("agentDefaultModel"), ctx.get("llm"));
4875
+ setRuntime(ctx.get("agents"), ctx.get("subagents"), ctx.get("workspaceRegistry"), ctx.get("agentDefaultModel"), ctx.get("llm"));
4876
+ ctx.inject(["sessionProjections"], (projectionCtx) => {
4877
+ setSessionProjections(projectionCtx.get("sessionProjections"));
4878
+ });
4325
4879
  loadActiveTeams();
4326
4880
  let interruptedCount = 0;
4327
4881
  try {
@@ -4379,7 +4933,9 @@ var TeamflowService = class extends TypertRemoteService {
4379
4933
  const j = runs.get(latest.id);
4380
4934
  return j ? snapshotOf(j) : null;
4381
4935
  }
4382
- /** 阶段详情:卡片点击查看 —— 状态/耗时/官方 usage + 产物全文(超 24k 截断)。 */
4936
+ /** 阶段详情:卡片点击查看 —— 状态/耗时/官方 usage + 产物全文(超 24k 截断)。
4937
+ * 2026-09-06 状态机化:返回同任务全部尝试(attempts 聚合——按 stage.taskKey(旧数据 label 兜底),
4938
+ * 按 seq 排序)——client 弹窗单次渲染现状、多次渲染时间线。 */
4383
4939
  stageDetail(runId, seq, sessionId) {
4384
4940
  if (typeof runId !== "string" || !runId || seq === void 0 || seq === null) return null;
4385
4941
  const sc = sessionScope(sessionId);
@@ -4388,6 +4944,21 @@ var TeamflowService = class extends TypertRemoteService {
4388
4944
  if (j.workspace && sc.projectKey && j.workspace !== sc.projectKey && sc.projectKey !== "default") return null;
4389
4945
  const s = (j.stages || []).find((st) => Number(st.seq) === Number(seq));
4390
4946
  if (!s) return null;
4947
+ const taskKeyOf = (x) => String(x.taskKey || String(x.label || "").replace(/^开发 · /, "").replace(/((?:第 \d+ 次重试|补跑))$/, "").trim());
4948
+ const taskKey = taskKeyOf(s);
4949
+ 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) => ({
4950
+ seq: x.seq,
4951
+ label: x.label,
4952
+ status: x.status,
4953
+ outcome: x.outcome || null,
4954
+ summary: clip(x.summary || "", 1500),
4955
+ output: clip(toText(x.output) || toText(x.handoff) || "", 12e3),
4956
+ usage: x.usage || null,
4957
+ verifyEvidence: x.verifyEvidence || null,
4958
+ childId: x.childId || null,
4959
+ startedAt: x.startedAt,
4960
+ endedAt: x.endedAt
4961
+ })) : null;
4391
4962
  return {
4392
4963
  seq: s.seq,
4393
4964
  label: s.label,
@@ -4399,8 +4970,10 @@ var TeamflowService = class extends TypertRemoteService {
4399
4970
  endedAt: s.endedAt,
4400
4971
  ownerSession: j.ownerSession || null,
4401
4972
  usage: s.usage || null,
4973
+ verifyEvidence: s.verifyEvidence || null,
4402
4974
  summary: clip(s.summary || "", 3e3),
4403
- output: clip(toText(s.output) || toText(s.handoff) || "", 24e3)
4975
+ output: clip(toText(s.output) || toText(s.handoff) || "", 24e3),
4976
+ attempts
4404
4977
  };
4405
4978
  }
4406
4979
  /** Backlog 条目详情:卡片点击查看 —— 完整字段 + 流转时间线 + 关联(子卡/缺陷)+ 任务夹路径。 */
@@ -4417,10 +4990,14 @@ var TeamflowService = class extends TypertRemoteService {
4417
4990
  if (!item) return null;
4418
4991
  const reqId = k === "req" ? item.id : item.reqId || null;
4419
4992
  let runDocs = null;
4993
+ let runDocsRoot = null;
4420
4994
  let runInfo = null;
4421
4995
  for (const j of runsFor(sc.projectKey)) {
4422
4996
  if (j.reqId !== reqId) continue;
4423
- if (j.runDocs && !runDocs) runDocs = j.runDocs;
4997
+ if (j.runDocs && !runDocs) {
4998
+ runDocs = j.runDocs;
4999
+ runDocsRoot = j.workspacePath || null;
5000
+ }
4424
5001
  if (!runInfo) runInfo = {
4425
5002
  runId: j.id,
4426
5003
  status: j.status,
@@ -4429,6 +5006,17 @@ var TeamflowService = class extends TypertRemoteService {
4429
5006
  endedAt: j.endedAt || null
4430
5007
  };
4431
5008
  }
5009
+ const runArtifacts = [];
5010
+ if (runDocs && runDocsRoot && typeof sessionId === "string" && sessionId) try {
5011
+ const present = new Set(readdirSync(join(runDocsRoot, runDocs)));
5012
+ for (const name of TEAMFLOW_ARTIFACT_ORDER) {
5013
+ if (!present.has(name)) continue;
5014
+ runArtifacts.push({
5015
+ name,
5016
+ address: fileAddressFor(sessionId, void 0, `${runDocs}/${name}`)
5017
+ });
5018
+ }
5019
+ } catch (e) {}
4432
5020
  const byRole = item.byRole || null;
4433
5021
  let subtasks = [];
4434
5022
  let bugs = [];
@@ -4498,6 +5086,7 @@ var TeamflowService = class extends TypertRemoteService {
4498
5086
  byRole: k === "task" ? byRole : null,
4499
5087
  reqId: reqId || null,
4500
5088
  runDocs,
5089
+ artifacts: runArtifacts,
4501
5090
  runInfo,
4502
5091
  subtasks,
4503
5092
  bugs
@@ -4625,8 +5214,7 @@ var TeamflowService = class extends TypertRemoteService {
4625
5214
  activeTeams.set(sid, tid);
4626
5215
  saveActiveTeams();
4627
5216
  const agent = runtime.agents && runtime.agents.get(sid);
4628
- const injectPayload = {
4629
- type: "user",
5217
+ const injectPayload = createUserMessage({
4630
5218
  content: [{
4631
5219
  type: "text",
4632
5220
  text: teamflowContextText(team.icon, team.name, tid)
@@ -4634,9 +5222,9 @@ var TeamflowService = class extends TypertRemoteService {
4634
5222
  source: {
4635
5223
  kind: "plugin",
4636
5224
  plugin: "dsh-plugin-teamflow",
4637
- form: "context"
5225
+ form: "instructions"
4638
5226
  }
4639
- };
5227
+ });
4640
5228
  if (agent && typeof agent.inject === "function") try {
4641
5229
  agent.inject(injectPayload);
4642
5230
  } catch (e) {}