dsh-plugin-teamflow 0.1.7 → 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
@@ -8,8 +8,20 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from
8
8
  import { fileAddressFor } from "@deepseek-ai/dsh-util-workspace-path";
9
9
  import { execFileSync } from "node:child_process";
10
10
  //#region host/constants.ts
11
- /** 单阶段 token 熔断预算(官方口径总消耗:input+cacheRead+cacheWrite+output 累计)。 */
12
- 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;
13
25
  /** 任务夹产物展示顺序(ADR-0008):工作台只列其中**真实存在**的文件,按此顺序出「一键右侧栏预览」按钮。 */
14
26
  const TEAMFLOW_ARTIFACT_ORDER = [
15
27
  "PRD.md",
@@ -25,8 +37,23 @@ const GUARD_POLL_MS = 15e3;
25
37
  const GUARD_SILENCE_MS = 6e5;
26
38
  /** 空转判定:会话仍在产出事件但连续这么久没有任何工具调用(纯推理打转/改写式循环)→ stalled。要求已见过至少一次工具调用。 */
27
39
  const GUARD_NO_TOOL_MS = 9e5;
28
- /** 假阳性完成检测:明确拒绝/放弃模式的输出视为未产出。 */
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
+ */
29
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;
30
57
  /** 各阶段最小产出长度(防"假完成":空话/一句话冒充交付)。 */
31
58
  const STAGE_MIN_LENGTH = {
32
59
  prd: 400,
@@ -302,6 +329,41 @@ const SAFE_SIGNAL = {
302
329
  function normalizeSignal(s) {
303
330
  return s && typeof s === "object" && typeof s.addEventListener === "function" && typeof s.aborted === "boolean" && typeof s.throwIfAborted === "function" ? s : SAFE_SIGNAL;
304
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
+ }
305
367
  /** 分支 slug 派生(ADR-2026-08-27):branchName > triageSlug > 需求中的英文标识词 > reqId 数字 > 'feature'。
306
368
  * 实锤 feat/feature:lite 显式时 triage 不跑(无 slug)+ 分支检查早于 reqId 生成 → fallback 'feature'。 */
307
369
  function deriveBranchSlug(requirement, reqId, triageSlug, branchName) {
@@ -330,12 +392,66 @@ function deriveBranchSlug(requirement, reqId, triageSlug, branchName) {
330
392
  if (num) return `r${num[0]}`;
331
393
  return "feature";
332
394
  }
333
- /** 产出物实质校验:非空 + 无拒绝词 + 达到阶段长度下限。 */
334
- function hasSubstance(phase, text) {
335
- if (!text || !text.trim()) return false;
336
- 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);
337
417
  const min = STAGE_MIN_LENGTH[phase] ?? 100;
338
- 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
+ };
339
455
  }
340
456
  /** 不可重试的失败原因(上下文耗尽/超长/provider 客户端拒绝等——重试同一 prompt 大概率复现)。
341
457
  * 实锤 tf-mtcnejqj:opencode-go 400 invalid_request_error(tool 消息序列非法)被当作可重试 → 烧 1.98M 熔断。 */
@@ -1102,6 +1218,288 @@ function hasOpenBlockingBugs(journal) {
1102
1218
  return false;
1103
1219
  }
1104
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
+ }
1105
1503
  /** 默认团队配置文件内容。 */
1106
1504
  const DEFAULT_TEAMS_FILE = {
1107
1505
  version: 1,
@@ -1227,6 +1625,32 @@ function gitCmd(cwd, args, timeoutMs = 8e3) {
1227
1625
  }
1228
1626
  }
1229
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
+ /**
1230
1654
  * 跑一次状态核对。
1231
1655
  * @param path - 工作区绝对路径(workspaceScopeOf(agent).path)。
1232
1656
  */
@@ -1416,11 +1840,24 @@ function scannedUsageOf(run) {
1416
1840
  buckets.calls = seen.size || 1;
1417
1841
  return buckets;
1418
1842
  }
1419
- /** 官方口径总消耗(billed input + output,含 cacheRead/cacheWrite)。 */
1843
+ /** 官方口径总消耗(billed input + output,含 cacheRead/cacheWrite)——**汇报/展示**口径。 */
1420
1844
  function totalTokensOf(usage) {
1421
1845
  if (!usage) return 0;
1422
1846
  return (usage.input || 0) + (usage.cacheRead || 0) + (usage.cacheWrite || 0) + (usage.output || 0);
1423
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
+ }
1424
1861
  //#endregion
1425
1862
  //#region host/core/guard.ts
1426
1863
  /**
@@ -1949,15 +2386,21 @@ async function runAgent(journal, parent, label, phase, prompt, signal, taskKey,
1949
2386
  const stop = result && result.stopReason;
1950
2387
  const text = extractText(result && result.output);
1951
2388
  stageText = text;
2389
+ const verdict = judgeDeliverable(phase, text);
1952
2390
  if (journal.cancelled) {
1953
2391
  stage.status = "cancelled";
1954
2392
  stage.outcome = "cancelled";
1955
2393
  return null;
1956
2394
  }
1957
- if (stop === "completed" && text && hasSubstance(phase, text)) {
2395
+ if (stop === "completed" && text && verdict.ok) {
1958
2396
  stage.status = "done";
1959
2397
  stage.outcome = "completed";
1960
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
+ });
1961
2404
  return text;
1962
2405
  }
1963
2406
  if (stage.guardReason) {
@@ -1976,20 +2419,19 @@ async function runAgent(journal, parent, label, phase, prompt, signal, taskKey,
1976
2419
  stage.outcome = stop === "completed" && text ? "insubstantial" : stop || "error";
1977
2420
  const errDetail = result && result.error;
1978
2421
  if (stage.outcome === "insubstantial") {
1979
- const hit = refusalHit(text);
1980
- if (hit) {
1981
- stage.summary = `产出未通过实质校验:命中拒绝词「${hit.phrase}」(原文:${hit.context}),视为未交付`;
2422
+ if (verdict.reason === "refusal" && verdict.refusal) {
2423
+ stage.summary = `产出未通过实质校验:无验证证据块且命中拒绝词「${verdict.refusal.phrase}」(原文:${verdict.refusal.context}),视为未交付`;
1982
2424
  journal.logs.push({
1983
2425
  t: Date.now(),
1984
2426
  level: "warn",
1985
- message: `${label} 产出命中拒绝词「${hit.phrase}」`
2427
+ message: `${label} 产出命中拒绝词「${verdict.refusal.phrase}」且无 [Verification evidence] 块`
1986
2428
  });
1987
2429
  } else {
1988
- stage.summary = `产出未通过实质校验:内容过短(${text.trim().length} 字符 < ${STAGE_MIN_LENGTH[phase] ?? 100} 下限),视为未交付`;
2430
+ stage.summary = `产出未通过实质校验:内容过短(${verdict.length} 字符 < ${verdict.min} 下限),视为未交付`;
1989
2431
  journal.logs.push({
1990
2432
  t: Date.now(),
1991
2433
  level: "warn",
1992
- message: `${label} 产出过短(${text.trim().length} 字符),未通过实质校验`
2434
+ message: `${label} 产出过短(${verdict.length} 字符),未通过实质校验`
1993
2435
  });
1994
2436
  }
1995
2437
  if (text) stage.output = clip(text, 4e3);
@@ -2029,11 +2471,14 @@ async function runAgent(journal, parent, label, phase, prompt, signal, taskKey,
2029
2471
  } catch (e2) {}
2030
2472
  }
2031
2473
  }
2032
- /** 单阶段重试 + token 熔断(官方口径:input+cacheRead+cacheWrite+output 累计)。
2474
+ /**
2475
+ * 单阶段重试 + token 熔断(**新增口径**:input+cacheWrite+output,排除 cacheRead——见
2476
+ * `FRESH_TOKEN_BUDGET` 与 metering.freshTokensOf;汇报仍走官方 totalTokensOf 口径)。
2477
+ * 顺序:不可重试/外部中止/护栏中止 → 预算门 → 自动重试(预算合理时重试优先于熔断,2026-09-11 修正)。
2033
2478
  * `effortHint`:机械阶段的推理强度降档提示(第 1 次尝试生效,重试自动回升 high,见 resolveStageEffort)。 */
2034
2479
  async function withRetry(journal, parent, label, phase, prompt, signal, taskKey, effortHint) {
2035
2480
  let attempts = 0;
2036
- let stageTokens = 0;
2481
+ let freshTokens = 0;
2037
2482
  let lastStage = null;
2038
2483
  for (let attempt = 1; attempt <= 2; attempt++) {
2039
2484
  attempts = attempt;
@@ -2042,17 +2487,17 @@ async function withRetry(journal, parent, label, phase, prompt, signal, taskKey,
2042
2487
  const beforeLen = journal.stages.length;
2043
2488
  const result = await runAgent(journal, parent, labelNow, phase, promptNow, signal, taskKey, attempt, effortHint);
2044
2489
  lastStage = journal.stages[beforeLen] || null;
2045
- if (lastStage && lastStage.phase === phase) stageTokens += totalTokensOf(lastStage.usage);
2490
+ if (lastStage && lastStage.phase === phase) freshTokens += freshTokensOf(lastStage.usage);
2046
2491
  if (result) return {
2047
2492
  text: result,
2048
2493
  attempts,
2049
- stageTokens,
2494
+ freshTokens,
2050
2495
  stage: lastStage
2051
2496
  };
2052
2497
  if (journal.cancelled) return {
2053
2498
  text: null,
2054
2499
  attempts,
2055
- stageTokens,
2500
+ freshTokens,
2056
2501
  stage: lastStage
2057
2502
  };
2058
2503
  if (lastStage && isUnretryable(lastStage.outcome, lastStage.outcome)) {
@@ -2065,7 +2510,7 @@ async function withRetry(journal, parent, label, phase, prompt, signal, taskKey,
2065
2510
  return {
2066
2511
  text: null,
2067
2512
  attempts,
2068
- stageTokens,
2513
+ freshTokens,
2069
2514
  stage: lastStage
2070
2515
  };
2071
2516
  }
@@ -2079,7 +2524,7 @@ async function withRetry(journal, parent, label, phase, prompt, signal, taskKey,
2079
2524
  return {
2080
2525
  text: null,
2081
2526
  attempts,
2082
- stageTokens,
2527
+ freshTokens,
2083
2528
  stage: lastStage
2084
2529
  };
2085
2530
  }
@@ -2093,7 +2538,7 @@ async function withRetry(journal, parent, label, phase, prompt, signal, taskKey,
2093
2538
  return {
2094
2539
  text: null,
2095
2540
  attempts,
2096
- stageTokens,
2541
+ freshTokens,
2097
2542
  stage: lastStage
2098
2543
  };
2099
2544
  }
@@ -2107,21 +2552,21 @@ async function withRetry(journal, parent, label, phase, prompt, signal, taskKey,
2107
2552
  return {
2108
2553
  text: null,
2109
2554
  attempts,
2110
- stageTokens,
2555
+ freshTokens,
2111
2556
  stage: lastStage
2112
2557
  };
2113
2558
  }
2114
- if (stageTokens >= 6e4) {
2559
+ if (freshTokens >= 2e5) {
2115
2560
  journal.logs.push({
2116
2561
  t: Date.now(),
2117
2562
  level: "error",
2118
- 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,熔断,需人工介入`
2119
2564
  });
2120
2565
  journal.humanIntervention = true;
2121
2566
  return {
2122
2567
  text: null,
2123
2568
  attempts,
2124
- stageTokens,
2569
+ freshTokens,
2125
2570
  stage: lastStage
2126
2571
  };
2127
2572
  }
@@ -2142,176 +2587,11 @@ async function withRetry(journal, parent, label, phase, prompt, signal, taskKey,
2142
2587
  return {
2143
2588
  text: null,
2144
2589
  attempts,
2145
- stageTokens,
2590
+ freshTokens,
2146
2591
  stage: lastStage
2147
2592
  };
2148
2593
  }
2149
2594
  //#endregion
2150
- //#region host/core/state.ts
2151
- /**
2152
- * dsh-plugin-teamflow core — state.json 预编译上下文索引。
2153
- *
2154
- * 目标:解决「每个新 run 都要从小代理全量读历史文档(PRD/TECH/QA)来重建认知」的 token 爆炸。
2155
- * state.json 是跨 run 累积的结构化索引:每次 run 结束后由各阶段把「精简结论」沉淀进来,
2156
- * 下一个 run 的子代理只读注入的 state slice,不再重复读全套历史文档。
2157
- *
2158
- * 设计原则:
2159
- * - memory.md 保持权威记忆(人读);state.json 是预编译索引(机器喂给子代理)。
2160
- * - 子代理不直接读 state.json 文件,由 host 在开工时按角色注入相关 slice 到 prompt。
2161
- * - state.json 只存「结论/指针」,不存全文;具体内容仍指向 docs/teamflow/ 下的活文档。
2162
- */
2163
- /** 空态 state。 */
2164
- function emptyState() {
2165
- return {
2166
- version: 1,
2167
- projectName: null,
2168
- updatedAt: null,
2169
- product: {
2170
- summary: null,
2171
- techStack: null
2172
- },
2173
- lastRunFolder: null,
2174
- modules: {},
2175
- verifyScripts: [],
2176
- acIndex: {},
2177
- stages: {},
2178
- lastRun: null
2179
- };
2180
- }
2181
- /** state.json 路径:$DSH_HOME/teamflow/<projectKey>/state.json */
2182
- function stateFile(projectKey) {
2183
- return join(teamflowRoot(), projectKey, "state.json");
2184
- }
2185
- /** 读取(不存在返回空态)。 */
2186
- function loadState(projectKey) {
2187
- const file = stateFile(projectKey);
2188
- try {
2189
- if (existsSync(file)) {
2190
- const raw = JSON.parse(readFileSync(file, "utf8"));
2191
- const base = emptyState();
2192
- if (raw && typeof raw === "object") {
2193
- base.projectName = raw.projectName ?? null;
2194
- base.updatedAt = raw.updatedAt ?? null;
2195
- base.product = {
2196
- ...base.product,
2197
- ...raw.product || {}
2198
- };
2199
- base.lastRunFolder = raw.lastRunFolder ?? null;
2200
- base.modules = raw.modules || {};
2201
- base.verifyScripts = Array.isArray(raw.verifyScripts) ? raw.verifyScripts : [];
2202
- base.acIndex = raw.acIndex || {};
2203
- base.stages = raw.stages || {};
2204
- base.lastRun = raw.lastRun ?? null;
2205
- return base;
2206
- }
2207
- }
2208
- } catch (e) {}
2209
- return emptyState();
2210
- }
2211
- /** 保存。 */
2212
- function saveState(projectKey, state) {
2213
- const file = stateFile(projectKey);
2214
- try {
2215
- mkdirSync(join(teamflowRoot(), projectKey), { recursive: true });
2216
- state.updatedAt = Date.now();
2217
- writeFileSync(file, JSON.stringify(state, null, 2), "utf8");
2218
- return true;
2219
- } catch (e) {
2220
- console.error("[teamflow] saveState failed", e?.message);
2221
- return false;
2222
- }
2223
- }
2224
- /** 从阶段产出文本中提取 `<!-- state -->{...}<!-- /state -->` 块(找不到返回 null)。 */
2225
- function extractStateBlock(text) {
2226
- const m = (text === null || text === void 0 ? "" : String(text)).match(/<!--\s*state\s*-->([\s\S]*?)(?:<!--\s*\/state\s*-->|$)/);
2227
- if (!m || !m[1]) return null;
2228
- try {
2229
- const raw = JSON.parse(m[1].trim());
2230
- if (raw && typeof raw === "object") return raw;
2231
- } catch (e) {}
2232
- return null;
2233
- }
2234
- /** 把阶段产出的 state 块合并进 state.json。 */
2235
- function mergeStateBlock(projectKey, block, phase) {
2236
- const state = loadState(projectKey);
2237
- const key = block && block.phase || phase || "other";
2238
- if (block) {
2239
- if (typeof block.summary === "string" && block.summary.trim()) state.stages[key] = block.summary.trim();
2240
- if (Array.isArray(block.touched)) {
2241
- for (const f of block.touched) if (typeof f === "string" && f) state.modules[f] = state.modules[f] || "touched";
2242
- }
2243
- if (typeof block.verdict === "string" && block.verdict) {
2244
- state.lastRun = state.lastRun || {};
2245
- state.lastRun.verdict = block.verdict;
2246
- }
2247
- if (block.extra && typeof block.extra === "object") {
2248
- if (Array.isArray(block.extra.verifyScripts)) {
2249
- for (const s of block.extra.verifyScripts) if (typeof s === "string" && s && state.verifyScripts.indexOf(s) === -1) state.verifyScripts.push(s);
2250
- }
2251
- if (block.extra.acIndex && typeof block.extra.acIndex === "object") state.acIndex = {
2252
- ...state.acIndex,
2253
- ...block.extra.acIndex
2254
- };
2255
- if (typeof block.extra.techStack === "string" && block.extra.techStack) state.product.techStack = block.extra.techStack;
2256
- if (typeof block.extra.moduleContracts === "object" && block.extra.moduleContracts) state.modules = {
2257
- ...state.modules,
2258
- ...block.extra.moduleContracts
2259
- };
2260
- }
2261
- }
2262
- saveState(projectKey, state);
2263
- return state;
2264
- }
2265
- /** 按 run 更新 lastRun / lastRunFolder(finally 时调用)。 */
2266
- function noteRun(projectKey, run) {
2267
- const state = loadState(projectKey);
2268
- if (run.runDocs) state.lastRunFolder = run.runDocs;
2269
- state.lastRun = {
2270
- runId: run.id || null,
2271
- requirement: run.requirement ? String(run.requirement).slice(0, 200) : null,
2272
- verdict: run.verdict || null,
2273
- folder: run.runDocs || null,
2274
- endedAt: run.endedAt ?? Date.now()
2275
- };
2276
- saveState(projectKey, state);
2277
- }
2278
- /** 按角色渲染 state slice(注入到子代理 prompt)。角色 → 只拿相关片段。 */
2279
- function stateSliceFor(state, role) {
2280
- const lines = [];
2281
- if (state.__runCtx) {
2282
- if (state.__runCtx.runDocs) lines.push(`【本次任务产物夹】${state.__runCtx.runDocs}/(host 已创建;本需求的 PRD/TECHNICAL/QA-REPORT/ACCEPTANCE 全部写这里,夹建后不可变、不归档不升版)`);
2283
- if (state.__runCtx.sanity) lines.push(state.__runCtx.sanity);
2284
- if (state.__runCtx.blueprint && (role === "arch" || role === "tech" || role === "dev")) lines.push(state.__runCtx.blueprint);
2285
- }
2286
- lines.push("【预编译产品状态(state.json · 权威记忆在 docs/teamflow/memory.md,本块已是够用的索引,勿再全量读历史文档)】");
2287
- if (state.product.summary) lines.push(`- 产品概要:${state.product.summary}`);
2288
- if (state.product.techStack && (role === "tech" || role === "dev" || role === "arch")) lines.push(`- 技术栈:${state.product.techStack}`);
2289
- if (Object.keys(state.acIndex).length && (role === "pm" || role === "qa" || role === "acceptance" || role === "tech")) {
2290
- const acs = Object.entries(state.acIndex).slice(0, 40);
2291
- lines.push(`- AC 索引(${acs.length} 条):${acs.map(([k, v]) => `${k} ${v}`).join(";")}`);
2292
- }
2293
- 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(",")}`);
2294
- if (state.verifyScripts.length && (role === "qa" || role === "tech" || role === "dev")) lines.push(`- 验证脚本:${state.verifyScripts.join(",")}`);
2295
- if (role === "pm" || role === "acceptance") {
2296
- if (state.stages.prd) lines.push(`- PRD 摘要:${state.stages.prd}`);
2297
- if (state.stages.tech) lines.push(`- 技术方案摘要:${state.stages.tech}`);
2298
- if (state.stages.qa) lines.push(`- QA 摘要:${state.stages.qa}`);
2299
- } else if (role === "dev" || role === "tech") {
2300
- if (state.stages.tech) lines.push(`- 技术方案摘要:${state.stages.tech}`);
2301
- if (state.stages.prd) lines.push(`- PRD 摘要:${state.stages.prd}`);
2302
- } else if (role === "qa") {
2303
- if (state.stages.qa) lines.push(`- 上轮 QA 摘要:${state.stages.qa}`);
2304
- }
2305
- if (state.lastRun) {
2306
- const r = state.lastRun;
2307
- lines.push(`- 上轮:${r.requirement ? r.requirement : ""}${r.verdict ? " → " + r.verdict : ""}${r.folder ? `(${r.folder})` : ""}`);
2308
- }
2309
- return lines.join("\n");
2310
- }
2311
- /** 让每个阶段产出末尾附带 state 块(将并入 stage output,由 host 提取)。 */
2312
- 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):
2313
- <!-- state -->{"phase":"<stage-key>","summary":"<≤500 chars: this stage's conclusion, useful for the next run>","memory":["<memory points>"]}<!-- /state -->`;
2314
- //#endregion
2315
2595
  //#region host/prompts/index.ts
2316
2596
  /**
2317
2597
  * dsh-plugin-teamflow — Prompt 模板(阶段提示词 + 团队模板)。
@@ -2369,7 +2649,7 @@ const AGENTS_TEMPLATE = `# AGENTS.md — 团队协作守则与文档索引({{P
2369
2649
  | 任务产物 | ${TF_DOCS}/<yyyyMMdd-rN-slug>/ | 每个需求一个自包含任务夹:PRD/设计/技术方案/QA 报告/验收报告(按日期倒序即迭代史) |
2370
2650
  | 架构总览 | ${TF_DOCS}/architecture/ARCHITECTURE.md | 工程方案与脚手架说明(产品级长期文档) |
2371
2651
  | 产品记忆 | ${TF_DOCS}/memory.md | 团队约定/技术栈/已知待办(低频更新) |
2372
- | 运行日志 | logs/teamflow/<runId>/ | TeamFlow 流水线各阶段命令日志(日常不读) |
2652
+ | 运行日志 | logs/teamflow/<runId>/ | TeamFlow 流水线各阶段命令日志(日常不读);**布局约定**(全部在该目录内,项目根不得出现 scripts/ probe/):regression-<phase>.log(套件输出,重跑追加)/ scripts/(一次性校验脚本)/ captures.json(命令载荷汇总)/ probe/(探针)——每用途一个文件,不新增同名变体 |
2373
2653
 
2374
2654
  ## 3. 团队角色与标准流程
2375
2655
 
@@ -2451,6 +2731,13 @@ const TOKEN_HYGIENE = (runId) => `[TOKEN HYGIENE · policy] Context is expensive
2451
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.
2452
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.
2453
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.
2454
2741
  - Keep reports/summaries tight (QA ≤150 lines, acceptance ≤80 lines, dev ≤40 lines); put details in files.
2455
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.
2456
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.
@@ -2573,7 +2860,7 @@ ${clip(tech, 12e3)}` : ""}
2573
2860
  4. Actually write/modify code (grep + segmented reads to locate; no repeated whole-file reads), then run relevant build/verification to ensure green.
2574
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.
2575
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.
2576
- 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).
2577
2864
  7. Output an implementation summary (≤40 lines): changed files, key implementation points, leftovers. No big code pastes.
2578
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):
2579
2866
  [Verification evidence]
@@ -2608,7 +2895,7 @@ ${clip(devSummary, 15e3)}
2608
2895
  - Always-available sandbox-legal paths: build/assembly checks, unit tests, DOM-level E2E (jsdom or equivalent), static audit, adversarial spot-checks.
2609
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.
2610
2897
  3. Read AGENTS.md §4 engineering conventions (verify commands) and the code changes first, then actually run those sandbox-legal verifications.
2611
- 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.
2612
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).
2613
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:
2614
2901
  | 编号 | 严重级(P0/P1/P2/P3) | 功能模块 | 复现步骤 | 期望行为 | 实际行为 | 关联验收项 |
@@ -2630,7 +2917,7 @@ ${tech && String(tech).trim() ? clip(tech, 12e3) : ""}
2630
2917
  1. [Confirm first, then fix] For each defect, verify one by one whether it truly holds (read code / reproduce / compare actual vs expected):
2631
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).
2632
2919
  2. Touch ONLY defect-related files (grep to locate; no whole-file reads of irrelevant big files); respect existing architecture & code style.
2633
- 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.
2634
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.
2635
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):
2636
2923
  [Verification evidence]
