duaer-spec 0.26.0 → 0.27.0

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.
Files changed (57) hide show
  1. package/.duaer/active-job.json +3 -3
  2. package/.duaer/specs/260-release-026/delivery.json +10 -9
  3. package/.duaer/specs/260-release-026/tasks.md +1 -3
  4. package/.duaer/specs/261-fde-baseline-sign/delivery.json +10 -0
  5. package/.duaer/specs/261-fde-baseline-sign/spec.md +37 -0
  6. package/.duaer/specs/261-fde-baseline-sign/tasks.md +8 -0
  7. package/.duaer/specs/262-fde01-readme-docs/delivery.json +9 -0
  8. package/.duaer/specs/262-fde01-readme-docs/spec.md +23 -0
  9. package/.duaer/specs/262-fde01-readme-docs/tasks.md +5 -0
  10. package/.duaer/specs/263-arch-edge-via/delivery.json +9 -0
  11. package/.duaer/specs/263-arch-edge-via/spec.md +25 -0
  12. package/.duaer/specs/263-arch-edge-via/tasks.md +5 -0
  13. package/.duaer/specs/264-baseline-field-guide/delivery.json +10 -0
  14. package/.duaer/specs/264-baseline-field-guide/spec.md +23 -0
  15. package/.duaer/specs/264-baseline-field-guide/tasks.md +5 -0
  16. package/.duaer/specs/265-baseline-chat-passthrough/delivery.json +9 -0
  17. package/.duaer/specs/265-baseline-chat-passthrough/spec.md +22 -0
  18. package/.duaer/specs/265-baseline-chat-passthrough/tasks.md +4 -0
  19. package/.duaer/specs/266-fde02-api-contract/delivery.json +10 -0
  20. package/.duaer/specs/266-fde02-api-contract/spec.md +28 -0
  21. package/.duaer/specs/266-fde02-api-contract/tasks.md +7 -0
  22. package/.duaer/specs/267-baseline-restore/delivery.json +9 -0
  23. package/.duaer/specs/267-baseline-restore/spec.md +24 -0
  24. package/.duaer/specs/267-baseline-restore/tasks.md +5 -0
  25. package/.duaer/specs/268-fde07-uat/delivery.json +9 -0
  26. package/.duaer/specs/268-fde07-uat/spec.md +30 -0
  27. package/.duaer/specs/268-fde07-uat/tasks.md +5 -0
  28. package/.duaer/specs/269-release-027/spec.md +12 -0
  29. package/.duaer/specs/269-release-027/tasks.md +4 -0
  30. package/CHANGELOG.md +46 -0
  31. package/README.md +22 -14
  32. package/README.zh-CN.md +6 -5
  33. package/bin/duaer-live.mjs +161 -77
  34. package/bin/live-archify.mjs +85 -0
  35. package/bin/live-deliverables.mjs +53 -0
  36. package/bin/live-modules.mjs +63 -2
  37. package/bin/live-project-chat.mjs +18 -1
  38. package/docs/agent/e2e-test-plan.md +14 -0
  39. package/docs/agent/live-desk.md +18 -1
  40. package/package.json +1 -1
  41. package/web/live-dev/api-contract.mjs +50 -0
  42. package/web/live-dev/app.js +278 -21
  43. package/web/live-dev/baseline.mjs +71 -0
  44. package/web/live-dev/confirm-card.mjs +19 -0
  45. package/web/live-dev/i18n-de.js +28 -0
  46. package/web/live-dev/i18n-es.js +28 -0
  47. package/web/live-dev/i18n-fr.js +28 -0
  48. package/web/live-dev/i18n-ja.js +28 -0
  49. package/web/live-dev/i18n-ko.js +28 -0
  50. package/web/live-dev/i18n-pt-BR.js +28 -0
  51. package/web/live-dev/i18n-ru.js +28 -0
  52. package/web/live-dev/i18n-vi.js +28 -0
  53. package/web/live-dev/i18n-zh-TW.js +28 -0
  54. package/web/live-dev/i18n.js +59 -2
  55. package/web/live-dev/index.html +42 -1
  56. package/web/live-dev/styles.css +4 -0
  57. package/web/live-dev/uat-pack.mjs +21 -0
@@ -48,6 +48,12 @@ import {
48
48
  } from "./live-archify.mjs";
49
49
  import { enrichChatOptions } from "../web/live-dev/choice-options.mjs";
50
50
  import { extractArchitectureIr } from "../web/live-dev/architecture-ir.mjs";
51
+ import {
52
+ baselineIsValid,
53
+ clipBaseline,
54
+ } from "../web/live-dev/baseline.mjs";
55
+ import { apiContractIsFilled, apiContractDeclaresNoHttp } from "../web/live-dev/api-contract.mjs";
56
+ import { missingUatCases, uatMentionsErrorCode } from "../web/live-dev/uat-pack.mjs";
51
57
  import { allocateUniqueBranch } from "./live-worktree-name.mjs";
