@twinsearth/dsh-master 3.3.4 → 3.3.6
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/README.md +1 -0
- package/client.js +83 -4
- package/cordis.patch.yml +2 -0
- package/dist-host/agents/pjm-agent.js +18 -2
- package/dist-host/agents/pm-agent.js +23 -1
- package/dist-host/agents/prompts.js +16 -6
- package/dist-host/channel/abort-registry.js +93 -0
- package/dist-host/channel/protocol.js +114 -0
- package/dist-host/channel/server.js +349 -0
- package/dist-host/channel/stream-state.js +343 -0
- package/dist-host/channel/ws.js +199 -0
- package/dist-host/dual/documents.js +36 -0
- package/dist-host/dual/orchestrator.js +214 -0
- package/dist-host/dual/schema.js +153 -0
- package/dist-host/engine/atomic-switch.js +159 -0
- package/dist-host/engine/health-probe.js +90 -0
- package/dist-host/engine/index.js +18 -0
- package/dist-host/engine/llm-stream.js +215 -0
- package/dist-host/engine/plugin-updater.js +392 -0
- package/dist-host/engine/safe-update.js +150 -0
- package/dist-host/index.js +114 -2
- package/dist-host/kernel.js +7 -7
- package/dist-host/plugins/cost-optimizer.js +3 -3
- package/dist-host/plugins/dual-manager.js +230 -6
- package/docs/AGENTS-PM-PJM.md +90 -6
- package/docs/CHANGELOG.md +63 -0
- package/docs/CHANNEL-WEBSOCKET.md +169 -0
- package/docs/DESIGN-command-center.md +147 -0
- package/docs/ENGINE-PRIMITIVES.md +215 -0
- package/docs/GAP-ANALYSIS-fang-session.md +75 -0
- package/docs/REVIEW-PM-PJM-3.3.4.md +32 -6
- package/docs/examples/command-center/README.md +167 -0
- package/package.json +3 -1
- package/src/agents/pjm-agent.ts +18 -2
- package/src/agents/pm-agent.ts +21 -1
- package/src/agents/prompts.ts +16 -6
- package/src/channel/abort-registry.ts +85 -0
- package/src/channel/protocol.ts +110 -0
- package/src/channel/server.ts +289 -0
- package/src/channel/stream-state.ts +349 -0
- package/src/channel/ws.ts +182 -0
- package/src/dual/documents.ts +35 -0
- package/src/dual/orchestrator.ts +223 -0
- package/src/dual/schema.ts +148 -1
- package/src/engine/atomic-switch.ts +146 -0
- package/src/engine/health-probe.ts +88 -0
- package/src/engine/index.ts +20 -0
- package/src/engine/llm-stream.ts +189 -0
- package/src/engine/plugin-updater.ts +359 -0
- package/src/engine/safe-update.ts +148 -0
- package/src/index.ts +105 -2
- package/src/kernel.ts +7 -7
- package/src/plugins/cost-optimizer.ts +3 -3
- package/src/plugins/dual-manager.ts +211 -6
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Copyright (c) 2026 dsh-Master contributors — see LICENSE-CORE.
|
|
3
|
+
// @ts-nocheck
|
|
4
|
+
/**
|
|
5
|
+
* 最小 RFC 6455 服务端实现(不引入 `ws` 依赖)。
|
|
6
|
+
*
|
|
7
|
+
* 只覆盖本通道需要的能力:
|
|
8
|
+
* * 握手:`Sec-WebSocket-Accept` = base64(sha1(key + GUID));
|
|
9
|
+
* * 收帧:客户端帧必须带掩码(RFC 要求),支持 7/16/64 位长度、分片续帧、close/ping/pong;
|
|
10
|
+
* * 发帧:服务端文本帧不加掩码,另发 ping/close。
|
|
11
|
+
*
|
|
12
|
+
* 不做:permessage-deflate、二进制 payload 处理(收到即忽略)、扩展协商。
|
|
13
|
+
*/
|
|
14
|
+
import crypto from 'node:crypto';
|
|
15
|
+
export const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
|
|
16
|
+
export const WS_MAX_PAYLOAD = 256 * 1024;
|
|
17
|
+
export const OPCODE = Object.freeze({ continuation: 0x0, text: 0x1, binary: 0x2, close: 0x8, ping: 0x9, pong: 0xa });
|
|
18
|
+
/** 计算握手 accept 值(RFC 6455 §1.3)。 */
|
|
19
|
+
export function acceptKey(clientKey) {
|
|
20
|
+
return crypto.createHash('sha1').update(String(clientKey) + WS_GUID).digest('base64');
|
|
21
|
+
}
|
|
22
|
+
/** 是否是合法的 WebSocket 升级请求;返回具名原因以便拒绝时说明。 */
|
|
23
|
+
export function validateUpgradeRequest(req) {
|
|
24
|
+
const headers = req?.headers ?? {};
|
|
25
|
+
const upgrade = String(headers.upgrade ?? '').toLowerCase();
|
|
26
|
+
const connection = String(headers.connection ?? '').toLowerCase();
|
|
27
|
+
const key = String(headers['sec-websocket-key'] ?? '').trim();
|
|
28
|
+
const version = String(headers['sec-websocket-version'] ?? '').trim();
|
|
29
|
+
if (upgrade !== 'websocket')
|
|
30
|
+
return { ok: false, code: 'not-websocket-upgrade' };
|
|
31
|
+
if (!connection.split(',').map((part) => part.trim()).includes('upgrade'))
|
|
32
|
+
return { ok: false, code: 'missing-connection-upgrade' };
|
|
33
|
+
if (!key || Buffer.from(key, 'base64').length !== 16)
|
|
34
|
+
return { ok: false, code: 'invalid-websocket-key' };
|
|
35
|
+
if (version !== '13')
|
|
36
|
+
return { ok: false, code: 'unsupported-websocket-version', version };
|
|
37
|
+
return { ok: true, key, accept: acceptKey(key) };
|
|
38
|
+
}
|
|
39
|
+
/** 生成 101 响应文本。 */
|
|
40
|
+
export function upgradeResponse(accept) {
|
|
41
|
+
return [
|
|
42
|
+
'HTTP/1.1 101 Switching Protocols',
|
|
43
|
+
'Upgrade: websocket',
|
|
44
|
+
'Connection: Upgrade',
|
|
45
|
+
`Sec-WebSocket-Accept: ${accept}`,
|
|
46
|
+
'', '',
|
|
47
|
+
].join('\r\n');
|
|
48
|
+
}
|
|
49
|
+
/** 生成拒绝响应(带状态码与具名原因)。 */
|
|
50
|
+
export function rejectionResponse(status, code, message) {
|
|
51
|
+
const body = JSON.stringify({ ok: false, code, message });
|
|
52
|
+
return [
|
|
53
|
+
`HTTP/1.1 ${status} ${status === 401 ? 'Unauthorized' : status === 400 ? 'Bad Request' : 'Forbidden'}`,
|
|
54
|
+
'Content-Type: application/json; charset=utf-8',
|
|
55
|
+
`Content-Length: ${Buffer.byteLength(body)}`,
|
|
56
|
+
'Connection: close',
|
|
57
|
+
'', body,
|
|
58
|
+
].join('\r\n');
|
|
59
|
+
}
|
|
60
|
+
function encodeFrame(opcode, payloadBuffer) {
|
|
61
|
+
const length = payloadBuffer.length;
|
|
62
|
+
let header = null;
|
|
63
|
+
if (length < 126) {
|
|
64
|
+
header = Buffer.alloc(2);
|
|
65
|
+
header[1] = length;
|
|
66
|
+
}
|
|
67
|
+
else if (length < 65536) {
|
|
68
|
+
header = Buffer.alloc(4);
|
|
69
|
+
header[1] = 126;
|
|
70
|
+
header.writeUInt16BE(length, 2);
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
header = Buffer.alloc(10);
|
|
74
|
+
header[1] = 127;
|
|
75
|
+
header.writeBigUInt64BE(BigInt(length), 2);
|
|
76
|
+
}
|
|
77
|
+
header[0] = 0x80 | opcode; // FIN + opcode
|
|
78
|
+
return Buffer.concat([header, payloadBuffer]);
|
|
79
|
+
}
|
|
80
|
+
export function encodeText(value) {
|
|
81
|
+
return encodeFrame(OPCODE.text, Buffer.from(String(value), 'utf8'));
|
|
82
|
+
}
|
|
83
|
+
export function encodeClose(code = 1000, reason = '') {
|
|
84
|
+
const reasonBuffer = Buffer.from(String(reason), 'utf8');
|
|
85
|
+
const payload = Buffer.alloc(2 + reasonBuffer.length);
|
|
86
|
+
payload.writeUInt16BE(Number(code) || 1000, 0);
|
|
87
|
+
reasonBuffer.copy(payload, 2);
|
|
88
|
+
return encodeFrame(OPCODE.close, payload);
|
|
89
|
+
}
|
|
90
|
+
export function encodePing() {
|
|
91
|
+
return encodeFrame(OPCODE.ping, Buffer.alloc(0));
|
|
92
|
+
}
|
|
93
|
+
export function encodePong(payload = Buffer.alloc(0)) {
|
|
94
|
+
return encodeFrame(OPCODE.pong, Buffer.from(payload));
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* 增量帧解析器。`push(buffer)` 返回本次解析出的完整消息与需要回应的控制帧。
|
|
98
|
+
* 超过 maxPayload 的帧返回 error 并停止后续解析(由调用方断开连接)。
|
|
99
|
+
*/
|
|
100
|
+
export function createFrameParser({ maxPayload = WS_MAX_PAYLOAD } = {}) {
|
|
101
|
+
let pending = Buffer.alloc(0);
|
|
102
|
+
let fragments = [];
|
|
103
|
+
let fragmentOpcode = null;
|
|
104
|
+
function takeMessage(opcode, payload) {
|
|
105
|
+
if (opcode === OPCODE.continuation) {
|
|
106
|
+
if (fragmentOpcode === null)
|
|
107
|
+
return { error: 'unexpected-continuation' };
|
|
108
|
+
fragments.push(payload);
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
fragments = [payload];
|
|
112
|
+
fragmentOpcode = opcode;
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
return {
|
|
116
|
+
push(chunk) {
|
|
117
|
+
pending = pending.length === 0 ? Buffer.from(chunk) : Buffer.concat([pending, chunk]);
|
|
118
|
+
const messages = [];
|
|
119
|
+
const controls = [];
|
|
120
|
+
const errors = [];
|
|
121
|
+
while (pending.length >= 2) {
|
|
122
|
+
const first = pending[0];
|
|
123
|
+
const second = pending[1];
|
|
124
|
+
const fin = (first & 0x80) !== 0;
|
|
125
|
+
const opcode = first & 0x0f;
|
|
126
|
+
const masked = (second & 0x80) !== 0;
|
|
127
|
+
let length = second & 0x7f;
|
|
128
|
+
let offset = 2;
|
|
129
|
+
if (length === 126) {
|
|
130
|
+
if (pending.length < offset + 2)
|
|
131
|
+
break;
|
|
132
|
+
length = pending.readUInt16BE(offset);
|
|
133
|
+
offset += 2;
|
|
134
|
+
}
|
|
135
|
+
else if (length === 127) {
|
|
136
|
+
if (pending.length < offset + 8)
|
|
137
|
+
break;
|
|
138
|
+
const big = pending.readBigUInt64BE(offset);
|
|
139
|
+
if (big > BigInt(maxPayload)) {
|
|
140
|
+
errors.push('payload-too-large');
|
|
141
|
+
return { messages, controls, errors };
|
|
142
|
+
}
|
|
143
|
+
length = Number(big);
|
|
144
|
+
offset += 8;
|
|
145
|
+
}
|
|
146
|
+
if (length > maxPayload) {
|
|
147
|
+
errors.push('payload-too-large');
|
|
148
|
+
return { messages, controls, errors };
|
|
149
|
+
}
|
|
150
|
+
if (!masked) {
|
|
151
|
+
errors.push('client-frame-must-be-masked');
|
|
152
|
+
return { messages, controls, errors };
|
|
153
|
+
}
|
|
154
|
+
if (pending.length < offset + 4 + length)
|
|
155
|
+
break;
|
|
156
|
+
const mask = pending.subarray(offset, offset + 4);
|
|
157
|
+
offset += 4;
|
|
158
|
+
const payload = Buffer.from(pending.subarray(offset, offset + length));
|
|
159
|
+
for (let index = 0; index < payload.length; index += 1)
|
|
160
|
+
payload[index] ^= mask[index % 4];
|
|
161
|
+
pending = pending.subarray(offset + length);
|
|
162
|
+
if (opcode === OPCODE.ping) {
|
|
163
|
+
controls.push({ type: 'ping', payload });
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
if (opcode === OPCODE.pong) {
|
|
167
|
+
controls.push({ type: 'pong', payload });
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
if (opcode === OPCODE.close) {
|
|
171
|
+
controls.push({ type: 'close', payload });
|
|
172
|
+
fragmentOpcode = null;
|
|
173
|
+
fragments = [];
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
if (opcode === OPCODE.binary) {
|
|
177
|
+
controls.push({ type: 'binary-ignored' });
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
const result = takeMessage(opcode, payload);
|
|
181
|
+
if (result && result.error) {
|
|
182
|
+
errors.push(result.error);
|
|
183
|
+
return { messages, controls, errors };
|
|
184
|
+
}
|
|
185
|
+
if (fin) {
|
|
186
|
+
const merged = Buffer.concat(fragments);
|
|
187
|
+
const frameOpcode = fragmentOpcode;
|
|
188
|
+
fragments = [];
|
|
189
|
+
fragmentOpcode = null;
|
|
190
|
+
if (frameOpcode === OPCODE.text)
|
|
191
|
+
messages.push(merged.toString('utf8'));
|
|
192
|
+
else
|
|
193
|
+
controls.push({ type: 'binary-ignored' });
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return { messages, controls, errors };
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
}
|
|
@@ -105,3 +105,39 @@ export function renderClosureDocument(plan) {
|
|
|
105
105
|
'#### 项目资料汇总', '', bullets(closure.artifacts), '',
|
|
106
106
|
].join('\n');
|
|
107
107
|
}
|
|
108
|
+
/** PM 的复核 + 复盘 + 总结(闭环第三步)。 */
|
|
109
|
+
export function renderReviewDocument(review, { feedback = null, handoff = null } = {}) {
|
|
110
|
+
const checks = review.acceptanceChecks;
|
|
111
|
+
const failed = checks.filter((check) => check.met !== true);
|
|
112
|
+
const out = [
|
|
113
|
+
`# 复核与复盘 · ${review.id}`, '',
|
|
114
|
+
`- 结论:**${review.verdict === 'accepted' ? '通过' : '退回修改'}**`,
|
|
115
|
+
`- 关联计划:${review.planId}`,
|
|
116
|
+
`- 关联 PRD:${review.prdId ?? '未关联'}`,
|
|
117
|
+
`- 反馈 ID:${review.feedbackId}`, '',
|
|
118
|
+
'## 一、复核:验收逐条核对', '',
|
|
119
|
+
'| 验收标准 | 结果 | 证据 | 备注 |',
|
|
120
|
+
'|---|---|---|---|',
|
|
121
|
+
...checks.map((check) => `| ${cell(check.criterion)} | ${check.met ? '通过' : '未通过'} | ${cell(check.evidence.join(';') || '—')} | ${cell(check.note || '—')} |`), '',
|
|
122
|
+
failed.length > 0 ? `**未通过项**:${failed.map((check) => check.criterion).join(';')}` : '**全部验收项通过。**', '',
|
|
123
|
+
];
|
|
124
|
+
if (feedback) {
|
|
125
|
+
out.push('## 二、执行回执(PJM 自反馈)', '', feedback.summary, '');
|
|
126
|
+
out.push(`- 步骤:${feedback.metrics.done}/${feedback.metrics.steps}(${feedback.metrics.percent}%)`);
|
|
127
|
+
out.push(`- 估时/实际:${feedback.metrics.estimatedMinutes} / ${feedback.metrics.actualMinutes} 分钟`);
|
|
128
|
+
if (feedback.deviations.length)
|
|
129
|
+
out.push('- 偏差:', bullets(feedback.deviations));
|
|
130
|
+
if (feedback.openIssues.length)
|
|
131
|
+
out.push('- 遗留问题:', bullets(feedback.openIssues));
|
|
132
|
+
out.push('');
|
|
133
|
+
}
|
|
134
|
+
if (handoff) {
|
|
135
|
+
out.push('## 三、交接对照', '', `- 交接包:${handoff.id}(${handoff.status})`, `- 验收标准数:${handoff.acceptance.length}`, '');
|
|
136
|
+
}
|
|
137
|
+
out.push('## 四、复盘', '');
|
|
138
|
+
out.push('### 保持(做对了什么)', '', bullets(review.retrospective.kept), '');
|
|
139
|
+
out.push('### 改进(哪里不顺)', '', bullets(review.retrospective.improve), '');
|
|
140
|
+
out.push('### 经验(下次怎么做)', '', bullets(review.retrospective.lessons), '');
|
|
141
|
+
out.push('## 五、总结', '', review.summary, '');
|
|
142
|
+
return out.join('\n').trim() + '\n';
|
|
143
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Copyright (c) 2026 dsh-Master contributors — see LICENSE-CORE.
|
|
3
|
+
// @ts-nocheck
|
|
4
|
+
/**
|
|
5
|
+
* PM/PJM 编排策略(3.3.5)。
|
|
6
|
+
*
|
|
7
|
+
* 解决的问题:模型知道工具清单,但不知道“现在这一步该用哪个”。让它在 7 个工具里猜,
|
|
8
|
+
* 就会出现「PRD 还没写就交接」「还有未决问题就派发」这类错误。
|
|
9
|
+
*
|
|
10
|
+
* 这里把闭环状态机写成确定性函数:给定结构化状态,返回**唯一推荐动作**与理由。
|
|
11
|
+
* 模型仍然负责内容(写 PRD、拆 WBS、判定验收),编排不替它做判断,也不产生副作用。
|
|
12
|
+
*
|
|
13
|
+
* 约束:
|
|
14
|
+
* * 纯函数,不读全局、不写数据;
|
|
15
|
+
* * 每个 action 都必须对应真实存在的工具名(测试会校验,防止漂移);
|
|
16
|
+
* * 无法推进时返回 `blocked` + 具名原因,而不是编一个动作。
|
|
17
|
+
*/
|
|
18
|
+
export const PM_ACTIONS = Object.freeze([
|
|
19
|
+
'create_prd', 'create_assessment', 'clarify', 'handoff', 'review', 'done', 'blocked',
|
|
20
|
+
]);
|
|
21
|
+
export const PJM_ACTIONS = Object.freeze([
|
|
22
|
+
'take_handoff', 'return_to_pm', 'create_plan', 'dispatch', 'report', 'complete_feedback', 'close', 'done', 'blocked',
|
|
23
|
+
]);
|
|
24
|
+
/** action → 真实工具名(agent preset 里注册过;测试比对)。 */
|
|
25
|
+
export const ACTION_TOOL = Object.freeze({
|
|
26
|
+
create_prd: 'pm_create_prd',
|
|
27
|
+
create_assessment: 'pm_create_assessment',
|
|
28
|
+
clarify: 'pm_create_prd',
|
|
29
|
+
handoff: 'pm_handoff_to_pjm',
|
|
30
|
+
review: 'pm_review_feedback',
|
|
31
|
+
take_handoff: 'pjm_take_handoff',
|
|
32
|
+
return_to_pm: 'pjm_take_handoff',
|
|
33
|
+
create_plan: 'pjm_create_plan',
|
|
34
|
+
dispatch: 'pjm_dispatch_step',
|
|
35
|
+
report: 'pjm_report',
|
|
36
|
+
complete_feedback: 'pjm_submit_feedback',
|
|
37
|
+
close: 'pjm_close_project',
|
|
38
|
+
done: null,
|
|
39
|
+
blocked: null,
|
|
40
|
+
});
|
|
41
|
+
/**
|
|
42
|
+
* PM 的下一步:PRD → 盘点 → 澄清 → 交接 → 复核。
|
|
43
|
+
* @param {{prd?: object|null, assessment?: object|null, handoff?: object|null, feedback?: object|null, review?: object|null}} state
|
|
44
|
+
*/
|
|
45
|
+
export function nextPmAction(state = {}) {
|
|
46
|
+
const prd = state.prd ?? null;
|
|
47
|
+
const assessment = state.assessment ?? null;
|
|
48
|
+
const handoff = state.handoff ?? null;
|
|
49
|
+
const feedback = state.feedback ?? null;
|
|
50
|
+
const review = state.review ?? null;
|
|
51
|
+
if (!prd) {
|
|
52
|
+
return { action: 'create_prd', tool: ACTION_TOOL.create_prd, reason: 'no-prd-yet', detail: '先写结构化 PRD(title + goal)' };
|
|
53
|
+
}
|
|
54
|
+
if (!assessment) {
|
|
55
|
+
return { action: 'create_assessment', tool: ACTION_TOOL.create_assessment, reason: 'no-assessment-yet', detail: '六类盘点必须先落库,completed 项要有证据' };
|
|
56
|
+
}
|
|
57
|
+
const openQuestions = Array.isArray(prd.openQuestions) ? prd.openQuestions.length : 0;
|
|
58
|
+
const conflicts = Array.isArray(assessment.conflicts) ? assessment.conflicts.length : 0;
|
|
59
|
+
if (openQuestions > 0 || conflicts > 0) {
|
|
60
|
+
return {
|
|
61
|
+
action: 'clarify',
|
|
62
|
+
tool: null,
|
|
63
|
+
reason: openQuestions > 0 ? 'open-questions-unresolved' : 'conflicts-unresolved',
|
|
64
|
+
detail: openQuestions > 0
|
|
65
|
+
? `PRD 还有 ${openQuestions} 个未决问题,必须用 ask_user_question 一次问一个决策点`
|
|
66
|
+
: `盘点里有 ${conflicts} 个冲突,必须让用户重新判断(回复:新需求新方案)`,
|
|
67
|
+
blocking: true,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
if (!handoff) {
|
|
71
|
+
return {
|
|
72
|
+
action: 'handoff',
|
|
73
|
+
tool: ACTION_TOOL.handoff,
|
|
74
|
+
reason: 'ready-to-handoff',
|
|
75
|
+
detail: '验收标准齐全且无未决项,打交接包给 PJM',
|
|
76
|
+
argsHint: { prdId: prd.id, assessmentId: assessment.id },
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
if (handoff.status === 'needs-clarification') {
|
|
80
|
+
return {
|
|
81
|
+
action: 'blocked',
|
|
82
|
+
tool: null,
|
|
83
|
+
reason: handoff.reason || 'handoff-not-ready',
|
|
84
|
+
detail: '交接包未就绪,先解决未决问题再重新交接',
|
|
85
|
+
blocking: true,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
if (feedback && !review) {
|
|
89
|
+
return {
|
|
90
|
+
action: 'review',
|
|
91
|
+
tool: ACTION_TOOL.review,
|
|
92
|
+
reason: 'feedback-awaiting-review',
|
|
93
|
+
detail: 'PJM 已交反馈:逐条核对验收(met + 证据),写复盘与总结;结论必须与验收一致',
|
|
94
|
+
argsHint: { feedbackId: feedback.id },
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
if (review) {
|
|
98
|
+
return {
|
|
99
|
+
action: 'done',
|
|
100
|
+
tool: null,
|
|
101
|
+
reason: review.verdict === 'accepted' ? 'loop-accepted' : 'loop-changes-requested',
|
|
102
|
+
detail: review.verdict === 'accepted' ? '本轮闭环已通过' : 'PJM 需按复核意见重做',
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
return {
|
|
106
|
+
action: 'blocked',
|
|
107
|
+
tool: null,
|
|
108
|
+
reason: 'waiting-for-pjm',
|
|
109
|
+
detail: '等 PJM 接单并提交反馈;PM 不替它派发',
|
|
110
|
+
blocking: false,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* PJM 的下一步:接单 → 计划 → 派发 → 反馈 → 结案。
|
|
115
|
+
* @param {{handoff?: object|null, plan?: object|null, feedback?: object|null}} state
|
|
116
|
+
*/
|
|
117
|
+
export function nextPjmAction(state = {}) {
|
|
118
|
+
const handoff = state.handoff ?? null;
|
|
119
|
+
const plan = state.plan ?? null;
|
|
120
|
+
const feedback = state.feedback ?? null;
|
|
121
|
+
if (handoff && handoff.status === 'needs-clarification') {
|
|
122
|
+
return {
|
|
123
|
+
action: 'return_to_pm',
|
|
124
|
+
tool: null,
|
|
125
|
+
reason: 'handoff-needs-clarification',
|
|
126
|
+
detail: '交接包未就绪:退回 PM 澄清,不得硬开工',
|
|
127
|
+
blocking: true,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
if (handoff && handoff.status === 'ready' && !plan) {
|
|
131
|
+
return {
|
|
132
|
+
action: 'take_handoff',
|
|
133
|
+
tool: ACTION_TOOL.take_handoff,
|
|
134
|
+
reason: 'handoff-ready',
|
|
135
|
+
detail: '接单后再拆 WBS',
|
|
136
|
+
argsHint: { handoffId: handoff.id },
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
if (!plan) {
|
|
140
|
+
return {
|
|
141
|
+
action: 'create_plan',
|
|
142
|
+
tool: ACTION_TOOL.create_plan,
|
|
143
|
+
reason: 'no-plan-yet',
|
|
144
|
+
detail: '用 pjm_create_plan 写七类项目内容,步骤必须自己拆(交接包不含 WBS)',
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
const steps = Array.isArray(plan.steps) ? plan.steps : [];
|
|
148
|
+
const failed = steps.filter((step) => step.status === 'failed');
|
|
149
|
+
const running = steps.filter((step) => step.status === 'running' || step.status === 'dispatching');
|
|
150
|
+
const pending = steps.filter((step) => step.status === 'pending');
|
|
151
|
+
const blockedReport = Array.isArray(plan.reports) ? [...plan.reports].reverse().find((report) => report.status === 'blocked') : null;
|
|
152
|
+
if (blockedReport && (blockedReport.blocker || blockedReport.summary)) {
|
|
153
|
+
return {
|
|
154
|
+
action: 'report',
|
|
155
|
+
tool: ACTION_TOOL.report,
|
|
156
|
+
reason: 'blocked-reported',
|
|
157
|
+
detail: `最近一次节点报告为阻塞:${blockedReport.blocker || blockedReport.summary};需要 PM/用户决策后才能继续`,
|
|
158
|
+
blocking: true,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
if (failed.length > 0) {
|
|
162
|
+
return {
|
|
163
|
+
action: 'report',
|
|
164
|
+
tool: ACTION_TOOL.report,
|
|
165
|
+
reason: 'step-failed',
|
|
166
|
+
detail: `${failed.length} 个步骤失败:如实汇报失败原因,不得写成完成`,
|
|
167
|
+
argsHint: { planId: plan.id, status: 'blocked' },
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
if (running.length > 0) {
|
|
171
|
+
return {
|
|
172
|
+
action: 'report',
|
|
173
|
+
tool: ACTION_TOOL.report,
|
|
174
|
+
reason: 'step-running',
|
|
175
|
+
detail: '有步骤在隔离执行体中运行:等结果并汇报节点,不重复派发',
|
|
176
|
+
argsHint: { planId: plan.id, status: 'progress' },
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
if (pending.length > 0) {
|
|
180
|
+
const next = pending[0];
|
|
181
|
+
return {
|
|
182
|
+
action: 'dispatch',
|
|
183
|
+
tool: ACTION_TOOL.dispatch,
|
|
184
|
+
reason: 'step-pending',
|
|
185
|
+
detail: '派发下一个待办步骤(走 fangForker.spawn;不可用即 fail-closed)',
|
|
186
|
+
argsHint: { planId: plan.id, stepId: next.id },
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
if (feedback) {
|
|
190
|
+
if (plan.status === 'closed') {
|
|
191
|
+
return { action: 'done', tool: null, reason: 'project-closed', detail: '项目已结案,等 PM 复核结论' };
|
|
192
|
+
}
|
|
193
|
+
return {
|
|
194
|
+
action: 'close',
|
|
195
|
+
tool: ACTION_TOOL.close,
|
|
196
|
+
reason: 'all-steps-done',
|
|
197
|
+
detail: '全部步骤完成且已提交反馈:结案需要总结、评审报告、验收与资料清单',
|
|
198
|
+
argsHint: { planId: plan.id },
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
return {
|
|
202
|
+
action: 'complete_feedback',
|
|
203
|
+
tool: ACTION_TOOL.complete_feedback,
|
|
204
|
+
reason: 'all-steps-done-no-feedback',
|
|
205
|
+
detail: '全部步骤完成:提交交付、偏差、遗留问题与测试证据,交给 PM 复核',
|
|
206
|
+
argsHint: { planId: plan.id },
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
/** 给 UI/测试用的紧凑摘要。 */
|
|
210
|
+
export function describeAction(result) {
|
|
211
|
+
if (!result || !result.action)
|
|
212
|
+
return 'blocked: unknown state';
|
|
213
|
+
return [result.action, result.reason, result.detail].filter(Boolean).join(' · ');
|
|
214
|
+
}
|
package/dist-host/dual/schema.js
CHANGED
|
@@ -212,3 +212,156 @@ export function closePlan(plan, input = {}, now = Date.now()) {
|
|
|
212
212
|
};
|
|
213
213
|
return { ok: true, plan, closure: plan.closure, progress };
|
|
214
214
|
}
|
|
215
|
+
// ── 闭环:PM 交接 → PJM 执行/反馈 → PM 复核/复盘/总结 ──────────────────────────
|
|
216
|
+
export const HANDOFF_STATUS = Object.freeze(['needs-clarification', 'ready', 'accepted', 'withdrawn']);
|
|
217
|
+
export const REVIEW_VERDICTS = Object.freeze(['accepted', 'changes-requested']);
|
|
218
|
+
/**
|
|
219
|
+
* PM → PJM 交接包。`ready` 是硬门禁:
|
|
220
|
+
* * 没有验收标准 → 不 ready(PJM 无法判定“做没做成”);
|
|
221
|
+
* * 还有未决问题/冲突 → 不 ready(PJM 不得替用户决定需求)。
|
|
222
|
+
*/
|
|
223
|
+
export function normalizeHandoff(prd, assessment, input = {}, { id, now = Date.now() } = {}) {
|
|
224
|
+
if (!prd)
|
|
225
|
+
return error('prd-not-found', 'PRD not found', { id: String(input.prdId ?? '') });
|
|
226
|
+
const acceptance = [...(list(input.acceptance).length ? list(input.acceptance) : prd.acceptance)];
|
|
227
|
+
const openQuestions = [...prd.openQuestions, ...list(input.openQuestions)];
|
|
228
|
+
const conflicts = Array.isArray(assessment?.conflicts) ? assessment.conflicts.map((item) => text(item.question)).filter(Boolean) : [];
|
|
229
|
+
const ready = acceptance.length > 0 && openQuestions.length === 0 && conflicts.length === 0;
|
|
230
|
+
const reason = ready
|
|
231
|
+
? null
|
|
232
|
+
: acceptance.length === 0
|
|
233
|
+
? 'acceptance-required'
|
|
234
|
+
: openQuestions.length > 0
|
|
235
|
+
? `open-questions-unresolved:${openQuestions.length}`
|
|
236
|
+
: `conflicts-unresolved:${conflicts.length}`;
|
|
237
|
+
return {
|
|
238
|
+
ok: true,
|
|
239
|
+
handoff: ({
|
|
240
|
+
id: String(id),
|
|
241
|
+
prdId: prd.id,
|
|
242
|
+
assessmentId: String(input.assessmentId ?? '') || null,
|
|
243
|
+
title: text(input.title, prd.title),
|
|
244
|
+
priority: prd.priority,
|
|
245
|
+
acceptance: Object.freeze(acceptance),
|
|
246
|
+
constraints: prd.constraints,
|
|
247
|
+
openQuestions: Object.freeze(openQuestions),
|
|
248
|
+
conflicts: Object.freeze(conflicts),
|
|
249
|
+
status: ready ? 'ready' : 'needs-clarification',
|
|
250
|
+
reason,
|
|
251
|
+
planId: null,
|
|
252
|
+
preparedAt: now,
|
|
253
|
+
acceptedAt: null,
|
|
254
|
+
}),
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
/** PJM 接单:只有 ready 的交接包可以被接走,否则退回 PM 澄清。 */
|
|
258
|
+
export function acceptHandoff(handoff, input = {}, now = Date.now()) {
|
|
259
|
+
if (!handoff)
|
|
260
|
+
return error('handoff-not-found', 'Handoff not found');
|
|
261
|
+
if (handoff.status === 'accepted')
|
|
262
|
+
return error('handoff-already-accepted', 'Handoff already accepted', { planId: handoff.planId });
|
|
263
|
+
if (handoff.status !== 'ready') {
|
|
264
|
+
return error('handoff-not-ready', 'Handoff is not ready; PM must resolve open questions or add acceptance criteria', {
|
|
265
|
+
status: handoff.status, reason: handoff.reason,
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
handoff.status = 'accepted';
|
|
269
|
+
handoff.acceptedAt = now;
|
|
270
|
+
handoff.planId = String(input.planId ?? '') || null;
|
|
271
|
+
return { ok: true, handoff };
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* PJM → PM 自反馈:必须在计划全部步骤完成后提交;只写实测结果,不写结论性评价。
|
|
275
|
+
*/
|
|
276
|
+
export function normalizeFeedback(plan, prd, input = {}, { id, now = Date.now() } = {}) {
|
|
277
|
+
if (!plan)
|
|
278
|
+
return error('plan-not-found', 'Plan not found', { id: String(input.planId ?? '') });
|
|
279
|
+
const progress = planProgress(plan);
|
|
280
|
+
if (!progress.canClose)
|
|
281
|
+
return error('plan-not-ready', 'feedback requires every step to be done', { progress });
|
|
282
|
+
const summary = text(input.summary);
|
|
283
|
+
if (!summary)
|
|
284
|
+
return error('summary-required', 'feedback summary is required');
|
|
285
|
+
const delivered = plan.steps
|
|
286
|
+
.filter((step) => step.status === 'done')
|
|
287
|
+
.map((step) => Object.freeze({
|
|
288
|
+
stepId: step.id,
|
|
289
|
+
title: step.title,
|
|
290
|
+
owner: step.owner,
|
|
291
|
+
summary: step.summary,
|
|
292
|
+
evidence: Object.freeze([...(step.evidence ?? [])]),
|
|
293
|
+
actualMinutes: step.actualMinutes ?? null,
|
|
294
|
+
}));
|
|
295
|
+
return {
|
|
296
|
+
ok: true,
|
|
297
|
+
feedback: ({
|
|
298
|
+
id: String(id),
|
|
299
|
+
planId: plan.id,
|
|
300
|
+
prdId: plan.prdId ?? prd?.id ?? null,
|
|
301
|
+
handoffId: String(input.handoffId ?? '') || null,
|
|
302
|
+
status: plan.closure?.status === 'closed' ? 'closed' : 'reported',
|
|
303
|
+
summary,
|
|
304
|
+
delivered: Object.freeze(delivered),
|
|
305
|
+
deviations: Object.freeze(list(input.deviations)),
|
|
306
|
+
openIssues: Object.freeze(list(input.openIssues)),
|
|
307
|
+
tests: Object.freeze(list(input.tests ?? plan.quality?.tests)),
|
|
308
|
+
metrics: Object.freeze({
|
|
309
|
+
steps: progress.total,
|
|
310
|
+
done: progress.done,
|
|
311
|
+
estimatedMinutes: progress.estimatedMinutes,
|
|
312
|
+
actualMinutes: progress.actualMinutes,
|
|
313
|
+
percent: progress.percent,
|
|
314
|
+
}),
|
|
315
|
+
createdAt: now,
|
|
316
|
+
}),
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* PM 复核:逐条验收标准必须给出 met 布尔与证据;结论必须与验收结果一致,
|
|
321
|
+
* 不允许「有未达标却判 accepted」或「全达标却判 changes-requested」。
|
|
322
|
+
*/
|
|
323
|
+
export function normalizeReview(feedback, input = {}, { id, now = Date.now() } = {}) {
|
|
324
|
+
if (!feedback)
|
|
325
|
+
return error('feedback-not-found', 'Feedback not found', { id: String(input.feedbackId ?? '') });
|
|
326
|
+
const checks = [];
|
|
327
|
+
for (const [index, raw] of records(input.acceptanceChecks).entries()) {
|
|
328
|
+
const criterion = text(raw.criterion);
|
|
329
|
+
if (!criterion)
|
|
330
|
+
return error(`acceptanceChecks[${index}].criterion-required`, `acceptanceChecks[${index}].criterion is required`);
|
|
331
|
+
if (typeof raw.met !== 'boolean')
|
|
332
|
+
return error(`acceptanceChecks[${index}].met-required`, `acceptanceChecks[${index}].met must be boolean`);
|
|
333
|
+
checks.push(Object.freeze({ criterion, met: raw.met, evidence: Object.freeze(list(raw.evidence)), note: text(raw.note) }));
|
|
334
|
+
}
|
|
335
|
+
if (checks.length === 0)
|
|
336
|
+
return error('acceptance-checks-required', 'at least one acceptance check is required');
|
|
337
|
+
const allMet = checks.every((check) => check.met === true);
|
|
338
|
+
const verdict = REVIEW_VERDICTS.includes(text(input.verdict)) ? text(input.verdict) : (allMet ? 'accepted' : 'changes-requested');
|
|
339
|
+
if (verdict === 'accepted' && !allMet) {
|
|
340
|
+
return error('verdict-inconsistent', 'cannot accept while an acceptance check is not met', { failed: checks.filter((check) => !check.met).map((check) => check.criterion) });
|
|
341
|
+
}
|
|
342
|
+
if (verdict === 'changes-requested' && allMet) {
|
|
343
|
+
return error('verdict-inconsistent', 'all checks are met; use accepted or explain with a new check');
|
|
344
|
+
}
|
|
345
|
+
const summary = text(input.summary);
|
|
346
|
+
if (!summary)
|
|
347
|
+
return error('summary-required', 'review summary is required');
|
|
348
|
+
const retrospective = input.retrospective && typeof input.retrospective === 'object' ? input.retrospective : {};
|
|
349
|
+
return {
|
|
350
|
+
ok: true,
|
|
351
|
+
review: ({
|
|
352
|
+
id: String(id),
|
|
353
|
+
feedbackId: feedback.id,
|
|
354
|
+
planId: feedback.planId,
|
|
355
|
+
prdId: feedback.prdId ?? null,
|
|
356
|
+
verdict,
|
|
357
|
+
acceptanceChecks: Object.freeze(checks),
|
|
358
|
+
retrospective: Object.freeze({
|
|
359
|
+
kept: Object.freeze(list(retrospective.kept ?? input.kept)),
|
|
360
|
+
improve: Object.freeze(list(retrospective.improve ?? input.improve)),
|
|
361
|
+
lessons: Object.freeze(list(retrospective.lessons ?? input.lessons)),
|
|
362
|
+
}),
|
|
363
|
+
summary,
|
|
364
|
+
createdAt: now,
|
|
365
|
+
}),
|
|
366
|
+
};
|
|
367
|
+
}
|