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.
- package/.duaer/active-job.json +3 -3
- package/.duaer/specs/260-release-026/delivery.json +10 -9
- package/.duaer/specs/260-release-026/tasks.md +1 -3
- package/.duaer/specs/261-fde-baseline-sign/delivery.json +10 -0
- package/.duaer/specs/261-fde-baseline-sign/spec.md +37 -0
- package/.duaer/specs/261-fde-baseline-sign/tasks.md +8 -0
- package/.duaer/specs/262-fde01-readme-docs/delivery.json +9 -0
- package/.duaer/specs/262-fde01-readme-docs/spec.md +23 -0
- package/.duaer/specs/262-fde01-readme-docs/tasks.md +5 -0
- package/.duaer/specs/263-arch-edge-via/delivery.json +9 -0
- package/.duaer/specs/263-arch-edge-via/spec.md +25 -0
- package/.duaer/specs/263-arch-edge-via/tasks.md +5 -0
- package/.duaer/specs/264-baseline-field-guide/delivery.json +10 -0
- package/.duaer/specs/264-baseline-field-guide/spec.md +23 -0
- package/.duaer/specs/264-baseline-field-guide/tasks.md +5 -0
- package/.duaer/specs/265-baseline-chat-passthrough/delivery.json +9 -0
- package/.duaer/specs/265-baseline-chat-passthrough/spec.md +22 -0
- package/.duaer/specs/265-baseline-chat-passthrough/tasks.md +4 -0
- package/.duaer/specs/266-fde02-api-contract/delivery.json +10 -0
- package/.duaer/specs/266-fde02-api-contract/spec.md +28 -0
- package/.duaer/specs/266-fde02-api-contract/tasks.md +7 -0
- package/.duaer/specs/267-baseline-restore/delivery.json +9 -0
- package/.duaer/specs/267-baseline-restore/spec.md +24 -0
- package/.duaer/specs/267-baseline-restore/tasks.md +5 -0
- package/.duaer/specs/268-fde07-uat/delivery.json +9 -0
- package/.duaer/specs/268-fde07-uat/spec.md +30 -0
- package/.duaer/specs/268-fde07-uat/tasks.md +5 -0
- package/.duaer/specs/269-release-027/spec.md +12 -0
- package/.duaer/specs/269-release-027/tasks.md +4 -0
- package/CHANGELOG.md +46 -0
- package/README.md +22 -14
- package/README.zh-CN.md +6 -5
- package/bin/duaer-live.mjs +161 -77
- package/bin/live-archify.mjs +85 -0
- package/bin/live-deliverables.mjs +53 -0
- package/bin/live-modules.mjs +63 -2
- package/bin/live-project-chat.mjs +18 -1
- package/docs/agent/e2e-test-plan.md +14 -0
- package/docs/agent/live-desk.md +18 -1
- package/package.json +1 -1
- package/web/live-dev/api-contract.mjs +50 -0
- package/web/live-dev/app.js +278 -21
- package/web/live-dev/baseline.mjs +71 -0
- package/web/live-dev/confirm-card.mjs +19 -0
- package/web/live-dev/i18n-de.js +28 -0
- package/web/live-dev/i18n-es.js +28 -0
- package/web/live-dev/i18n-fr.js +28 -0
- package/web/live-dev/i18n-ja.js +28 -0
- package/web/live-dev/i18n-ko.js +28 -0
- package/web/live-dev/i18n-pt-BR.js +28 -0
- package/web/live-dev/i18n-ru.js +28 -0
- package/web/live-dev/i18n-vi.js +28 -0
- package/web/live-dev/i18n-zh-TW.js +28 -0
- package/web/live-dev/i18n.js +59 -2
- package/web/live-dev/index.html +42 -1
- package/web/live-dev/styles.css +4 -0
- package/web/live-dev/uat-pack.mjs +21 -0
package/bin/duaer-live.mjs
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
-
|
|
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
|
|
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.
|
|
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:
|
|
6297
|
-
outOfScope:
|
|
6298
|
-
acceptance:
|
|
6299
|
-
assumptions:
|
|
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:
|
|
6351
|
-
outOfScope:
|
|
6352
|
-
acceptance:
|
|
6353
|
-
assumptions:
|
|
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:
|
|
6413
|
-
outOfScope:
|
|
6414
|
-
acceptance:
|
|
6415
|
-
assumptions:
|
|
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:
|
|
6528
|
-
outOfScope:
|
|
6529
|
-
acceptance:
|
|
6530
|
-
assumptions:
|
|
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,
|
package/bin/live-archify.mjs
CHANGED
|
@@ -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") {
|