@suifei/dsh-ai-team 0.3.33 → 0.3.35
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 +16 -0
- package/README.md +1 -0
- package/client.js +108 -2
- package/index.js +41 -1
- package/lib/host/pmo.js +46 -0
- package/lib/host/service.js +73 -1
- package/lib/host/waker.js +9 -1
- package/lib/ledger/fold.js +13 -0
- package/lib/runtime/core-skill.js +4 -0
- package/lib/runtime/skill-loader.js +3 -3
- package/package.json +1 -1
- package/skills/ai-team-core/SKILL.md +26 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.35 — 2026-11-10
|
|
4
|
+
|
|
5
|
+
- 内置 `ai-team-core` skill 自动注入所有 Agent wake;
|
|
6
|
+
- 状态变化 toast 气泡、成员视图最近 Task 状态气泡;
|
|
7
|
+
- PMO 自动推进 prompt 加入复盘/头脑风暴/深度研究会议要求。
|
|
8
|
+
|
|
9
|
+
## 0.3.34 — 2026-11-10
|
|
10
|
+
|
|
11
|
+
- 新增 Project 级 PMO 全自动开关,默认开启;关闭后决策回到收件箱;
|
|
12
|
+
- PMO 每 60s 检查:未读结构化决策按最高价值建议自动决策,不需要 Human 再确认;
|
|
13
|
+
- 无 active Task 时自动唤醒 Project owner 续派下一批 Task,避免「全部完成但停住」;
|
|
14
|
+
- PMO 自动决策写入 Task Thread,author=mem_pmo,并唤醒 owner/assignee;
|
|
15
|
+
- 插件新增状态变化 toast 气泡;成员视图新增最近 Task 状态气泡;
|
|
16
|
+
- 内置 `ai-team-core` skill(单一实现、网页取证/代理、懒惰高级程序员、最小依赖、日志埋点),所有 Agent wake 自动注入;
|
|
17
|
+
- PMO 续派 prompt 要求先做复盘/头脑风暴/深度研究会议,再产出验收标准与下一批 Task。
|
|
18
|
+
|
|
3
19
|
## 0.3.33 — 2026-11-10
|
|
4
20
|
|
|
5
21
|
- 看板「需要你」的收件箱数量改为统计真实未读决策项,不再受 Q1 items 最多 5 条的限制,也不再混入进度汇报;
|
package/README.md
CHANGED
|
@@ -85,6 +85,7 @@ dsh --profile ai-team --no-open --port 33271
|
|
|
85
85
|
```
|
|
86
86
|
|
|
87
87
|
- **主对话**:Human 的一句话进入 Main stream,Project owner 拆 Task;顶部「团队动态」直接显示正在推进的 Task 和运行中的成员,点击 Task 可打开 Thread。
|
|
88
|
+
- **PMO 全自动**:Project header 的「PMO 自动」默认开启;每 60s 按最高价值建议自动处理 Human 决策,无 active Task 时自动唤醒 owner 续派下一批 Task;关闭后决策回到收件箱。
|
|
88
89
|
- **看板**:四列;进行中的卡片显示 accountable owner 与执行者;数量只统计 active Task。
|
|
89
90
|
- **态势**:四象限——需要你、团队在做、等待受阻、最近完成。点击条目打开对应 Task 的 Thread。
|
|
90
91
|
- **收件箱**:跨 Project 聚合;顶部「待你处理 · N」一键进入;只放真正需要 Human 的决策/失败,Agent 的进度汇报留在 Thread/动态,不制造“打开 Task 后无事可做”。
|
package/client.js
CHANGED
|
@@ -221,6 +221,21 @@ window.__ModuleLoader__.load({
|
|
|
221
221
|
workspaceHint: '先选择代码目录再开工;每个 Project 使用独立目录。',
|
|
222
222
|
workspaceChoose: '选择目录',
|
|
223
223
|
workspaceChange: '更换目录',
|
|
224
|
+
pmoOn: 'PMO 自动 · 开',
|
|
225
|
+
pmoOff: 'PMO 自动 · 关',
|
|
226
|
+
pmoHint: 'PMO 自动处理 Human 决策并续派下一批 Task;默认开启',
|
|
227
|
+
pmoEnabled: 'PMO 自动已开启。',
|
|
228
|
+
pmoDisabled: 'PMO 自动已关闭,决策回到收件箱。',
|
|
229
|
+
notifyTaskCreated: '新 Task 已创建',
|
|
230
|
+
notifyTaskClaimed: '成员已认领 Task',
|
|
231
|
+
notifyTaskSubmitted: 'Task 已提交待验收',
|
|
232
|
+
notifyTaskAccepted: 'Task 已验收完成',
|
|
233
|
+
notifyTaskChanges: 'Task 被退回修改',
|
|
234
|
+
notifyThread: 'Thread 有新消息',
|
|
235
|
+
notifyInbox: '新的待办已进入收件箱',
|
|
236
|
+
notifyWake: 'Agent 已唤醒',
|
|
237
|
+
notifyStream: '主对话有新消息',
|
|
238
|
+
notifyClose: '关闭通知',
|
|
224
239
|
workspaceRequired: '请先设置项目工作目录。',
|
|
225
240
|
workspaceSaved: '项目工作目录已保存。',
|
|
226
241
|
workspaceDialogTitle: '设置项目工作目录',
|
|
@@ -542,6 +557,21 @@ window.__ModuleLoader__.load({
|
|
|
542
557
|
workspaceHint: 'Choose a code directory before starting; each Project uses its own directory.',
|
|
543
558
|
workspaceChoose: 'Choose directory',
|
|
544
559
|
workspaceChange: 'Change directory',
|
|
560
|
+
pmoOn: 'PMO auto · on',
|
|
561
|
+
pmoOff: 'PMO auto · off',
|
|
562
|
+
pmoHint: 'PMO answers Human decisions and dispatches the next Tasks; on by default',
|
|
563
|
+
pmoEnabled: 'PMO auto enabled.',
|
|
564
|
+
pmoDisabled: 'PMO auto disabled; decisions go back to the inbox.',
|
|
565
|
+
notifyTaskCreated: 'New Task created',
|
|
566
|
+
notifyTaskClaimed: 'Member claimed a Task',
|
|
567
|
+
notifyTaskSubmitted: 'Task submitted for acceptance',
|
|
568
|
+
notifyTaskAccepted: 'Task accepted',
|
|
569
|
+
notifyTaskChanges: 'Task returned for changes',
|
|
570
|
+
notifyThread: 'New Thread message',
|
|
571
|
+
notifyInbox: 'New inbox item',
|
|
572
|
+
notifyWake: 'Agent woken',
|
|
573
|
+
notifyStream: 'New main-stream message',
|
|
574
|
+
notifyClose: 'Dismiss notification',
|
|
545
575
|
workspaceRequired: 'Set the Project workspace first.',
|
|
546
576
|
workspaceSaved: 'Project workspace saved.',
|
|
547
577
|
workspaceDialogTitle: 'Set Project workspace',
|
|
@@ -954,6 +984,11 @@ window.__ModuleLoader__.load({
|
|
|
954
984
|
const CSS = `
|
|
955
985
|
.at-root { height: 100%; min-height: 0; display: flex; flex-direction: column; position: relative; overflow: hidden; color: var(--dsw-alias-label-primary); background: var(--dsw-alias-bg-base); font-family: var(--dsw-font-family); font-size: 13px; line-height: 21px; container-type: inline-size; container-name: at-wb; }
|
|
956
986
|
.at-root *, .at-root *::before, .at-root *::after { box-sizing: border-box; }
|
|
987
|
+
.at-toaststack { position: fixed; right: 18px; bottom: 18px; z-index: 60; display: flex; flex-direction: column; align-items: flex-end; gap: 7px; max-width: min(360px, calc(100vw - 36px)); pointer-events: none; }
|
|
988
|
+
.at-toast { pointer-events: auto; display: flex; align-items: center; gap: 8px; max-width: 100%; padding: 7px 9px; border: 1px solid var(--dsw-alias-border-l2); border-radius: var(--dsw-radius-md); background: var(--dsw-alias-bg-layer-1); box-shadow: 0 8px 24px rgba(0,0,0,.16); color: var(--dsw-alias-label-primary); font-size: 12px; line-height: 18px; }
|
|
989
|
+
.at-toasticon { display: inline-flex; color: var(--dsw-alias-state-business-primary); flex: 0 0 auto; }
|
|
990
|
+
.at-toasttext { min-width: 0; overflow-wrap: anywhere; }
|
|
991
|
+
.at-toastclose { border: 0; background: transparent; color: var(--dsw-alias-label-tertiary); font: inherit; font-size: 14px; line-height: 16px; cursor: pointer; padding: 0 2px; }
|
|
957
992
|
.at-root :focus-visible { outline: 2px solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); outline-offset: 2px; }
|
|
958
993
|
.at-top { height: 40px; flex: 0 0 auto; display: flex; align-items: center; gap: 8px; min-width: 0; padding: 0 12px; border-bottom: 1px solid var(--dsw-alias-border-l1); background: var(--dsw-alias-bg-layer-1); }
|
|
959
994
|
.at-brand { color: var(--dsw-alias-label-primary); font-size: 12px; font-weight: 500; white-space: nowrap; }
|
|
@@ -1044,6 +1079,11 @@ window.__ModuleLoader__.load({
|
|
|
1044
1079
|
.at-teampulsehint { color: var(--dsw-alias-label-tertiary); font-size: 11px; line-height: 16px; }
|
|
1045
1080
|
.at-teampulseagents { margin-top: 6px; color: var(--dsw-alias-label-tertiary); font-size: 11px; line-height: 16px; }
|
|
1046
1081
|
.at-memberview { flex: 1 1 auto; min-height: 0; overflow: auto; padding: 18px; }
|
|
1082
|
+
.at-agentbubbles { display: flex; flex-direction: column; gap: 6px; max-width: 620px; margin: 10px 0 4px 40px; }
|
|
1083
|
+
.at-agentbubble { display: flex; align-items: center; gap: 8px; width: fit-content; max-width: 100%; padding: 7px 10px; border: 1px solid var(--dsw-alias-border-l2); border-radius: 12px 12px 12px 3px; background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-primary); font: inherit; text-align: left; cursor: pointer; }
|
|
1084
|
+
.at-agentbubble:hover { border-color: var(--dsw-alias-state-business-primary); }
|
|
1085
|
+
.at-agentbubbleno { flex: 0 0 auto; color: var(--dsw-alias-label-tertiary); font-size: 11px; }
|
|
1086
|
+
.at-agentbubbletext { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; }
|
|
1047
1087
|
.at-memberviewhead { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; padding-bottom: 12px; border-bottom: 1px solid var(--dsw-alias-border-l1); }
|
|
1048
1088
|
.at-memberviewid { display: flex; flex-direction: column; min-width: 0; flex: 1 1 180px; }
|
|
1049
1089
|
.at-memberviewid strong { font-size: 15px; line-height: 22px; }
|
|
@@ -1065,6 +1105,8 @@ window.__ModuleLoader__.load({
|
|
|
1065
1105
|
.at-workspacepath { flex: 1 1 auto; min-width: 0; color: var(--dsw-alias-label-tertiary); font-size: 11px; line-height: 16px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
1066
1106
|
.at-workspacebtn { flex: 0 0 auto; padding: 2px 7px; border: 1px solid var(--dsw-alias-border-l2); border-radius: var(--dsw-radius-sm); background: transparent; color: var(--dsw-alias-label-secondary); font: inherit; font-size: 11px; line-height: 16px; cursor: pointer; }
|
|
1067
1107
|
.at-workspacebtn:hover { color: var(--dsw-alias-label-primary); border-color: var(--dsw-alias-label-tertiary); }
|
|
1108
|
+
.at-pmobtn { flex: 0 0 auto; padding: 2px 7px; border: 1px solid var(--dsw-alias-border-l2); border-radius: var(--dsw-radius-sm); background: transparent; color: var(--dsw-alias-label-tertiary); font: inherit; font-size: 11px; line-height: 16px; cursor: pointer; white-space: nowrap; }
|
|
1109
|
+
.at-pmobtn.at-pmo-on { color: var(--dsw-alias-state-success-primary); border-color: var(--dsw-alias-state-success-primary); }
|
|
1068
1110
|
.at-tabs { display: flex; gap: 4px; margin-top: 8px; border-bottom: 1px solid var(--dsw-alias-border-l1); }
|
|
1069
1111
|
.at-tab { min-height: 36px; display: inline-flex; align-items: center; gap: 6px; padding: 0 12px; border: 0; border-bottom: 2px solid transparent; background: transparent; color: var(--dsw-alias-label-tertiary); font: inherit; font-size: 13px; font-weight: 500; cursor: pointer; }
|
|
1070
1112
|
.at-tab:hover { color: var(--dsw-alias-label-primary); }
|
|
@@ -1504,6 +1546,15 @@ window.__ModuleLoader__.load({
|
|
|
1504
1546
|
));
|
|
1505
1547
|
}
|
|
1506
1548
|
|
|
1549
|
+
function ToastStack({ t, toasts, onClose }) {
|
|
1550
|
+
if (toasts.length === 0) return null;
|
|
1551
|
+
return h('div', { className: 'at-toaststack', role: 'status', 'aria-live': 'polite' },
|
|
1552
|
+
toasts.map((toast) => h('div', { className: 'at-toast', key: toast.id },
|
|
1553
|
+
h('span', { className: 'at-toasticon', 'aria-hidden': 'true' }, h(Ico, { name: 'inbox', size: 13 })),
|
|
1554
|
+
h('span', { className: 'at-toasttext' }, toast.text),
|
|
1555
|
+
h('button', { className: 'at-toastclose', type: 'button', 'aria-label': t('notifyClose'), onClick: () => onClose(toast.id) }, '×'))));
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1507
1558
|
function EmptyState({ t, icon, title, body, action, compact }) {
|
|
1508
1559
|
return h('div', { className: cx('at-empty', compact && 'at-empty-compact'), role: 'status' },
|
|
1509
1560
|
icon ? h('span', { className: 'at-emptyicon', 'aria-hidden': 'true' }, h(Ico, { name: icon, size: 28 })) : null,
|
|
@@ -1723,6 +1774,7 @@ window.__ModuleLoader__.load({
|
|
|
1723
1774
|
}
|
|
1724
1775
|
|
|
1725
1776
|
function MemberView({ t, agent, tasks, onBack, onOpenTask, onOpenChat, onOpenProfile }) {
|
|
1777
|
+
const recentTasks = tasks.slice().sort((left, right) => (right.number ?? 0) - (left.number ?? 0)).slice(0, 3);
|
|
1726
1778
|
return h('div', { className: 'at-memberview' },
|
|
1727
1779
|
h('div', { className: 'at-memberviewhead' },
|
|
1728
1780
|
h('button', { className: 'at-btn at-btn-small', type: 'button', onClick: onBack }, '← ' + t('back')),
|
|
@@ -1733,6 +1785,13 @@ window.__ModuleLoader__.load({
|
|
|
1733
1785
|
h('div', { className: 'at-memberviewactions' },
|
|
1734
1786
|
h('button', { className: 'at-btn at-btn-small at-btn-primary', type: 'button', onClick: () => onOpenChat(agent) }, t('agentChat')),
|
|
1735
1787
|
h('button', { className: 'at-btn at-btn-small', type: 'button', onClick: () => onOpenProfile(agent) }, t('memberProfileTitle')))),
|
|
1788
|
+
recentTasks.length ? h('div', { className: 'at-agentbubbles', role: 'status', 'aria-label': t('memberTasks') },
|
|
1789
|
+
recentTasks.map((task) => h('button', {
|
|
1790
|
+
key: task.id,
|
|
1791
|
+
className: 'at-agentbubble',
|
|
1792
|
+
type: 'button',
|
|
1793
|
+
onClick: () => onOpenTask(task),
|
|
1794
|
+
}, h('span', { className: 'at-agentbubbleno' }, '#' + taskNo(task)), h('span', { className: 'at-agentbubbletext' }, task.title), h(StatusPill, { t, status: task.status })))) : null,
|
|
1736
1795
|
h('div', { className: 'at-memberviewbody' },
|
|
1737
1796
|
h('div', { className: 'at-memberviewsub' }, t('memberTasks') + ' · ' + tasks.length),
|
|
1738
1797
|
tasks.length
|
|
@@ -2209,6 +2268,7 @@ window.__ModuleLoader__.load({
|
|
|
2209
2268
|
const [qaChecklistText, setQaChecklistText] = React.useState('');
|
|
2210
2269
|
const [draft, setDraft] = React.useState('');
|
|
2211
2270
|
const [note, setNote] = React.useState('');
|
|
2271
|
+
const [toasts, setToasts] = React.useState([]);
|
|
2212
2272
|
const [rightNote, setRightNote] = React.useState('');
|
|
2213
2273
|
const [reply, setReply] = React.useState('');
|
|
2214
2274
|
const [follows, setFollows] = React.useState({});
|
|
@@ -2304,7 +2364,14 @@ window.__ModuleLoader__.load({
|
|
|
2304
2364
|
const source = typeof EventSource === 'function'
|
|
2305
2365
|
? new EventSource('/ai-team/api/projects/' + encodeURIComponent(projectId || 'prj_main') + '/events')
|
|
2306
2366
|
: null;
|
|
2307
|
-
if (source) source.addEventListener('change', () => {
|
|
2367
|
+
if (source) source.addEventListener('change', (raw) => {
|
|
2368
|
+
if (!alive) return;
|
|
2369
|
+
try {
|
|
2370
|
+
const frame = JSON.parse(raw.data);
|
|
2371
|
+
pushToast(notificationText(frame?.event));
|
|
2372
|
+
} catch { /* malformed frame ignored */ }
|
|
2373
|
+
load();
|
|
2374
|
+
});
|
|
2308
2375
|
return () => {
|
|
2309
2376
|
alive = false;
|
|
2310
2377
|
if (controller) controller.abort();
|
|
@@ -2406,6 +2473,27 @@ window.__ModuleLoader__.load({
|
|
|
2406
2473
|
|
|
2407
2474
|
function say(text) { setNote(text); }
|
|
2408
2475
|
function sayRight(text) { setRightNote(text); }
|
|
2476
|
+
function pushToast(text) {
|
|
2477
|
+
if (!text) return;
|
|
2478
|
+
const id = Date.now() + ':' + Math.random().toString(36).slice(2, 6);
|
|
2479
|
+
setToasts((prev) => [...prev.slice(-3), { id, text }]);
|
|
2480
|
+
setTimeout(() => setToasts((prev) => prev.filter((toast) => toast.id !== id)), 7000);
|
|
2481
|
+
}
|
|
2482
|
+
function notificationText(event) {
|
|
2483
|
+
if (!event) return '';
|
|
2484
|
+
switch (event.type) {
|
|
2485
|
+
case 'task/created': return t('notifyTaskCreated');
|
|
2486
|
+
case 'task/claimed': return t('notifyTaskClaimed');
|
|
2487
|
+
case 'task/submitted': return t('notifyTaskSubmitted');
|
|
2488
|
+
case 'task/accepted': return t('notifyTaskAccepted');
|
|
2489
|
+
case 'task/changes-requested': return t('notifyTaskChanges');
|
|
2490
|
+
case 'thread/posted': return t('notifyThread');
|
|
2491
|
+
case 'inbox/item-created': return t('notifyInbox');
|
|
2492
|
+
case 'wake/delivered': return t('notifyWake');
|
|
2493
|
+
case 'stream/posted': return t('notifyStream');
|
|
2494
|
+
default: return '';
|
|
2495
|
+
}
|
|
2496
|
+
}
|
|
2409
2497
|
async function toggleFollow(card) {
|
|
2410
2498
|
if (!card) return;
|
|
2411
2499
|
const next = !followingOf(card);
|
|
@@ -2508,6 +2596,16 @@ window.__ModuleLoader__.load({
|
|
|
2508
2596
|
setNError('');
|
|
2509
2597
|
setNewOpen(true);
|
|
2510
2598
|
}
|
|
2599
|
+
async function togglePmo() {
|
|
2600
|
+
if (!project) return;
|
|
2601
|
+
try {
|
|
2602
|
+
await runCommand('project.pmo.update', { pmoAuto: project.pmoAuto === false }, project.id);
|
|
2603
|
+
say(project.pmoAuto === false ? t('pmoEnabled') : t('pmoDisabled'));
|
|
2604
|
+
await reloadData();
|
|
2605
|
+
} catch (error) {
|
|
2606
|
+
say(tf(t, 'sendFailed', { message: error?.message ?? String(error) }));
|
|
2607
|
+
}
|
|
2608
|
+
}
|
|
2511
2609
|
async function pickWorkspacePath() {
|
|
2512
2610
|
const service = ctx?.get?.('uiWorkspace') ?? ctx?.uiWorkspace;
|
|
2513
2611
|
if (service === undefined || service === null || typeof service.pickDirectory !== 'function') {
|
|
@@ -3231,6 +3329,7 @@ window.__ModuleLoader__.load({
|
|
|
3231
3329
|
h('summary', { className: 'at-mobilesummary', 'aria-label': t('menuAria') }, h(Ico, { name: 'menu', size: 14 }), h('span', null, t('menu'))),
|
|
3232
3330
|
h('div', { className: 'at-mobilepop' }, h(LeftPanel, leftProps))),
|
|
3233
3331
|
h('span', { className: 'at-topproject', title: headerTitle }, inInbox ? t('inboxTitle') + (unread ? ' · ' + unread : '') : (project?.name ?? ''))),
|
|
3332
|
+
h(ToastStack, { t, toasts, onClose: (id) => setToasts((prev) => prev.filter((toast) => toast.id !== id)) }),
|
|
3234
3333
|
h('div', { className: cx('at-grid', threadOpen && selectedTask && 'at-withthread') },
|
|
3235
3334
|
h('aside', { className: 'at-left', role: 'region', 'aria-label': t('projectsAria') }, h(LeftPanel, leftProps)),
|
|
3236
3335
|
h('main', { className: 'at-center' },
|
|
@@ -3240,7 +3339,14 @@ window.__ModuleLoader__.load({
|
|
|
3240
3339
|
!inInbox && project ? h('div', { className: 'at-workspacebar' },
|
|
3241
3340
|
h('span', { className: 'at-workspacepath', title: project.workspacePath || t('workspaceHint') },
|
|
3242
3341
|
project.workspacePath ? t('workspace') + ':' + project.workspacePath : t('workspaceUnset')),
|
|
3243
|
-
h('button', { className: 'at-workspacebtn', type: 'button', onClick: openWorkspaceEditor }, project.workspacePath ? t('workspaceChange') : t('workspaceChoose'))
|
|
3342
|
+
h('button', { className: 'at-workspacebtn', type: 'button', onClick: openWorkspaceEditor }, project.workspacePath ? t('workspaceChange') : t('workspaceChoose')),
|
|
3343
|
+
h('button', {
|
|
3344
|
+
className: cx('at-pmobtn', project.pmoAuto !== false && 'at-pmo-on'),
|
|
3345
|
+
type: 'button',
|
|
3346
|
+
title: t('pmoHint'),
|
|
3347
|
+
'aria-pressed': project.pmoAuto !== false,
|
|
3348
|
+
onClick: togglePmo,
|
|
3349
|
+
}, project.pmoAuto !== false ? t('pmoOn') : t('pmoOff'))) : null,
|
|
3244
3350
|
inInbox ? null : h('div', { className: 'at-tabs', role: 'tablist', 'aria-label': t('tabsAria'), onKeyDown: tabKeyDown },
|
|
3245
3351
|
h('button', {
|
|
3246
3352
|
className: cx('at-tab', tab === 'main' && 'at-on'),
|
package/index.js
CHANGED
|
@@ -34,7 +34,7 @@ export const name = 'ai-team';
|
|
|
34
34
|
/** The workbench only makes sense where the Web carrier exists. */
|
|
35
35
|
export const inject = ['webServer', 'permissionPresets', 'sandboxPolicy'];
|
|
36
36
|
|
|
37
|
-
const VERSION = '0.3.
|
|
37
|
+
const VERSION = '0.3.35';
|
|
38
38
|
const PROJECT_ID = 'prj_main';
|
|
39
39
|
const HUMAN_ID = 'mem_human';
|
|
40
40
|
|
|
@@ -166,6 +166,17 @@ export function apply(ctx) {
|
|
|
166
166
|
}
|
|
167
167
|
}
|
|
168
168
|
}
|
|
169
|
+
for (const runtime of value.runtimes.values()) {
|
|
170
|
+
void pmoTick(runtime).catch((error) => console.warn('[ai-team] PMO tick failed:', error?.message ?? error));
|
|
171
|
+
}
|
|
172
|
+
if (pmoTimer === null) {
|
|
173
|
+
pmoTimer = setInterval(() => {
|
|
174
|
+
for (const runtime of booted?.runtimes?.values() ?? []) {
|
|
175
|
+
void pmoTick(runtime).catch((error) => console.warn('[ai-team] PMO tick failed:', error?.message ?? error));
|
|
176
|
+
}
|
|
177
|
+
}, 60_000);
|
|
178
|
+
pmoTimer.unref?.();
|
|
179
|
+
}
|
|
169
180
|
return value;
|
|
170
181
|
})
|
|
171
182
|
.catch((error) => {
|
|
@@ -245,6 +256,8 @@ export function apply(ctx) {
|
|
|
245
256
|
let hostPromise = null;
|
|
246
257
|
const wakeKeys = new Set();
|
|
247
258
|
const activationTasks = new Map();
|
|
259
|
+
const pmoLastKick = new Map();
|
|
260
|
+
let pmoTimer = null;
|
|
248
261
|
const projectCwd = (projectRuntime, member) => member?.workspace
|
|
249
262
|
|| projectRuntime?.service?.state?.projects?.[projectRuntime.id]?.workspacePath
|
|
250
263
|
|| projectRuntime?.dir
|
|
@@ -321,6 +334,32 @@ export function apply(ctx) {
|
|
|
321
334
|
}
|
|
322
335
|
}
|
|
323
336
|
};
|
|
337
|
+
const pmoTick = async (projectRuntime) => {
|
|
338
|
+
if (projectRuntime === null || projectRuntime === undefined) return;
|
|
339
|
+
const state = projectRuntime.service.state;
|
|
340
|
+
const project = state.projects?.[projectRuntime.id];
|
|
341
|
+
if (project === undefined || project.pmoAuto === false || project.status === 'archived') return;
|
|
342
|
+
const results = await projectRuntime.service.autoAnswerDecisions();
|
|
343
|
+
for (const result of results) {
|
|
344
|
+
const taskId = result?.item?.ref?.taskId;
|
|
345
|
+
if (typeof taskId === 'string' && result.entry !== null && result.entry !== undefined) {
|
|
346
|
+
await wakeFromResult(projectRuntime, {
|
|
347
|
+
event: { type: 'thread/posted', payload: { taskId, entry: result.entry } },
|
|
348
|
+
}, { agentId: 'mem_pmo' });
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
const active = Object.values(state.tasks ?? {}).filter((task) =>
|
|
352
|
+
task.tombstone !== true && ['pending', 'in_progress', 'pending_acceptance'].includes(task.status));
|
|
353
|
+
if (active.length > 0) return;
|
|
354
|
+
const now = Date.now();
|
|
355
|
+
const last = pmoLastKick.get(projectRuntime.id) ?? 0;
|
|
356
|
+
if (now - last < 10 * 60_000) return;
|
|
357
|
+
pmoLastKick.set(projectRuntime.id, now);
|
|
358
|
+
await wakeFromResult(projectRuntime, {
|
|
359
|
+
event: { type: 'pmo/continue', payload: { bucket: Math.floor(now / 600_000) } },
|
|
360
|
+
}, {});
|
|
361
|
+
};
|
|
362
|
+
|
|
324
363
|
const distillMemberWithModel = async (projectRuntime, payload) => {
|
|
325
364
|
const member = projectRuntime?.service.state.members?.[payload.memberId];
|
|
326
365
|
if (member === undefined || member.kind !== 'agent') {
|
|
@@ -948,6 +987,7 @@ export function apply(ctx) {
|
|
|
948
987
|
try { client.res.end(); } catch { /* socket already gone */ }
|
|
949
988
|
}
|
|
950
989
|
sseClients.clear();
|
|
990
|
+
if (pmoTimer !== null) { clearInterval(pmoTimer); pmoTimer = null; }
|
|
951
991
|
for (const activation of activations.values()) {
|
|
952
992
|
try { activation.dispose?.().catch?.(() => {}); } catch { /* already gone */ }
|
|
953
993
|
}
|
package/lib/host/pmo.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PMO decision policy.
|
|
3
|
+
*
|
|
4
|
+
* Human is not in the loop when PMO auto mode is on: choose the recommendation
|
|
5
|
+
* when one exists, otherwise choose the option that protects delivery value
|
|
6
|
+
* (complete/core) over minimum-effort options. The answer is intentionally
|
|
7
|
+
* explicit so it is auditable in the Task Thread.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const LOW_VALUE = /最小|暂缓|延后|不做|暂停|维持现状|先不|仅文档|只做骨架/i;
|
|
11
|
+
const HIGH_VALUE = /完整|核心|全量|推荐|生产可用|全平台|优先/i;
|
|
12
|
+
|
|
13
|
+
function clean(value) {
|
|
14
|
+
return typeof value === 'string' ? value.trim() : '';
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function scoreOption(option, question) {
|
|
18
|
+
const value = clean(option);
|
|
19
|
+
if (value === '') return -100;
|
|
20
|
+
let score = 0;
|
|
21
|
+
if (/推荐/i.test(value)) score += 20;
|
|
22
|
+
if (HIGH_VALUE.test(value)) score += 12;
|
|
23
|
+
if (/B\b|C\b|核心|完整/i.test(value)) score += 6;
|
|
24
|
+
if (LOW_VALUE.test(value)) score -= 20;
|
|
25
|
+
if (/A\b|最小/i.test(value)) score -= 8;
|
|
26
|
+
const recommendation = clean(question?.recommendation);
|
|
27
|
+
if (recommendation !== '' && recommendation.includes(value)) score += 30;
|
|
28
|
+
if (recommendation !== '' && value.includes(recommendation)) score += 20;
|
|
29
|
+
return score;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Return [ { id, value } ] for every question. */
|
|
33
|
+
export function chooseDecisionAnswers(questions) {
|
|
34
|
+
return (Array.isArray(questions) ? questions : []).map((question, index) => {
|
|
35
|
+
const id = typeof question?.id === 'string' && question.id !== '' ? question.id : `q${index + 1}`;
|
|
36
|
+
const options = Array.isArray(question?.options) ? question.options.map(clean).filter(Boolean) : [];
|
|
37
|
+
const recommendation = clean(question?.recommendation);
|
|
38
|
+
if (options.length > 0) {
|
|
39
|
+
const picked = options
|
|
40
|
+
.map((option) => ({ option, score: scoreOption(option, question) }))
|
|
41
|
+
.sort((left, right) => right.score - left.score)[0];
|
|
42
|
+
return { id, value: picked?.option || recommendation || '按完整核心方案执行' };
|
|
43
|
+
}
|
|
44
|
+
return { id, value: recommendation || '按完整、可验收、长期价值最高的方案执行' };
|
|
45
|
+
});
|
|
46
|
+
}
|
package/lib/host/service.js
CHANGED
|
@@ -3,6 +3,7 @@ import { ERRORS, fail } from '../domain/errors.js';
|
|
|
3
3
|
import { findReportPath, parseDecisionQuestions } from './decision-report.js';
|
|
4
4
|
import { ids } from '../domain/ids.js';
|
|
5
5
|
import { ackInboxItem, createInboxItem, isInformationalThreadMention } from '../domain/inbox.js';
|
|
6
|
+
import { chooseDecisionAnswers } from './pmo.js';
|
|
6
7
|
import {
|
|
7
8
|
acceptTask,
|
|
8
9
|
assignTask,
|
|
@@ -74,7 +75,7 @@ export class TeamService {
|
|
|
74
75
|
bootstrap(projectId, threadTaskId) {
|
|
75
76
|
const state = this.state;
|
|
76
77
|
const projects = Object.values(state.projects ?? {}).map((p) => ({
|
|
77
|
-
id: p.id, name: p.name, goal: p.goal ?? '', owner: p.ownerName ?? p.ownerMemberId ?? p.ownerId, workspacePath: p.workspacePath ?? '', active: p.status !== 'archived',
|
|
78
|
+
id: p.id, name: p.name, goal: p.goal ?? '', owner: p.ownerName ?? p.ownerMemberId ?? p.ownerId, workspacePath: p.workspacePath ?? '', pmoAuto: p.pmoAuto !== false, active: p.status !== 'archived',
|
|
78
79
|
}));
|
|
79
80
|
const members = Object.values(state.members ?? {}).map((m) => ({
|
|
80
81
|
id: m.id,
|
|
@@ -227,6 +228,7 @@ export class TeamService {
|
|
|
227
228
|
case 'project.create': return await this.createProject(command, payload);
|
|
228
229
|
case 'project.owner.update': return await this.changeProjectOwner(command, payload);
|
|
229
230
|
case 'project.workspace.update': return await this.changeProjectWorkspace(command, payload);
|
|
231
|
+
case 'project.pmo.update': return await this.setProjectPmo(command, payload);
|
|
230
232
|
case 'stream.post': return await this.postStream(command, payload);
|
|
231
233
|
case 'member.add': return await this.addMember(command, payload);
|
|
232
234
|
case 'member.bind': return await this.bindMemberSession(command, payload);
|
|
@@ -294,6 +296,7 @@ export class TeamService {
|
|
|
294
296
|
goal: project.goal,
|
|
295
297
|
ownerMemberId,
|
|
296
298
|
workspacePath: project.workspacePath,
|
|
299
|
+
pmoAuto: payload.pmoAuto !== false,
|
|
297
300
|
status: project.status,
|
|
298
301
|
});
|
|
299
302
|
return { project };
|
|
@@ -316,6 +319,17 @@ export class TeamService {
|
|
|
316
319
|
return { project: { ...project, workspacePath } };
|
|
317
320
|
}
|
|
318
321
|
|
|
322
|
+
async setProjectPmo(command, payload) {
|
|
323
|
+
const projectId = command.projectId;
|
|
324
|
+
const project = this.state.projects?.[projectId];
|
|
325
|
+
if (project === undefined) fail(ERRORS.NOT_FOUND, `project ${String(projectId)} not found`);
|
|
326
|
+
if (typeof payload.pmoAuto !== 'boolean') fail(ERRORS.INVALID_ARGUMENT, 'pmoAuto must be boolean');
|
|
327
|
+
const isOwner = command.actor.kind === 'agent' && command.actor.memberId === project.ownerMemberId;
|
|
328
|
+
if (command.actor.kind !== 'human' && !isOwner) fail(ERRORS.UNAUTHORIZED, 'only Human or the Project owner may change PMO mode');
|
|
329
|
+
await this.append(command, 'project/pmo-updated', { projectId, pmoAuto: payload.pmoAuto, reason: payload.reason ?? '' });
|
|
330
|
+
return { project: { ...project, pmoAuto: payload.pmoAuto } };
|
|
331
|
+
}
|
|
332
|
+
|
|
319
333
|
async changeProjectOwner(command, payload) {
|
|
320
334
|
if (command.actor.kind !== 'human') fail(ERRORS.UNAUTHORIZED, 'only Human may change the Project owner');
|
|
321
335
|
const projectId = command.projectId;
|
|
@@ -900,6 +914,64 @@ export class TeamService {
|
|
|
900
914
|
return { thread: outcome.thread, entry: outcome.entry };
|
|
901
915
|
}
|
|
902
916
|
|
|
917
|
+
/** PMO: answer all pending structured questions with the max-value policy. */
|
|
918
|
+
async autoAnswerDecisions() {
|
|
919
|
+
const humanId = this.state.humanMemberId
|
|
920
|
+
?? Object.values(this.state.members ?? {}).find((member) => member.kind === 'human')?.id
|
|
921
|
+
?? null;
|
|
922
|
+
if (humanId === null) return 0;
|
|
923
|
+
const items = Object.values(this.state.inbox ?? {}).filter((item) =>
|
|
924
|
+
item.memberId === humanId
|
|
925
|
+
&& item.type === 'decision_required'
|
|
926
|
+
&& item.state === 'unread'
|
|
927
|
+
&& Array.isArray(item.ref?.questions)
|
|
928
|
+
&& item.ref.questions.length > 0);
|
|
929
|
+
const results = [];
|
|
930
|
+
for (const item of items) {
|
|
931
|
+
const answers = chooseDecisionAnswers(item.ref.questions);
|
|
932
|
+
results.push(await this.autoAnswerDecision(item, answers));
|
|
933
|
+
}
|
|
934
|
+
return results;
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
async autoAnswerDecision(item, answers) {
|
|
938
|
+
const taskId = typeof item.ref?.taskId === 'string' ? item.ref.taskId : null;
|
|
939
|
+
const task = taskId === null ? undefined : this.state.tasks?.[taskId];
|
|
940
|
+
const questionById = new Map((item.ref?.questions ?? []).map((question) => [question.id, question.text]));
|
|
941
|
+
const lines = answers.map((answer) => `- ${questionById.get(answer.id) ?? answer.id}: ${answer.value}`);
|
|
942
|
+
const body = `【PMO 自动决策】按最高价值建议执行,不需要 Human 再确认:\n${lines.join('\n')}`;
|
|
943
|
+
let entry = null;
|
|
944
|
+
if (task !== undefined) {
|
|
945
|
+
const thread = this.state.threads?.[task.id] ?? createThread(task.id);
|
|
946
|
+
const outcome = appendReply(thread, {
|
|
947
|
+
authorId: 'mem_pmo',
|
|
948
|
+
actorKind: 'system',
|
|
949
|
+
baseSeq: thread.headSeq,
|
|
950
|
+
body,
|
|
951
|
+
}, { at: this.now() });
|
|
952
|
+
await this.ledger.append({
|
|
953
|
+
type: 'thread/posted',
|
|
954
|
+
payload: { taskId: task.id, ...outcome.entry },
|
|
955
|
+
actor: { kind: 'system', memberId: 'mem_pmo' },
|
|
956
|
+
projectId: Object.keys(this.state.projects ?? {})[0],
|
|
957
|
+
at: this.now(),
|
|
958
|
+
});
|
|
959
|
+
entry = outcome.entry;
|
|
960
|
+
}
|
|
961
|
+
const next = ackInboxItem(item, {
|
|
962
|
+
disposition: 'done',
|
|
963
|
+
decision: { answers, auto: true, by: 'mem_pmo', body },
|
|
964
|
+
}, { at: this.now() });
|
|
965
|
+
await this.ledger.append({
|
|
966
|
+
type: 'inbox/acked',
|
|
967
|
+
payload: { itemId: next.id, memberId: next.memberId, state: next.state, disposition: next.disposition, at: next.ackedAt },
|
|
968
|
+
actor: { kind: 'system', memberId: 'mem_pmo' },
|
|
969
|
+
projectId: Object.keys(this.state.projects ?? {})[0],
|
|
970
|
+
at: this.now(),
|
|
971
|
+
});
|
|
972
|
+
return { item: next, entry };
|
|
973
|
+
}
|
|
974
|
+
|
|
903
975
|
/** Surface an explicit Human decision request with all questions inline. */
|
|
904
976
|
async requestDecision(command, payload) {
|
|
905
977
|
const humanId = this.state.humanMemberId
|
package/lib/host/waker.js
CHANGED
|
@@ -62,6 +62,9 @@ function promptFor(reason, state, taskId) {
|
|
|
62
62
|
if (reason === 'inbox') {
|
|
63
63
|
return 'Team event: 你的收件箱有新条目。调用 team 工具查看 inbox,处理与你有关的事项。';
|
|
64
64
|
}
|
|
65
|
+
if (reason === 'pmo_continue') {
|
|
66
|
+
return 'PMO 自动推进:当前没有 active Task。先开一次复盘/头脑风暴/深度研究会议(读取已完成 Task、Thread 与核心 skill;在 Thread/@ 相关成员形成意见),据此自己产出验收标准与下一批 Task;resultDefinition 必须可验证,qaRequired 必须有 qaChecklist。不要等待 Human、不要只汇报、不要重复已完成卡。Human 只观测,所有需要决策的问题交 PMO 自动决策。';
|
|
67
|
+
}
|
|
65
68
|
return `Team event: ${task?.title ?? taskId}。调用 team 工具查看 tasks/thread 后行动。`;
|
|
66
69
|
}
|
|
67
70
|
|
|
@@ -165,9 +168,14 @@ export function planWakes(state, result, actor = {}) {
|
|
|
165
168
|
null,
|
|
166
169
|
'human_message',
|
|
167
170
|
`human_message:${event.seq ?? event.payload?.seq}`,
|
|
168
|
-
`你是 Project owner。Human 在 Main stream 说:“${body}”。本轮必须先用 team action=members 查看成员,再用 action=create 创建至少一个 Task;ownerId 必须使用成员 id,写明可验证的 resultDefinition。可用成员:${roster || '无'}。如果需求很大或外部链接只是参考,先创建“调研/澄清/拆解”Task
|
|
171
|
+
`你是 Project owner。Human 在 Main stream 说:“${body}”。本轮必须先用 team action=members 查看成员,再用 action=create 创建至少一个 Task;ownerId 必须使用成员 id,写明可验证的 resultDefinition。可用成员:${roster || '无'}。如果需求很大或外部链接只是参考,先创建“调研/澄清/拆解”Task,不要直接开始实现。先做复盘/头脑风暴/深度研究,再产出验收标准;不要等 Human、不要把决策推回 Human。需要记录决策时调用 team action=askHuman,PMO 会自动按最高价值建议处理;不要只写“见报告/§6/已提交 Human”。resultDefinition 只写验收标准,不要写“等待 Human 验收”;不要只读文件或只回一段话结束。`,
|
|
169
172
|
);
|
|
170
173
|
}
|
|
174
|
+
if (event?.type === 'pmo/continue') {
|
|
175
|
+
const project = Object.values(state.projects ?? {})[0];
|
|
176
|
+
const bucket = event.payload?.bucket ?? Math.floor(Date.now() / 600_000);
|
|
177
|
+
add(project?.ownerMemberId, null, 'pmo_continue', `pmo:${project?.id ?? 'project'}:${bucket}`);
|
|
178
|
+
}
|
|
171
179
|
if (event?.type === 'inbox/item-created') {
|
|
172
180
|
add(event.payload?.item?.memberId, event.payload?.item?.ref?.taskId ?? null, 'inbox', `inbox:${event.payload?.item?.id ?? event.seq}`);
|
|
173
181
|
}
|
package/lib/ledger/fold.js
CHANGED
|
@@ -157,6 +157,7 @@ function makeProject(payload, event) {
|
|
|
157
157
|
ownerMemberId: owner,
|
|
158
158
|
ownerName: stringOrNull(source.ownerName),
|
|
159
159
|
workspacePath: stringOrNull(source.workspacePath),
|
|
160
|
+
pmoAuto: source.pmoAuto !== false,
|
|
160
161
|
status: textOr(source.status, 'active'),
|
|
161
162
|
createdAt: intOr(source.createdAt, event.at),
|
|
162
163
|
archivedAt: null,
|
|
@@ -637,6 +638,18 @@ export function applyEvent(state, event) {
|
|
|
637
638
|
return advance(state, event, { projects: { ...state.projects, [project.id]: project } });
|
|
638
639
|
}
|
|
639
640
|
|
|
641
|
+
case 'project/pmo-updated': {
|
|
642
|
+
const projectId = textOr(payload.projectId, event.projectId);
|
|
643
|
+
const project = state.projects?.[projectId];
|
|
644
|
+
if (project === undefined || typeof payload.pmoAuto !== 'boolean') return advance(state, event);
|
|
645
|
+
return advance(state, event, {
|
|
646
|
+
projects: {
|
|
647
|
+
...state.projects,
|
|
648
|
+
[projectId]: { ...project, pmoAuto: payload.pmoAuto },
|
|
649
|
+
},
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
|
|
640
653
|
case 'project/workspace-changed': {
|
|
641
654
|
const projectId = textOr(payload.projectId, event.projectId);
|
|
642
655
|
const workspacePath = textOr(payload.workspacePath, null);
|
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises';
|
|
2
2
|
import { basename, join } from 'node:path';
|
|
3
3
|
import { isSkillExcluded } from './skill-policy.js';
|
|
4
|
+
import { CORE_SKILL_TEXT } from './core-skill.js';
|
|
4
5
|
|
|
5
6
|
const MAX_SKILL_CHARS = 1800;
|
|
6
7
|
|
|
7
8
|
/** Read bounded SKILL.md files for member-declared skills from the profile skill library. */
|
|
8
9
|
export async function loadSkillContext(member, dataDir) {
|
|
9
10
|
const skills = Array.isArray(member?.skills) ? member.skills : [];
|
|
10
|
-
|
|
11
|
-
const
|
|
12
|
-
for (const raw of skills.slice(0, 4)) {
|
|
11
|
+
const chunks = [CORE_SKILL_TEXT.replace(/^---[\s\S]*?\n---\s*/, '').trim()];
|
|
12
|
+
for (const raw of (typeof dataDir === 'string' ? skills.slice(0, 4) : [])) {
|
|
13
13
|
const name = basename(String(raw)).replace(/[^a-zA-Z0-9._-]/g, '');
|
|
14
14
|
if (name === '' || name === '.' || name === '..' || name.startsWith('.')) continue;
|
|
15
15
|
if (isSkillExcluded(name)) continue;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@suifei/dsh-ai-team",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.35",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "文渊阁 (Wenyuan Pavilion) — AI Team mode for DeepSeek Harness: concurrent projects, owned tasks, threads, inbox and an append-only team ledger",
|
|
6
6
|
"license": "MIT",
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: ai-team-core
|
|
3
|
+
description: 文渊阁 AI Team 核心工程纪律:单一实现、网页取证与代理、懒惰的高级程序员、最小依赖、日志埋点。
|
|
4
|
+
whenToUse: 所有 Agent(PMO/主理人/执行/验收)在规划、实现、验收、排查和汇报前都应加载并遵守。
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# AI Team 核心工程纪律
|
|
8
|
+
|
|
9
|
+
**同一能力在仓库内只允许一份实现,禁止另立简化版并存。**
|
|
10
|
+
|
|
11
|
+
**抓取网页可以模拟请求带 useragent,海外如果联不通,则探查本地是否有存在 http 代理再试。**
|
|
12
|
+
|
|
13
|
+
## 开发规程(长期生效)
|
|
14
|
+
|
|
15
|
+
**核心理念:懒惰的高级程序员思维**,这里的“懒惰”不是敷衍,而是对成本、风险与维护负担的敏感。
|
|
16
|
+
|
|
17
|
+
- **能不写就不写。** 每一行新代码都是维护负债;写代码是最后手段,不是第一反应。
|
|
18
|
+
- **能复用就不重造。** 已有且经过验证的实现优先;能扩展现有实现,就不另起一套。
|
|
19
|
+
- **系统原生能解决就不引入新依赖。** 在目标平台与架构允许的边界内,优先使用标准库、运行时与操作系统已有能力。
|
|
20
|
+
- 必须实现日志埋点,记录关键操作和流程,用于 debug 分析插件工作状态。
|
|
21
|
+
|
|
22
|
+
## 角色要求
|
|
23
|
+
|
|
24
|
+
- PMO/主理人:用复盘、头脑风暴、深度研究形成验收标准与 Task 草案,不把决策推给 Human;需要 Human 时交给 PMO 自动决策。
|
|
25
|
+
- 执行:先读核心纪律与结果定义;交付证据必须实测,不手填数字。
|
|
26
|
+
- 验收:独立复跑;只按 resultDefinition / QA checklist 判定;不共享执行者的 claim。
|