dsh-plugin-teamflow 0.1.6 → 0.1.8

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
@@ -4,19 +4,56 @@ import { TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
4
4
  import { createUserMessage } from "@deepseek-ai/dsh-llm";
5
5
  import { parameterSchemaSpecToJsonSchema } from "@deepseek-ai/dsh-tools";
6
6
  import { dirname, join } from "node:path";
7
- 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";
8
9
  import { execFileSync } from "node:child_process";
9
10
  //#region host/constants.ts
10
- /** 单阶段 token 熔断预算(官方口径总消耗:input+cacheRead+cacheWrite+output 累计)。 */
11
- const STAGE_TOKEN_BUDGET = 6e4;
11
+ /**
12
+ * 单次 withRetry 调用的**新增** token 熔断预算(2026-09-11 口径修正)。
13
+ *
14
+ * 口径 = `freshTokensOf`(input + cacheWrite + output),**排除 cacheRead**:
15
+ * 缓存命中是上下文复用的廉价重放,把它计入「烧钱」会得出荒谬结论——实锤 assetd
16
+ * tf-mtwvwpxa-p3vw08 的 T5:熔断日志报「累计 token 1886k 超出阶段预算 60k」,
17
+ * 而其中 1830k 是 cacheRead,真实新增只有 55k。旧口径的后果不是数字难看,而是
18
+ * **任何 dev 任务只要失败一次就必然熔断**(正常 dev 单次新增实测 45–80k,而缓存命中
19
+ * 恒在 1M 量级)→ RETRY_LIMIT=2 形同虚设,一次失败直接转人工停线。
20
+ *
21
+ * 量级依据(实测单次尝试新增 token):T5 55.4k / T8 49.3k / T9 79.6k / T11 45.9k
22
+ * → 200k ≈ 允许 RETRY_LIMIT 的两轮尝试各留余量,只在该量级的 3 倍以上(真跑飞)才熔断。
23
+ */
24
+ const FRESH_TOKEN_BUDGET = 2e5;
25
+ /** 任务夹产物展示顺序(ADR-0008):工作台只列其中**真实存在**的文件,按此顺序出「一键右侧栏预览」按钮。 */
26
+ const TEAMFLOW_ARTIFACT_ORDER = [
27
+ "PRD.md",
28
+ "DESIGN.md",
29
+ "TECHNICAL.md",
30
+ "QA-REPORT.md",
31
+ "ACCEPTANCE.md",
32
+ "meta.json"
33
+ ];
12
34
  /** 护栏轮询间隔 ms。 */
13
35
  const GUARD_POLL_MS = 15e3;
14
36
  /** 挂死判定:连续这么久没有任何新会话事件(provider 挂起/静默死亡)→ stalled(走预算门转人工)。 */
15
37
  const GUARD_SILENCE_MS = 6e5;
16
38
  /** 空转判定:会话仍在产出事件但连续这么久没有任何工具调用(纯推理打转/改写式循环)→ stalled。要求已见过至少一次工具调用。 */
17
39
  const GUARD_NO_TOOL_MS = 9e5;
18
- /** 假阳性完成检测:明确拒绝/放弃模式的输出视为未产出。 */
40
+ /**
41
+ * 拒绝/放弃措辞词表(诊断信号 + 兜底判据,**不再是唯一的交付门禁**)。
42
+ *
43
+ * 2026-09-11 信号换轨:旧实现把它当交付门禁全文扫描,实锤 assetd tf-mtwvwpxa-p3vw08 的 T5
44
+ * ——子代理 `stopReason=completed`、41 次工具调用、证据块与 state 块齐全、代码已落盘,
45
+ * 只因**如实汇报环境限制**(「7 条 runCli 用例与 spec/verify.mjs 全部 26 例无法执行」)
46
+ * 命中「无法执行」→ 判 insubstantial「视为未交付」→ 提测门禁停线 + 人工 resume。
47
+ * 模型汇报环境限制是本分,不是拒绝——措辞不能当交付判据。
48
+ * 现用法见 `judgeDeliverable`:仅在**无验证证据块**时才作为否决依据;命中即回传供留痕。
49
+ */
19
50
  const REFUSAL_PATTERN = /(无法完成|不能完成|无法继续|抱歉|对不起|我(无法|不能)|无法执行|cannot complete|unable to)/i;
51
+ /**
52
+ * 真交付信号(结构件,非措辞):prompt 强制的 `[Verification evidence]` 块——「命令 + 退出码 +
53
+ * 断言计数」的具体自述。拒绝/放弃类产出给不出具体命令细节,故它出现即判交付,与措辞无关。
54
+ * 这是「防假完成(光说不做)」的客观判据,取代此前对散文措辞的依赖。
55
+ */
56
+ const DELIVERY_EVIDENCE_PATTERN = /\[Verification evidence\]/i;
20
57
  /** 各阶段最小产出长度(防"假完成":空话/一句话冒充交付)。 */
21
58
  const STAGE_MIN_LENGTH = {
22
59
  prd: 400,
@@ -292,6 +329,41 @@ const SAFE_SIGNAL = {
292
329
  function normalizeSignal(s) {
293
330
  return s && typeof s === "object" && typeof s.addEventListener === "function" && typeof s.aborted === "boolean" && typeof s.throwIfAborted === "function" ? s : SAFE_SIGNAL;
294
331
  }
332
+ /**
333
+ * 幂等合并 .gitignore 条目(纯函数,便于回归测试)。
334
+ *
335
+ * 场景(实锤 assetd `tf-mtwvwpxa-p3vw08`):插件强制把命令日志/临时脚本写进 `logs/teamflow/`,
336
+ * 目标仓库没忽略它时,收口提交的 227 个文件里 208 个是这批噪音(92%)——本函数负责「补规则」这一半,
337
+ * 另一半(提交面强制排除)在 `sanity.tfAddArgs()`。
338
+ *
339
+ * 覆盖判定不只看字面相等:已有 `logs/`、`logs/**` 这类**更宽的目录规则**同样算已忽略
340
+ * (否则会往一个已经生效的仓库里塞冗余规则)。返回 `changed=false` 时调用方**不要写文件**。
341
+ */
342
+ function mergeGitignore(existing, entries) {
343
+ const src = existing === null || existing === void 0 ? "" : String(existing);
344
+ const lines = src.split(/\r?\n/).map((l) => l.trim());
345
+ /** 归一:去首尾斜杠与尾部 glob(`logs/`、`logs/**` → `logs`)。 */
346
+ const norm = (s) => s.replace(/^\/+/, "").replace(/\/+$/, "").replace(/\/\*\*?$/, "");
347
+ const coveredBy = (entry) => {
348
+ const e = norm(entry);
349
+ return lines.some((l) => {
350
+ if (!l || l.startsWith("#")) return false;
351
+ const n = norm(l);
352
+ return !!n && (n === e || e.startsWith(`${n}/`));
353
+ });
354
+ };
355
+ const added = entries.filter((e) => e && !coveredBy(e));
356
+ if (added.length === 0) return {
357
+ text: src,
358
+ changed: false,
359
+ added: []
360
+ };
361
+ return {
362
+ text: (src ? `${src}${src.endsWith("\n") ? "" : "\n"}` : "") + `${src ? "\n" : ""}# TeamFlow 运行日志(插件自有产物,非交付物;host 提交时另有 pathspec 强制排除)\n${added.join("\n")}\n`,
363
+ changed: true,
364
+ added
365
+ };
366
+ }
295
367
  /** 分支 slug 派生(ADR-2026-08-27):branchName > triageSlug > 需求中的英文标识词 > reqId 数字 > 'feature'。
296
368
  * 实锤 feat/feature:lite 显式时 triage 不跑(无 slug)+ 分支检查早于 reqId 生成 → fallback 'feature'。 */
297
369
  function deriveBranchSlug(requirement, reqId, triageSlug, branchName) {
@@ -320,12 +392,66 @@ function deriveBranchSlug(requirement, reqId, triageSlug, branchName) {
320
392
  if (num) return `r${num[0]}`;
321
393
  return "feature";
322
394
  }
323
- /** 产出物实质校验:非空 + 无拒绝词 + 达到阶段长度下限。 */
324
- function hasSubstance(phase, text) {
325
- if (!text || !text.trim()) return false;
326
- if (REFUSAL_PATTERN.test(text)) return false;
395
+ /**
396
+ * 交付判定(信号分级;2026-09-11 信号换轨,原 `hasSubstance`)。
397
+ *
398
+ * 旧判据 = 非空 + **全文拒绝词** + 长度下限——把「措辞」当交付门禁。实锤 assetd
399
+ * tf-mtwvwpxa-p3vw08 的 T5:子代理 `stopReason=completed`、41 次工具调用、证据块与
400
+ * state 块齐全、`src/query.mjs` 已落盘,只因如实汇报「7 条 runCli 用例与 spec/verify.mjs
401
+ * 全部 26 例无法执行(沙箱禁止子进程管道)」命中「无法执行」→ 判 insubstantial
402
+ * 「视为未交付」→ 提测门禁停整条线 + 人工 resume(16 分钟 + 一轮重跑)。
403
+ * 教训不是「词表少了一个词」,而是**措辞不能用来判定是否交付**:模型如实汇报环境限制
404
+ * 是本分,换一种说法(跑不通/环境不允许/需在无限制 shell 复跑)旧判据照样误杀。
405
+ *
406
+ * 现判据按「客观优先、措辞退为兜底」分级:
407
+ * 1. 客观形态:非空 + 达阶段长度下限(不读语义);
408
+ * 2. 真交付信号:含 `[Verification evidence]` 块 → 判交付(拒绝/放弃类产出给不出具体
409
+ * 命令+退出码细节);命中拒绝词只记诊断、不否决;
410
+ * 3. 兜底:无证据块且命中拒绝词 → 判未交付(这才是「光说不做 / 自称做不到」的形态)。
411
+ *
412
+ * 因此「如实汇报环境限制」这类假阳性在**结构上**消失,而「没干活就说完成」仍被抓:
413
+ * 无证据块的假交付照旧落到第 3 级或长度级。
414
+ */
415
+ function judgeDeliverable(phase, text) {
416
+ const s = toText(text);
327
417
  const min = STAGE_MIN_LENGTH[phase] ?? 100;
328
- return text.trim().length >= min;
418
+ const length = s.trim().length;
419
+ if (length === 0) return {
420
+ ok: false,
421
+ reason: "empty",
422
+ refusal: null,
423
+ min,
424
+ length
425
+ };
426
+ if (length < min) return {
427
+ ok: false,
428
+ reason: "too-short",
429
+ refusal: null,
430
+ min,
431
+ length
432
+ };
433
+ const refusal = refusalHit(s);
434
+ if (!refusal) return {
435
+ ok: true,
436
+ reason: "ok",
437
+ refusal: null,
438
+ min,
439
+ length
440
+ };
441
+ if (DELIVERY_EVIDENCE_PATTERN.test(s)) return {
442
+ ok: true,
443
+ reason: "ok",
444
+ refusal,
445
+ min,
446
+ length
447
+ };
448
+ return {
449
+ ok: false,
450
+ reason: "refusal",
451
+ refusal,
452
+ min,
453
+ length
454
+ };
329
455
  }
330
456
  /** 不可重试的失败原因(上下文耗尽/超长/provider 客户端拒绝等——重试同一 prompt 大概率复现)。
331
457
  * 实锤 tf-mtcnejqj:opencode-go 400 invalid_request_error(tool 消息序列非法)被当作可重试 → 烧 1.98M 熔断。 */
@@ -471,20 +597,27 @@ function extractBlueprint(text) {
471
597
  //#region host/core/context.ts
472
598
  /**
473
599
  * dsh-plugin-teamflow core — 运行期共享状态(进程单例)。
474
- * - runtime(agents/subagents/tokenMeter/workspaceRegistry/agentDefaultModel):由 index=TeamflowService 的 static inject 注入(setRuntime)。
600
+ * - runtime(agents/subagents/workspaceRegistry/agentDefaultModel/llm):由 index=TeamflowService 的 static inject 注入(setRuntime)。
475
601
  * - runs/inFlight/activeProducts:流水线运行期 Map(跨 runner/pipeline/report/服务共享)。
476
602
  * 这是 ADR-0004「共享状态」在编排层的落点:共享对象集中、单向被 core 各模块 import(不反向)。
477
603
  */
478
604
  /** 子代理/计量等宿主能力(由 TeamflowService 装配时 setRuntime 注入)。字段为鸭子类型:消费方自行窄化。 */
479
605
  const runtime = {};
480
- function setRuntime(agents, subagents, tokenMeter, workspaceRegistry, agentDefaultModel, llm) {
606
+ function setRuntime(agents, subagents, workspaceRegistry, agentDefaultModel, llm) {
481
607
  runtime.agents = agents;
482
608
  runtime.subagents = subagents;
483
- runtime.tokenMeter = tokenMeter;
484
609
  runtime.workspaceRegistry = workspaceRegistry;
485
610
  runtime.agentDefaultModel = agentDefaultModel;
486
611
  runtime.llm = llm;
487
612
  }
613
+ /**
614
+ * 可选能力:官方 Session 投影注册表(ctx.sessionProjections,dsh-session-projection)。
615
+ * 单独 setter 而非并入 setRuntime——它是**可选**依赖:用 ctx.inject 在服务可用时注册,
616
+ * 未挂载(最小 profile)时计量自动回退事件扫描,插件照常加载。
617
+ */
618
+ function setSessionProjections(projections) {
619
+ runtime.sessionProjections = projections;
620
+ }
488
621
  /** 运行期 run 注册表(runId → Journal)。 */
489
622
  const runs = /* @__PURE__ */ new Map();
490
623
  /** 进行中的 stage 注册表(runId → { run, stage }),供取消/完成清理。 */
@@ -530,15 +663,19 @@ async function currentModelSupportsVision(provider, model) {
530
663
  /**
531
664
  * 从发起会话推导工作区作用域。
532
665
  *
533
- * 优先级:
534
- * 1. workspaceRegistry.resolveByPath(cwd) → 用 workspace.id(UUID,稳定)作 projectKey
535
- * 2. 回退到 session cwd basename + 短 hash(兼容无 workspaceRegistry 的场景)
666
+ * 优先级(**实际生效的只有第 2 条**):
667
+ * 1. workspaceRegistry.resolveByPath(cwd) → 用 workspace.id(UUID)作 projectKey
668
+ * —— ⚠️ **当前不可达**:宿主 `resolveByPath` `async`(返回 Promise,见
669
+ * `packages/workspace/workspace/src/index.ts`),本函数同步调用 → `ws.id` 恒为 undefined,
670
+ * 永远落到第 2 条。分支保留是为将来迁移(需 await + 存储 key 迁移,见 docs/TODO.md)。
671
+ * 2. session cwd 的 basename + 短 hash(`slugPath`)—— **当前实际使用的 key**
536
672
  * 3. 兜底 'default'
537
673
  *
538
674
  * projectKey 用于 $DSH_HOME/teamflow/<projectKey>/ 目录,要求:
539
- * - 同一 workspace 永远解析到同一个 key(UUID 天然满足)
540
- * - 不同 workspace 即使 basename 相同也不碰撞(UUID 天然满足)
675
+ * - 同一路径永远解析到同一个 key(sha1 派生,满足)
676
+ * - 不同 cwd 即使 basename 相同也不碰撞(hash 参与,满足)
541
677
  * - 目录名安全(只含 [a-zA-Z0-9_-])
678
+ * ⚠️ 代价:key 绑定**路径字符串**,同一工作区换个写法(盘符大小写/软链/尾斜杠)会得到不同 key。
542
679
  */
543
680
  function workspaceScopeOf(agent) {
544
681
  const session = agent?.session;
@@ -1081,6 +1218,288 @@ function hasOpenBlockingBugs(journal) {
1081
1218
  return false;
1082
1219
  }
1083
1220
  }
1221
+ //#endregion
1222
+ //#region host/core/state.ts
1223
+ /**
1224
+ * dsh-plugin-teamflow core — state.json 预编译上下文索引。
1225
+ *
1226
+ * 目标:解决「每个新 run 都要从小代理全量读历史文档(PRD/TECH/QA)来重建认知」的 token 爆炸。
1227
+ * state.json 是跨 run 累积的结构化索引:每次 run 结束后由各阶段把「精简结论」沉淀进来,
1228
+ * 下一个 run 的子代理只读注入的 state slice,不再重复读全套历史文档。
1229
+ *
1230
+ * 设计原则:
1231
+ * - memory.md 保持权威记忆(人读);state.json 是预编译索引(机器喂给子代理)。
1232
+ * - 子代理不直接读 state.json 文件,由 host 在开工时按角色注入相关 slice 到 prompt。
1233
+ * - state.json 只存「结论/指针」,不存全文;具体内容仍指向 docs/teamflow/ 下的活文档。
1234
+ */
1235
+ /** 空态 state。 */
1236
+ function emptyState() {
1237
+ return {
1238
+ version: 1,
1239
+ projectName: null,
1240
+ updatedAt: null,
1241
+ product: {
1242
+ summary: null,
1243
+ techStack: null
1244
+ },
1245
+ lastRunFolder: null,
1246
+ modules: {},
1247
+ verifyScripts: [],
1248
+ acIndex: {},
1249
+ stages: {},
1250
+ lastRun: null
1251
+ };
1252
+ }
1253
+ /** state.json 路径:$DSH_HOME/teamflow/<projectKey>/state.json */
1254
+ function stateFile(projectKey) {
1255
+ return join(teamflowRoot(), projectKey, "state.json");
1256
+ }
1257
+ /** 读取(不存在返回空态)。 */
1258
+ function loadState(projectKey) {
1259
+ const file = stateFile(projectKey);
1260
+ try {
1261
+ if (existsSync(file)) {
1262
+ const raw = JSON.parse(readFileSync(file, "utf8"));
1263
+ const base = emptyState();
1264
+ if (raw && typeof raw === "object") {
1265
+ base.projectName = raw.projectName ?? null;
1266
+ base.updatedAt = raw.updatedAt ?? null;
1267
+ base.product = {
1268
+ ...base.product,
1269
+ ...raw.product || {}
1270
+ };
1271
+ base.lastRunFolder = raw.lastRunFolder ?? null;
1272
+ base.modules = raw.modules || {};
1273
+ base.verifyScripts = Array.isArray(raw.verifyScripts) ? raw.verifyScripts : [];
1274
+ base.acIndex = raw.acIndex || {};
1275
+ base.stages = raw.stages || {};
1276
+ base.lastRun = raw.lastRun ?? null;
1277
+ return base;
1278
+ }
1279
+ }
1280
+ } catch (e) {}
1281
+ return emptyState();
1282
+ }
1283
+ /** 保存。 */
1284
+ function saveState(projectKey, state) {
1285
+ const file = stateFile(projectKey);
1286
+ try {
1287
+ mkdirSync(join(teamflowRoot(), projectKey), { recursive: true });
1288
+ state.updatedAt = Date.now();
1289
+ writeFileSync(file, JSON.stringify(state, null, 2), "utf8");
1290
+ return true;
1291
+ } catch (e) {
1292
+ console.error("[teamflow] saveState failed", e?.message);
1293
+ return false;
1294
+ }
1295
+ }
1296
+ /** 从阶段产出文本中提取 `<!-- state -->{...}<!-- /state -->` 块(找不到返回 null)。 */
1297
+ function extractStateBlock(text) {
1298
+ const m = (text === null || text === void 0 ? "" : String(text)).match(/<!--\s*state\s*-->([\s\S]*?)(?:<!--\s*\/state\s*-->|$)/);
1299
+ if (!m || !m[1]) return null;
1300
+ try {
1301
+ const raw = JSON.parse(m[1].trim());
1302
+ if (raw && typeof raw === "object") return raw;
1303
+ } catch (e) {}
1304
+ return null;
1305
+ }
1306
+ /** 把阶段产出的 state 块合并进 state.json。 */
1307
+ function mergeStateBlock(projectKey, block, phase) {
1308
+ const state = loadState(projectKey);
1309
+ const key = block && block.phase || phase || "other";
1310
+ if (block) {
1311
+ if (typeof block.summary === "string" && block.summary.trim()) state.stages[key] = block.summary.trim();
1312
+ if (Array.isArray(block.touched)) {
1313
+ for (const f of block.touched) if (typeof f === "string" && f) state.modules[f] = state.modules[f] || "touched";
1314
+ }
1315
+ if (typeof block.verdict === "string" && block.verdict) {
1316
+ state.lastRun = state.lastRun || {};
1317
+ state.lastRun.verdict = block.verdict;
1318
+ }
1319
+ if (block.extra && typeof block.extra === "object") {
1320
+ if (Array.isArray(block.extra.verifyScripts)) {
1321
+ for (const s of block.extra.verifyScripts) if (typeof s === "string" && s && state.verifyScripts.indexOf(s) === -1) state.verifyScripts.push(s);
1322
+ }
1323
+ if (block.extra.acIndex && typeof block.extra.acIndex === "object") state.acIndex = {
1324
+ ...state.acIndex,
1325
+ ...block.extra.acIndex
1326
+ };
1327
+ if (typeof block.extra.techStack === "string" && block.extra.techStack) state.product.techStack = block.extra.techStack;
1328
+ if (typeof block.extra.moduleContracts === "object" && block.extra.moduleContracts) state.modules = {
1329
+ ...state.modules,
1330
+ ...block.extra.moduleContracts
1331
+ };
1332
+ }
1333
+ }
1334
+ saveState(projectKey, state);
1335
+ return state;
1336
+ }
1337
+ /** 按 run 更新 lastRun / lastRunFolder(finally 时调用)。 */
1338
+ function noteRun(projectKey, run) {
1339
+ const state = loadState(projectKey);
1340
+ if (run.runDocs) state.lastRunFolder = run.runDocs;
1341
+ state.lastRun = {
1342
+ runId: run.id || null,
1343
+ requirement: run.requirement ? String(run.requirement).slice(0, 200) : null,
1344
+ verdict: run.verdict || null,
1345
+ folder: run.runDocs || null,
1346
+ endedAt: run.endedAt ?? Date.now()
1347
+ };
1348
+ saveState(projectKey, state);
1349
+ }
1350
+ /** 按角色渲染 state slice(注入到子代理 prompt)。角色 → 只拿相关片段。 */
1351
+ function stateSliceFor(state, role) {
1352
+ const lines = [];
1353
+ if (state.__runCtx) {
1354
+ if (state.__runCtx.runDocs) lines.push(`【本次任务产物夹】${state.__runCtx.runDocs}/(host 已创建;本需求的 PRD/TECHNICAL/QA-REPORT/ACCEPTANCE 全部写这里,夹建后不可变、不归档不升版)`);
1355
+ if (state.__runCtx.sanity) lines.push(state.__runCtx.sanity);
1356
+ if (state.__runCtx.blueprint && (role === "arch" || role === "tech" || role === "dev")) lines.push(state.__runCtx.blueprint);
1357
+ }
1358
+ lines.push("【预编译产品状态(state.json · 权威记忆在 docs/teamflow/memory.md,本块已是够用的索引,勿再全量读历史文档)】");
1359
+ if (state.product.summary) lines.push(`- 产品概要:${state.product.summary}`);
1360
+ if (state.product.techStack && (role === "tech" || role === "dev" || role === "arch")) lines.push(`- 技术栈:${state.product.techStack}`);
1361
+ if (Object.keys(state.acIndex).length && (role === "pm" || role === "qa" || role === "acceptance" || role === "tech")) {
1362
+ const acs = Object.entries(state.acIndex).slice(0, 40);
1363
+ lines.push(`- AC 索引(${acs.length} 条):${acs.map(([k, v]) => `${k} ${v}`).join(";")}`);
1364
+ }
1365
+ if (Object.keys(state.modules).length && (role === "tech" || role === "dev" || role === "arch" || role === "qa")) lines.push(`- 模块(${Object.keys(state.modules).length}):${Object.entries(state.modules).map(([f, c]) => `${f}${c ? "→" + c : ""}`).join(",")}`);
1366
+ if (state.verifyScripts.length && (role === "qa" || role === "tech" || role === "dev")) lines.push(`- 验证脚本:${state.verifyScripts.join(",")}`);
1367
+ if (role === "pm" || role === "acceptance") {
1368
+ if (state.stages.prd) lines.push(`- PRD 摘要:${state.stages.prd}`);
1369
+ if (state.stages.tech) lines.push(`- 技术方案摘要:${state.stages.tech}`);
1370
+ if (state.stages.qa) lines.push(`- QA 摘要:${state.stages.qa}`);
1371
+ } else if (role === "dev" || role === "tech") {
1372
+ if (state.stages.tech) lines.push(`- 技术方案摘要:${state.stages.tech}`);
1373
+ if (state.stages.prd) lines.push(`- PRD 摘要:${state.stages.prd}`);
1374
+ } else if (role === "qa") {
1375
+ if (state.stages.qa) lines.push(`- 上轮 QA 摘要:${state.stages.qa}`);
1376
+ }
1377
+ if (state.lastRun) {
1378
+ const r = state.lastRun;
1379
+ lines.push(`- 上轮:${r.requirement ? r.requirement : ""}${r.verdict ? " → " + r.verdict : ""}${r.folder ? `(${r.folder})` : ""}`);
1380
+ }
1381
+ return lines.join("\n");
1382
+ }
1383
+ /** 让每个阶段产出末尾附带 state 块(将并入 stage output,由 host 提取)。 */
1384
+ const STATE_BLOCK_INSTRUCTION = `\n\n[STATE BLOCK · mandatory at the end] Append one section at the END of your answer (same output as the body; the host indexes it):
1385
+ <!-- state -->{"phase":"<stage-key>","summary":"<≤500 chars: this stage's conclusion, useful for the next run>","memory":["<memory points>"]}<!-- /state -->`;
1386
+ //#endregion
1387
+ //#region host/core/products.ts
1388
+ /**
1389
+ * dsh-plugin-teamflow core — 产品线(product line)装配。
1390
+ *
1391
+ * 全局面板(侧边栏图标 + root `main` 面板)没有会话上下文:宿主据产品线 key
1392
+ * (`$DSH_HOME/teamflow/<key>/`)装配「产品线清单 / 产品线视图 / run 摘要」,
1393
+ * 与按 sessionId 寻址的路由**共用同一批 journal 与 state.json**(同源、不新增数据模型)。
1394
+ *
1395
+ * 依赖方向:types/constants/util → store → core/*;本文件只依赖 store、core/context、core/state。
1396
+ */
1397
+ /** 按产品线 key 过滤运行(未落 workspace 的旧运行只见于 default)。 */
1398
+ function runsFor(ws) {
1399
+ const arr = [];
1400
+ for (const j of runs.values()) {
1401
+ const rec = j;
1402
+ if (ws) {
1403
+ if ((rec.workspace || (ws === "default" ? "default" : null)) !== ws) continue;
1404
+ }
1405
+ arr.push(rec);
1406
+ }
1407
+ arr.sort((a, b) => (b.startedAt || 0) - (a.startedAt || 0));
1408
+ return arr;
1409
+ }
1410
+ /** run 详情地址(host 生成,client 直接交给右侧栏 openResource——与产物地址同一条原则:
1411
+ * 地址里的产品线 + runId 由 host 决定,client 不拼地址、不引宿主包)。 */
1412
+ function runAddress(product, runId) {
1413
+ return `dsh-resource://teamflow/run/${encodeURIComponent(String(product || "default"))}/${encodeURIComponent(String(runId))}`;
1414
+ }
1415
+ /** 产品线 key 归一化:复用工具层白名单(拒绝盘符/穿越/空白),非法 → null。 */
1416
+ function productKeyOf(product) {
1417
+ return normalizeRoot(product);
1418
+ }
1419
+ /** 该 run 是否属于该产品线(无 workspace 的旧 run 只在 default 兜底可见)。 */
1420
+ function runVisibleIn(j, key) {
1421
+ return !j.workspace || j.workspace === key || key === "default";
1422
+ }
1423
+ /** 单 run 官方口径 usage 汇总(run 列表展示;stage 级明细仍走 snapshot)。 */
1424
+ function runUsageSum(j) {
1425
+ const t = {
1426
+ input: 0,
1427
+ cacheRead: 0,
1428
+ cacheWrite: 0,
1429
+ output: 0,
1430
+ calls: 0
1431
+ };
1432
+ for (const s of j.stages || []) {
1433
+ const u = s && s.usage;
1434
+ if (!u) continue;
1435
+ t.input += u.input || 0;
1436
+ t.cacheRead += u.cacheRead || 0;
1437
+ t.cacheWrite += u.cacheWrite || 0;
1438
+ t.output += u.output || 0;
1439
+ t.calls += u.calls || 0;
1440
+ }
1441
+ return t;
1442
+ }
1443
+ /** run 摘要(list() 与 productView() 共用同一形状)。 */
1444
+ function runBrief(j) {
1445
+ const stages = j.stages || [];
1446
+ return {
1447
+ id: j.id,
1448
+ status: j.status,
1449
+ mode: j.options && j.options.mode || null,
1450
+ startedAt: j.startedAt,
1451
+ endedAt: j.endedAt,
1452
+ agentsStarted: j.agentsStarted,
1453
+ stageCount: stages.length,
1454
+ doneStages: stages.filter((x) => x.status === "done").length,
1455
+ incompleteStages: stages.some((x) => x.status !== "done"),
1456
+ requirement: clip(j.requirement, 60),
1457
+ usage: runUsageSum(j),
1458
+ address: runAddress(j.workspace || "default", j.id),
1459
+ ownerSession: j.ownerSession || null
1460
+ };
1461
+ }
1462
+ /** 单产品线元信息(产品线清单与产品线视图共用)。 */
1463
+ function productMetaOf(key) {
1464
+ const js = runsFor(key);
1465
+ const st = loadState(key);
1466
+ const path = (js.find((j) => j.workspacePath) || {}).workspacePath || null;
1467
+ const lastRun = st.lastRun || null;
1468
+ return {
1469
+ key,
1470
+ title: st.projectName || (path ? String(path).replace(/\\/g, "/").split("/").filter(Boolean).pop() : key),
1471
+ path,
1472
+ updatedAt: st.updatedAt || (js[0] ? js[0].endedAt || js[0].startedAt || null : null),
1473
+ totalRuns: js.length,
1474
+ activeRuns: js.filter((j) => j.status === "running" || j.status === "pending").length,
1475
+ lastRequirement: lastRun && lastRun.requirement ? clip(lastRun.requirement, 80) : js[0] ? clip(js[0].requirement, 80) : null,
1476
+ lastVerdict: lastRun && lastRun.verdict || null
1477
+ };
1478
+ }
1479
+ /** 产品线清单:`$DSH_HOME/teamflow` 下带 `backlog/` 或 `runs/` 的目录(无会话上下文也能用)。 */
1480
+ function listProducts() {
1481
+ const out = [];
1482
+ const root = teamflowRoot();
1483
+ let entries = [];
1484
+ try {
1485
+ entries = readdirSync(root, { withFileTypes: true });
1486
+ } catch (e) {
1487
+ return out;
1488
+ }
1489
+ for (const ent of entries) {
1490
+ if (!ent.isDirectory() || ent.name === "runs") continue;
1491
+ let sub = [];
1492
+ try {
1493
+ sub = readdirSync(join(root, ent.name));
1494
+ } catch (e) {
1495
+ continue;
1496
+ }
1497
+ if (!sub.includes("backlog") && !sub.includes("runs")) continue;
1498
+ out.push(productMetaOf(ent.name));
1499
+ }
1500
+ out.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
1501
+ return out;
1502
+ }
1084
1503
  /** 默认团队配置文件内容。 */
1085
1504
  const DEFAULT_TEAMS_FILE = {
1086
1505
  version: 1,
@@ -1206,6 +1625,32 @@ function gitCmd(cwd, args, timeoutMs = 8e3) {
1206
1625
  }
1207
1626
  }
1208
1627
  /**
1628
+ * TeamFlow 自有日志命名空间(工作区相对路径)。
1629
+ *
1630
+ * 为什么单独拎出来:prompts 强制子代理把命令输出与临时验证脚本写进这里(Log discipline / TOKEN_HYGIENE),
1631
+ * 而 prompts 的资源表同时把它定性为**非交付物**(「运行日志 … 日常不读」)。也就是说这批文件是插件
1632
+ * 自己必然生产、且自己声明不该交付的东西——绝不能靠目标仓库的 .gitignore 兜底。
1633
+ * 实锤 assetd `tf-mtwvwpxa-p3vw08`:收口提交 227 个文件里 **208 个(92%)** 是这里的内容
1634
+ * (100 log / 52 json / 44 临时 .mjs / 5 .cjs,623.8 KB),真交付只有 19 个文件。
1635
+ */
1636
+ const TF_LOG_DIR = "logs/teamflow";
1637
+ /**
1638
+ * 插件发起的提交统一走这里(**禁止裸 `git add -A`**)。
1639
+ *
1640
+ * 用 git magic pathspec 强制排除自有日志:不依赖目标仓库有没有配 .gitignore、也不怕用户改回去。
1641
+ * `-- .` 把提交面收敛到工作区(workspace = 项目根)——与旧 `add -A` 在根目录等价,
1642
+ * 但不再把工作区之外/无关路径一并卷入。
1643
+ */
1644
+ function tfAddArgs() {
1645
+ return [
1646
+ "add",
1647
+ "-A",
1648
+ "--",
1649
+ ".",
1650
+ `:(exclude)${TF_LOG_DIR}`
1651
+ ];
1652
+ }
1653
+ /**
1209
1654
  * 跑一次状态核对。
1210
1655
  * @param path - 工作区绝对路径(workspaceScopeOf(agent).path)。
1211
1656
  */
@@ -1254,7 +1699,63 @@ function runSanityCheck(path) {
1254
1699
  //#endregion
1255
1700
  //#region host/core/metering.ts
1256
1701
  /**
1257
- * 采集子代理会话事件(多源回退——2026-09-07 实锤 r38 usage 全空):
1702
+ * dsh-plugin-teamflow core token 计量(官方口径)。
1703
+ * 依赖:types.ts、context.ts(runtime.sessionProjections)。
1704
+ *
1705
+ * 口径与模型 provider 账单一致(模型无关):
1706
+ * - input : 输入(缓存未命中)
1707
+ * - cacheRead : 输入(缓存命中)
1708
+ * - cacheWrite : 输入写入缓存
1709
+ * - output : 输出
1710
+ * billed input = input + cacheRead + cacheWrite。
1711
+ * 缓存命中率 = cacheRead / (input + cacheRead)。
1712
+ *
1713
+ * 来源优先级(2026-09-10 适配 dsh 0.1.5-rc.2):
1714
+ * 1) **官方 Session 投影**(首选):`ctx.sessionProjections.stateOf(session,'tokenUsage')` 取四桶 +
1715
+ * `'sessionStats'` 取调用数——零历史扫描,且与官方 token-meter 同一份 fold(不再自行复刻口径)。
1716
+ * 2) **事件扫描回退**(存量路径):宿主未挂载投影(最小 profile/未来移除)或投影无 provider usage 时,
1717
+ * 沿用 events → snapshotEvents() → ownEvents() 多源回退。宿主自 2026-09-09 起把这三个同步历史读取器
1718
+ * 标记为 deprecated(存量可留、新调用禁止),本路径仅为无投影宿主保底,不再扩展(见 docs/TODO.md)。
1719
+ */
1720
+ /** 投影字段读数(宽进严出:非法/非正一律 0,不虚报)。 */
1721
+ function countOf(value) {
1722
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0;
1723
+ }
1724
+ /**
1725
+ * 投影路径(官方口径首选):
1726
+ * - `tokenUsage`(dsh-token-meter 注册,stateVersion 2)→ totals 四桶:与官方同一份 fold,
1727
+ * `assistant/attempt` 内嵌 stream usage 同样计入、`llm/retry-started` 会先关掉被替换的重试槽位
1728
+ * ——比旧事件扫描(只认 assistant/message)更准,重试不重复计。
1729
+ * - `sessionStats`(dsh-session-stats 注册)steps → 调用数(一个 step = 一次模型请求;
1730
+ * 旧扫描按 assistant/message 的 turn.step 去重,语义等价)。
1731
+ * 返回 null = 投影不可用或该会话无 provider usage —— 交给事件扫描回退(不虚报 0)。
1732
+ */
1733
+ function projectedUsageOf(run) {
1734
+ try {
1735
+ const projections = runtime.sessionProjections;
1736
+ if (!projections || typeof projections.stateOf !== "function") return null;
1737
+ const session = run && run.localAgent ? run.localAgent.session : null;
1738
+ if (!session) return null;
1739
+ const usage = projections.stateOf(session, "tokenUsage");
1740
+ const totals = usage && usage.totals;
1741
+ if (!totals) return null;
1742
+ const buckets = {
1743
+ input: countOf(totals.uncachedInputTokens),
1744
+ cacheRead: countOf(totals.cacheReadTokens),
1745
+ cacheWrite: countOf(totals.cacheWriteTokens),
1746
+ output: countOf(totals.outputTokens),
1747
+ calls: 0
1748
+ };
1749
+ if (totalTokensOf(buckets) <= 0) return null;
1750
+ const stats = projections.stateOf(session, "sessionStats");
1751
+ buckets.calls = countOf(stats && stats.steps) || 1;
1752
+ return buckets;
1753
+ } catch (e) {
1754
+ return null;
1755
+ }
1756
+ }
1757
+ /**
1758
+ * 采集子代理会话事件(存量回退路径——2026-09-07 实锤 r38 usage 全空):
1258
1759
  * 宿主新版 Session(session v2)已无 `events` 属性/getter(仅私有 eventsSnapshot 缓存 +
1259
1760
  * 官方 snapshotEvents()/ownEvents() 方法),旧实现读 session.events = undefined → usage 全 null。
1260
1761
  * 回退链(与 guard.eventsOf 同款语义):events(老宿主快照,兼容)→ snapshotEvents()(官方完整日志)
@@ -1302,9 +1803,16 @@ function usageOfEvent(e) {
1302
1803
  }
1303
1804
  /**
1304
1805
  * 累计子代理会话中所有 LLM 调用的真实 usage(官方三桶 + 调用数)。
1305
- * 返回 null 表示拿不到 usage(会话未暴露 events / 无数据)。
1806
+ * 来源优先级:官方 Session 投影(首选,零历史扫描)→ 事件扫描(无投影宿主的存量回退)。
1807
+ * 返回 null 表示两条路径都拿不到 usage(会话未暴露投影与事件 / 无数据)。
1306
1808
  */
1307
1809
  function accumulateSessionUsage(run) {
1810
+ const projected = projectedUsageOf(run);
1811
+ if (projected) return projected;
1812
+ return scannedUsageOf(run);
1813
+ }
1814
+ /** 事件扫描回退(投影未挂载/无数据时使用;沿用 2026-09-07 的多源回退语义,不改行为)。 */
1815
+ function scannedUsageOf(run) {
1308
1816
  const events = sessionEventsOf(run);
1309
1817
  if (events.length === 0) return null;
1310
1818
  const buckets = {
@@ -1332,11 +1840,24 @@ function accumulateSessionUsage(run) {
1332
1840
  buckets.calls = seen.size || 1;
1333
1841
  return buckets;
1334
1842
  }
1335
- /** 官方口径总消耗(billed input + output,含 cacheRead/cacheWrite)。 */
1843
+ /** 官方口径总消耗(billed input + output,含 cacheRead/cacheWrite)——**汇报/展示**口径。 */
1336
1844
  function totalTokensOf(usage) {
1337
1845
  if (!usage) return 0;
1338
1846
  return (usage.input || 0) + (usage.cacheRead || 0) + (usage.cacheWrite || 0) + (usage.output || 0);
1339
1847
  }
1848
+ /**
1849
+ * 熔断口径「新增消耗」= input + cacheWrite + output(**排除 cacheRead**)。
1850
+ *
1851
+ * 为什么与汇报口径分家(2026-09-11):cacheRead 是上下文复用的缓存重放,单价低且是
1852
+ * **复用证据**而非烧钱信号;把它计入熔断,会让预算被「每步 1M 量级的命中」瞬间打爆——
1853
+ * 实锤 assetd tf-mtwvwpxa-p3vw08 的 T5:报「累计 token 1886k 超出阶段预算 60k」,
1854
+ * 其中 1830k 是 cacheRead,真实新增仅 55k;后果是任何 dev 任务一失败就熔断,
1855
+ * RETRY_LIMIT 永不生效。汇报仍用 `totalTokensOf`(官方口径,AGENTS §4 不变)。
1856
+ */
1857
+ function freshTokensOf(usage) {
1858
+ if (!usage) return 0;
1859
+ return (usage.input || 0) + (usage.cacheWrite || 0) + (usage.output || 0);
1860
+ }
1340
1861
  //#endregion
1341
1862
  //#region host/core/guard.ts
1342
1863
  /**
@@ -1353,8 +1874,12 @@ function totalTokensOf(usage) {
1353
1874
  * ⚠️ 状态判定(实锤 run tf-mte906e9):大文件 read-edit 循环是正常模式——模型反复 read 同一大文件
1354
1875
  * (每次 edit 后内容已变,必须重读确认)、输出高度相似的「读后分析」,逐字片段在 400 条窗口内
1355
1876
  * 可累积 ≥12 次——伴随 edit/write 变更调用时只记录观察,不中止(否则大文件修改任务全被误杀)。
1356
- * B. 挂死检测:连续 GUARD_SILENCE_MS 一个新事件都没有(provider 层挂起/连接静默死亡)
1877
+ * B. 挂死检测:连续 GUARD_SILENCE_MS 没有**任何已提交事件**(provider 层挂起/连接静默死亡)
1357
1878
  * → outcome='stalled'(走正常预算门 → 熔断转人工,不自动重试烧钱)。
1879
+ * 2026-09-10:时间来源改为**官方 `subagentTiming` 投影**(`active.through` = 该投影 cut 上
1880
+ * 最新事件时间,由宿主在已提交事件上折叠)——不再依赖「三源取最长视图」的长度启发式
1881
+ * (r1 QA 误判的根因就是那个视图会失明);投影不可用时回退旧启发式。长工具静默执行
1882
+ * (跑 12 分钟测试无输出)仍由 agent 活动守卫豁免,不做误杀。
1358
1883
  * C. 空转检测:会话仍在产出事件,但连续 GUARD_NO_TOOL_MS 没有任何工具调用
1359
1884
  * (纯推理打转/改写式循环;正常 agent 每分钟都在调工具)→ outcome='stalled'。
1360
1885
  * 兜底关系:复读判定放宽后,edit 后陷入死循环的漏网场景由 C(长时间无工具调用)兜住。
@@ -1414,36 +1939,54 @@ function eventsOf(run) {
1414
1939
  function normalizeFragment(s) {
1415
1940
  return String(s || "").toLowerCase().replace(/[^a-z0-9\u4e00-\u9fa5]+/g, "");
1416
1941
  }
1417
- /** 观测→执行闭环:向运行中的子代理注入轻提醒(不打断,下一轮 step 可见)。
1418
- * 通道:subagents.start 句柄无 inject——用 DSH 官方 session.append('user/message')(in-process driver 同款用法)。
1419
- * ⚠️ 安全窗口(实锤 tf-mtcnejqj):绝不能插在 assistant(tool_calls) tool/result 之间——
1420
- * provider 校验「tool 消息必须响应前序 tool_calls」,插入 user 消息会 400 invalid_request_error。
1421
- * 因此只入队(pendingInjects),在观察到 step/end(该 step tool/result 已写入)后统一 flush。 */
1422
- function injectReminder(run, text) {
1423
- const queue = run;
1942
+ /**
1943
+ * 官方 `subagentTiming` 投影读数(挂死检测首选源,2026-09-10 改)。
1944
+ * 形状 `{settledMs, active?:{since, through}}`——`through` 是该投影 cut 上**最新事件时间**,
1945
+ * 由宿主在已提交事件上折叠,不受 session.events 快照失明影响(r1 QA 误判根因)。
1946
+ * 返回 null = 投影不可用(未挂载 / 该子代理无 descriptor)→ 回退事件数增长启发式。
1947
+ */
1948
+ function timingOf(run) {
1424
1949
  try {
1425
- if (!Array.isArray(queue.__teamflowPending)) queue.__teamflowPending = [];
1426
- queue.__teamflowPending.push(text);
1427
- } catch (e) {}
1950
+ const projections = runtime.sessionProjections;
1951
+ if (!projections || typeof projections.stateOf !== "function") return null;
1952
+ const local = run && run.localAgent;
1953
+ const session = local && local.session;
1954
+ if (!session) return null;
1955
+ const timing = projections.stateOf(session, "subagentTiming");
1956
+ if (!timing || typeof timing !== "object") return null;
1957
+ const through = timing.active && timing.active.through;
1958
+ return { activeThrough: typeof through === "number" && Number.isFinite(through) ? through : void 0 };
1959
+ } catch (e) {
1960
+ return null;
1961
+ }
1428
1962
  }
1429
- function flushReminders(run) {
1963
+ /** 观测→执行闭环:向运行中的子代理注入轻提醒(不打断,下一 step 可见)。
1964
+ *
1965
+ * 通道(2026-09-10 改):`run.localAgent.inject()` —— 宿主官方 Agent 通道。next-step 队列由
1966
+ * agent loop 在 `preStep` 内、tool/result 之后整批认领,因此**不存在**「插进
1967
+ * assistant(tool_calls) → tool/result 之间触发 provider 400」的窗口;旧实现「先入队
1968
+ * `__teamflowPending`、观察到 step/end 再 session.append」的时序状态机整体删除。
1969
+ * (旧注释「subagents.start 句柄无 inject」是错的:`run.localAgent` 是活 Agent,
1970
+ * 有 `inject/steer/followup` —— `packages/core/agent/src/runtime-types.ts`。)
1971
+ *
1972
+ * 语义:`inject` 是 best-effort(可能晚一个 step),且不唤醒 idle driver——提醒只用于
1973
+ * 「仍在跑的 agent」;退化中止仍走 fire()/dispose(),不改为 steer 纠偏(后者是独立课题)。 */
1974
+ function injectReminder(run, text) {
1430
1975
  try {
1431
- const queue = run.__teamflowPending;
1432
- if (!queue || queue.length === 0) return;
1433
- const local = run.localAgent;
1434
- if (!local || typeof local.session?.append !== "function") return;
1435
- for (const text of queue.splice(0)) local.session.append("user/message", {
1436
- id: crypto.randomUUID(),
1437
- role: "user",
1976
+ const agent = run && run.localAgent;
1977
+ if (!agent || typeof agent.inject !== "function") return;
1978
+ agent.inject(createUserMessage({
1438
1979
  content: [{
1439
1980
  type: "text",
1440
1981
  text
1441
1982
  }],
1442
1983
  source: {
1443
1984
  kind: "plugin",
1444
- plugin: "teamflow"
1985
+ plugin: "dsh-plugin-teamflow",
1986
+ form: "notice",
1987
+ summary: "护栏轻提醒"
1445
1988
  }
1446
- }, { surfaceOp: "append" });
1989
+ }));
1447
1990
  } catch (e) {}
1448
1991
  }
1449
1992
  /**
@@ -1485,31 +2028,37 @@ function startStageGuard(opts) {
1485
2028
  stage.guardReason = reason;
1486
2029
  stage.guardOutcome = outcome;
1487
2030
  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");
2031
+ const timing = timingOf(run);
2032
+ let detail;
2033
+ if (timing) detail = `subagentTiming.through=${timing.activeThrough === void 0 ? "(无 open turn)" : timing.activeThrough}`;
2034
+ else {
2035
+ const local = run.localAgent;
2036
+ const session = local && local.session;
2037
+ const lens = [];
2038
+ if (session) {
2039
+ try {
2040
+ const r = session.events;
2041
+ lens.push(`events=${Array.isArray(r) ? r.length : typeof r === "function" ? r().length : "?"}`);
2042
+ } catch (e) {
2043
+ lens.push("events=err");
2044
+ }
2045
+ try {
2046
+ lens.push(`snap=${typeof session.snapshotEvents === "function" ? session.snapshotEvents().length : "-"}`);
2047
+ } catch (e) {
2048
+ lens.push("snap=err");
2049
+ }
2050
+ try {
2051
+ lens.push(`own=${typeof session.ownEvents === "function" ? session.ownEvents().length : "-"}`);
2052
+ } catch (e) {
2053
+ lens.push("own=err");
2054
+ }
1507
2055
  }
2056
+ detail = `投影不可用,回退事件视图:${lens.join(" / ") || "session 不可访问"}`;
1508
2057
  }
1509
2058
  journal.logs.push({
1510
2059
  t: Date.now(),
1511
2060
  level: "warn",
1512
- message: `${label} 挂死诊断:${lens.join(" / ") || "session 不可访问"}`
2061
+ message: `${label} 挂死诊断:${detail}`
1513
2062
  });
1514
2063
  } catch (e) {}
1515
2064
  try {
@@ -1567,7 +2116,6 @@ function startStageGuard(opts) {
1567
2116
  }
1568
2117
  }
1569
2118
  observeToolCalls(newEvents);
1570
- if (newEvents.some((ev) => ev?.type === "step/end")) flushReminders(run);
1571
2119
  processed = events.length;
1572
2120
  }
1573
2121
  for (const ev of events.slice(-200)) {
@@ -1581,7 +2129,28 @@ function startStageGuard(opts) {
1581
2129
  break;
1582
2130
  }
1583
2131
  }
1584
- if (events.length !== lastEventCount) {
2132
+ const timing = timingOf(run);
2133
+ if (timing) {
2134
+ if (timing.activeThrough === void 0) lastGrowthAt = Date.now();
2135
+ else if (Date.now() - timing.activeThrough > 6e5) {
2136
+ if (lastMutationAt > 0 && isAgentBusy(run)) {
2137
+ if (!busyWarned) {
2138
+ busyWarned = true;
2139
+ try {
2140
+ journal.logs.push({
2141
+ t: Date.now(),
2142
+ level: "warn",
2143
+ message: `${label} 已提交事件静默(subagentTiming.through ${Math.round((Date.now() - timing.activeThrough) / 1e3)}s 未推进)但 agent 仍活动——视为长工具执行而非挂死,继续观察`
2144
+ });
2145
+ } catch (e) {}
2146
+ }
2147
+ lastGrowthAt = Date.now();
2148
+ } else {
2149
+ fire(`挂死(${Math.round(GUARD_SILENCE_MS / 6e4)} 分钟无任何已提交事件,来源:subagentTiming 投影)`, "stalled");
2150
+ return;
2151
+ }
2152
+ } else lastGrowthAt = Date.now();
2153
+ } else if (events.length !== lastEventCount) {
1585
2154
  lastEventCount = events.length;
1586
2155
  lastGrowthAt = Date.now();
1587
2156
  } else if (Date.now() - lastGrowthAt > 6e5) {
@@ -1644,6 +2213,45 @@ function startStageGuard(opts) {
1644
2213
  * dsh-plugin-teamflow core — 子代理执行器(并发池 / 单阶段运行 / 重试与熔断)。
1645
2214
  * 依赖:util/constants/types + core(context/metering)。
1646
2215
  */
2216
+ /**
2217
+ * 推理强度能力探测(缓存,2026-09-11):只有宿主明确声明该路由支持某档位才下发。
2218
+ * 宿主对**不支持的值硬失败且不降级**(`UNSUPPORTED_REASONING_EFFORT`),所以宁可不下发。
2219
+ * 返回 null = 探测不可用(老宿主/未声明容量)→ 调用方一律不下发,保持宿主默认。
2220
+ */
2221
+ const effortSupportCache = /* @__PURE__ */ new Map();
2222
+ async function supportedEfforts(route) {
2223
+ if (!route.provider || !route.model) return null;
2224
+ const llm = runtime.llm;
2225
+ if (!llm || typeof llm.resolveModelInfo !== "function") return null;
2226
+ const key = `${route.provider}/${route.model || ""}`;
2227
+ if (effortSupportCache.has(key)) return effortSupportCache.get(key) || null;
2228
+ let out = null;
2229
+ try {
2230
+ const info = await llm.resolveModelInfo(route.provider, route.model);
2231
+ const efforts = info && info.reasoning && info.reasoning.efforts;
2232
+ 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);
2233
+ } catch (e) {
2234
+ out = null;
2235
+ }
2236
+ effortSupportCache.set(key, out);
2237
+ return out;
2238
+ }
2239
+ /**
2240
+ * 解析本阶段要下发的推理强度:
2241
+ * - 阶段未要求降档(effortHint 空)→ 不传,宿主默认(DeepSeek high)
2242
+ * - 第 1 次尝试用 hint;**重试回升 'high'**(质量优先,ADR-0006)
2243
+ * - 只有探测到该路由支持该档位才返回,否则不传(防 `UNSUPPORTED_REASONING_EFFORT` 硬失败)
2244
+ * - 未下发时返回原因文本 → 调用方记 warn(这类静默失败必须可见,见 2026-09-11 实锤)
2245
+ */
2246
+ async function resolveStageEffort(route, attempt, effortHint) {
2247
+ const base = effortHint && String(effortHint).trim() ? String(effortHint).trim() : null;
2248
+ if (!base) return {};
2249
+ const wanted = attempt > 1 ? "high" : base;
2250
+ const supported = await supportedEfforts(route);
2251
+ if (!supported) return { skip: `路由 ${route.provider || "?"}/${route.model || "?"} 未声明 reasoning.efforts(或探测不可用)` };
2252
+ if (supported.indexOf(wanted) === -1) return { skip: `路由不支持 ${wanted}(可用:${supported.join("/") || "无"})` };
2253
+ return { effort: wanted };
2254
+ }
1647
2255
  /** 并发池:按 max 个 worker 消费 items,返回同序结果。 */
1648
2256
  async function runPool(items, max, fn) {
1649
2257
  const results = new Array(items.length);
@@ -1702,7 +2310,7 @@ function resolveChildRoute(parent) {
1702
2310
  return out;
1703
2311
  }
1704
2312
  /** 运行单个阶段子代理:执行 + 产出实质校验 + token 双口径计量 + stage 状态流转。 */
1705
- async function runAgent(journal, parent, label, phase, prompt, signal, taskKey) {
2313
+ async function runAgent(journal, parent, label, phase, prompt, signal, taskKey, attempt = 1, effortHint) {
1706
2314
  const maxSeq = journal.stages.length ? Math.max(...journal.stages.map((s) => s.seq)) : 0;
1707
2315
  let stageText = null;
1708
2316
  const stage = {
@@ -1726,11 +2334,24 @@ async function runAgent(journal, parent, label, phase, prompt, signal, taskKey)
1726
2334
  let cancelGuard = null;
1727
2335
  try {
1728
2336
  const route = resolveChildRoute(parent);
1729
- const agentOptions = route.provider || route.model ? {
2337
+ const eff = await resolveStageEffort(route, attempt, effortHint);
2338
+ const effort = eff.effort;
2339
+ const agentOptions = route.provider || route.model || effort ? {
1730
2340
  ...route.provider ? { provider: route.provider } : {},
1731
2341
  ...route.model ? { model: route.model } : {},
1732
- ...route.maxTokens ? { maxTokens: route.maxTokens } : {}
2342
+ ...route.maxTokens ? { maxTokens: route.maxTokens } : {},
2343
+ ...effort ? { reasoningEffort: effort } : {}
1733
2344
  } : void 0;
2345
+ if (effort && !journal.cancelled) journal.logs.push({
2346
+ t: Date.now(),
2347
+ level: "info",
2348
+ message: `${label} 推理强度:${effort}${attempt > 1 ? "(重试回升)" : "(机械阶段降档)"}`
2349
+ });
2350
+ else if (eff.skip && !journal.cancelled) journal.logs.push({
2351
+ t: Date.now(),
2352
+ level: "warn",
2353
+ message: `${label} 推理强度未降档:${eff.skip}——保持宿主默认`
2354
+ });
1734
2355
  run = await runtime.subagents.start(providerName(), {
1735
2356
  label,
1736
2357
  prompt: [{
@@ -1765,15 +2386,21 @@ async function runAgent(journal, parent, label, phase, prompt, signal, taskKey)
1765
2386
  const stop = result && result.stopReason;
1766
2387
  const text = extractText(result && result.output);
1767
2388
  stageText = text;
2389
+ const verdict = judgeDeliverable(phase, text);
1768
2390
  if (journal.cancelled) {
1769
2391
  stage.status = "cancelled";
1770
2392
  stage.outcome = "cancelled";
1771
2393
  return null;
1772
2394
  }
1773
- if (stop === "completed" && text && hasSubstance(phase, text)) {
2395
+ if (stop === "completed" && text && verdict.ok) {
1774
2396
  stage.status = "done";
1775
2397
  stage.outcome = "completed";
1776
2398
  stage.output = clip(text, 5e4);
2399
+ if (verdict.refusal) journal.logs.push({
2400
+ t: Date.now(),
2401
+ level: "warn",
2402
+ message: `${label} 产出含疑似拒绝措辞「${verdict.refusal.phrase}」(原文:${verdict.refusal.context})——但已带 [Verification evidence] 块,判为交付;措辞仅作诊断不再否决`
2403
+ });
1777
2404
  return text;
1778
2405
  }
1779
2406
  if (stage.guardReason) {
@@ -1792,20 +2419,19 @@ async function runAgent(journal, parent, label, phase, prompt, signal, taskKey)
1792
2419
  stage.outcome = stop === "completed" && text ? "insubstantial" : stop || "error";
1793
2420
  const errDetail = result && result.error;
1794
2421
  if (stage.outcome === "insubstantial") {
1795
- const hit = refusalHit(text);
1796
- if (hit) {
1797
- stage.summary = `产出未通过实质校验:命中拒绝词「${hit.phrase}」(原文:${hit.context}),视为未交付`;
2422
+ if (verdict.reason === "refusal" && verdict.refusal) {
2423
+ stage.summary = `产出未通过实质校验:无验证证据块且命中拒绝词「${verdict.refusal.phrase}」(原文:${verdict.refusal.context}),视为未交付`;
1798
2424
  journal.logs.push({
1799
2425
  t: Date.now(),
1800
2426
  level: "warn",
1801
- message: `${label} 产出命中拒绝词「${hit.phrase}」`
2427
+ message: `${label} 产出命中拒绝词「${verdict.refusal.phrase}」且无 [Verification evidence] 块`
1802
2428
  });
1803
2429
  } else {
1804
- stage.summary = `产出未通过实质校验:内容过短(${text.trim().length} 字符 < ${STAGE_MIN_LENGTH[phase] ?? 100} 下限),视为未交付`;
2430
+ stage.summary = `产出未通过实质校验:内容过短(${verdict.length} 字符 < ${verdict.min} 下限),视为未交付`;
1805
2431
  journal.logs.push({
1806
2432
  t: Date.now(),
1807
2433
  level: "warn",
1808
- message: `${label} 产出过短(${text.trim().length} 字符),未通过实质校验`
2434
+ message: `${label} 产出过短(${verdict.length} 字符),未通过实质校验`
1809
2435
  });
1810
2436
  }
1811
2437
  if (text) stage.output = clip(text, 4e3);
@@ -1845,29 +2471,33 @@ async function runAgent(journal, parent, label, phase, prompt, signal, taskKey)
1845
2471
  } catch (e2) {}
1846
2472
  }
1847
2473
  }
1848
- /** 单阶段重试 + token 熔断(官方口径:input+cacheRead+cacheWrite+output 累计)。 */
1849
- async function withRetry(journal, parent, label, phase, prompt, signal, taskKey) {
2474
+ /**
2475
+ * 单阶段重试 + token 熔断(**新增口径**:input+cacheWrite+output,排除 cacheRead——见
2476
+ * `FRESH_TOKEN_BUDGET` 与 metering.freshTokensOf;汇报仍走官方 totalTokensOf 口径)。
2477
+ * 顺序:不可重试/外部中止/护栏中止 → 预算门 → 自动重试(预算合理时重试优先于熔断,2026-09-11 修正)。
2478
+ * `effortHint`:机械阶段的推理强度降档提示(第 1 次尝试生效,重试自动回升 high,见 resolveStageEffort)。 */
2479
+ async function withRetry(journal, parent, label, phase, prompt, signal, taskKey, effortHint) {
1850
2480
  let attempts = 0;
1851
- let stageTokens = 0;
2481
+ let freshTokens = 0;
1852
2482
  let lastStage = null;
1853
2483
  for (let attempt = 1; attempt <= 2; attempt++) {
1854
2484
  attempts = attempt;
1855
2485
  const labelNow = attempt > 1 ? `${label}(第 ${attempt} 次重试)` : label;
1856
2486
  const promptNow = attempt > 1 && lastStage ? prompt + buildRetryDiagnostic(attempt, lastStage) : prompt;
1857
2487
  const beforeLen = journal.stages.length;
1858
- const result = await runAgent(journal, parent, labelNow, phase, promptNow, signal, taskKey);
2488
+ const result = await runAgent(journal, parent, labelNow, phase, promptNow, signal, taskKey, attempt, effortHint);
1859
2489
  lastStage = journal.stages[beforeLen] || null;
1860
- if (lastStage && lastStage.phase === phase) stageTokens += totalTokensOf(lastStage.usage);
2490
+ if (lastStage && lastStage.phase === phase) freshTokens += freshTokensOf(lastStage.usage);
1861
2491
  if (result) return {
1862
2492
  text: result,
1863
2493
  attempts,
1864
- stageTokens,
2494
+ freshTokens,
1865
2495
  stage: lastStage
1866
2496
  };
1867
2497
  if (journal.cancelled) return {
1868
2498
  text: null,
1869
2499
  attempts,
1870
- stageTokens,
2500
+ freshTokens,
1871
2501
  stage: lastStage
1872
2502
  };
1873
2503
  if (lastStage && isUnretryable(lastStage.outcome, lastStage.outcome)) {
@@ -1880,7 +2510,7 @@ async function withRetry(journal, parent, label, phase, prompt, signal, taskKey)
1880
2510
  return {
1881
2511
  text: null,
1882
2512
  attempts,
1883
- stageTokens,
2513
+ freshTokens,
1884
2514
  stage: lastStage
1885
2515
  };
1886
2516
  }
@@ -1894,7 +2524,7 @@ async function withRetry(journal, parent, label, phase, prompt, signal, taskKey)
1894
2524
  return {
1895
2525
  text: null,
1896
2526
  attempts,
1897
- stageTokens,
2527
+ freshTokens,
1898
2528
  stage: lastStage
1899
2529
  };
1900
2530
  }
@@ -1908,7 +2538,7 @@ async function withRetry(journal, parent, label, phase, prompt, signal, taskKey)
1908
2538
  return {
1909
2539
  text: null,
1910
2540
  attempts,
1911
- stageTokens,
2541
+ freshTokens,
1912
2542
  stage: lastStage
1913
2543
  };
1914
2544
  }
@@ -1922,21 +2552,21 @@ async function withRetry(journal, parent, label, phase, prompt, signal, taskKey)
1922
2552
  return {
1923
2553
  text: null,
1924
2554
  attempts,
1925
- stageTokens,
2555
+ freshTokens,
1926
2556
  stage: lastStage
1927
2557
  };
1928
2558
  }
1929
- if (stageTokens >= 6e4) {
2559
+ if (freshTokens >= 2e5) {
1930
2560
  journal.logs.push({
1931
2561
  t: Date.now(),
1932
2562
  level: "error",
1933
- message: `${label} 累计 token ${Math.round(stageTokens / 1e3)}k 超出阶段预算 ${Math.round(STAGE_TOKEN_BUDGET / 1e3)}k,熔断,需人工介入`
2563
+ message: `${label} 累计新增 token ${Math.round(freshTokens / 1e3)}k(input+cacheWrite+output,不含缓存命中)超出预算 ${Math.round(FRESH_TOKEN_BUDGET / 1e3)}k,熔断,需人工介入`
1934
2564
  });
1935
2565
  journal.humanIntervention = true;
1936
2566
  return {
1937
2567
  text: null,
1938
2568
  attempts,
1939
- stageTokens,
2569
+ freshTokens,
1940
2570
  stage: lastStage
1941
2571
  };
1942
2572
  }
@@ -1957,176 +2587,11 @@ async function withRetry(journal, parent, label, phase, prompt, signal, taskKey)
1957
2587
  return {
1958
2588
  text: null,
1959
2589
  attempts,
1960
- stageTokens,
2590
+ freshTokens,
1961
2591
  stage: lastStage
1962
2592
  };
1963
2593
  }
1964
2594
  //#endregion
1965
- //#region host/core/state.ts
1966
- /**
1967
- * dsh-plugin-teamflow core — state.json 预编译上下文索引。
1968
- *
1969
- * 目标:解决「每个新 run 都要从小代理全量读历史文档(PRD/TECH/QA)来重建认知」的 token 爆炸。
1970
- * state.json 是跨 run 累积的结构化索引:每次 run 结束后由各阶段把「精简结论」沉淀进来,
1971
- * 下一个 run 的子代理只读注入的 state slice,不再重复读全套历史文档。
1972
- *
1973
- * 设计原则:
1974
- * - memory.md 保持权威记忆(人读);state.json 是预编译索引(机器喂给子代理)。
1975
- * - 子代理不直接读 state.json 文件,由 host 在开工时按角色注入相关 slice 到 prompt。
1976
- * - state.json 只存「结论/指针」,不存全文;具体内容仍指向 docs/teamflow/ 下的活文档。
1977
- */
1978
- /** 空态 state。 */
1979
- function emptyState() {
1980
- return {
1981
- version: 1,
1982
- projectName: null,
1983
- updatedAt: null,
1984
- product: {
1985
- summary: null,
1986
- techStack: null
1987
- },
1988
- lastRunFolder: null,
1989
- modules: {},
1990
- verifyScripts: [],
1991
- acIndex: {},
1992
- stages: {},
1993
- lastRun: null
1994
- };
1995
- }
1996
- /** state.json 路径:$DSH_HOME/teamflow/<projectKey>/state.json */
1997
- function stateFile(projectKey) {
1998
- return join(teamflowRoot(), projectKey, "state.json");
1999
- }
2000
- /** 读取(不存在返回空态)。 */
2001
- function loadState(projectKey) {
2002
- const file = stateFile(projectKey);
2003
- try {
2004
- if (existsSync(file)) {
2005
- const raw = JSON.parse(readFileSync(file, "utf8"));
2006
- const base = emptyState();
2007
- if (raw && typeof raw === "object") {
2008
- base.projectName = raw.projectName ?? null;
2009
- base.updatedAt = raw.updatedAt ?? null;
2010
- base.product = {
2011
- ...base.product,
2012
- ...raw.product || {}
2013
- };
2014
- base.lastRunFolder = raw.lastRunFolder ?? null;
2015
- base.modules = raw.modules || {};
2016
- base.verifyScripts = Array.isArray(raw.verifyScripts) ? raw.verifyScripts : [];
2017
- base.acIndex = raw.acIndex || {};
2018
- base.stages = raw.stages || {};
2019
- base.lastRun = raw.lastRun ?? null;
2020
- return base;
2021
- }
2022
- }
2023
- } catch (e) {}
2024
- return emptyState();
2025
- }
2026
- /** 保存。 */
2027
- function saveState(projectKey, state) {
2028
- const file = stateFile(projectKey);
2029
- try {
2030
- mkdirSync(join(teamflowRoot(), projectKey), { recursive: true });
2031
- state.updatedAt = Date.now();
2032
- writeFileSync(file, JSON.stringify(state, null, 2), "utf8");
2033
- return true;
2034
- } catch (e) {
2035
- console.error("[teamflow] saveState failed", e?.message);
2036
- return false;
2037
- }
2038
- }
2039
- /** 从阶段产出文本中提取 `<!-- state -->{...}<!-- /state -->` 块(找不到返回 null)。 */
2040
- function extractStateBlock(text) {
2041
- const m = (text === null || text === void 0 ? "" : String(text)).match(/<!--\s*state\s*-->([\s\S]*?)(?:<!--\s*\/state\s*-->|$)/);
2042
- if (!m || !m[1]) return null;
2043
- try {
2044
- const raw = JSON.parse(m[1].trim());
2045
- if (raw && typeof raw === "object") return raw;
2046
- } catch (e) {}
2047
- return null;
2048
- }
2049
- /** 把阶段产出的 state 块合并进 state.json。 */
2050
- function mergeStateBlock(projectKey, block, phase) {
2051
- const state = loadState(projectKey);
2052
- const key = block && block.phase || phase || "other";
2053
- if (block) {
2054
- if (typeof block.summary === "string" && block.summary.trim()) state.stages[key] = block.summary.trim();
2055
- if (Array.isArray(block.touched)) {
2056
- for (const f of block.touched) if (typeof f === "string" && f) state.modules[f] = state.modules[f] || "touched";
2057
- }
2058
- if (typeof block.verdict === "string" && block.verdict) {
2059
- state.lastRun = state.lastRun || {};
2060
- state.lastRun.verdict = block.verdict;
2061
- }
2062
- if (block.extra && typeof block.extra === "object") {
2063
- if (Array.isArray(block.extra.verifyScripts)) {
2064
- for (const s of block.extra.verifyScripts) if (typeof s === "string" && s && state.verifyScripts.indexOf(s) === -1) state.verifyScripts.push(s);
2065
- }
2066
- if (block.extra.acIndex && typeof block.extra.acIndex === "object") state.acIndex = {
2067
- ...state.acIndex,
2068
- ...block.extra.acIndex
2069
- };
2070
- if (typeof block.extra.techStack === "string" && block.extra.techStack) state.product.techStack = block.extra.techStack;
2071
- if (typeof block.extra.moduleContracts === "object" && block.extra.moduleContracts) state.modules = {
2072
- ...state.modules,
2073
- ...block.extra.moduleContracts
2074
- };
2075
- }
2076
- }
2077
- saveState(projectKey, state);
2078
- return state;
2079
- }
2080
- /** 按 run 更新 lastRun / lastRunFolder(finally 时调用)。 */
2081
- function noteRun(projectKey, run) {
2082
- const state = loadState(projectKey);
2083
- if (run.runDocs) state.lastRunFolder = run.runDocs;
2084
- state.lastRun = {
2085
- runId: run.id || null,
2086
- requirement: run.requirement ? String(run.requirement).slice(0, 200) : null,
2087
- verdict: run.verdict || null,
2088
- folder: run.runDocs || null,
2089
- endedAt: run.endedAt ?? Date.now()
2090
- };
2091
- saveState(projectKey, state);
2092
- }
2093
- /** 按角色渲染 state slice(注入到子代理 prompt)。角色 → 只拿相关片段。 */
2094
- function stateSliceFor(state, role) {
2095
- const lines = [];
2096
- if (state.__runCtx) {
2097
- if (state.__runCtx.runDocs) lines.push(`【本次任务产物夹】${state.__runCtx.runDocs}/(host 已创建;本需求的 PRD/TECHNICAL/QA-REPORT/ACCEPTANCE 全部写这里,夹建后不可变、不归档不升版)`);
2098
- if (state.__runCtx.sanity) lines.push(state.__runCtx.sanity);
2099
- if (state.__runCtx.blueprint && (role === "arch" || role === "tech" || role === "dev")) lines.push(state.__runCtx.blueprint);
2100
- }
2101
- lines.push("【预编译产品状态(state.json · 权威记忆在 docs/teamflow/memory.md,本块已是够用的索引,勿再全量读历史文档)】");
2102
- if (state.product.summary) lines.push(`- 产品概要:${state.product.summary}`);
2103
- if (state.product.techStack && (role === "tech" || role === "dev" || role === "arch")) lines.push(`- 技术栈:${state.product.techStack}`);
2104
- if (Object.keys(state.acIndex).length && (role === "pm" || role === "qa" || role === "acceptance" || role === "tech")) {
2105
- const acs = Object.entries(state.acIndex).slice(0, 40);
2106
- lines.push(`- AC 索引(${acs.length} 条):${acs.map(([k, v]) => `${k} ${v}`).join(";")}`);
2107
- }
2108
- if (Object.keys(state.modules).length && (role === "tech" || role === "dev" || role === "arch" || role === "qa")) lines.push(`- 模块(${Object.keys(state.modules).length}):${Object.entries(state.modules).map(([f, c]) => `${f}${c ? "→" + c : ""}`).join(",")}`);
2109
- if (state.verifyScripts.length && (role === "qa" || role === "tech" || role === "dev")) lines.push(`- 验证脚本:${state.verifyScripts.join(",")}`);
2110
- if (role === "pm" || role === "acceptance") {
2111
- if (state.stages.prd) lines.push(`- PRD 摘要:${state.stages.prd}`);
2112
- if (state.stages.tech) lines.push(`- 技术方案摘要:${state.stages.tech}`);
2113
- if (state.stages.qa) lines.push(`- QA 摘要:${state.stages.qa}`);
2114
- } else if (role === "dev" || role === "tech") {
2115
- if (state.stages.tech) lines.push(`- 技术方案摘要:${state.stages.tech}`);
2116
- if (state.stages.prd) lines.push(`- PRD 摘要:${state.stages.prd}`);
2117
- } else if (role === "qa") {
2118
- if (state.stages.qa) lines.push(`- 上轮 QA 摘要:${state.stages.qa}`);
2119
- }
2120
- if (state.lastRun) {
2121
- const r = state.lastRun;
2122
- lines.push(`- 上轮:${r.requirement ? r.requirement : ""}${r.verdict ? " → " + r.verdict : ""}${r.folder ? `(${r.folder})` : ""}`);
2123
- }
2124
- return lines.join("\n");
2125
- }
2126
- /** 让每个阶段产出末尾附带 state 块(将并入 stage output,由 host 提取)。 */
2127
- const STATE_BLOCK_INSTRUCTION = `\n\n[STATE BLOCK · mandatory at the end] Append one section at the END of your answer (same output as the body; the host indexes it):
2128
- <!-- state -->{"phase":"<stage-key>","summary":"<≤500 chars: this stage's conclusion, useful for the next run>","memory":["<memory points>"]}<!-- /state -->`;
2129
- //#endregion
2130
2595
  //#region host/prompts/index.ts
2131
2596
  /**
2132
2597
  * dsh-plugin-teamflow — Prompt 模板(阶段提示词 + 团队模板)。
@@ -2184,7 +2649,7 @@ const AGENTS_TEMPLATE = `# AGENTS.md — 团队协作守则与文档索引({{P
2184
2649
  | 任务产物 | ${TF_DOCS}/<yyyyMMdd-rN-slug>/ | 每个需求一个自包含任务夹:PRD/设计/技术方案/QA 报告/验收报告(按日期倒序即迭代史) |
2185
2650
  | 架构总览 | ${TF_DOCS}/architecture/ARCHITECTURE.md | 工程方案与脚手架说明(产品级长期文档) |
2186
2651
  | 产品记忆 | ${TF_DOCS}/memory.md | 团队约定/技术栈/已知待办(低频更新) |
2187
- | 运行日志 | logs/teamflow/<runId>/ | TeamFlow 流水线各阶段命令日志(日常不读) |
2652
+ | 运行日志 | logs/teamflow/<runId>/ | TeamFlow 流水线各阶段命令日志(日常不读);**布局约定**(全部在该目录内,项目根不得出现 scripts/ probe/):regression-<phase>.log(套件输出,重跑追加)/ scripts/(一次性校验脚本)/ captures.json(命令载荷汇总)/ probe/(探针)——每用途一个文件,不新增同名变体 |
2188
2653
 
2189
2654
  ## 3. 团队角色与标准流程
2190
2655
 
@@ -2249,6 +2714,16 @@ function headTailClip(text, head, tail) {
2249
2714
  if (s.length <= head + tail) return s;
2250
2715
  return s.slice(0, head) + "\n...\n[CHANGED SECTION]\n" + s.slice(-tail);
2251
2716
  }
2717
+ /**
2718
+ * 产物交付 · policy(2026-09-11):把任务夹产物交给官方的 `present` 工具,用户在该会话里得到
2719
+ * 「交付文件卡」(预览 / 默认程序打开 / 文件管理器定位)。
2720
+ * 诚实机制说明:present 是**模型工具**,host 不强制(没调用只是少一张卡,产物文件仍是唯一交付物);
2721
+ * 卡片渲染在**本子代理会话**的轮次尾部(宿主 ui-deliverables 挂 conversation.chat.turnTail),
2722
+ * 主会话不显示——所以它是增强项,工作台侧的「📄 产物」按钮才是主路径。
2723
+ */
2724
+ 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):
2725
+ present({ files: [{ path: "${runDocs}/<your deliverable>.md", description: "<one-line what it is>" }] })
2726
+ 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.`;
2252
2727
  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:
2253
2728
  - [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.
2254
2729
  - [No duplicate reads] Same file: read ≤1 times. To verify a change, grep the change point instead of re-reading the whole file.
@@ -2256,6 +2731,13 @@ const TOKEN_HYGIENE = (runId) => `[TOKEN HYGIENE · policy] Context is expensive
2256
2731
  - [Batch fixes] When verification fails: read ALL failing cases at once → fix them ALL in one edit → run verification once more. Never "fix one → run → fix one → run". At most 3 fix-verify rounds; beyond that, output a diagnostic summary and stop.
2257
2732
  - Never whole-file read a file over 200 lines (use grep + limit segments for the rest); whole-file read targets ≤2 files; everything else: grep + limited segments.
2258
2733
  - Redirect command output to a file (under logs/teamflow/${runId || "<runId>"}/) and read the tail summary; never echo hundreds of lines inline.
2734
+ - [Log layout · policy] Reuse one file per purpose — never multiply variants of the same run. **Every path below is INSIDE logs/teamflow/${runId || "<runId>"}/ — never create scripts/ or probe/ at the project root** (they land in the delivery commit as pollution; the Doc boundary already forbids scattering there). Observed cost of ignoring this: one run left 51 full-suite dumps (78% of all log bytes) + 49 loose scripts, and a later run still leaked 6 root-level scratch files into its commit.
2735
+ - Full suite output → logs/teamflow/${runId || "<runId>"}/regression-<phase>.log, APPENDED on re-run with a "--- <timestamp> <task> ---" header (no -run2 / -nopipe / -shim variants of the same run).
2736
+ - Your own one-off checkers → logs/teamflow/${runId || "<runId>"}/scripts/ (name each for what it checks).
2737
+ - Captured command payloads → logs/teamflow/${runId || "<runId>"}/captures.json, not one file per invocation.
2738
+ - Probes / scratch fixtures → logs/teamflow/${runId || "<runId>"}/probe/.
2739
+ - Optional bash/zsh helpers → logs/teamflow/${runId || "<runId>"}/helpers/ (same rule: inside the run dir).
2740
+ - Anything else scattered in the run root is noise that the next agent — and the human auditing your [Verification evidence] — has to wade through.
2259
2741
  - Keep reports/summaries tight (QA ≤150 lines, acceptance ≤80 lines, dev ≤40 lines); put details in files.
2260
2742
  - AGENTS.md and the memory index are already injected above — no call needed to read them in full; grep keywords if you need a particular rule.
2261
2743
  - 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.
@@ -2283,6 +2765,7 @@ ${requirement}
2283
2765
  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).
2284
2766
  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.
2285
2767
  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.
2768
+ ${ARTIFACT_DELIVERY(RUN(state))}
2286
2769
  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}`;
2287
2770
  const designPrompt = (prd, root, runId, state) => `You are a senior UI/UX designer. The current workspace IS the target project.
2288
2771
  ${productCtx(root)}${stateSliceFor(state, "design")}
@@ -2339,6 +2822,7 @@ ${JSON.stringify(tasks)}
2339
2822
  - modules: per touched file — responsibility + deps + assembly order + **architecture rationale (why)**.
2340
2823
  - tasks: parallelizable tasks split by file boundary (disjoint files → parallel); merge or sequence where dependencies/conflicts exist.
2341
2824
  - If you find duplication or a module that should be extracted (e.g. unified storage wrapper), add it to modules with the why.
2825
+ ${ARTIFACT_DELIVERY(RUN(state))}
2342
2826
  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}`;
2343
2827
  /**
2344
2828
  * 架构师 prompt(M1「认知前置 + 架构落地」):全模式启用,轻量版(lite/tech/patch)只产架构蓝图 JSON,
@@ -2376,7 +2860,7 @@ ${clip(tech, 12e3)}` : ""}
2376
2860
  4. Actually write/modify code (grep + segmented reads to locate; no repeated whole-file reads), then run relevant build/verification to ensure green.
2377
2861
  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.
2378
2862
  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.
2379
- 6. [Log discipline] Redirect command output to logs/teamflow/${runId || "<runId>"}/.
2863
+ 6. [Log discipline] Redirect command output to logs/teamflow/${runId || "<runId>"}/ following the log layout above — INSIDE that dir: logs/teamflow/${runId || "<runId>"}/regression-dev.log (append on re-run), .../scripts/, .../captures.json. Never create scripts/ or probe/ at the project root (they would be committed as pollution).
2380
2864
  7. Output an implementation summary (≤40 lines): changed files, key implementation points, leftovers. No big code pastes.
2381
2865
  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
2866
  [Verification evidence]
@@ -2411,12 +2895,13 @@ ${clip(devSummary, 15e3)}
2411
2895
  - Always-available sandbox-legal paths: build/assembly checks, unit tests, DOM-level E2E (jsdom or equivalent), static audit, adversarial spot-checks.
2412
2896
  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.
2413
2897
  3. Read AGENTS.md §4 engineering conventions (verify commands) and the code changes first, then actually run those sandbox-legal verifications.
2414
- 4. [Log discipline] Redirect command output to logs/teamflow/${runId || "<runId>"}/ (e.g. qa-out.log); no scatter at project root.
2898
+ 4. [Log discipline] Redirect command output to logs/teamflow/${runId || "<runId>"}/ following the log layout above — INSIDE that dir: .../regression-qa.log (append on re-run), .../scripts/, .../captures.json. Never create scripts/ or probe/ at the project root; no scatter at project root.
2415
2899
  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
2900
  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:
2417
2901
  | 编号 | 严重级(P0/P1/P2/P3) | 功能模块 | 复现步骤 | 期望行为 | 实际行为 | 关联验收项 |
2418
2902
  If no defects: explicitly output 「未发现缺陷」.
2419
2903
  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}/.
2904
+ ${ARTIFACT_DELIVERY(RUN(state))}
2420
2905
  8. [State] End with a state block (phase="qa"), summary = test conclusion / blocked items, extra = { "verifyScripts": [...] }.${STATE_BLOCK_INSTRUCTION}`;
2421
2906
  /** QA 打回后的开发修复 prompt:确认缺陷是否属实 → 修复 → 复验交接(QA→dev 打回闭环用)。 */
2422
2907
  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.
@@ -2432,7 +2917,7 @@ ${tech && String(tech).trim() ? clip(tech, 12e3) : ""}
2432
2917
  1. [Confirm first, then fix] For each defect, verify one by one whether it truly holds (read code / reproduce / compare actual vs expected):
2433
2918
  — confirmed → fix it directly; QA false positive / contradicts reality → state evidence explicit in the summary (no fabricated changes, and no ignoring real defects either).
2434
2919
  2. Touch ONLY defect-related files (grep to locate; no whole-file reads of irrelevant big files); respect existing architecture & code style.
2435
- 3. After fixing, run relevant verification to ensure green (regression floor: existing verify suites pass untouched); redirect output to logs/teamflow/${runId || "<runId>"}/.
2920
+ 3. After fixing, run relevant verification to ensure green (regression floor: existing verify suites pass untouched); redirect output to logs/teamflow/${runId || "<runId>"}/ following the log layout above — INSIDE that dir: .../regression-devfix.log (append on re-run, no -run2 variants). Never create scripts/ or probe/ at the project root.
2436
2921
  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
2922
  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
2923
  [Verification evidence]
@@ -2459,6 +2944,7 @@ ${vision ? "[Visual re-check] If QA saved screenshots under the task folder, spo
2459
2944
  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".
2460
2945
  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.
2461
2946
  5. Chinese Markdown.
2947
+ ${ARTIFACT_DELIVERY(RUN(state))}
2462
2948
  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}`;
2463
2949
  /** 需求分诊模型 prompt(模型驱动 triage;供 core/triage.runTriage 使用)。 */
2464
2950
  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.
@@ -2855,6 +3341,42 @@ function buildResumeProducts(journal) {
2855
3341
  }
2856
3342
  return products;
2857
3343
  }
3344
+ /**
3345
+ * 让工作区的 .gitignore 忽略插件自有日志目录(幂等;返回是否真的写了)。
3346
+ *
3347
+ * 为什么需要(实锤 assetd `tf-mtwvwpxa-p3vw08`):插件强制子代理把命令日志与临时验证脚本写进
3348
+ * `logs/teamflow/`(Log discipline / TOKEN_HYGIENE),而收口提交用裸 `add -A`——目标仓库没配
3349
+ * .gitignore 时,一次提交 227 个文件里 208 个(92%)是这批日志(100 log / 52 json / 44 临时 .mjs),
3350
+ * 真交付只有 19 个。注意当时子代理的交付报告写的是「docs/ and logs/ remain untracked as required」
3351
+ * ——交付前完全属实,是 host 在最后一刻扫进去的:**契约在 host 这一侧破的**。
3352
+ *
3353
+ * 两道防线缺一不可:
3354
+ * ① 本函数写 .gitignore → IDE / `git status` / 用户自己的 CI 也不再看到这批文件(卫生);
3355
+ * ② `tfAddArgs()` 的 pathspec 强制排除 → 目标仓库只读、非 git、或用户把规则删回去时仍然兜得住(保证)。
3356
+ * 只在**即将提交**时写入:跑失败/取消的 run 不留下一份未提交的 .gitignore 改动。
3357
+ */
3358
+ function ensureLogGitignore(cwd, journal) {
3359
+ if (!cwd) return false;
3360
+ try {
3361
+ const file = `${cwd}/.gitignore`;
3362
+ const merged = mergeGitignore(existsSync(file) ? readFileSync(file, "utf8") : null, [`${TF_LOG_DIR}/`]);
3363
+ if (!merged.changed) return false;
3364
+ writeFileSync(file, merged.text, "utf8");
3365
+ journal.logs.push({
3366
+ t: Date.now(),
3367
+ level: "info",
3368
+ message: `工作区 .gitignore 已补忽略 ${TF_LOG_DIR}/(插件自有运行日志,非交付物;随本次提交可见)`
3369
+ });
3370
+ return true;
3371
+ } catch (e) {
3372
+ journal.logs.push({
3373
+ t: Date.now(),
3374
+ level: "warn",
3375
+ message: `补写 .gitignore 失败(不影响提交面排除):${String(e && e.message || e)}`
3376
+ });
3377
+ return false;
3378
+ }
3379
+ }
2858
3380
  /** 任务夹产物读取(单轨契约:文件即产物——QA/验收 host 只读文件,回复仅摘要)。
2859
3381
  * 缺失/空/读取异常返回 null(调用方决定硬失败或 journal 兜底)。 */
2860
3382
  function artifactText(journal, fileName) {
@@ -3001,10 +3523,10 @@ async function executePipeline(journal, parent, requirement, options, signal, re
3001
3523
  const stageFailError = (label, r) => {
3002
3524
  const last = [...journal.stages || []].reverse().find((s) => phaseKeyOf(s.phase) === label);
3003
3525
  const attempts = r && r.attempts ? r.attempts : 2;
3004
- const burnt = Math.round((r && r.stageTokens || 0) / 1e3);
3005
- const breaker = (r && r.stageTokens || 0) >= 6e4 ? ",超出阶段预算熔断" : "";
3526
+ const burnt = Math.round((r && r.freshTokens || 0) / 1e3);
3527
+ const breaker = (r && r.freshTokens || 0) >= 2e5 ? ",超出新增 token 预算熔断" : "";
3006
3528
  const detail = last ? `末次 ${last.outcome || "unknown"}${last.summary ? `(${last.summary})` : ""}` : "无阶段记录";
3007
- return /* @__PURE__ */ new Error(`${PHASE_KEY_OF[label] || label} 阶段失败:${attempts} 次尝试未交付,${detail},累计消耗 ${burnt}k token${breaker},需人工介入`);
3529
+ return /* @__PURE__ */ new Error(`${PHASE_KEY_OF[label] || label} 阶段失败:${attempts} 次尝试未交付,${detail},累计新增消耗 ${burnt}k token(不含缓存命中)${breaker},需人工介入`);
3008
3530
  };
3009
3531
  try {
3010
3532
  if (resume) journal.logs.push({
@@ -3116,7 +3638,8 @@ async function executePipeline(journal, parent, requirement, options, signal, re
3116
3638
  }
3117
3639
  else if (!resume && journal.workspacePath && options.preAction === "commit") try {
3118
3640
  const msg = typeof options.commitMessage === "string" && options.commitMessage.trim() ? options.commitMessage.trim() : `chore(teamflow): 流水线启动前提交现有改动(${journal.id})`;
3119
- const add = gitCmd(journal.workspacePath, ["add", "-A"]);
3641
+ ensureLogGitignore(journal.workspacePath, journal);
3642
+ const add = gitCmd(journal.workspacePath, tfAddArgs());
3120
3643
  const cm = gitCmd(journal.workspacePath, [
3121
3644
  "commit",
3122
3645
  "-m",
@@ -3187,6 +3710,15 @@ async function executePipeline(journal, parent, requirement, options, signal, re
3187
3710
  if (stage) stage.verifyEvidence = ev;
3188
3711
  } catch (e) {}
3189
3712
  };
3713
+ /**
3714
+ * 失败尝试的真实产出取用(2026-09-11 修「文本凭空丢失」):
3715
+ * runAgent 失败路径已把产出截断落盘到 `stage.output`(供重试诊断/详情浮层),
3716
+ * 但 withRetry 的 `text` 为 null → 证据存证 / state 回写 / 子卡产物全部拿不到文本,
3717
+ * 还会派生一条**误导性 warn**「回复缺少 [Verification evidence] 块」(实锤 assetd
3718
+ * tf-mtwvwpxa-p3vw08 的 T5:块明明在,只因该轮被判失败就报「契约未兑现」)。
3719
+ * 展示/存证/诊断一律用真实文本,成功与否仍只由 `text` 决定。
3720
+ */
3721
+ const stageTextOf = (r) => r.text || r.stage && r.stage.output || null;
3190
3722
  let prd = null;
3191
3723
  if (resumed("prd")) {
3192
3724
  prd = resume.products.prd;
@@ -3208,7 +3740,7 @@ async function executePipeline(journal, parent, requirement, options, signal, re
3208
3740
  label: "产品经理 · 梳理 PRD",
3209
3741
  fn: prdPrompt
3210
3742
  };
3211
- const prdR = await withRetry(journal, parent, pForm.label, "prd", pForm.fn(requirement, root, journal.id, state), signal);
3743
+ const prdR = await withRetry(journal, parent, pForm.label, "prd", pForm.fn(requirement, root, journal.id, state), signal, void 0, options.mode === "patch" ? "low" : null);
3212
3744
  if (!prdR.text) throw stageFailError("prd", prdR);
3213
3745
  prd = prdR.text;
3214
3746
  timeline.prd = prd;
@@ -3249,7 +3781,7 @@ async function executePipeline(journal, parent, requirement, options, signal, re
3249
3781
  level: "phase",
3250
3782
  message: "进入阶段:架构规划"
3251
3783
  });
3252
- const scR = await withRetry(journal, parent, "架构师 · 脚手架规划与落地", "scaffold", scaffoldPrompt(requirement, design, root, journal.id, state), signal);
3784
+ const scR = await withRetry(journal, parent, "架构师 · 脚手架规划与落地", "scaffold", scaffoldPrompt(requirement, design, root, journal.id, state), signal, void 0, "low");
3253
3785
  if (!scR.text) throw stageFailError("scaffold", scR);
3254
3786
  scaffold = scR.text;
3255
3787
  timeline.scaffold = scaffold;
@@ -3326,12 +3858,13 @@ async function executePipeline(journal, parent, requirement, options, signal, re
3326
3858
  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
3859
  const resumePrompt = devPrompt(task, tech, prd, root, journal.id, state) + (prevStage ? buildRetryDiagnostic(2, prevStage) : "");
3328
3860
  const devR = await withRetry(journal, parent, `开发 · ${task.title}(补跑)`, "dev", resumePrompt, signal, task.title);
3329
- noteVerifyEvidence(devR.stage, devR.text);
3861
+ const rerunText = stageTextOf(devR);
3862
+ noteVerifyEvidence(devR.stage, rerunText);
3330
3863
  const ok = !!devR.text;
3331
3864
  return {
3332
3865
  title: task.title,
3333
3866
  failed: !ok,
3334
- output: devR.text || "开发失败(Agent 未产出结果)"
3867
+ output: rerunText || "开发失败(Agent 未产出结果)"
3335
3868
  };
3336
3869
  });
3337
3870
  for (const t of rerun) {
@@ -3382,16 +3915,17 @@ async function executePipeline(journal, parent, requirement, options, signal, re
3382
3915
  }
3383
3916
  }
3384
3917
  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);
3918
+ const devText = stageTextOf(devR);
3919
+ noteVerifyEvidence(devR.stage, devText);
3386
3920
  const ok = !!devR.text;
3387
3921
  if (sub) {
3388
- completeSubtask(journal, sub.id, !ok, devR.text ? snippet(devR.text, 1e3) : null, null);
3922
+ completeSubtask(journal, sub.id, !ok, devText ? snippet(devText, 1e3) : null, null);
3389
3923
  if (devR.stage) noteSubtaskUsage(journal, sub.id, devR.stage);
3390
3924
  }
3391
3925
  return {
3392
3926
  title: task.title,
3393
3927
  failed: !ok,
3394
- output: devR.text || "开发失败(Agent 未产出结果)"
3928
+ output: devText || "开发失败(Agent 未产出结果)"
3395
3929
  };
3396
3930
  });
3397
3931
  timeline.dev = devResults;
@@ -3472,7 +4006,7 @@ async function executePipeline(journal, parent, requirement, options, signal, re
3472
4006
  advanceTask(journal, "needs-human", null, "QA-REPORT.md 未落盘(单轨契约未兑现)", { by: "qa" });
3473
4007
  throw stageFailError("qa", {
3474
4008
  attempts: qaR.attempts,
3475
- stageTokens: qaR.stageTokens
4009
+ freshTokens: qaR.freshTokens
3476
4010
  });
3477
4011
  }
3478
4012
  timeline.qa = qa;
@@ -3514,7 +4048,7 @@ async function executePipeline(journal, parent, requirement, options, signal, re
3514
4048
  });
3515
4049
  advanceTask(journal, "rework", snippet(qa, 3e3), `QA 打回开发修复(第 ${round}/3 轮)`, { by: "qa" });
3516
4050
  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);
4051
+ noteVerifyEvidence(fixR.stage, stageTextOf(fixR));
3518
4052
  if (!fixR.text) {
3519
4053
  advanceTask(journal, "needs-human", null, "QA 打回后开发修复失败", { by: "qa" });
3520
4054
  throw stageFailError("开发(QA 打回修复)", fixR);
@@ -3580,7 +4114,7 @@ async function executePipeline(journal, parent, requirement, options, signal, re
3580
4114
  advanceTask(journal, "needs-human", null, "ACCEPTANCE.md 未落盘(单轨契约未兑现)", { by: "pm" });
3581
4115
  throw stageFailError("acceptance", {
3582
4116
  attempts: accR.attempts,
3583
- stageTokens: accR.stageTokens
4117
+ freshTokens: accR.freshTokens
3584
4118
  });
3585
4119
  }
3586
4120
  timeline.acceptance = acceptance;
@@ -3664,7 +4198,8 @@ async function executePipeline(journal, parent, requirement, options, signal, re
3664
4198
  activeProducts.delete(scopeKey);
3665
4199
  if (journal.workspacePath && journal.status === "completed" && !journal.humanIntervention) try {
3666
4200
  const reqHead = String(journal.requirement || "").replace(/\s+/g, " ").trim().slice(0, 80);
3667
- const add = gitCmd(journal.workspacePath, ["add", "-A"]);
4201
+ ensureLogGitignore(journal.workspacePath, journal);
4202
+ const add = gitCmd(journal.workspacePath, tfAddArgs());
3668
4203
  if ((add === null ? null : gitCmd(journal.workspacePath, [
3669
4204
  "commit",
3670
4205
  "-m",
@@ -3909,18 +4444,7 @@ function resumeRun(runId, sessionId) {
3909
4444
  * 运行环境:宿主组合(web profile)的真实 Node 进程。
3910
4445
  */
3911
4446
  /** 阶段顺序/key 映射见 constants.ts(PHASE_ORDER/PHASE_KEY_OF/PHASE_KEY_BY_NAME)。 */
3912
- /** 按工作区作用域过滤运行(sessionId 推导出 workspace slug;未落 workspace 的旧运行只见于 default)。 */
3913
- function runsFor(ws) {
3914
- const arr = [];
3915
- for (const j of runs.values()) {
3916
- if (ws) {
3917
- if ((j.workspace || (ws === "default" ? "default" : null)) !== ws) continue;
3918
- }
3919
- arr.push(j);
3920
- }
3921
- arr.sort((a, b) => (b.startedAt || 0) - (a.startedAt || 0));
3922
- return arr;
3923
- }
4447
+ /** 按工作区作用域过滤运行见 core/products.ts(runsFor;全局面板与远程面共用)。 */
3924
4448
  /** 由 sessionId 推导会话所属 workspace(项目)作用域。 */
3925
4449
  function sessionScope(sessionId) {
3926
4450
  const sid = typeof sessionId === "string" && sessionId ? sessionId : null;
@@ -3933,6 +4457,8 @@ function snapshotOf(j) {
3933
4457
  status: j.status,
3934
4458
  startedAt: j.startedAt,
3935
4459
  endedAt: j.endedAt,
4460
+ address: runAddress(j.workspace || "default", j.id),
4461
+ ownerSession: j.ownerSession || null,
3936
4462
  requirement: clip(j.requirement, 2e3),
3937
4463
  options: sanitizeSnapOptions(j.options),
3938
4464
  agentsStarted: j.agentsStarted,
@@ -4667,14 +5193,16 @@ var TeamflowService = class extends TypertRemoteService {
4667
5193
  static inject = [
4668
5194
  "agents",
4669
5195
  "subagents",
4670
- "tokenMeter",
4671
5196
  "typert",
4672
5197
  "tools",
4673
5198
  "llm"
4674
5199
  ];
4675
5200
  constructor(ctx) {
4676
5201
  super(ctx, "teamflow");
4677
- setRuntime(ctx.get("agents"), ctx.get("subagents"), ctx.get("tokenMeter"), ctx.get("workspaceRegistry"), ctx.get("agentDefaultModel"), ctx.get("llm"));
5202
+ setRuntime(ctx.get("agents"), ctx.get("subagents"), ctx.get("workspaceRegistry"), ctx.get("agentDefaultModel"), ctx.get("llm"));
5203
+ ctx.inject(["sessionProjections"], (projectionCtx) => {
5204
+ setSessionProjections(projectionCtx.get("sessionProjections"));
5205
+ });
4678
5206
  loadActiveTeams();
4679
5207
  let interruptedCount = 0;
4680
5208
  try {
@@ -4706,28 +5234,21 @@ var TeamflowService = class extends TypertRemoteService {
4706
5234
  list(sessionId) {
4707
5235
  const sc = sessionScope(sessionId);
4708
5236
  return {
4709
- runs: runsFor(sc.projectKey).slice(0, 30).map((j) => ({
4710
- id: j.id,
4711
- status: j.status,
4712
- startedAt: j.startedAt,
4713
- endedAt: j.endedAt,
4714
- agentsStarted: j.agentsStarted,
4715
- stageCount: j.stages.length,
4716
- incompleteStages: (j.stages || []).some((x) => x.status !== "done"),
4717
- requirement: clip(j.requirement, 60)
4718
- })),
5237
+ runs: runsFor(sc.projectKey).slice(0, 30).map(runBrief),
4719
5238
  workspace: sc
4720
5239
  };
4721
5240
  }
4722
- snapshot(runId, sessionId) {
4723
- const sc = sessionScope(sessionId);
5241
+ /** run 详情(快照)。productOverride:全局面板按产品线 key 寻址时传入(跳过会话推导)。 */
5242
+ snapshot(runId, sessionId, productOverride) {
5243
+ const key = productOverride ? productKeyOf(productOverride) : sessionScope(sessionId).projectKey;
5244
+ if (!key) return null;
4724
5245
  if (runId && typeof runId === "string") {
4725
5246
  const j = runs.get(runId);
4726
5247
  if (!j) return null;
4727
- if (j.workspace && sc.projectKey && j.workspace !== sc.projectKey && sc.projectKey !== "default") return null;
5248
+ if (!runVisibleIn(j, key)) return null;
4728
5249
  return snapshotOf(j);
4729
5250
  }
4730
- const latest = runsFor(sc.projectKey)[0];
5251
+ const latest = runsFor(key)[0];
4731
5252
  if (!latest) return null;
4732
5253
  const j = runs.get(latest.id);
4733
5254
  return j ? snapshotOf(j) : null;
@@ -4735,12 +5256,13 @@ var TeamflowService = class extends TypertRemoteService {
4735
5256
  /** 阶段详情:卡片点击查看 —— 状态/耗时/官方 usage + 产物全文(超 24k 截断)。
4736
5257
  * 2026-09-06 状态机化:返回同任务全部尝试(attempts 聚合——按 stage.taskKey(旧数据 label 兜底),
4737
5258
  * 按 seq 排序)——client 弹窗单次渲染现状、多次渲染时间线。 */
4738
- stageDetail(runId, seq, sessionId) {
5259
+ stageDetail(runId, seq, sessionId, productOverride) {
4739
5260
  if (typeof runId !== "string" || !runId || seq === void 0 || seq === null) return null;
4740
- const sc = sessionScope(sessionId);
5261
+ const key = productOverride ? productKeyOf(productOverride) : sessionScope(sessionId).projectKey;
5262
+ if (!key) return null;
4741
5263
  const j = runs.get(runId);
4742
5264
  if (!j) return null;
4743
- if (j.workspace && sc.projectKey && j.workspace !== sc.projectKey && sc.projectKey !== "default") return null;
5265
+ if (!runVisibleIn(j, key)) return null;
4744
5266
  const s = (j.stages || []).find((st) => Number(st.seq) === Number(seq));
4745
5267
  if (!s) return null;
4746
5268
  const taskKeyOf = (x) => String(x.taskKey || String(x.label || "").replace(/^开发 · /, "").replace(/((?:第 \d+ 次重试|补跑))$/, "").trim());
@@ -4776,31 +5298,49 @@ var TeamflowService = class extends TypertRemoteService {
4776
5298
  };
4777
5299
  }
4778
5300
  /** Backlog 条目详情:卡片点击查看 —— 完整字段 + 流转时间线 + 关联(子卡/缺陷)+ 任务夹路径。 */
4779
- itemDetail(kind, id, sessionId) {
5301
+ itemDetail(kind, id, sessionId, productOverride) {
4780
5302
  const k = typeof kind === "string" && [
4781
5303
  "req",
4782
5304
  "task",
4783
5305
  "bug"
4784
5306
  ].indexOf(kind) !== -1 ? kind : null;
4785
5307
  if (!k || typeof id !== "string" || !id) return null;
4786
- const sc = sessionScope(sessionId);
4787
- const store = storeFor(sc.projectKey);
5308
+ const key = productOverride ? productKeyOf(productOverride) : sessionScope(sessionId).projectKey;
5309
+ if (!key) return null;
5310
+ const store = storeFor(key);
4788
5311
  const item = store.find(k, id);
4789
5312
  if (!item) return null;
4790
5313
  const reqId = k === "req" ? item.id : item.reqId || null;
4791
5314
  let runDocs = null;
5315
+ let runDocsRoot = null;
4792
5316
  let runInfo = null;
4793
- for (const j of runsFor(sc.projectKey)) {
5317
+ for (const j of runsFor(key)) {
4794
5318
  if (j.reqId !== reqId) continue;
4795
- if (j.runDocs && !runDocs) runDocs = j.runDocs;
5319
+ if (j.runDocs && !runDocs) {
5320
+ runDocs = j.runDocs;
5321
+ runDocsRoot = j.workspacePath || null;
5322
+ }
4796
5323
  if (!runInfo) runInfo = {
4797
5324
  runId: j.id,
4798
5325
  status: j.status,
4799
5326
  requirement: String(j.requirement || ""),
4800
5327
  startedAt: j.startedAt || null,
4801
- endedAt: j.endedAt || null
5328
+ endedAt: j.endedAt || null,
5329
+ ownerSession: j.ownerSession || null
4802
5330
  };
4803
5331
  }
5332
+ const artifactSession = runInfo && runInfo.ownerSession || sessionId;
5333
+ const runArtifacts = [];
5334
+ if (runDocs && runDocsRoot && typeof artifactSession === "string" && artifactSession) try {
5335
+ const present = new Set(readdirSync(join(runDocsRoot, runDocs)));
5336
+ for (const name of TEAMFLOW_ARTIFACT_ORDER) {
5337
+ if (!present.has(name)) continue;
5338
+ runArtifacts.push({
5339
+ name,
5340
+ address: fileAddressFor(artifactSession, void 0, `${runDocs}/${name}`)
5341
+ });
5342
+ }
5343
+ } catch (e) {}
4804
5344
  const byRole = item.byRole || null;
4805
5345
  let subtasks = [];
4806
5346
  let bugs = [];
@@ -4870,11 +5410,47 @@ var TeamflowService = class extends TypertRemoteService {
4870
5410
  byRole: k === "task" ? byRole : null,
4871
5411
  reqId: reqId || null,
4872
5412
  runDocs,
5413
+ artifacts: runArtifacts,
4873
5414
  runInfo,
4874
5415
  subtasks,
4875
5416
  bugs
4876
5417
  };
4877
5418
  }
5419
+ /** 产品线清单 + 当前会话所属产品线(client 用它做默认选中)。 */
5420
+ products(sessionId) {
5421
+ return {
5422
+ current: sessionScope(sessionId).projectKey || null,
5423
+ products: listProducts()
5424
+ };
5425
+ }
5426
+ /** 产品线视图:元信息 + backlog + run 列表(全局面板一次取全,少往返)。 */
5427
+ productView(product) {
5428
+ const key = productKeyOf(product);
5429
+ if (!key) return null;
5430
+ return {
5431
+ product: productMetaOf(key),
5432
+ backlog: this.backlog(null, key),
5433
+ runs: runsFor(key).slice(0, 50).map(runBrief)
5434
+ };
5435
+ }
5436
+ /** 产品线级 run 详情(右栏 tab 与面板内联共用同一形状)。 */
5437
+ productRunDetail(product, runId) {
5438
+ const key = productKeyOf(product);
5439
+ if (!key) return null;
5440
+ return this.snapshot(runId, null, key);
5441
+ }
5442
+ /** 产品线级阶段详情(同 stageDetail 形状:attempts 聚合 + 验证证据)。 */
5443
+ productStageDetail(product, runId, seq) {
5444
+ const key = productKeyOf(product);
5445
+ if (!key) return null;
5446
+ return this.stageDetail(runId, seq, null, key);
5447
+ }
5448
+ /** 产品线级 backlog 条目详情(sessionId 可选:仅用于把任务夹产物地址绑到某个会话)。 */
5449
+ productItemDetail(product, kind, id, sessionId) {
5450
+ const key = productKeyOf(product);
5451
+ if (!key) return null;
5452
+ return this.itemDetail(kind, id, sessionId, key);
5453
+ }
4878
5454
  start(sessionId, requirement, options) {
4879
5455
  const sid = typeof sessionId === "string" ? sessionId : null;
4880
5456
  const req = typeof requirement === "string" && requirement.trim() ? requirement.trim() : null;
@@ -4911,11 +5487,12 @@ var TeamflowService = class extends TypertRemoteService {
4911
5487
  };
4912
5488
  return { ok: cancelRun(id) };
4913
5489
  }
4914
- /** 工作区级 backlog 视图(自动按当前会话 workspace 隔离)。 */
4915
- backlog(sessionId) {
4916
- const sc = sessionScope(sessionId);
4917
- const sum = backlogSummary(sc.projectKey);
4918
- const js = runsFor(sc.projectKey);
5490
+ /** 工作区级 backlog 视图(自动按当前会话 workspace 隔离)。productOverride:全局面板按产品线 key 寻址。 */
5491
+ backlog(sessionId, productOverride) {
5492
+ const key = productOverride ? productKeyOf(productOverride) : sessionScope(sessionId).projectKey;
5493
+ if (!key) return null;
5494
+ const sum = backlogSummary(key);
5495
+ const js = runsFor(key);
4919
5496
  const runOf = (reqId) => {
4920
5497
  if (!reqId) return null;
4921
5498
  let last = null;