52
58
  import {
53
59
  ensureGitInstalled,
@@ -82,6 +88,7 @@ import {
82
88
  writeDeliverablesHtmlFile,
83
89
  } from "./live-deliverables.mjs";
84
90
  import {
91
+ clipCard,
85
92
  clipModules,
86
93
  clipActiveModuleId,
87
94
  modulesAllConfirmed,
@@ -729,18 +736,23 @@ const SYSTEM_PROMPT = `你是「Duaer-spec FDE」需求助手。通过多轮对
729
736
 
730
737
  规则:
731
738
  1. 缺关键可执行信息时,每次只问 1 个卡点问题;信息够时不要用「请从多种风格/方向里选一个」代替可执行的验收标准。
732
- 2. 系统可能很大:边聊边发现模块清单 modules(id/title/status)。对话可以乱跳模块;把内容写进对应模块的四块,不要强迫用户按顺序说完。
733
- 3. 每个模块维护四块:goal、outOfScope、acceptance、assumptions。小项目可只有一个模块(id=main)。
739
+ 2. 系统可能很大:边聊边发现模块清单 modules(id/title/status)。对话可以乱跳模块;把内容写进对应模块的确认卡字段,不要强迫用户按顺序说完。
740
+ 3. 每个功能模块维护确认卡字段:goal、outOfScope、acceptance、assumptions,以及 FDE-01 基线三块与 FDE-02 契约:
741
+ - deviceMatrix = 浏览器/设备矩阵(具体版本,如「Chrome 最近两版、手机 Safari」;禁止只写「主流浏览器」)
742
+ - criticalPaths = 联调必须覆盖的主路径(短列表)
743
+ - exceptionCases = UAT 包,必须同时覆盖空态、失败、权限不足、超时、重试;有接口契约时写出对应错误码或 HTTP status(禁止只写一类)
744
+ - apiContract = 接口契约路径(如 openapi/openapi.yaml 或 src/types/api.ts),或明确写「本模块无 HTTP API」
745
+ 用户没提时:先根据产品形态填合理默认并写进 JSON,再在正文用一句话请用户改;不要长期留空。小项目可只有一个模块(id=main)。
734
746
  4. acceptance 必须可客观检查(打开何处、看到什么、哪条命令通过);禁止只写「更好用/更好看」。
735
- 5. ready=true 只表示**当前 active 模块**可确认,不是整系统开工。不要催用户立刻派工。
747
+ 5. ready=true 只表示**当前 active 模块**可确认,且上述字段都已非空、验收可检查。不是整系统开工。不要催用户立刻派工。
736
748
  6. 不要写代码。不要假设用户仓库路径。
737
- 7. 只要问题是让用户做选择,必须在 JSON 的 options 填 2~5 个短选项(每个≤20字)。禁止只在正文列选项却把 options 留空。
749
+ 7. 只要问题是让用户做选择,必须在 JSON 的 options 填 2~5 个短选项(每个≤20字)。禁止只在正文列选项却把 options 留空。缺基线/契约字段时,可用 options 让用户点选,点选后立刻写入对应字段。
738
750
  8. 输出格式(严格):
739
751
  - 先写对用户说的纯文本(可多行,不要 JSON;正文里不要再列一遍选项清单)
740
752
  - 然后单独一行:<<<JSON>>>
741
753
  - 再输出一个 JSON 对象(不要 markdown 围栏):
742
- {"modules":[{"id":"auth","title":"登录","status":"draft","goal":"...","outOfScope":"...","acceptance":"...","assumptions":"..."}],"activeModuleId":"auth","goal":"...","outOfScope":"...","acceptance":"...","assumptions":"...","ready":false,"options":["可选A","可选B"]}
743
- 说明:顶层 goal/outOfScope/acceptance/assumptions/ready 对应 activeModuleId 那一模块;modules 为完整清单(可增删改名)。`;
754
+ {"modules":[{"id":"auth","title":"登录","status":"draft","goal":"...","outOfScope":"...","acceptance":"...","assumptions":"...","deviceMatrix":"...","criticalPaths":"...","exceptionCases":"...","apiContract":"..."}],"activeModuleId":"auth","goal":"...","outOfScope":"...","acceptance":"...","assumptions":"...","deviceMatrix":"...","criticalPaths":"...","exceptionCases":"...","apiContract":"...","ready":false,"options":["可选A","可选B"]}
755
+ 说明:顶层字段与 ready 对应 activeModuleId 那一模块;modules 为完整清单(可增删改名)。`;
744
756
 
745
757
  const BUG_CHAT_PROMPT = `你是「Duaer-spec FDE」缺陷助手。用户要修 bug,不是做新功能。通过多轮对话整理成一张可派工的缺陷卡,使数字员工能复现、修复并回归。
746
758
 
@@ -766,20 +778,22 @@ const CHAT_JSON_MARKER = "<<<JSON>>>";
766
778
  const REVISE_CHAT_PROMPT = `你是「Duaer-spec FDE」改进对话助手。用户已看过成品但不满意。通过多轮对话弄清:为什么不满意、要改成什么样、什么不要动。目标是改完后用户能满意。
767
779
 
768
780
  规则:
769
- 1. 缺关键信息时每次只问 1 个问题;信息够时直接填可执行的四块并 ready=true,不要用「请从 A/B/C/D 风格里选」代替验收标准。
770
- 2. 维护四块(仍用确认卡字段名,便于前端复用):
781
+ 1. 缺关键信息时每次只问 1 个问题;信息够时直接填可执行的确认卡字段并 ready=true,不要用「请从 A/B/C/D 风格里选」代替验收标准。
782
+ 2. 维护确认卡字段(仍用确认卡字段名,便于前端复用):
771
783
  - goal = 本轮要改什么(具体可执行)
772
784
  - outOfScope = 本轮不要动什么
773
785
  - acceptance = 怎么算改好了(可检查:打开/看到/命令通过)
774
786
  - assumptions = 用户不满意的原因 / 背景摘要
775
- 3. 四块够清楚且可执行时 ready=true(确认前系统会自动校验)。
787
+ - deviceMatrix / criticalPaths / exceptionCases = 若本轮改动触及联调范围则更新;否则可沿用或写「同基线」
788
+ - apiContract = 若触及接口则更新路径,或写「本模块无 HTTP API」/「同基线」
789
+ 3. 字段够清楚且可执行时 ready=true(确认前系统会自动校验)。缺基线/契约字段时先填合理默认或用 options 点选。
776
790
  4. 不要写代码。不要立刻派工。不要假设仓库路径。
777
791
  5. 只要问题是让用户做选择,必须在 options 填 2~5 个短选项(≤20字);界面可点选发送。禁止只让用户手打或「请回复数字」。
778
792
  6. 输出格式(严格):
779
793
  - 先写对用户说的纯文本(正文不要再列选项清单)
780
794
  - 然后单独一行:<<<JSON>>>
781
795
  - 再输出 JSON(不要 markdown 围栏):
782
- {"goal":"...","outOfScope":"...","acceptance":"...","assumptions":"...","ready":false,"options":["可选A","可选B"]}`;
796
+ {"goal":"...","outOfScope":"...","acceptance":"...","assumptions":"...","deviceMatrix":"...","criticalPaths":"...","exceptionCases":"...","apiContract":"...","ready":false,"options":["可选A","可选B"]}`;
783
797
 
784
798
  const ARCHITECTURE_CHAT_PROMPT = `你是「Duaer-spec FDE」架构助手。需求已确认。通过多轮对话设计系统架构图,供数字员工按图开发。系统会把 JSON 自动渲染成图,用户看不到原始 JSON。
785
799
 
@@ -805,23 +819,25 @@ const ACCEPT_PROMPT = `你是「Duaer-spec FDE」需求验收官。用户即将
805
819
  2. acceptance 是否可客观检查:必须写清「打开/看到/点击/返回/命令通过/接口返回」等可核对结果;禁止仅「更好用 / 更好看 / nicer / looks better」这类空话
806
820
  3. outOfScope 是否划清边界(可简短)
807
821
  4. assumptions 是否合理、不偷换目标
808
- 5. 按该验收标准做完后,用户是否有理由满意(成品可核对,而非过程叙事)
822
+ 5. deviceMatrix / criticalPaths / exceptionCases 是否已填且具体(禁止空;deviceMatrix 禁止只写「主流浏览器」;exceptionCases 必须覆盖空态、失败、权限不足、超时、重试;有接口时还要错误码或 HTTP status)
823
+ 6. apiContract 是否已填:OpenAPI/类型等路径,或明确「本模块无 HTTP API」(禁止空)
824
+ 7. 按该验收标准做完后,用户是否有理由满意(成品可核对,而非过程叙事)
809
825
 
810
826
  规则:
811
- - 若小改即可通过:修订四块(尤其把 acceptance 改成可检查句子),passed=true
827
+ - 若小改即可通过:修订确认卡字段(尤其把 acceptance 改成可检查句子,并补全基线与契约),passed=true
812
828
  - 若缺关键信息:passed=false,issues 列出缺什么(中文,短句)
813
829
  - 不要写代码。不要假设仓库路径。不要催派工。
814
830
  - 只输出一个 JSON,不要 markdown 围栏:
815
- {"passed":false,"summary":"一句话结论","issues":["问题1"],"goal":"...","outOfScope":"...","acceptance":"...","assumptions":"..."}`;
831
+ {"passed":false,"summary":"一句话结论","issues":["问题1"],"goal":"...","outOfScope":"...","acceptance":"...","assumptions":"...","deviceMatrix":"...","criticalPaths":"...","exceptionCases":"...","apiContract":"..."}`;
816
832
 
817
- const FIX_ACCEPT_PROMPT = `你是「Duaer-spec FDE」需求修正助手。自动验收未通过,请根据 issues 修订确认卡四块。优先把 acceptance 改成可客观检查的句子(打开何处、看到什么、哪条命令通过),不要编造用户没提过的大功能。
833
+ const FIX_ACCEPT_PROMPT = `你是「Duaer-spec FDE」需求修正助手。自动验收未通过,请根据 issues 修订确认卡。优先把 acceptance 改成可客观检查的句子(打开何处、看到什么、哪条命令通过),并补全 deviceMatrix / criticalPaths / exceptionCases / apiContract;不要编造用户没提过的大功能。
818
834
 
819
835
  规则:
820
- 1. 针对每条 issue 修改 goal / outOfScope / acceptance / assumptions
821
- 2. 保持用户原意;缺信息时写合理、可检查的默认假设,并写进 assumptions
836
+ 1. 针对每条 issue 修改 goal / outOfScope / acceptance / assumptions / deviceMatrix / criticalPaths / exceptionCases / apiContract
837
+ 2. 保持用户原意;缺信息时写合理、可检查的默认(如 Chrome 最近两版;主路径对齐 goal;异常态须同时写上空态、失败、权限不足、超时、重试,有接口则补错误码;无接口则写「本模块无 HTTP API」),并写进 assumptions
822
838
  3. 不要写代码。不要假设仓库路径。
823
839
  4. 只输出一个 JSON,不要 markdown 围栏:
824
- {"summary":"一句话说明改了什么","goal":"...","outOfScope":"...","acceptance":"...","assumptions":"..."}`;
840
+ {"summary":"一句话说明改了什么","goal":"...","outOfScope":"...","acceptance":"...","assumptions":"...","deviceMatrix":"...","criticalPaths":"...","exceptionCases":"...","apiContract":"..."}`;
825
841
 
826
842
  const BUG_ACCEPT_PROMPT = `你是「Duaer-spec FDE」缺陷验收官。用户即将锁定缺陷卡(尚未派工)。目标是:规范缺陷描述,使数字员工能复现、修复并回归。
827
843
 
@@ -1093,6 +1109,10 @@ function parseChatResult(content) {
1093
1109
  outOfScope: flat(m.outOfScope ?? m.card?.outOfScope),
1094
1110
  acceptance: flat(m.acceptance ?? m.card?.acceptance),
1095
1111
  assumptions: flat(m.assumptions ?? m.card?.assumptions),
1112
+ deviceMatrix: flat(m.deviceMatrix ?? m.card?.deviceMatrix),
1113
+ criticalPaths: flat(m.criticalPaths ?? m.card?.criticalPaths),
1114
+ exceptionCases: flat(m.exceptionCases ?? m.card?.exceptionCases),
1115
+ apiContract: flat(m.apiContract ?? m.card?.apiContract),
1096
1116
  dependsOn: Array.isArray(m.dependsOn)
1097
1117
  ? m.dependsOn.map((x) => flat(x)).filter(Boolean).slice(0, 20)
1098
1118
  : [],
@@ -1107,6 +1127,10 @@ function parseChatResult(content) {
1107
1127
  outOfScope: flat(obj.outOfScope),
1108
1128
  acceptance: flat(obj.acceptance),
1109
1129
  assumptions: flat(obj.assumptions),
1130
+ deviceMatrix: flat(obj.deviceMatrix),
1131
+ criticalPaths: flat(obj.criticalPaths),
1132
+ exceptionCases: flat(obj.exceptionCases),
1133
+ apiContract: flat(obj.apiContract),
1110
1134
  ready: Boolean(obj.ready),
1111
1135
  activeModuleId: flat(obj.activeModuleId) || undefined,
1112
1136
  modules,
@@ -1125,6 +1149,10 @@ function parseChatResult(content) {
1125
1149
  outOfScope: "",
1126
1150
  acceptance: "",
1127
1151
  assumptions: "",
1152
+ deviceMatrix: "",
1153
+ criticalPaths: "",
1154
+ exceptionCases: "",
1155
+ apiContract: "",
1128
1156
  ready: false,
1129
1157
  options: [],
1130
1158
  };
@@ -1140,6 +1168,10 @@ function chatDoneSsePayload(parsed, { includeJsonBlock = true } = {}) {
1140
1168
  outOfScope: String(parsed?.outOfScope || ""),
1141
1169
  acceptance: String(parsed?.acceptance || ""),
1142
1170
  assumptions: String(parsed?.assumptions || ""),
1171
+ deviceMatrix: String(parsed?.deviceMatrix || ""),
1172
+ criticalPaths: String(parsed?.criticalPaths || ""),
1173
+ exceptionCases: String(parsed?.exceptionCases || ""),
1174
+ apiContract: String(parsed?.apiContract || ""),
1143
1175
  ready: Boolean(parsed?.ready),
1144
1176
  activeModuleId: parsed?.activeModuleId
1145
1177
  ? String(parsed.activeModuleId).slice(0, 80)
@@ -1153,6 +1185,10 @@ function chatDoneSsePayload(parsed, { includeJsonBlock = true } = {}) {
1153
1185
  outOfScope: String(m.outOfScope || "").slice(0, 4000),
1154
1186
  acceptance: String(m.acceptance || "").slice(0, 4000),
1155
1187
  assumptions: String(m.assumptions || "").slice(0, 4000),
1188
+ deviceMatrix: String(m.deviceMatrix || "").slice(0, 4000),
1189
+ criticalPaths: String(m.criticalPaths || "").slice(0, 4000),
1190
+ exceptionCases: String(m.exceptionCases || "").slice(0, 4000),
1191
+ apiContract: String(m.apiContract || "").slice(0, 4000),
1156
1192
  dependsOn: Array.isArray(m.dependsOn)
1157
1193
  ? m.dependsOn.map(String).slice(0, 20)
1158
1194
  : [],
@@ -1312,6 +1348,10 @@ function parseAcceptResult(content, fallback) {
1312
1348
  outOfScope: pick("outOfScope"),
1313
1349
  acceptance: pick("acceptance"),
1314
1350
  assumptions: pick("assumptions"),
1351
+ deviceMatrix: pick("deviceMatrix"),
1352
+ criticalPaths: pick("criticalPaths"),
1353
+ exceptionCases: pick("exceptionCases"),
1354
+ apiContract: pick("apiContract"),
1315
1355
  };
1316
1356
  }
1317
1357
 
@@ -1352,6 +1392,38 @@ function localAcceptCheck(card, { kind = "feature" } = {}) {
1352
1392
  `${acceptLabel}须可客观检查(打开/看到/点击/命令通过等),避免无法核对的形容词`,
1353
1393
  );
1354
1394
  }
1395
+ if (kind !== "bug") {
1396
+ const deviceMatrix = String(card.deviceMatrix || "").trim();
1397
+ const criticalPaths = String(card.criticalPaths || "").trim();
1398
+ const exceptionCases = String(card.exceptionCases || "").trim();
1399
+ if (deviceMatrix.length < 4) {
1400
+ issues.push("「浏览器/设备矩阵」必填(如 Chrome 最近两版 / 手机 Safari)");
1401
+ }
1402
+ if (criticalPaths.length < 4) {
1403
+ issues.push("「关键路径」必填(联调必须覆盖的主路径)");
1404
+ }
1405
+ if (exceptionCases.length < 4 || missingUatCases(exceptionCases).length) {
1406
+ const missing = missingUatCases(exceptionCases);
1407
+ issues.push(
1408
+ missing.length
1409
+ ? `「异常态」未覆盖 UAT:${missing.join("、")}(须含空态、失败、权限不足、超时、重试)`
1410
+ : "「异常态」必填,须覆盖空态、失败、权限不足、超时、重试",
1411
+ );
1412
+ } else if (
1413
+ apiContractIsFilled(card.apiContract) &&
1414
+ !apiContractDeclaresNoHttp(card.apiContract) &&
1415
+ !uatMentionsErrorCode(exceptionCases)
1416
+ ) {
1417
+ issues.push(
1418
+ "「异常态」须与接口契约错误码对齐(写出错误码或 HTTP status)",
1419
+ );
1420
+ }
1421
+ if (!apiContractIsFilled(card.apiContract)) {
1422
+ issues.push(
1423
+ "「接口契约」必填(OpenAPI/类型路径,或写「本模块无 HTTP API」)",
1424
+ );
1425
+ }
1426
+ }
1355
1427
  return issues;
1356
1428
  }
1357
1429
 
@@ -1366,6 +1438,10 @@ async function autoAcceptCard(cfg, card, { kind = "feature", projectContext = nu
1366
1438
  outOfScope: card.outOfScope,
1367
1439
  acceptance: card.acceptance,
1368
1440
  assumptions: card.assumptions,
1441
+ deviceMatrix: card.deviceMatrix,
1442
+ criticalPaths: card.criticalPaths,
1443
+ exceptionCases: card.exceptionCases,
1444
+ apiContract: card.apiContract,
1369
1445
  };
1370
1446
  }
1371
1447
  const ctxNote =
@@ -1392,6 +1468,10 @@ async function validateCardOnly(cfg, card, { kind = "feature", projectContext =
1392
1468
  outOfScope: String(card.outOfScope || "").trim(),
1393
1469
  acceptance: String(card.acceptance || "").trim(),
1394
1470
  assumptions: String(card.assumptions || "").trim(),
1471
+ deviceMatrix: String(card.deviceMatrix || "").trim(),
1472
+ criticalPaths: String(card.criticalPaths || "").trim(),
1473
+ exceptionCases: String(card.exceptionCases || "").trim(),
1474
+ apiContract: String(card.apiContract || "").trim(),
1395
1475
  };
1396
1476
  if (kind === "bug" && projectContext) {
1397
1477
  normalized = {
@@ -1446,6 +1526,10 @@ async function validateCardOnly(cfg, card, { kind = "feature", projectContext =
1446
1526
  outOfScope: review.outOfScope || normalized.outOfScope,
1447
1527
  acceptance: review.acceptance || normalized.acceptance,
1448
1528
  assumptions,
1529
+ deviceMatrix: review.deviceMatrix || normalized.deviceMatrix,
1530
+ criticalPaths: review.criticalPaths || normalized.criticalPaths,
1531
+ exceptionCases: review.exceptionCases || normalized.exceptionCases,
1532
+ apiContract: review.apiContract || normalized.apiContract,
1449
1533
  };
1450
1534
  }
1451
1535
 
@@ -1489,6 +1573,10 @@ async function autoFixConfirmCard(cfg, card, issues, { kind = "feature", project
1489
1573
  outOfScope: String(obj.outOfScope || card.outOfScope || "").trim(),
1490
1574
  acceptance: String(obj.acceptance || card.acceptance || "").trim(),
1491
1575
  assumptions,
1576
+ deviceMatrix: String(obj.deviceMatrix || card.deviceMatrix || "").trim(),
1577
+ criticalPaths: String(obj.criticalPaths || card.criticalPaths || "").trim(),
1578
+ exceptionCases: String(obj.exceptionCases || card.exceptionCases || "").trim(),
1579
+ apiContract: String(obj.apiContract || card.apiContract || "").trim(),
1492
1580
  };
1493
1581
  }
1494
1582
 
@@ -6292,12 +6380,16 @@ async function handleApi(req, res) {
6292
6380
  }
6293
6381
  try {
6294
6382
  const body = await readJson(req);
6295
- const card = {
6296
- goal: String(body.goal || body.change || "").trim(),
6297
- outOfScope: String(body.outOfScope || body.keep || "").trim(),
6298
- acceptance: String(body.acceptance || "").trim(),
6299
- assumptions: String(body.assumptions || body.reason || "").trim(),
6300
- };
6383
+ const card = clipCard({
6384
+ goal: body.goal || body.change,
6385
+ outOfScope: body.outOfScope || body.keep,
6386
+ acceptance: body.acceptance,
6387
+ assumptions: body.assumptions || body.reason,
6388
+ deviceMatrix: body.deviceMatrix,
6389
+ criticalPaths: body.criticalPaths,
6390
+ exceptionCases: body.exceptionCases,
6391
+ apiContract: body.apiContract,
6392
+ });
6301
6393
  const deskKind =
6302
6394
  String(body.deskKind || body.kind || "").trim() === "bug"
6303
6395
  ? "bug"
@@ -6319,12 +6411,7 @@ async function handleApi(req, res) {
6319
6411
  passed: review.passed,
6320
6412
  summary: review.summary,
6321
6413
  issues: review.issues,
6322
- card: {
6323
- goal: review.goal,
6324
- outOfScope: review.outOfScope,
6325
- acceptance: review.acceptance,
6326
- assumptions: review.assumptions,
6327
- },
6414
+ card: clipCard(review),
6328
6415
  error: review.passed ? undefined : review.summary || "自动验收未通过",
6329
6416
  });
6330
6417
  } catch (err) {
@@ -6346,12 +6433,16 @@ async function handleApi(req, res) {
6346
6433
  }
6347
6434
  try {
6348
6435
  const body = await readJson(req);
6349
- const card = {
6350
- goal: String(body.goal || body.change || "").trim(),
6351
- outOfScope: String(body.outOfScope || body.keep || "").trim(),
6352
- acceptance: String(body.acceptance || "").trim(),
6353
- assumptions: String(body.assumptions || body.reason || "").trim(),
6354
- };
6436
+ const card = clipCard({
6437
+ goal: body.goal || body.change,
6438
+ outOfScope: body.outOfScope || body.keep,
6439
+ acceptance: body.acceptance,
6440
+ assumptions: body.assumptions || body.reason,
6441
+ deviceMatrix: body.deviceMatrix,
6442
+ criticalPaths: body.criticalPaths,
6443
+ exceptionCases: body.exceptionCases,
6444
+ apiContract: body.apiContract,
6445
+ });
6355
6446
  const issues = Array.isArray(body.issues) ? body.issues : [];
6356
6447
  const deskKind =
6357
6448
  String(body.deskKind || body.kind || "").trim() === "bug"
@@ -6381,12 +6472,7 @@ async function handleApi(req, res) {
6381
6472
  fixSummary: fixed.summary,
6382
6473
  summary: review.summary,
6383
6474
  issues: review.issues,
6384
- card: {
6385
- goal: review.goal,
6386
- outOfScope: review.outOfScope,
6387
- acceptance: review.acceptance,
6388
- assumptions: review.assumptions,
6389
- },
6475
+ card: clipCard(review),
6390
6476
  error: review.passed ? undefined : review.summary || "自动验收未通过",
6391
6477
  });
6392
6478
  } catch (err) {
@@ -6408,12 +6494,16 @@ async function handleApi(req, res) {
6408
6494
  }
6409
6495
  try {
6410
6496
  const body = await readJson(req);
6411
- const card = {
6412
- goal: String(body.goal || "").trim(),
6413
- outOfScope: String(body.outOfScope || "").trim(),
6414
- acceptance: String(body.acceptance || "").trim(),
6415
- assumptions: String(body.assumptions || "").trim(),
6416
- };
6497
+ const card = clipCard({
6498
+ goal: body.goal,
6499
+ outOfScope: body.outOfScope,
6500
+ acceptance: body.acceptance,
6501
+ assumptions: body.assumptions,
6502
+ deviceMatrix: body.deviceMatrix,
6503
+ criticalPaths: body.criticalPaths,
6504
+ exceptionCases: body.exceptionCases,
6505
+ apiContract: body.apiContract,
6506
+ });
6417
6507
  if (!card.goal || !card.acceptance) {
6418
6508
  send(res, 400, { error: "goal and acceptance are required" });
6419
6509
  return;
@@ -6441,21 +6531,11 @@ async function handleApi(req, res) {
6441
6531
  error: review.summary || "自动验收未通过",
6442
6532
  summary: review.summary,
6443
6533
  issues: review.issues,
6444
- card: {
6445
- goal: review.goal,
6446
- outOfScope: review.outOfScope,
6447
- acceptance: review.acceptance,
6448
- assumptions: review.assumptions,
6449
- },
6534
+ card: clipCard(review),
6450
6535
  });
6451
6536
  return;
6452
6537
  }
6453
- const acceptedCard = {
6454
- goal: review.goal,
6455
- outOfScope: review.outOfScope,
6456
- acceptance: review.acceptance,
6457
- assumptions: review.assumptions,
6458
- };
6538
+ const acceptedCard = clipCard(review);
6459
6539
  const incomingModules = Array.isArray(body.modules) ? body.modules : [];
6460
6540
  const moduleId =
6461
6541
  deskKind === "bug"
@@ -6523,12 +6603,16 @@ async function handleApi(req, res) {
6523
6603
  }
6524
6604
  try {
6525
6605
  const body = await readJson(req);
6526
- const card = {
6527
- goal: String(body.goal || "").trim(),
6528
- outOfScope: String(body.outOfScope || "").trim(),
6529
- acceptance: String(body.acceptance || "").trim(),
6530
- assumptions: String(body.assumptions || "").trim(),
6531
- };
6606
+ const card = clipCard({
6607
+ goal: body.goal,
6608
+ outOfScope: body.outOfScope,
6609
+ acceptance: body.acceptance,
6610
+ assumptions: body.assumptions,
6611
+ deviceMatrix: body.deviceMatrix,
6612
+ criticalPaths: body.criticalPaths,
6613
+ exceptionCases: body.exceptionCases,
6614
+ apiContract: body.apiContract,
6615
+ });
6532
6616
  const issues = Array.isArray(body.issues) ? body.issues : [];
6533
6617
  if (!card.goal && !card.acceptance) {
6534
6618
  send(res, 400, { error: "确认卡为空,无法修正" });
@@ -6565,21 +6649,11 @@ async function handleApi(req, res) {
6565
6649
  summary: review.summary,
6566
6650
  fixSummary: fixed.summary,
6567
6651
  issues: review.issues,
6568
- card: {
6569
- goal: review.goal,
6570
- outOfScope: review.outOfScope,
6571
- acceptance: review.acceptance,
6572
- assumptions: review.assumptions,
6573
- },
6652
+ card: clipCard(review),
6574
6653
  });
6575
6654
  return;
6576
6655
  }
6577
- const acceptedCard = {
6578
- goal: review.goal,
6579
- outOfScope: review.outOfScope,
6580
- acceptance: review.acceptance,
6581
- assumptions: review.assumptions,
6582
- };
6656
+ const acceptedCard = clipCard(review);
6583
6657
  const incomingModules = Array.isArray(body.modules) ? body.modules : [];
6584
6658
  const moduleId =
6585
6659
  deskKind === "bug"
@@ -6778,6 +6852,16 @@ async function handleApi(req, res) {
6778
6852
  if (req.method === "POST" && url.pathname === "/api/dispatch") {
6779
6853
  try {
6780
6854
  const body = await readJson(req);
6855
+ const modules = Array.isArray(body.modules) ? body.modules : [];
6856
+ const baseline = clipBaseline(body.baseline);
6857
+ if (!baselineIsValid(baseline, modules)) {
6858
+ send(res, 422, {
6859
+ error:
6860
+ "范围+验收基线未签署或已过期;签署后才能写入 Brief 并启动(FDE-01)",
6861
+ code: "BASELINE_REQUIRED",
6862
+ });
6863
+ return;
6864
+ }
6781
6865
  const result = dispatchToRepo({
6782
6866
  jobId: body.jobId,
6783
6867
  repoPath: body.repoPath,
@@ -363,6 +363,79 @@ export function repairArchitectureGeometry(ir) {
363
363
  return ir;
364
364
  }
365
365
 
366
+ /**
367
+ * Detour long same-row edges that would pass through unrelated components.
368
+ * Archify clean-flow rejects those as edge-through-node.
369
+ * @param {object} ir
370
+ */
371
+ export function routeCrossingEdges(ir) {
372
+ if (!ir || typeof ir !== "object") return ir;
373
+ const components = Array.isArray(ir.components) ? ir.components : [];
374
+ const connections = Array.isArray(ir.connections) ? ir.connections : [];
375
+ if (components.length < 3 || !connections.length) return ir;
376
+
377
+ const byId = new Map();
378
+ for (const c of components) {
379
+ if (!c?.id || !hasFinitePos(c)) continue;
380
+ const w = hasFiniteSize(c) ? c.size[0] : 140;
381
+ const h = hasFiniteSize(c) ? c.size[1] : 64;
382
+ byId.set(c.id, {
383
+ id: c.id,
384
+ x: c.pos[0],
385
+ y: c.pos[1],
386
+ w,
387
+ h,
388
+ cx: c.pos[0] + w / 2,
389
+ cy: c.pos[1] + h / 2,
390
+ bottom: c.pos[1] + h,
391
+ top: c.pos[1],
392
+ });
393
+ }
394
+ if (byId.size < 3) return ir;
395
+
396
+ let floorY = 0;
397
+ for (const b of byId.values()) floorY = Math.max(floorY, b.bottom);
398
+ let detourIndex = 0;
399
+
400
+ for (const e of connections) {
401
+ if (!e || typeof e !== "object") continue;
402
+ if (Array.isArray(e.via) && e.via.length) continue;
403
+ const a = byId.get(e.from);
404
+ const b = byId.get(e.to);
405
+ if (!a || !b) continue;
406
+ const span = Math.abs(a.cx - b.cx);
407
+ if (span < 160) continue;
408
+
409
+ const minX = Math.min(a.cx, b.cx);
410
+ const maxX = Math.max(a.cx, b.cx);
411
+ const bandTop = Math.min(a.top, b.top) - 12;
412
+ const bandBot = Math.max(a.bottom, b.bottom) + 12;
413
+ let blocked = false;
414
+ for (const other of byId.values()) {
415
+ if (other.id === a.id || other.id === b.id) continue;
416
+ if (other.x + other.w <= minX + 12 || other.x >= maxX - 12) continue;
417
+ if (other.y < bandBot && other.y + other.h > bandTop) {
418
+ blocked = true;
419
+ break;
420
+ }
421
+ }
422
+ if (!blocked) continue;
423
+
424
+ const viaY = floorY + 48 + detourIndex * 28;
425
+ detourIndex += 1;
426
+ e.fromSide = "bottom";
427
+ e.toSide = "bottom";
428
+ e.via = [
429
+ [Math.round(a.cx), Math.round(viaY)],
430
+ [Math.round(b.cx), Math.round(viaY)],
431
+ ];
432
+ if (e.label && e.labelAt == null && e.labelDy == null) {
433
+ e.labelDy = -28;
434
+ }
435
+ }
436
+ return ir;
437
+ }
438
+
366
439
  function recomputeViewBox(ir) {
367
440
  const components = ir.components || [];
368
441
  let maxX = 320;
@@ -374,6 +447,15 @@ function recomputeViewBox(ir) {
374
447
  maxX = Math.max(maxX, c.pos[0] + w + 48);
375
448
  maxY = Math.max(maxY, c.pos[1] + h + 160);
376
449
  }
450
+ for (const e of ir.connections || []) {
451
+ if (!Array.isArray(e?.via)) continue;
452
+ for (const pt of e.via) {
453
+ if (!Array.isArray(pt) || pt.length < 2) continue;
454
+ if (!Number.isFinite(pt[0]) || !Number.isFinite(pt[1])) continue;
455
+ maxX = Math.max(maxX, pt[0] + 48);
456
+ maxY = Math.max(maxY, pt[1] + 80);
457
+ }
458
+ }
377
459
  ir.meta.viewBox = [maxX, maxY];
378
460
  }
379
461
 
@@ -394,6 +476,7 @@ export function layoutArchitectureIr(raw) {
394
476
  if (!hasFiniteSize(c)) c.size = [140, 64];
395
477
  }
396
478
  repairArchitectureGeometry(ir);
479
+ routeCrossingEdges(ir);
397
480
  recomputeViewBox(ir);
398
481
  return ir;
399
482
  }
@@ -462,11 +545,13 @@ export function layoutArchitectureIr(raw) {
462
545
  });
463
546
  }
464
547
  repairArchitectureGeometry(ir);
548
+ routeCrossingEdges(ir);
465
549
  const maxX = originX + (layers.length || 1) * colW + 80;
466
550
  ir.meta.viewBox = [
467
551
  Math.max(320, maxX),
468
552
  Math.max(240, maxY + 160),
469
553
  ];
554
+ recomputeViewBox(ir);
470
555
  return ir;
471
556
  }
472
557
 
@@ -214,6 +214,10 @@ function cardBlock(card, labels) {
214
214
  [labels.out, c.outOfScope, "chips"],
215
215
  [labels.accept, c.acceptance, "list"],
216
216
  [labels.assume, c.assumptions, "list"],
217
+ [labels.device, c.deviceMatrix, "list"],
218
+ [labels.paths, c.criticalPaths, "list"],
219
+ [labels.exceptions, c.exceptionCases, "list"],
220
+ [labels.apiContract, c.apiContract, "list"],
217
221
  ].filter(([, v]) => String(v || "").trim());
218
222
  if (!rows.length) return `<p class="empty">${esc(labels.emptyCard)}</p>`;
219
223
  return `<dl class="card-dl">${rows
@@ -386,6 +390,17 @@ export function buildDeliverablesModel(session, opts = {}) {
386
390
  card: m.card || {},
387
391
  })),
388
392
  },
393
+ {
394
+ id: "req-baseline",
395
+ title: pickLang(lang, {
396
+ en: "Scope + acceptance baseline",
397
+ ja: "範囲・受入ベースライン",
398
+ zh: "范围+验收基线",
399
+ }),
400
+ kind: "baseline",
401
+ ready: Boolean(s.baseline?.signedAt && s.baseline?.signer),
402
+ baseline: s.baseline || null,
403
+ },
389
404
  ],
390
405
  },
391
406
  {
@@ -559,6 +574,11 @@ function labelsFor(lang) {
559
574
  out: "Out of scope",
560
575
  accept: "Acceptance",
561
576
  assume: "Assumptions",
577
+ device: "Browser / device matrix",
578
+ paths: "Critical paths",
579
+ exceptions: "Exception cases",
580
+ apiContract: "API contract",
581
+ baseline: "Baseline sign-off",
562
582
  statusDone: "Ready",
563
583
  statusPartial: "In progress",
564
584
  statusEmpty: "Pending",
@@ -590,6 +610,11 @@ function labelsFor(lang) {
590
610
  out: "対象外",
591
611
  accept: "受入基準",
592
612
  assume: "前提",
613
+ device: "ブラウザ/端末",
614
+ paths: "重要パス",
615
+ exceptions: "異常系",
616
+ apiContract: "API契約",
617
+ baseline: "ベースライン署名",
593
618
  statusDone: "完了",
594
619
  statusPartial: "進行中",
595
620
  statusEmpty: "未着手",
@@ -620,6 +645,11 @@ function labelsFor(lang) {
620
645
  out: "不做",
621
646
  accept: "验收标准",
622
647
  assume: "假设",
648
+ device: "浏览器/设备矩阵",
649
+ paths: "关键路径",
650
+ exceptions: "异常态",
651
+ apiContract: "接口契约",
652
+ baseline: "基线签署",
623
653
  statusDone: "已产出",
624
654
  statusPartial: "进行中",
625
655
  statusEmpty: "未开始",
@@ -691,6 +721,29 @@ function renderArtifact(art, L, lang) {
691
721
  <thead><tr><th>#</th><th>${esc(L.module)}</th><th></th><th></th></tr></thead>
692
722
  <tbody>${rows}</tbody>
693
723
  </table>
724
+ </article>`;
725
+ }
726
+ if (art.kind === "baseline") {
727
+ const b = art.baseline && typeof art.baseline === "object" ? art.baseline : {};
728
+ if (!b.signedAt || !b.signer) {
729
+ return `<article class="artifact" id="${artId}"><h3>${esc(art.title)}</h3><p class="empty">${esc(L.emptyCard)}</p></article>`;
730
+ }
731
+ const changes = Array.isArray(b.changes) ? b.changes : [];
732
+ const changeHtml = changes.length
733
+ ? `<ul class="baseline-changes">${changes
734
+ .map(
735
+ (c) =>
736
+ `<li><time>${esc(fmtAt(c.at, lang))}</time> — ${esc(c.reason || "")}${c.signer ? ` (${esc(c.signer)})` : ""}</li>`,
737
+ )
738
+ .join("")}</ul>`
739
+ : "";
740
+ return `<article class="artifact" id="${artId}">
741
+ <h3>${esc(art.title)}</h3>
742
+ <p class="eyebrow">${esc(L.baseline)}</p>
743
+ <dl class="card-dl">
744
+ <div class="card-row"><dt>${esc(L.baseline)}</dt><dd><p class="struct-p">${esc(b.signer)} · ${esc(fmtAt(b.signedAt, lang))}</p></dd></div>
745
+ </dl>
746
+ ${changeHtml}
694
747
  </article>`;
695
748
  }
696
749
  if (art.kind === "architecture") {