@tea-agent/loop-agent 0.38.0 → 0.38.1
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/CHANGELOG.md +95 -0
- package/dist/build-stamp.json +3 -3
- package/dist/commands/init-upgrade.js +97 -19
- package/dist/commands/init.js +14 -2
- package/dist/executors/dag-pi-executor.js +1 -0
- package/dist/governance/manifest-types.js +2 -0
- package/dist/infrastructure/harness/atomic-write.js +2 -1
- package/dist/shared/openspec-spec.js +76 -0
- package/dist/shared/operator/capabilities.js +65 -8
- package/dist/task/config-types.js +21 -0
- package/dist/task/frontend-project-capability.js +292 -38
- package/dist/worker/console/chat/mcp-inventory.js +221 -0
- package/dist/worker/console/chat/pi-runtime.js +155 -78
- package/dist/worker/console/chat/repo-walk.js +10 -1
- package/dist/worker/console/chat/routes.js +19 -8
- package/dist/worker/console/chat/semantic-activity.js +17 -5
- package/dist/worker/console/chat/turn-process.js +17 -30
- package/dist/worker/console/harness-pi-model-matrix.js +146 -0
- package/dist/worker/console/operator-actions.js +123 -0
- package/dist/worker/console/operator-user-error.js +19 -0
- package/dist/worker/console/pi-readiness.js +7 -1
- package/dist/worker/console/security.js +20 -1
- package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-DSvF6RXQ.js → abnfDiagram-N423BO3Z-Hfsot6rP.js} +1 -1
- package/dist/worker/console/static/assets/{arc-DuI4ogtJ.js → arc-YGwEASin.js} +1 -1
- package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-DdBbgD4y.js → architectureDiagram-T3A2C74G-CmOM0F92.js} +1 -1
- package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-xQC2HELm.js → blockDiagram-VBNYF7ZC-DDLuaHC5.js} +1 -1
- package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-DPMSgwIZ.js → c4Diagram-5PPSVZJV-B0Um43W4.js} +1 -1
- package/dist/worker/console/static/assets/channel-Dp_2Aqf1.js +1 -0
- package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-WTEKTxin.js → chunk-2GRJ4B5K-DvBN4JZc.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-B6vZ0Pv_.js → chunk-2Q5K7J3B-B1UVgW4E.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-5RXB4S5H-BrerNAix.js → chunk-5RXB4S5H-Cf0GgXzX.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-5VM5RSS4-D_ch2aaI.js → chunk-5VM5RSS4-DbNA24sj.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-VvfacNrq.js → chunk-6Q2QTUOP-Bi_0Be06.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-GF5L2VYU-B-uGFIg2.js → chunk-GF5L2VYU-C8pPGvRd.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-JWPE2WC7-BlysCtod.js → chunk-JWPE2WC7-BitQY8Z-.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-KBJHAD2P-BjAUAfcT.js → chunk-KBJHAD2P-U67GCiEo.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-RYQCIY6F-BeZnBmb-.js → chunk-RYQCIY6F-IoJwXPBA.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-XXDRQBXY-Bbfe3UJG.js → chunk-XXDRQBXY-YDZ_UHgE.js} +1 -1
- package/dist/worker/console/static/assets/classDiagram-JCYQIIEL--CODy37j.js +1 -0
- package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE--CODy37j.js +1 -0
- package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-BKJQnYVM.js → cose-bilkent-JH36ORCC-CUZgsiRC.js} +1 -1
- package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-B1wtI3hU.js → cynefin-VYW2F7L2-COmCh4pT.js} +1 -1
- package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-DCF6DBSu.js → cynefinDiagram-MW4NZA55-Ta6FSm68.js} +1 -1
- package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-BJyNGRlP.js → dagre-VZM6K2ZE-UdcR-VW9.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-7IWD3JNH-B7j1-VsZ.js → diagram-7IWD3JNH-DybcO3Et.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-BREJ140a.js → diagram-B4RE2ZJO-wErDFWLw.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-LBJQPF4R-CDPHmHGH.js → diagram-LBJQPF4R-DkVHZWqz.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-Q27KOJAE-BtZLrez1.js → diagram-Q27KOJAE-DdqWs7QN.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-UB23O5K3-mzybu8oU.js → diagram-UB23O5K3-C9dMVmDo.js} +1 -1
- package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-Dk3I2uO6.js → ebnfDiagram-BXEA7PRR-B2T2WcBK.js} +1 -1
- package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-BT97u_tQ.js → erDiagram-JOGREHBK-Bt9rjDFq.js} +1 -1
- package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-CMOtYzui.js → flowDiagram-UKHOOZJN-U8ic_tvi.js} +1 -1
- package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-DiNydkuY.js → ganttDiagram-PKOTCBZU-BfQJdQoO.js} +1 -1
- package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-Cwt6fLkl.js → gitGraphDiagram-DS77QQ5N-CpMftH4O.js} +1 -1
- package/dist/worker/console/static/assets/index-BA5-N5ER.css +1 -0
- package/dist/worker/console/static/assets/index-DKPXBrdc.js +325 -0
- package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-AsGIGKKB.js → infoDiagram-6WML65LV-DzqWXNO_.js} +1 -1
- package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-BL8ITZh3.js → ishikawaDiagram-WSZJBQD7-CJmGQ5Yp.js} +1 -1
- package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-VPKAUX8M.js → journeyDiagram-NVQOT4AX-ChPtKFLz.js} +1 -1
- package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-BL56hfGp.js → kanban-definition-27J2QSJJ-PRe7wB5u.js} +1 -1
- package/dist/worker/console/static/assets/{linear-BmSG3nIZ.js → linear-DpKX6QNp.js} +1 -1
- package/dist/worker/console/static/assets/{mermaid.core-DWGK9kwP.js → mermaid.core-Dnl76b7Y.js} +5 -5
- package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS--vvKLjo0.js → mindmap-definition-FAOFIHXS-S9PCXc3J.js} +1 -1
- package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-B41mxb2-.js → pegDiagram-VL7TDLO6-Du8e-FSX.js} +1 -1
- package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-DTlu77sw.js → pieDiagram-7S7Q4E2Y-CY6OmHTc.js} +1 -1
- package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-Bk5A7alv.js → quadrantDiagram-CIZ2JOQS-C65GPhpj.js} +1 -1
- package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-Csf9jzQy.js → railroadDiagram-AXF67PYL-U6IUiTCL.js} +1 -1
- package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-CeptoSNx.js → requirementDiagram-LRYGKXZP-D-I_9Qhi.js} +1 -1
- package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-A-DLCLer.js → sankeyDiagram-W5VNT64P-B57Fhed0.js} +1 -1
- package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-22VbPdOI.js → sequenceDiagram-SI44F4Z6-CJOtiw21.js} +1 -1
- package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-DWvAyBC9.js → sizeCapture-X5ZJPWSS-FyX8zxSh.js} +1 -1
- package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-qICZ75yT.js → stateDiagram-OKZ733FA-eF-pbs9t.js} +1 -1
- package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-B-mvnLEc.js +1 -0
- package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-xi7lcMyp.js → swimlanes-SLNWSIFB-CcfkA-Dq.js} +2 -2
- package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-JtpBmIv4.js +8 -0
- package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-Bu6Nl6O2.js → timeline-definition-Z64GVDOM-DH8FjnOK.js} +1 -1
- package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-BzMz4Bn9.js → vennDiagram-T6HMQDX7-beoTagJg.js} +1 -1
- package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-CxaJnzc5.js → wardleyDiagram-T6FBY63Y-CQSfw4cK.js} +1 -1
- package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-5JK-omQK.js → xychartDiagram-ELKLHX3M-CQgzQ6jn.js} +1 -1
- package/dist/worker/console/static/index.html +2 -2
- package/dist/worker/console/static-src/app/console-types.js +51 -0
- package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +44 -21
- package/dist/worker/console/static-src/operator-chat/mutation-gate.js +45 -0
- package/dist/worker/console/static-src/operator-chat/resource-diagnostics.js +204 -0
- package/dist/worker/console/static-src/operator-chat/useChatSessions.js +15 -0
- package/dist/worker/console/static-src/operator-chat/useChatStream.js +17 -4
- package/dist/worker/console/static-src/operator-chat/useChatThread.js +28 -22
- package/dist/worker/console/static-src/operator-chat/useRepoBrowser.js +26 -1
- package/dist/workflows/dag/contract-validator-registrations.js +2 -1
- package/dist/workflows/dag/failure-routing.js +9 -4
- package/dist/workflows/dag/frontend-implementation-contract.js +158 -0
- package/dist/workflows/dag/frontend-plan-render.js +90 -0
- package/dist/workflows/dag/frontend-prewrite-gate.js +411 -20
- package/dist/workflows/dag/init-hybrid.js +272 -27
- package/dist/workflows/dag/lifecycle.js +4 -0
- package/dist/workflows/dag/node-execution.js +51 -9
- package/dist/workflows/dag/report.js +6 -0
- package/dist/workflows/dag/retry-policy.js +15 -0
- package/dist/workflows/dag/types.js +50 -4
- package/dist/workflows/dag/validate.js +7 -2
- package/docs/templates/frontend-design-contract.md +40 -14
- package/docs/templates/frontend-implementation-contract.schema.json +9 -2
- package/docs/templates/frontend-task-constraints.md +40 -13
- package/docs/templates/frontend-task-requirement.md +54 -20
- package/docs/templates/harness.schema.json +8 -0
- package/harness.json +11 -2
- package/package.json +1 -1
- package/skills/frontend-design-review/references/review-checklist.md +2 -0
- package/skills/frontend-implementation/references/design-spec.md +16 -19
- package/skills/frontend-implementation/references/node-contracts.md +15 -11
- package/dist/worker/console/static/assets/channel-DLVz9TYW.js +0 -1
- package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-BeAuGcO9.js +0 -1
- package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-BeAuGcO9.js +0 -1
- package/dist/worker/console/static/assets/index-DQxiso1w.js +0 -325
- package/dist/worker/console/static/assets/index-_j0Hzo69.css +0 -1
- package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-DzKvfHTX.js +0 -1
- package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-Cxqr_qM1.js +0 -8
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resource-panel diagnostics presentation.
|
|
3
|
+
*
|
|
4
|
+
* Pi ResourceLoader emits machine English (`warning` / `collision` + full
|
|
5
|
+
* Windows paths). The Skills / Packages / Models panels must not dump that
|
|
6
|
+
* block above the inventory — it pushes the list off-screen. Classify here,
|
|
7
|
+
* then render a header chip + grouped popover with natural Chinese copy.
|
|
8
|
+
*/
|
|
9
|
+
const COLLISION_NAME = /name\s+"([^"]+)"\s+collision/i;
|
|
10
|
+
const INVALID_CHARS = /invalid characters|must be lowercase/i;
|
|
11
|
+
const INVALID_EDGE_HYPHEN = /must not start or end with a hyphen/i;
|
|
12
|
+
const INVALID_DOUBLE_HYPHEN = /consecutive hyphens/i;
|
|
13
|
+
const GROUP_COPY = {
|
|
14
|
+
"load-failed": {
|
|
15
|
+
heading: "需要处理",
|
|
16
|
+
hint: "有些清单没读完整,下面的列表可能不全。",
|
|
17
|
+
},
|
|
18
|
+
"invalid-name": {
|
|
19
|
+
heading: "名称不规范",
|
|
20
|
+
hint: "技能已经加载,还能用。名称请改成小写字母、数字和连字符,例如 sdd-requirement-analysis。",
|
|
21
|
+
},
|
|
22
|
+
collision: {
|
|
23
|
+
heading: "同名未生效",
|
|
24
|
+
hint: "同名技能只保留先扫到的那一份,后面的不会进当前会话。",
|
|
25
|
+
},
|
|
26
|
+
other: {
|
|
27
|
+
heading: "其他提示",
|
|
28
|
+
hint: "不影响你浏览当前列表。",
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
const GROUP_ORDER = [
|
|
32
|
+
"load-failed",
|
|
33
|
+
"invalid-name",
|
|
34
|
+
"collision",
|
|
35
|
+
"other",
|
|
36
|
+
];
|
|
37
|
+
export function shortResourcePath(path) {
|
|
38
|
+
if (!path)
|
|
39
|
+
return undefined;
|
|
40
|
+
const parts = path.replace(/\\/g, "/").split("/").filter(Boolean);
|
|
41
|
+
if (parts.length === 0)
|
|
42
|
+
return path;
|
|
43
|
+
const file = parts.at(-1);
|
|
44
|
+
if (!file)
|
|
45
|
+
return path;
|
|
46
|
+
const parent = parts.at(-2);
|
|
47
|
+
return parent ? `${parent}/${file}` : file;
|
|
48
|
+
}
|
|
49
|
+
export function skillNameFromPath(path) {
|
|
50
|
+
if (!path)
|
|
51
|
+
return undefined;
|
|
52
|
+
const parts = path.replace(/\\/g, "/").split("/").filter(Boolean);
|
|
53
|
+
if (parts.length === 0)
|
|
54
|
+
return undefined;
|
|
55
|
+
const last = parts.at(-1);
|
|
56
|
+
if (!last)
|
|
57
|
+
return undefined;
|
|
58
|
+
if (/^skill\.md$/i.test(last) && parts.length >= 2) {
|
|
59
|
+
return parts.at(-2);
|
|
60
|
+
}
|
|
61
|
+
return last.replace(/\.md$/i, "");
|
|
62
|
+
}
|
|
63
|
+
export function diagnosticMatchesSkill(item, skill) {
|
|
64
|
+
if (item.path) {
|
|
65
|
+
const left = item.path.replace(/\\/g, "/").toLowerCase();
|
|
66
|
+
const right = skill.path.replace(/\\/g, "/").toLowerCase();
|
|
67
|
+
if (left === right || left.endsWith(right) || right.endsWith(left)) {
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return Boolean(item.skillName && item.skillName === skill.name);
|
|
72
|
+
}
|
|
73
|
+
function shortenMessage(message) {
|
|
74
|
+
const trimmed = message.trim();
|
|
75
|
+
if (trimmed.length <= 80)
|
|
76
|
+
return trimmed;
|
|
77
|
+
return `${trimmed.slice(0, 79)}…`;
|
|
78
|
+
}
|
|
79
|
+
function titleForLoadFailed(diag) {
|
|
80
|
+
const hay = `${diag.code ?? ""} ${diag.message}`.toLowerCase();
|
|
81
|
+
if (hay.includes("preference"))
|
|
82
|
+
return "自动使用偏好读不出来";
|
|
83
|
+
if (hay.includes("package"))
|
|
84
|
+
return "软件包清单没读完整";
|
|
85
|
+
if (hay.includes("skill"))
|
|
86
|
+
return "技能清单没读完整";
|
|
87
|
+
if (hay.includes("extension"))
|
|
88
|
+
return "扩展没读完整";
|
|
89
|
+
if (hay.includes("prompt"))
|
|
90
|
+
return "提示词没读完整";
|
|
91
|
+
if (hay.includes("theme"))
|
|
92
|
+
return "主题没读完整";
|
|
93
|
+
if (hay.includes("context"))
|
|
94
|
+
return "上下文文件没读完整";
|
|
95
|
+
return "有些内容没读完整";
|
|
96
|
+
}
|
|
97
|
+
function detailForInvalidName(message) {
|
|
98
|
+
if (INVALID_DOUBLE_HYPHEN.test(message)) {
|
|
99
|
+
return "名称里不要连用两个连字符。现在这样也能用。";
|
|
100
|
+
}
|
|
101
|
+
if (INVALID_EDGE_HYPHEN.test(message)) {
|
|
102
|
+
return "名称开头和结尾不要用连字符。现在这样也能用。";
|
|
103
|
+
}
|
|
104
|
+
return "名称请只用小写字母、数字和连字符。现在这样也能用。";
|
|
105
|
+
}
|
|
106
|
+
export function classifyResourceDiagnostic(diag) {
|
|
107
|
+
const type = (diag.type ?? "warning").toLowerCase();
|
|
108
|
+
const message = diag.message ?? "";
|
|
109
|
+
const collisionMatch = message.match(COLLISION_NAME) ??
|
|
110
|
+
(type === "collision" ? message.match(/"([^"]+)"/) : null);
|
|
111
|
+
if (type === "collision" || collisionMatch) {
|
|
112
|
+
const skillName = collisionMatch?.[1] ?? skillNameFromPath(diag.path);
|
|
113
|
+
return {
|
|
114
|
+
kind: "collision",
|
|
115
|
+
tone: "warning",
|
|
116
|
+
title: skillName ? `「${skillName}」有多份` : "有一份同名技能没生效",
|
|
117
|
+
detail: "同名只保留先扫到的那一份,这份被盖掉了。",
|
|
118
|
+
path: diag.path,
|
|
119
|
+
shortPath: shortResourcePath(diag.path),
|
|
120
|
+
skillName,
|
|
121
|
+
retryable: false,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
if (INVALID_CHARS.test(message) ||
|
|
125
|
+
INVALID_EDGE_HYPHEN.test(message) ||
|
|
126
|
+
INVALID_DOUBLE_HYPHEN.test(message)) {
|
|
127
|
+
const skillName = skillNameFromPath(diag.path);
|
|
128
|
+
return {
|
|
129
|
+
kind: "invalid-name",
|
|
130
|
+
tone: "warning",
|
|
131
|
+
title: skillName ?? "有个技能名称不合规范",
|
|
132
|
+
detail: detailForInvalidName(message),
|
|
133
|
+
path: diag.path,
|
|
134
|
+
shortPath: shortResourcePath(diag.path),
|
|
135
|
+
skillName,
|
|
136
|
+
retryable: false,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
const isLoadFailed = type === "error" ||
|
|
140
|
+
diag.retryable === true ||
|
|
141
|
+
diag.code === "RESOURCE_LOAD_FAILED" ||
|
|
142
|
+
diag.code === "PACKAGE_INVENTORY_FAILED" ||
|
|
143
|
+
diag.code === "PI_RESOURCE_PREFERENCES_MALFORMED";
|
|
144
|
+
if (isLoadFailed) {
|
|
145
|
+
return {
|
|
146
|
+
kind: "load-failed",
|
|
147
|
+
tone: "error",
|
|
148
|
+
title: titleForLoadFailed(diag),
|
|
149
|
+
detail: shortenMessage(message),
|
|
150
|
+
path: diag.path,
|
|
151
|
+
shortPath: shortResourcePath(diag.path),
|
|
152
|
+
retryable: diag.retryable === true,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
return {
|
|
156
|
+
kind: "other",
|
|
157
|
+
tone: type === "error" ? "error" : "warning",
|
|
158
|
+
title: shortenMessage(message) || "一条提示",
|
|
159
|
+
path: diag.path,
|
|
160
|
+
shortPath: shortResourcePath(diag.path),
|
|
161
|
+
retryable: false,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
export function summarizeResourceDiagnostics(diagnostics) {
|
|
165
|
+
const items = diagnostics.map(classifyResourceDiagnostic);
|
|
166
|
+
const buckets = new Map();
|
|
167
|
+
for (const item of items) {
|
|
168
|
+
const list = buckets.get(item.kind) ?? [];
|
|
169
|
+
list.push(item);
|
|
170
|
+
buckets.set(item.kind, list);
|
|
171
|
+
}
|
|
172
|
+
const groups = GROUP_ORDER.flatMap((kind) => {
|
|
173
|
+
const groupItems = buckets.get(kind);
|
|
174
|
+
if (!groupItems?.length)
|
|
175
|
+
return [];
|
|
176
|
+
return [{ kind, ...GROUP_COPY[kind], items: groupItems }];
|
|
177
|
+
});
|
|
178
|
+
const errorCount = items.filter((item) => item.tone === "error").length;
|
|
179
|
+
const warningCount = items.length - errorCount;
|
|
180
|
+
return {
|
|
181
|
+
total: items.length,
|
|
182
|
+
errorCount,
|
|
183
|
+
warningCount,
|
|
184
|
+
chipLabel: chipLabel(errorCount, warningCount),
|
|
185
|
+
groups,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
function chipLabel(errorCount, warningCount) {
|
|
189
|
+
if (errorCount > 0 && warningCount > 0) {
|
|
190
|
+
return `${errorCount} 个问题 · ${warningCount} 条提示`;
|
|
191
|
+
}
|
|
192
|
+
if (errorCount > 0) {
|
|
193
|
+
return errorCount === 1 ? "1 个问题" : `${errorCount} 个问题`;
|
|
194
|
+
}
|
|
195
|
+
if (warningCount === 1)
|
|
196
|
+
return "1 条提示";
|
|
197
|
+
return `${warningCount} 条提示`;
|
|
198
|
+
}
|
|
199
|
+
export function skillHasNameWarning(skill, items) {
|
|
200
|
+
return items.some((item) => item.kind === "invalid-name" && diagnosticMatchesSkill(item, skill));
|
|
201
|
+
}
|
|
202
|
+
export function noticesForSkill(skill, items) {
|
|
203
|
+
return items.filter((item) => item.kind !== "collision" && diagnosticMatchesSkill(item, skill));
|
|
204
|
+
}
|
|
@@ -5,6 +5,7 @@ import { hydrateSessionThread } from "../../chat/turn-process.js";
|
|
|
5
5
|
import { ACTIVE_CHAT_SESSION_STORAGE_KEY } from "../../chat/workspace-landing.js";
|
|
6
6
|
import { CHAT_SESSION_LIST_LIMIT } from "../chat-view-types.js";
|
|
7
7
|
import { confirmationToken } from "./format.js";
|
|
8
|
+
import { describeMutationGateFailure, isMutationGateFailureCode, isStaleTokenFailure, } from "./mutation-gate.js";
|
|
8
9
|
import { SessionTitleWatcher } from "./session-title-watcher.js";
|
|
9
10
|
export async function createSessionAndPrimeTitleWatcher(input) {
|
|
10
11
|
if (input.creatingRef?.current) {
|
|
@@ -69,6 +70,8 @@ export function useChatSessions(params) {
|
|
|
69
70
|
// Shared busy gate for sidebar + Landing create entries (one render feedback).
|
|
70
71
|
const [creatingSession, setCreatingSession] = useState(false);
|
|
71
72
|
const creatingSessionRef = useRef(false);
|
|
73
|
+
/** One-shot guard so a stale-token failure schedules a single page reload. */
|
|
74
|
+
const compactReloadScheduledRef = useRef(false);
|
|
72
75
|
// Only the latest activate/create/restore intent may commit active-session
|
|
73
76
|
// state. This prevents slow responses from an older selection overwriting a
|
|
74
77
|
// newer user choice.
|
|
@@ -396,6 +399,18 @@ export function useChatSessions(params) {
|
|
|
396
399
|
});
|
|
397
400
|
const body = (await response.json().catch(() => ({})));
|
|
398
401
|
if (!response.ok) {
|
|
402
|
+
const gateCode = body.error?.code;
|
|
403
|
+
if (isMutationGateFailureCode(gateCode)) {
|
|
404
|
+
// Stale boot tokens: the page predates the current server boot.
|
|
405
|
+
// Auto-reload once so the fresh boot page re-injects tokens; keep
|
|
406
|
+
// an error line up while reloading in case reload is blocked.
|
|
407
|
+
setError(describeMutationGateFailure(gateCode));
|
|
408
|
+
if (isStaleTokenFailure(gateCode) && !compactReloadScheduledRef.current) {
|
|
409
|
+
compactReloadScheduledRef.current = true;
|
|
410
|
+
window.setTimeout(() => window.location.reload(), 1200);
|
|
411
|
+
}
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
399
414
|
setError(body.error?.message ?? "上下文压缩失败");
|
|
400
415
|
return;
|
|
401
416
|
}
|
|
@@ -242,9 +242,14 @@ export function useChatStream(params) {
|
|
|
242
242
|
/** Issue real POST /abort for a known turnId (Stop never disposes Session). */
|
|
243
243
|
const postAbortForTurn = useCallback(async (sessionId, generation, turnId) => {
|
|
244
244
|
refs.stoppingTurnIdRef.current = turnId;
|
|
245
|
-
//
|
|
246
|
-
|
|
247
|
-
|
|
245
|
+
// Optimistic stop (pi-web parity): the abort signal kills the SDK
|
|
246
|
+
// process tree promptly, so flip the UI idle NOW instead of holding
|
|
247
|
+
// the busy spinner through the whole POST /abort round-trip. If the
|
|
248
|
+
// server later reports the turn still running (TURN_ABORT_PENDING
|
|
249
|
+
// or owner-lost reconcile), applyAbortDecision restores busy state.
|
|
250
|
+
refs.streamingRef.current = false;
|
|
251
|
+
setStreaming(false);
|
|
252
|
+
setStreamingAssistantId(null);
|
|
248
253
|
try {
|
|
249
254
|
const res = await fetch(operatorChatAbortUrl(origin, sessionId, turnId), {
|
|
250
255
|
method: "POST",
|
|
@@ -285,6 +290,10 @@ export function useChatStream(params) {
|
|
|
285
290
|
markIdle: () => markIdleIfCurrent(sessionId, generation, refs.assistantIdRef.current),
|
|
286
291
|
});
|
|
287
292
|
applyOwnershipBagToRefs(refs, bag);
|
|
293
|
+
// Sync the post-decision busy state back to React: optimistic stop
|
|
294
|
+
// flipped it idle up front; pending/keep-busy outcomes restore it.
|
|
295
|
+
if (bag.streaming)
|
|
296
|
+
setStreaming(true);
|
|
288
297
|
}
|
|
289
298
|
catch {
|
|
290
299
|
// Abort network failure: keep busy (UIS-abort-network-fail).
|
|
@@ -297,6 +306,10 @@ export function useChatStream(params) {
|
|
|
297
306
|
networkError: true,
|
|
298
307
|
}), noticeSink());
|
|
299
308
|
applyOwnershipBagToRefs(refs, bag);
|
|
309
|
+
// Network failure: the optimistic idle was premature — restore busy
|
|
310
|
+
// so the turn is not silently dropped; /state reconcile converges.
|
|
311
|
+
refs.streamingRef.current = true;
|
|
312
|
+
setStreaming(true);
|
|
300
313
|
}
|
|
301
314
|
}, [
|
|
302
315
|
origin,
|
|
@@ -371,7 +384,7 @@ export function useChatStream(params) {
|
|
|
371
384
|
refs.streamingRef.current ||
|
|
372
385
|
refs.submitInFlightRef.current ||
|
|
373
386
|
// AC-R5-002: ownership lease blocks send until terminal release.
|
|
374
|
-
|
|
387
|
+
refs.pendingSubmissionRef.current)
|
|
375
388
|
return;
|
|
376
389
|
// AC-001: acquire synchronous submit ownership before the first await.
|
|
377
390
|
refs.submitInFlightRef.current = true;
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
2
|
-
import {
|
|
3
|
-
import { buildTurnProcessGroups } from "../../chat/turn-process.js";
|
|
2
|
+
import { buildTurnProcessGroups, hydrateSessionThread, selectRecentTurnGroups, } from "../../chat/turn-process.js";
|
|
4
3
|
import { createChatSseEventApplier, } from "./chat-sse-events.js";
|
|
5
4
|
import { taskContextUnchanged } from "./format.js";
|
|
6
5
|
const EMPTY_USAGE = {
|
|
@@ -121,9 +120,7 @@ export function useChatThread(params) {
|
|
|
121
120
|
}, 350);
|
|
122
121
|
return () => window.clearTimeout(timer);
|
|
123
122
|
}, [sessionId, operationCards, humanGateCards, interview, loadTaskContext]);
|
|
124
|
-
const
|
|
125
|
-
.filter((m) => m.role !== "system" || showSystemInMessages)
|
|
126
|
-
.slice(-historyLimit), [messages, showSystemInMessages, historyLimit]);
|
|
123
|
+
const timelineMessages = useMemo(() => messages.filter((m) => m.role !== "system" || showSystemInMessages), [messages, showSystemInMessages]);
|
|
127
124
|
useEffect(() => {
|
|
128
125
|
const onVisibility = () => {
|
|
129
126
|
if (!document.hidden)
|
|
@@ -158,12 +155,30 @@ export function useChatThread(params) {
|
|
|
158
155
|
statusKind,
|
|
159
156
|
};
|
|
160
157
|
}, [toolCalls]);
|
|
161
|
-
const turnGroups = useMemo(() =>
|
|
162
|
-
|
|
158
|
+
const turnGroups = useMemo(() => {
|
|
159
|
+
const all = buildTurnProcessGroups({
|
|
160
|
+
messages: timelineMessages,
|
|
161
|
+
toolCalls,
|
|
162
|
+
streaming,
|
|
163
|
+
activeAssistantId: streamingAssistantId,
|
|
164
|
+
});
|
|
165
|
+
return selectRecentTurnGroups(all, historyLimit);
|
|
166
|
+
}, [
|
|
167
|
+
timelineMessages,
|
|
163
168
|
toolCalls,
|
|
164
169
|
streaming,
|
|
165
|
-
|
|
166
|
-
|
|
170
|
+
streamingAssistantId,
|
|
171
|
+
historyLimit,
|
|
172
|
+
]);
|
|
173
|
+
const visibleMessages = useMemo(() => {
|
|
174
|
+
const next = [];
|
|
175
|
+
for (const group of turnGroups) {
|
|
176
|
+
if (group.user)
|
|
177
|
+
next.push(group.user);
|
|
178
|
+
next.push(...group.assistants);
|
|
179
|
+
}
|
|
180
|
+
return next;
|
|
181
|
+
}, [turnGroups]);
|
|
167
182
|
/** Reset all session-scoped projections before adopting a fresh session. */
|
|
168
183
|
const resetForNewSession = useCallback(() => {
|
|
169
184
|
setMessages([]);
|
|
@@ -220,18 +235,9 @@ export function useChatThread(params) {
|
|
|
220
235
|
refs.seenEventIdsRef.current = new Set();
|
|
221
236
|
refs.turnAssistantIdsRef.current = new Map();
|
|
222
237
|
refs.sealedAssistantIdsRef.current = new Set();
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
const content = parseAssistantContent(message.content);
|
|
227
|
-
return {
|
|
228
|
-
id: message.id,
|
|
229
|
-
role: message.role,
|
|
230
|
-
text: message.text,
|
|
231
|
-
createdAt: Date.parse(message.at) || Date.now(),
|
|
232
|
-
...(content && content.length > 0 ? { content } : {}),
|
|
233
|
-
};
|
|
234
|
-
}));
|
|
238
|
+
const hydrated = hydrateSessionThread(body.session.messages ?? []);
|
|
239
|
+
setMessages(hydrated.messages);
|
|
240
|
+
setToolCalls(hydrated.toolCalls);
|
|
235
241
|
setStreamingAssistantId(null);
|
|
236
242
|
return true;
|
|
237
243
|
}
|
|
@@ -240,7 +246,7 @@ export function useChatThread(params) {
|
|
|
240
246
|
// trigger another reconcile and retry (best-effort recovery).
|
|
241
247
|
return false;
|
|
242
248
|
}
|
|
243
|
-
}, [origin, refs, setMessages, setStreamingAssistantId]);
|
|
249
|
+
}, [origin, refs, setMessages, setToolCalls, setStreamingAssistantId]);
|
|
244
250
|
return {
|
|
245
251
|
messages,
|
|
246
252
|
setMessages,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useCallback, useEffect, useState } from "react";
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
2
|
import { ACTIVE_CHAT_SESSION_STORAGE_KEY } from "../../chat/workspace-landing.js";
|
|
3
3
|
/** Left-rail repo tree + file preview tabs state. */
|
|
4
4
|
export function useRepoBrowser(params) {
|
|
@@ -9,6 +9,8 @@ export function useRepoBrowser(params) {
|
|
|
9
9
|
const [previews, setPreviews] = useState([]);
|
|
10
10
|
const [activePreviewPath, setActivePreviewPath] = useState(null);
|
|
11
11
|
const [previewLoading, setPreviewLoading] = useState(false);
|
|
12
|
+
const expandedDirsRef = useRef(new Set());
|
|
13
|
+
expandedDirsRef.current = expandedDirs;
|
|
12
14
|
const loadTree = useCallback(async (directory = "") => {
|
|
13
15
|
try {
|
|
14
16
|
setTreeError(null);
|
|
@@ -28,6 +30,28 @@ export function useRepoBrowser(params) {
|
|
|
28
30
|
useEffect(() => {
|
|
29
31
|
void loadTree();
|
|
30
32
|
}, [loadTree]);
|
|
33
|
+
/** pi-web parity: re-fetch the root + every expanded directory. Bump
|
|
34
|
+
* `refreshTick` to trigger; cached entries are NOT reused, so newly
|
|
35
|
+
* created/deleted files appear without a manual refresh. */
|
|
36
|
+
const [refreshTick, setRefreshTick] = useState(0);
|
|
37
|
+
const refreshTree = useCallback(() => {
|
|
38
|
+
setRefreshTick((tick) => tick + 1);
|
|
39
|
+
}, []);
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
const dirs = ["", ...expandedDirsRef.current];
|
|
42
|
+
for (const dir of dirs)
|
|
43
|
+
void loadTree(dir);
|
|
44
|
+
}, [loadTree, refreshTick]);
|
|
45
|
+
// Also refresh when the tab regains focus: external tools (editor, git,
|
|
46
|
+
// CLI) may have changed the tree while the console was hidden.
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
const onVisibility = () => {
|
|
49
|
+
if (!document.hidden)
|
|
50
|
+
refreshTree();
|
|
51
|
+
};
|
|
52
|
+
document.addEventListener("visibilitychange", onVisibility);
|
|
53
|
+
return () => document.removeEventListener("visibilitychange", onVisibility);
|
|
54
|
+
}, [refreshTree]);
|
|
31
55
|
const openPreview = useCallback(async (file) => {
|
|
32
56
|
setPreviewLoading(true);
|
|
33
57
|
try {
|
|
@@ -116,6 +140,7 @@ export function useRepoBrowser(params) {
|
|
|
116
140
|
previewLoading,
|
|
117
141
|
setPreviews,
|
|
118
142
|
loadTree,
|
|
143
|
+
refreshTree,
|
|
119
144
|
openPreview,
|
|
120
145
|
closePreview,
|
|
121
146
|
toggleDir,
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* its schemaId in the contract output registry. Imported once by the node
|
|
4
4
|
* executor; keeps per-contract imports out of the executor.
|
|
5
5
|
*/
|
|
6
|
-
import { validateFrontendContractNodeOutput } from "./frontend-implementation-contract.js";
|
|
6
|
+
import { validateFrontendContractNodeOutput, validateFrontendRevisionPatchNodeOutput, } from "./frontend-implementation-contract.js";
|
|
7
7
|
import { registerStructuredContractValidator } from "./contract-output-registry.js";
|
|
8
8
|
registerStructuredContractValidator("frontend-implementation-contract-v1", validateFrontendContractNodeOutput);
|
|
9
|
+
registerStructuredContractValidator("frontend-implementation-contract-revision-patch-v1", validateFrontendRevisionPatchNodeOutput);
|
|
@@ -44,7 +44,9 @@ function routeToProductLine(input) {
|
|
|
44
44
|
case "decision-envelope":
|
|
45
45
|
return "NeedsHuman";
|
|
46
46
|
case "skipped":
|
|
47
|
-
return "
|
|
47
|
+
return input.skippedReason === "frontend-prewrite-not-authorized"
|
|
48
|
+
? "ContractMismatch"
|
|
49
|
+
: "DependencyFailure";
|
|
48
50
|
case "shell-command":
|
|
49
51
|
if (/\b(ENOENT|PATH|command not found|No such file|not found in PATH|bash: .*: No such file)\b/i.test(raw)) {
|
|
50
52
|
return "EnvFailure";
|
|
@@ -90,9 +92,12 @@ export function routeDagFailure(input) {
|
|
|
90
92
|
if (!productLineFailureCategory)
|
|
91
93
|
return {};
|
|
92
94
|
const recommendedFollowUp = productLineFailureCategory === "ContractMismatch" &&
|
|
93
|
-
|
|
94
|
-
? "frontend-
|
|
95
|
-
:
|
|
95
|
+
input.skippedReason === "frontend-prewrite-not-authorized"
|
|
96
|
+
? "fix-frontend-task-contract-and-regenerate-dag"
|
|
97
|
+
: productLineFailureCategory === "ContractMismatch" &&
|
|
98
|
+
isFrontendDesignGateNode(input.nodeId ?? "")
|
|
99
|
+
? "frontend-plan-revision-and-rerun"
|
|
100
|
+
: FOLLOW_UP_BY_PRODUCT_LINE[productLineFailureCategory];
|
|
96
101
|
return {
|
|
97
102
|
productLineFailureCategory,
|
|
98
103
|
recommendedFollowUp,
|
|
@@ -191,6 +191,42 @@ const gap = z
|
|
|
191
191
|
blocking: z.boolean(),
|
|
192
192
|
})
|
|
193
193
|
.strict();
|
|
194
|
+
/**
|
|
195
|
+
* Structured component/token selection contract (ADR 0016). Every user-visible
|
|
196
|
+
* UI purpose the plan touches must be declared here so the prewrite gate can
|
|
197
|
+
* deterministically cross-check that the selection comes from the frontend
|
|
198
|
+
* specification (openspec/ai_workspace component + theme specs) instead of
|
|
199
|
+
* relying on design-review's soft model judgment.
|
|
200
|
+
*
|
|
201
|
+
* - `specified`: the frontend spec mandates this component for the purpose →
|
|
202
|
+
* `specReference` is required (path/section/line hit).
|
|
203
|
+
* - `reuse-existing`: reuse an existing repo component/convention not named by
|
|
204
|
+
* the spec → `specReference` may be null, rationale explains the basis.
|
|
205
|
+
* - `new`: neither spec nor existing code fits → `specReference` must be null,
|
|
206
|
+
* rationale must declare the deviation (design-review approves it).
|
|
207
|
+
*
|
|
208
|
+
* `specReference` is nullable + optional because `stripNullValuesDeep` strips
|
|
209
|
+
* null object values before validation, so a `new` choice's `specReference:
|
|
210
|
+
* null` becomes an absent field; the superRefine below treats absent and null
|
|
211
|
+
* identically.
|
|
212
|
+
*/
|
|
213
|
+
const uiComponentSpecReferenceSchema = z
|
|
214
|
+
.object({
|
|
215
|
+
path: safePath,
|
|
216
|
+
section: z.string(),
|
|
217
|
+
line: z.number().int().nullable().optional(),
|
|
218
|
+
})
|
|
219
|
+
.strict();
|
|
220
|
+
const uiComponentChoiceSchema = z
|
|
221
|
+
.object({
|
|
222
|
+
purpose: z.string().min(1),
|
|
223
|
+
component: z.string().min(1),
|
|
224
|
+
decision: z.enum(["specified", "reuse-existing", "new"]),
|
|
225
|
+
specReference: uiComponentSpecReferenceSchema.nullable().optional(),
|
|
226
|
+
rationale: z.string().min(1),
|
|
227
|
+
})
|
|
228
|
+
.strict();
|
|
229
|
+
const uiComponentChoicesSchema = z.array(uiComponentChoiceSchema);
|
|
194
230
|
/**
|
|
195
231
|
* Normalize unstable model emissions before the strict schema sees them:
|
|
196
232
|
* recursively drop null values (object entries and array elements). A null for
|
|
@@ -316,6 +352,12 @@ export const frontendImplementationContractSchema = z
|
|
|
316
352
|
})
|
|
317
353
|
.strict()).min(1),
|
|
318
354
|
evidenceGaps: z.array(gap),
|
|
355
|
+
implementationSteps: z.array(z.string().min(1)).optional(),
|
|
356
|
+
stylingStrategy: z.string().min(1).optional(),
|
|
357
|
+
uiComponentChoices: uiComponentChoicesSchema.optional(),
|
|
358
|
+
dependencyPolicy: z.string().min(1).optional(),
|
|
359
|
+
residualRisks: z.array(z.string().min(1)).optional(),
|
|
360
|
+
realIntegrationGap: z.string().min(1).optional(),
|
|
319
361
|
})
|
|
320
362
|
.strict()
|
|
321
363
|
.superRefine((value, ctx) => {
|
|
@@ -391,6 +433,26 @@ export const frontendImplementationContractSchema = z
|
|
|
391
433
|
path: ["uiStates"],
|
|
392
434
|
});
|
|
393
435
|
}
|
|
436
|
+
for (const [index, choice] of (value.uiComponentChoices ?? []).entries()) {
|
|
437
|
+
if (choice.decision === "specified") {
|
|
438
|
+
if (!choice.specReference || !choice.specReference.path) {
|
|
439
|
+
ctx.addIssue({
|
|
440
|
+
code: "custom",
|
|
441
|
+
message: "specified component choice requires a non-empty specReference.path",
|
|
442
|
+
path: ["uiComponentChoices", index, "specReference"],
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
else if (choice.decision === "new") {
|
|
447
|
+
if (choice.specReference) {
|
|
448
|
+
ctx.addIssue({
|
|
449
|
+
code: "custom",
|
|
450
|
+
message: "new component choice must not carry a specReference",
|
|
451
|
+
path: ["uiComponentChoices", index, "specReference"],
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
394
456
|
if (value.mockApi.strategy !== "not-needed") {
|
|
395
457
|
if (value.mockApi.endpoints.length === 0) {
|
|
396
458
|
ctx.addIssue({
|
|
@@ -426,6 +488,44 @@ export const frontendImplementationContractSchema = z
|
|
|
426
488
|
});
|
|
427
489
|
}
|
|
428
490
|
}));
|
|
491
|
+
function isPlainObject(value) {
|
|
492
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
493
|
+
}
|
|
494
|
+
/**
|
|
495
|
+
* Apply an RFC 7386 merge-patch on a target contract without mutating it.
|
|
496
|
+
* `null` removes the key; plain objects merge recursively when the target value
|
|
497
|
+
* is also a plain object; arrays and scalars replace the whole value. The
|
|
498
|
+
* result is re-validated by analyzeFrontendImplementationContract before it is
|
|
499
|
+
* ever materialized, so a patch that removes a required field still fails
|
|
500
|
+
* closed at the gate.
|
|
501
|
+
*/
|
|
502
|
+
export function applyFrontendImplementationContractPatch(target, patch) {
|
|
503
|
+
if (!isPlainObject(patch)) {
|
|
504
|
+
throw new Error("frontend revision patch must be a JSON object");
|
|
505
|
+
}
|
|
506
|
+
const merge = (base, delta) => {
|
|
507
|
+
if (delta === null)
|
|
508
|
+
return undefined;
|
|
509
|
+
if (!isPlainObject(delta))
|
|
510
|
+
return delta;
|
|
511
|
+
const result = isPlainObject(base)
|
|
512
|
+
? { ...base }
|
|
513
|
+
: {};
|
|
514
|
+
for (const [key, value] of Object.entries(delta)) {
|
|
515
|
+
if (value === null) {
|
|
516
|
+
delete result[key];
|
|
517
|
+
}
|
|
518
|
+
else if (isPlainObject(value) && isPlainObject(result[key])) {
|
|
519
|
+
result[key] = merge(result[key], value);
|
|
520
|
+
}
|
|
521
|
+
else {
|
|
522
|
+
result[key] = value;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
return result;
|
|
526
|
+
};
|
|
527
|
+
return merge(target, patch);
|
|
528
|
+
}
|
|
429
529
|
export async function assertFrontendSourceBindingFresh(input) {
|
|
430
530
|
for (const source of input.binding.sources) {
|
|
431
531
|
const absolute = resolveDagTaskSourcePath({
|
|
@@ -648,6 +748,18 @@ function asStringArray(value) {
|
|
|
648
748
|
.map((item) => asString(item))
|
|
649
749
|
.filter((item) => item.length > 0);
|
|
650
750
|
}
|
|
751
|
+
/**
|
|
752
|
+
* Pass structured component choices through the free-form compatibility
|
|
753
|
+
* branch. The strict-shape branch already spreads unknown fields, so only this
|
|
754
|
+
* explicit constructor needs to re-emit `uiComponentChoices`; a missing/empty
|
|
755
|
+
* array normalizes to undefined (absent) so the optional schema field stays
|
|
756
|
+
* compatible with legacy free-form contracts.
|
|
757
|
+
*/
|
|
758
|
+
function normalizeUiComponentChoices(value) {
|
|
759
|
+
if (!Array.isArray(value) || value.length === 0)
|
|
760
|
+
return undefined;
|
|
761
|
+
return value;
|
|
762
|
+
}
|
|
651
763
|
function normalizeFrontendRoute(value) {
|
|
652
764
|
const route = asString(value).replaceAll("\\", "/").replace(/^\/+/, "");
|
|
653
765
|
if (!route || route === "." || route.split("/").includes(".."))
|
|
@@ -1331,6 +1443,12 @@ export function coerceFrontendImplementationContractInput(value, canonicalBindin
|
|
|
1331
1443
|
},
|
|
1332
1444
|
verificationTargets,
|
|
1333
1445
|
evidenceGaps,
|
|
1446
|
+
implementationSteps: asStringArray(record.implementationSteps),
|
|
1447
|
+
stylingStrategy: asString(record.stylingStrategy) || undefined,
|
|
1448
|
+
dependencyPolicy: asString(record.dependencyPolicy) || undefined,
|
|
1449
|
+
residualRisks: asStringArray(record.residualRisks),
|
|
1450
|
+
realIntegrationGap: asString(record.realIntegrationGap) || undefined,
|
|
1451
|
+
uiComponentChoices: normalizeUiComponentChoices(record.uiComponentChoices),
|
|
1334
1452
|
};
|
|
1335
1453
|
}
|
|
1336
1454
|
/**
|
|
@@ -1514,6 +1632,46 @@ export async function validateFrontendContractNodeOutput(input) {
|
|
|
1514
1632
|
};
|
|
1515
1633
|
}
|
|
1516
1634
|
}
|
|
1635
|
+
/**
|
|
1636
|
+
* Node-output self-check for the plan revision node, which emits an RFC 7386
|
|
1637
|
+
* merge-patch delta against the original contract instead of a full contract.
|
|
1638
|
+
* Deliberately loose and format-level: the output must contain at least one
|
|
1639
|
+
* ```openspec-citations fenced block and must extract (after the citations
|
|
1640
|
+
* blocks are stripped) to exactly one JSON object. The delta carries no
|
|
1641
|
+
* schemaVersion/targets, so no full-contract schema/semantic checks run here;
|
|
1642
|
+
* content parsing, patch application, and merged-contract validation stay with
|
|
1643
|
+
* the prewrite gate, which remains the only authority.
|
|
1644
|
+
*/
|
|
1645
|
+
export async function validateFrontendRevisionPatchNodeOutput(input) {
|
|
1646
|
+
// Mirror the prewrite gate's block-presence semantics (a fenced
|
|
1647
|
+
// ```openspec-citations block with a newline-terminated body).
|
|
1648
|
+
if (!/```openspec-citations[ \t]*\r?\n[\s\S]*?\r?\n```/.test(input.text)) {
|
|
1649
|
+
return {
|
|
1650
|
+
ok: false,
|
|
1651
|
+
reason: "revision patch output must include at least one ```openspec-citations fenced block",
|
|
1652
|
+
};
|
|
1653
|
+
}
|
|
1654
|
+
// Strip the citation blocks before extraction: their per-row JSON objects
|
|
1655
|
+
// would otherwise count as competing balanced objects in the extractor.
|
|
1656
|
+
const textWithoutCitations = input.text.replace(/```openspec-citations[ \t]*\r?\n[\s\S]*?\r?\n```/g, "");
|
|
1657
|
+
let patch;
|
|
1658
|
+
try {
|
|
1659
|
+
patch = extractFrontendImplementationJson(textWithoutCitations);
|
|
1660
|
+
}
|
|
1661
|
+
catch (error) {
|
|
1662
|
+
return {
|
|
1663
|
+
ok: false,
|
|
1664
|
+
reason: `revision patch output must contain exactly one valid JSON object: ${error instanceof Error ? error.message : String(error)}`,
|
|
1665
|
+
};
|
|
1666
|
+
}
|
|
1667
|
+
if (!patch || typeof patch !== "object" || Array.isArray(patch)) {
|
|
1668
|
+
return {
|
|
1669
|
+
ok: false,
|
|
1670
|
+
reason: "revision patch output must extract to exactly one JSON object (RFC 7386 merge-patch delta)",
|
|
1671
|
+
};
|
|
1672
|
+
}
|
|
1673
|
+
return { ok: true, contract: patch };
|
|
1674
|
+
}
|
|
1517
1675
|
export async function materializeFrontendImplementationContract(input) {
|
|
1518
1676
|
const analysis = await analyzeFrontendImplementationContract(input);
|
|
1519
1677
|
return writeFrontendImplementationContractArtifact({
|