@@ -3054,6 +3341,42 @@ function buildResumeProducts(journal) {
3054
3341
  }
3055
3342
  return products;
3056
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
+ }
3057
3380
  /** 任务夹产物读取(单轨契约:文件即产物——QA/验收 host 只读文件,回复仅摘要)。
3058
3381
  * 缺失/空/读取异常返回 null(调用方决定硬失败或 journal 兜底)。 */
3059
3382
  function artifactText(journal, fileName) {
@@ -3200,10 +3523,10 @@ async function executePipeline(journal, parent, requirement, options, signal, re
3200
3523
  const stageFailError = (label, r) => {
3201
3524
  const last = [...journal.stages || []].reverse().find((s) => phaseKeyOf(s.phase) === label);
3202
3525
  const attempts = r && r.attempts ? r.attempts : 2;
3203
- const burnt = Math.round((r && r.stageTokens || 0) / 1e3);
3204
- 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 预算熔断" : "";
3205
3528
  const detail = last ? `末次 ${last.outcome || "unknown"}${last.summary ? `(${last.summary})` : ""}` : "无阶段记录";
3206
- 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},需人工介入`);
3207
3530
  };
3208
3531
  try {
3209
3532
  if (resume) journal.logs.push({
@@ -3315,7 +3638,8 @@ async function executePipeline(journal, parent, requirement, options, signal, re
3315
3638
  }
3316
3639
  else if (!resume && journal.workspacePath && options.preAction === "commit") try {
3317
3640
  const msg = typeof options.commitMessage === "string" && options.commitMessage.trim() ? options.commitMessage.trim() : `chore(teamflow): 流水线启动前提交现有改动(${journal.id})`;
3318
- const add = gitCmd(journal.workspacePath, ["add", "-A"]);
3641
+ ensureLogGitignore(journal.workspacePath, journal);
3642
+ const add = gitCmd(journal.workspacePath, tfAddArgs());
3319
3643
  const cm = gitCmd(journal.workspacePath, [
3320
3644
  "commit",
3321
3645
  "-m",
@@ -3386,6 +3710,15 @@ async function executePipeline(journal, parent, requirement, options, signal, re
3386
3710
  if (stage) stage.verifyEvidence = ev;
3387
3711
  } catch (e) {}
3388
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;
3389
3722
  let prd = null;
3390
3723
  if (resumed("prd")) {
3391
3724
  prd = resume.products.prd;
@@ -3525,12 +3858,13 @@ async function executePipeline(journal, parent, requirement, options, signal, re
3525
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 || ""))));
3526
3859
  const resumePrompt = devPrompt(task, tech, prd, root, journal.id, state) + (prevStage ? buildRetryDiagnostic(2, prevStage) : "");
3527
3860
  const devR = await withRetry(journal, parent, `开发 · ${task.title}(补跑)`, "dev", resumePrompt, signal, task.title);
3528
- noteVerifyEvidence(devR.stage, devR.text);
3861
+ const rerunText = stageTextOf(devR);
3862
+ noteVerifyEvidence(devR.stage, rerunText);
3529
3863
  const ok = !!devR.text;
3530
3864
  return {
3531
3865
  title: task.title,
3532
3866
  failed: !ok,
3533
- output: devR.text || "开发失败(Agent 未产出结果)"
3867
+ output: rerunText || "开发失败(Agent 未产出结果)"
3534
3868
  };
3535
3869
  });
3536
3870
  for (const t of rerun) {
@@ -3581,16 +3915,17 @@ async function executePipeline(journal, parent, requirement, options, signal, re
3581
3915
  }
3582
3916
  }
3583
3917
  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);
3918
+ const devText = stageTextOf(devR);
3919
+ noteVerifyEvidence(devR.stage, devText);
3585
3920
  const ok = !!devR.text;
3586
3921
  if (sub) {
3587
- 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);
3588
3923
  if (devR.stage) noteSubtaskUsage(journal, sub.id, devR.stage);
3589
3924
  }
3590
3925
  return {
3591
3926
  title: task.title,
3592
3927
  failed: !ok,
3593
- output: devR.text || "开发失败(Agent 未产出结果)"
3928
+ output: devText || "开发失败(Agent 未产出结果)"
3594
3929
  };
3595
3930
  });
3596
3931
  timeline.dev = devResults;
@@ -3671,7 +4006,7 @@ async function executePipeline(journal, parent, requirement, options, signal, re
3671
4006
  advanceTask(journal, "needs-human", null, "QA-REPORT.md 未落盘(单轨契约未兑现)", { by: "qa" });
3672
4007
  throw stageFailError("qa", {
3673
4008
  attempts: qaR.attempts,
3674
- stageTokens: qaR.stageTokens
4009
+ freshTokens: qaR.freshTokens
3675
4010
  });
3676
4011
  }
3677
4012
  timeline.qa = qa;
@@ -3713,7 +4048,7 @@ async function executePipeline(journal, parent, requirement, options, signal, re
3713
4048
  });
3714
4049
  advanceTask(journal, "rework", snippet(qa, 3e3), `QA 打回开发修复(第 ${round}/3 轮)`, { by: "qa" });
3715
4050
  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);
4051
+ noteVerifyEvidence(fixR.stage, stageTextOf(fixR));
3717
4052
  if (!fixR.text) {
3718
4053
  advanceTask(journal, "needs-human", null, "QA 打回后开发修复失败", { by: "qa" });
3719
4054
  throw stageFailError("开发(QA 打回修复)", fixR);
@@ -3779,7 +4114,7 @@ async function executePipeline(journal, parent, requirement, options, signal, re
3779
4114
  advanceTask(journal, "needs-human", null, "ACCEPTANCE.md 未落盘(单轨契约未兑现)", { by: "pm" });
3780
4115
  throw stageFailError("acceptance", {
3781
4116
  attempts: accR.attempts,
3782
- stageTokens: accR.stageTokens
4117
+ freshTokens: accR.freshTokens
3783
4118
  });
3784
4119
  }
3785
4120
  timeline.acceptance = acceptance;
@@ -3863,7 +4198,8 @@ async function executePipeline(journal, parent, requirement, options, signal, re
3863
4198
  activeProducts.delete(scopeKey);
3864
4199
  if (journal.workspacePath && journal.status === "completed" && !journal.humanIntervention) try {
3865
4200
  const reqHead = String(journal.requirement || "").replace(/\s+/g, " ").trim().slice(0, 80);
3866
- const add = gitCmd(journal.workspacePath, ["add", "-A"]);
4201
+ ensureLogGitignore(journal.workspacePath, journal);
4202
+ const add = gitCmd(journal.workspacePath, tfAddArgs());
3867
4203
  if ((add === null ? null : gitCmd(journal.workspacePath, [
3868
4204
  "commit",
3869
4205
  "-m",
@@ -4108,18 +4444,7 @@ function resumeRun(runId, sessionId) {
4108
4444
  * 运行环境:宿主组合(web profile)的真实 Node 进程。
4109
4445
  */
4110
4446
  /** 阶段顺序/key 映射见 constants.ts(PHASE_ORDER/PHASE_KEY_OF/PHASE_KEY_BY_NAME)。 */
4111
- /** 按工作区作用域过滤运行(sessionId 推导出 workspace slug;未落 workspace 的旧运行只见于 default)。 */
4112
- function runsFor(ws) {
4113
- const arr = [];
4114
- for (const j of runs.values()) {
4115
- if (ws) {
4116
- if ((j.workspace || (ws === "default" ? "default" : null)) !== ws) continue;
4117
- }
4118
- arr.push(j);
4119
- }
4120
- arr.sort((a, b) => (b.startedAt || 0) - (a.startedAt || 0));
4121
- return arr;
4122
- }
4447
+ /** 按工作区作用域过滤运行见 core/products.ts(runsFor;全局面板与远程面共用)。 */
4123
4448
  /** 由 sessionId 推导会话所属 workspace(项目)作用域。 */
4124
4449
  function sessionScope(sessionId) {
4125
4450
  const sid = typeof sessionId === "string" && sessionId ? sessionId : null;
@@ -4132,6 +4457,8 @@ function snapshotOf(j) {
4132
4457
  status: j.status,
4133
4458
  startedAt: j.startedAt,
4134
4459
  endedAt: j.endedAt,
4460
+ address: runAddress(j.workspace || "default", j.id),
4461
+ ownerSession: j.ownerSession || null,
4135
4462
  requirement: clip(j.requirement, 2e3),
4136
4463
  options: sanitizeSnapOptions(j.options),
4137
4464
  agentsStarted: j.agentsStarted,
@@ -4907,28 +5234,21 @@ var TeamflowService = class extends TypertRemoteService {
4907
5234
  list(sessionId) {
4908
5235
  const sc = sessionScope(sessionId);
4909
5236
  return {
4910
- runs: runsFor(sc.projectKey).slice(0, 30).map((j) => ({
4911
- id: j.id,
4912
- status: j.status,
4913
- startedAt: j.startedAt,
4914
- endedAt: j.endedAt,
4915
- agentsStarted: j.agentsStarted,
4916
- stageCount: j.stages.length,
4917
- incompleteStages: (j.stages || []).some((x) => x.status !== "done"),
4918
- requirement: clip(j.requirement, 60)
4919
- })),
5237
+ runs: runsFor(sc.projectKey).slice(0, 30).map(runBrief),
4920
5238
  workspace: sc
4921
5239
  };
4922
5240
  }
4923
- snapshot(runId, sessionId) {
4924
- 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;
4925
5245
  if (runId && typeof runId === "string") {
4926
5246
  const j = runs.get(runId);
4927
5247
  if (!j) return null;
4928
- if (j.workspace && sc.projectKey && j.workspace !== sc.projectKey && sc.projectKey !== "default") return null;
5248
+ if (!runVisibleIn(j, key)) return null;
4929
5249
  return snapshotOf(j);
4930
5250
  }
4931
- const latest = runsFor(sc.projectKey)[0];
5251
+ const latest = runsFor(key)[0];
4932
5252
  if (!latest) return null;
4933
5253
  const j = runs.get(latest.id);
4934
5254
  return j ? snapshotOf(j) : null;
@@ -4936,12 +5256,13 @@ var TeamflowService = class extends TypertRemoteService {
4936
5256
  /** 阶段详情:卡片点击查看 —— 状态/耗时/官方 usage + 产物全文(超 24k 截断)。
4937
5257
  * 2026-09-06 状态机化:返回同任务全部尝试(attempts 聚合——按 stage.taskKey(旧数据 label 兜底),
4938
5258
  * 按 seq 排序)——client 弹窗单次渲染现状、多次渲染时间线。 */
4939
- stageDetail(runId, seq, sessionId) {
5259
+ stageDetail(runId, seq, sessionId, productOverride) {
4940
5260
  if (typeof runId !== "string" || !runId || seq === void 0 || seq === null) return null;
4941
- const sc = sessionScope(sessionId);
5261
+ const key = productOverride ? productKeyOf(productOverride) : sessionScope(sessionId).projectKey;
5262
+ if (!key) return null;
4942
5263
  const j = runs.get(runId);
4943
5264
  if (!j) return null;
4944
- if (j.workspace && sc.projectKey && j.workspace !== sc.projectKey && sc.projectKey !== "default") return null;
5265
+ if (!runVisibleIn(j, key)) return null;
4945
5266
  const s = (j.stages || []).find((st) => Number(st.seq) === Number(seq));
4946
5267
  if (!s) return null;
4947
5268
  const taskKeyOf = (x) => String(x.taskKey || String(x.label || "").replace(/^开发 · /, "").replace(/((?:第 \d+ 次重试|补跑))$/, "").trim());
@@ -4977,22 +5298,23 @@ var TeamflowService = class extends TypertRemoteService {
4977
5298
  };
4978
5299
  }
4979
5300
  /** Backlog 条目详情:卡片点击查看 —— 完整字段 + 流转时间线 + 关联(子卡/缺陷)+ 任务夹路径。 */
4980
- itemDetail(kind, id, sessionId) {
5301
+ itemDetail(kind, id, sessionId, productOverride) {
4981
5302
  const k = typeof kind === "string" && [
4982
5303
  "req",
4983
5304
  "task",
4984
5305
  "bug"
4985
5306
  ].indexOf(kind) !== -1 ? kind : null;
4986
5307
  if (!k || typeof id !== "string" || !id) return null;
4987
- const sc = sessionScope(sessionId);
4988
- const store = storeFor(sc.projectKey);
5308
+ const key = productOverride ? productKeyOf(productOverride) : sessionScope(sessionId).projectKey;
5309
+ if (!key) return null;
5310
+ const store = storeFor(key);
4989
5311
  const item = store.find(k, id);
4990
5312
  if (!item) return null;
4991
5313
  const reqId = k === "req" ? item.id : item.reqId || null;
4992
5314
  let runDocs = null;
4993
5315
  let runDocsRoot = null;
4994
5316
  let runInfo = null;
4995
- for (const j of runsFor(sc.projectKey)) {
5317
+ for (const j of runsFor(key)) {
4996
5318
  if (j.reqId !== reqId) continue;
4997
5319
  if (j.runDocs && !runDocs) {
4998
5320
  runDocs = j.runDocs;
@@ -5003,17 +5325,19 @@ var TeamflowService = class extends TypertRemoteService {
5003
5325
  status: j.status,
5004
5326
  requirement: String(j.requirement || ""),
5005
5327
  startedAt: j.startedAt || null,
5006
- endedAt: j.endedAt || null
5328
+ endedAt: j.endedAt || null,
5329
+ ownerSession: j.ownerSession || null
5007
5330
  };
5008
5331
  }
5332
+ const artifactSession = runInfo && runInfo.ownerSession || sessionId;
5009
5333
  const runArtifacts = [];
5010
- if (runDocs && runDocsRoot && typeof sessionId === "string" && sessionId) try {
5334
+ if (runDocs && runDocsRoot && typeof artifactSession === "string" && artifactSession) try {
5011
5335
  const present = new Set(readdirSync(join(runDocsRoot, runDocs)));
5012
5336
  for (const name of TEAMFLOW_ARTIFACT_ORDER) {
5013
5337
  if (!present.has(name)) continue;
5014
5338
  runArtifacts.push({
5015
5339
  name,
5016
- address: fileAddressFor(sessionId, void 0, `${runDocs}/${name}`)
5340
+ address: fileAddressFor(artifactSession, void 0, `${runDocs}/${name}`)
5017
5341
  });
5018
5342
  }
5019
5343
  } catch (e) {}
@@ -5092,6 +5416,41 @@ var TeamflowService = class extends TypertRemoteService {
5092
5416
  bugs
5093
5417
  };
5094
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
+ }
5095
5454
  start(sessionId, requirement, options) {
5096
5455
  const sid = typeof sessionId === "string" ? sessionId : null;
5097
5456
  const req = typeof requirement === "string" && requirement.trim() ? requirement.trim() : null;
@@ -5128,11 +5487,12 @@ var TeamflowService = class extends TypertRemoteService {
5128
5487
  };
5129
5488
  return { ok: cancelRun(id) };
5130
5489
  }
5131
- /** 工作区级 backlog 视图(自动按当前会话 workspace 隔离)。 */
5132
- backlog(sessionId) {
5133
- const sc = sessionScope(sessionId);
5134
- const sum = backlogSummary(sc.projectKey);
5135
- 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);
5136
5496
  const runOf = (reqId) => {
5137
5497
  if (!reqId) return null;
5138
5498
  let last = null;