@dsh-fly/dsh-plugin-fly-smart 0.0.0-stage → 0.2.2
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/LICENSE +21 -0
- package/README.md +122 -2
- package/THIRD_PARTY_NOTICES.md +73 -0
- package/assets/smart-debug/SKILL.md +319 -0
- package/assets/smart-diagnose/SKILL.md +170 -0
- package/assets/smart-parallel/SKILL.md +190 -0
- package/assets/smart-plan/SKILL.md +184 -0
- package/assets/smart-review-receive/SKILL.md +230 -0
- package/assets/smart-skills/SKILL.md +96 -0
- package/assets/smart-tdd/SKILL.md +333 -0
- package/assets/smart-verify-claim/SKILL.md +129 -0
- package/assets/smart-worktree/SKILL.md +182 -0
- package/assets/smart-write-skill/SKILL.md +277 -0
- package/cordis.patch.yml +12 -0
- package/index.js +847 -0
- package/lib/client.js +1569 -0
- package/lib/state.mjs +2389 -0
- package/package.json +68 -4
- package/panel/reader.js +481 -0
- package/panel/routes.mjs +393 -0
- package/tools/index.js +1505 -0
package/tools/index.js
ADDED
|
@@ -0,0 +1,1505 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fly smart mode — the agent-plane half (design: `docs/smart-mode-design.md`).
|
|
3
|
+
*
|
|
4
|
+
* Smart mode is the "design first, then execute, then accept" workflow of a
|
|
5
|
+
* three-model collaboration: the session's model is the *brain*, a subagent on
|
|
6
|
+
* the *hand* route executes, and an independent *review* route audits feasibility
|
|
7
|
+
* and acceptance evidence. This file mounts the phase machine as tools; the state itself lives in `lib/state.mjs` so every
|
|
8
|
+
* transition is testable without cordis.
|
|
9
|
+
*
|
|
10
|
+
* S1 scope (this file): `smart_begin`, `smart_artifact`, `smart_status`.
|
|
11
|
+
* `smart_consult` / `smart_gate` (S2) and `smart_execute` / `smart_verify` /
|
|
12
|
+
* `smart_rework` / `smart_finish` (S3) follow the design's staging; the machine
|
|
13
|
+
* they need is already here — recording an artifact opens the phase's gate and
|
|
14
|
+
* nothing advances until that gate is passed.
|
|
15
|
+
*
|
|
16
|
+
* @module dsh-plugin-fly-smart
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
20
|
+
import { join } from 'node:path'
|
|
21
|
+
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
|
22
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
23
|
+
import {
|
|
24
|
+
ARTIFACT_FILE,
|
|
25
|
+
BRIEFING_PHASES,
|
|
26
|
+
DESIGN_PHASES,
|
|
27
|
+
GATE_AFTER,
|
|
28
|
+
briefingGaps,
|
|
29
|
+
briefingStatusOf,
|
|
30
|
+
cancelTask,
|
|
31
|
+
candidateTaskRoots,
|
|
32
|
+
createTask,
|
|
33
|
+
currentHandRoute,
|
|
34
|
+
currentReviewRoute,
|
|
35
|
+
gateGaps,
|
|
36
|
+
gateOwner,
|
|
37
|
+
grantRework,
|
|
38
|
+
openDecisions,
|
|
39
|
+
listTasks,
|
|
40
|
+
locateTask,
|
|
41
|
+
passGate,
|
|
42
|
+
readArtifact,
|
|
43
|
+
readState,
|
|
44
|
+
recordArtifact,
|
|
45
|
+
recordBriefing,
|
|
46
|
+
recordReviewBundle,
|
|
47
|
+
freshReviewBundle,
|
|
48
|
+
recordPlanAcceptance,
|
|
49
|
+
companionFileOf,
|
|
50
|
+
recordBriefingFailure,
|
|
51
|
+
confirmBriefing,
|
|
52
|
+
missingBriefingSections,
|
|
53
|
+
recordConsult,
|
|
54
|
+
readConsult,
|
|
55
|
+
recordDecisions,
|
|
56
|
+
recordVerification,
|
|
57
|
+
resolveConfig,
|
|
58
|
+
resolveBrainstormEffort,
|
|
59
|
+
resolveMirrorPath,
|
|
60
|
+
resolveTaskRoot,
|
|
61
|
+
reworkBudget,
|
|
62
|
+
reworkTo,
|
|
63
|
+
finishTask,
|
|
64
|
+
taskStatus,
|
|
65
|
+
taskDir,
|
|
66
|
+
recordRuling,
|
|
67
|
+
rulingsFromReport,
|
|
68
|
+
escalatedEffort,
|
|
69
|
+
assertFinalReviewRoute,
|
|
70
|
+
reworkUsedIn,
|
|
71
|
+
briefingSourceFor,
|
|
72
|
+
gitHeadOf,} from '../lib/state.mjs'
|
|
73
|
+
|
|
74
|
+
/** Cordis plugin name, matched by the preset row's `name`. */
|
|
75
|
+
export const name = 'fly-smart'
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Every service this row **reads** must be declared here.
|
|
79
|
+
*
|
|
80
|
+
* Cordis throws on an undeclared service *property access*
|
|
81
|
+
* (`cannot get property "systemPrompt" without inject`), and a throw inside
|
|
82
|
+
* `apply` is not a local failure: the preset mounts as one composition, so the
|
|
83
|
+
* activation audit rejects the whole mount and sessions on this preset never
|
|
84
|
+
* become composable. Measured on 0.1.7-rc.2 — the row used to declare only
|
|
85
|
+
* `tools` while reading `ctx.systemPrompt`, which took the entire Fly preset
|
|
86
|
+
* down (see docs/smart-mode-design.md §12.6).
|
|
87
|
+
*
|
|
88
|
+
* `tools` (registry), `commands` (`/smart`), `systemPrompt` (the mode-discipline
|
|
89
|
+
* section). The capability seams the tools reach for later (`subagents`, the
|
|
90
|
+
* hand-model route) are resolved through `ctx.get(name)`, which is the
|
|
91
|
+
* un-injected reflector getter and therefore needs no declaration.
|
|
92
|
+
*/
|
|
93
|
+
export const inject = ['tools', 'commands', 'systemPrompt']
|
|
94
|
+
|
|
95
|
+
/** JSON tool output, rendered as text (the `fly-diag` shape). */
|
|
96
|
+
const textOutput = {
|
|
97
|
+
schema: { type: 'string' },
|
|
98
|
+
render: (_args, value) => [{ type: 'text', text: value }],
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Mount the smart-mode tools.
|
|
103
|
+
* @param ctx - cordis context of the preset's smart row.
|
|
104
|
+
* @param rawConfig - row `config` (`{ enabled, rootDir, brainRoute, handRoute, maxRework, requireConsult }`).
|
|
105
|
+
*/
|
|
106
|
+
export function apply(ctx, rawConfig = {}) {
|
|
107
|
+
const config = resolveConfig(rawConfig)
|
|
108
|
+
// 行 rootDir 只允许在"我就是要压掉工作区根"的显式声明下使用(测试/无头)。少了
|
|
109
|
+
// allowGlobalRoot 就**在挂载时**大声报错:静默压掉工作区根会让产物落到一个没人看的
|
|
110
|
+
// 目录里,而那正是这次改造要消灭的失败模式(acceptance.md Y1/M7)。
|
|
111
|
+
if (typeof config.rootDir === 'string' && config.rootDir.trim() !== '' && config.allowGlobalRoot !== true) {
|
|
112
|
+
throw new Error(`smart(fly-smart): 行配置 rootDir=${JSON.stringify(config.rootDir)} 缺少 allowGlobalRoot: true —— 它会静默压掉每个会话的工作区根。测试/无头请显式写 allowGlobalRoot: true;产品会话请删掉 rootDir。`)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The mirror path this composition reads (fixed; see `lib/state.mjs`). */
|
|
116
|
+
const mirrorPath = resolveMirrorPath()
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The workspace a session says it is in — `session.header.cwd`, never
|
|
120
|
+
* `process.cwd()` (the host's cwd is not the user's workspace; guessing there
|
|
121
|
+
* would file tasks into the repository root).
|
|
122
|
+
*/
|
|
123
|
+
const workspaceOf = exec => {
|
|
124
|
+
const session = exec?.agent?.session
|
|
125
|
+
const cwd = session?.header?.cwd
|
|
126
|
+
return typeof cwd === 'string' && cwd !== '' ? { workspace: cwd, workspaceSource: 'session.header.cwd' } : null
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Where a **new** task lands: preset row (guarded) → workspace → legacy. */
|
|
130
|
+
const rootOf = exec => resolveTaskRoot({ config, session: exec?.agent?.session })
|
|
131
|
+
|
|
132
|
+
/** Where an **existing** task is: disk scan across the candidates, else the write root. */
|
|
133
|
+
const taskRootOf = (id, exec) => {
|
|
134
|
+
const { roots } = candidateTaskRoots({ config, env: process.env, session: exec?.agent?.session })
|
|
135
|
+
const found = locateTask(id, roots)
|
|
136
|
+
return found === null ? rootOf(exec).root : found.root
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The hand route for **this call**, read fresh every time.
|
|
141
|
+
*
|
|
142
|
+
* Read lazily on purpose: the page writes the mirror while the session is live,
|
|
143
|
+
* so a route captured at `apply()` would make "saved" and "in effect" two
|
|
144
|
+
* different things — the user would change the model and the next consult would
|
|
145
|
+
* still hand the work to the old one (`acceptance.md` S2b-1, A/B#2).
|
|
146
|
+
*/
|
|
147
|
+
const routeOf = (role, exec) => {
|
|
148
|
+
const found = role === 'review'
|
|
149
|
+
? currentReviewRoute({ config, session: exec?.agent?.session, mirrorPath })
|
|
150
|
+
: currentHandRoute({ config, session: exec?.agent?.session, mirrorPath })
|
|
151
|
+
if (found === null) {
|
|
152
|
+
const field = role === 'review' ? 'review' : 'hand'
|
|
153
|
+
// **可移植的文案**(2026-10-10 定):这一行会随"模式包"装进别人的 profile,而那里
|
|
154
|
+
// **不一定有我们的工作台抽屉**(`fly-smart-panel` 是宿主平面的另一个包,装不装由用户
|
|
155
|
+
// 决定),所以指路的顺序必须反过来 —— 先给**行配置**(任何 profile 都有),再说面板
|
|
156
|
+
// (有才用得上);中英各一句(官方 profile 的界面是英文)。镜像路径照给:它是"未配置"
|
|
157
|
+
// 这件事最具体的证据。
|
|
158
|
+
const fallback = field === 'review' ? '(review 未配置时会回退到 hand:配好 hand 就能跑)' : ''
|
|
159
|
+
throw new Error(
|
|
160
|
+
`smart: no ${field} model is configured — 给 tool-fly-smart 的行配置加 ${field}Route: { provider, model }`
|
|
161
|
+
+ `(有 Fly 工作台时也可在右抽屉「Smart 工作流 → 三模型路由」里选)${fallback};当前镜像:${mirrorPath}。`
|
|
162
|
+
+ ` 三个来源任选其一:① 行配置 tool-fly-smart 的 config.${field}Route: { provider, model };`
|
|
163
|
+
+ `② 工作台「Fly → Smart」右上角的路由抽屉;`
|
|
164
|
+
+ `③ **没有面板时**手改镜像文件 ${resolveMirrorPath()}(JSON:{"${field}": {"provider": "…", "model": "…"}})。`
|
|
165
|
+
+ ` drawer when present (mirror: ${mirrorPath}), then retry.`,
|
|
166
|
+
)
|
|
167
|
+
}
|
|
168
|
+
return found
|
|
169
|
+
}
|
|
170
|
+
const handRouteOf = exec => routeOf('hand', exec)
|
|
171
|
+
const reviewRouteOf = exec => routeOf('review', exec)
|
|
172
|
+
|
|
173
|
+
// **按路由查**,不写死 brainRoute:升档用在 review/hand 上,而它们与 brain 可能是
|
|
174
|
+
// 不同的模型 —— 拿 brain 的档位表去抬 review 的 effort,会造出该模型没有的档位名。
|
|
175
|
+
const supportedEffortsOf = async (exec, forRoute) => {
|
|
176
|
+
const route = forRoute ?? config.brainRoute
|
|
177
|
+
const directories = ctx.get?.('modelDirectories')
|
|
178
|
+
|
|
179
|
+
if (route === undefined || directories?.directoryFor === undefined) return undefined
|
|
180
|
+
const sessionId = exec?.agent?.session?.id
|
|
181
|
+
if (typeof sessionId !== 'string') return undefined
|
|
182
|
+
const snapshot = await directories.directoryFor(sessionId)?.load?.()
|
|
183
|
+
for (const group of snapshot?.groups ?? []) {
|
|
184
|
+
if (group.id !== route.provider) continue
|
|
185
|
+
for (const model of group.models ?? []) if (model.id === route.model) return (model.reasoning?.efforts ?? []).map(effort => effort.id)
|
|
186
|
+
}
|
|
187
|
+
return []
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Deliberately per-session with NO global fallback: a "last touched task"
|
|
191
|
+
* shortcut would let one session write artifacts into another session's task,
|
|
192
|
+
* which is exactly the kind of cross-talk this machine must not have. A call
|
|
193
|
+
* without an active task either names its `taskId` or fails loudly.
|
|
194
|
+
*/
|
|
195
|
+
const activeTasks = new WeakMap()
|
|
196
|
+
|
|
197
|
+
const assertEnabled = exec => {
|
|
198
|
+
if (!config.enabled) throw new Error('smart: smart mode is disabled for this row (set `enabled: true` in the fly-smart config)')
|
|
199
|
+
// **子代理不许驱动工作流**(2026-10-20 补):老宿主靠给孩子的 toolFilter 禁掉 smart_*,
|
|
200
|
+
// 而 0.2.x 的 `startActivation` 请求里**没有** toolFilter 这个字段(只剩在冷恢复的描述符里),
|
|
201
|
+
// 于是这层约束必须由插件自己守 —— 否则执行子代理能把门关了、把产物改了。
|
|
202
|
+
// 判据用会话自己的血缘字段(不是"看起来像孩子"):`origin === 'subagent'` 或 delegationDepth > 0。
|
|
203
|
+
const header = exec?.agent?.session?.header
|
|
204
|
+
const isChild = header?.origin === 'subagent'
|
|
205
|
+
|| (typeof header?.delegationDepth === 'number' && header.delegationDepth > 0)
|
|
206
|
+
if (isChild) {
|
|
207
|
+
throw new Error('smart: this session is a subagent child — the workflow tools belong to the top-level session, not to a delegated child (the host no longer restricts a child\'s tool list, so the plugin refuses here)')
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Resolve the task a call refers to: explicit argument, else this session's. */
|
|
212
|
+
const pickTaskId = (exec, args = {}) => {
|
|
213
|
+
if (typeof args.taskId === 'string' && args.taskId.length > 0) return args.taskId
|
|
214
|
+
const session = exec?.agent?.session
|
|
215
|
+
if (session !== undefined) {
|
|
216
|
+
const remembered = activeTasks.get(session)
|
|
217
|
+
if (remembered !== undefined) return remembered
|
|
218
|
+
}
|
|
219
|
+
throw new Error('smart: no active task in this session — call smart_begin first, or pass `taskId`')
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const remember = (exec, id) => {
|
|
223
|
+
const session = exec?.agent?.session
|
|
224
|
+
if (session !== undefined) activeTasks.set(session, id)
|
|
225
|
+
return id
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Fetch a capability seam, failing loudly with what to install instead. */
|
|
229
|
+
const service = (name, what) => {
|
|
230
|
+
const found = ctx.get(name)
|
|
231
|
+
if (found === undefined) throw new Error(`smart: the ${what} service (\`${name}\`) is unavailable — smart mode needs it for this step`)
|
|
232
|
+
return found
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Flatten whatever a subagent returned into review text. */
|
|
236
|
+
const outputText = value => {
|
|
237
|
+
if (typeof value === 'string') return value
|
|
238
|
+
if (Array.isArray(value)) return value.map(part => (typeof part === 'string' ? part : part?.text ?? '')).join('')
|
|
239
|
+
if (value !== null && typeof value === 'object') return typeof value.text === 'string' ? value.text : JSON.stringify(value)
|
|
240
|
+
return value === undefined || value === null ? '' : String(value)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Smart's own tools, denied to every child we spawn.
|
|
245
|
+
*
|
|
246
|
+
* A child getting them could drive the machine (close its own gates, rewrite
|
|
247
|
+
* artifacts) — the pair collaborates, but only the session's model holds the
|
|
248
|
+
* workflow keys. Children still get the full capability surface for the *work*
|
|
249
|
+
* (拍板 2: 执行与会话同权), which is exactly what this list does not touch.
|
|
250
|
+
*/
|
|
251
|
+
/**
|
|
252
|
+
* 门问询里 summary 的字数上限。它不是审美:上游的用户问询卡(QuestionComposer,
|
|
253
|
+
* @deepseek-ai/dsh-client-ui-user-questions)把 question 渲染成卡片的**标题**,而标题所在的
|
|
254
|
+
* header 既不收缩也不滚 —— 塞一整篇产物进去就会把正文(选项在这里)和提交按钮挤出卡的可见
|
|
255
|
+
* 区域,用户看得到问题、却点不到任何东西(2026-10-01 实机)。超过上限就截断,并如实告诉
|
|
256
|
+
* agent:完整正文本来就该落在产物里,产物在面板里可读。
|
|
257
|
+
*/
|
|
258
|
+
const GATE_SUMMARY_LIMIT = 300
|
|
259
|
+
|
|
260
|
+
/** 截断长 summary:优先在换行/句末断,别让用户停在半句话上。 */
|
|
261
|
+
const capGateSummary = text => {
|
|
262
|
+
const trimmed = text.trim()
|
|
263
|
+
if (trimmed.length <= GATE_SUMMARY_LIMIT) return { text: trimmed, truncated: false }
|
|
264
|
+
const cut = trimmed.slice(0, GATE_SUMMARY_LIMIT)
|
|
265
|
+
const boundary = Math.max(cut.lastIndexOf('\n'), cut.lastIndexOf('。'), cut.lastIndexOf(';'))
|
|
266
|
+
const head = (boundary >= GATE_SUMMARY_LIMIT * 0.6 ? cut.slice(0, boundary + 1) : cut).trimEnd()
|
|
267
|
+
return {
|
|
268
|
+
text: `${head}…\n(摘要已截断:门问询只写决策所需的最短理由,完整正文见产物文件)`,
|
|
269
|
+
truncated: true,
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const SMART_TOOL_NAMES = ['smart_begin', 'smart_artifact', 'smart_consult', 'smart_decide', 'smart_ruling', 'smart_gate', 'smart_execute', 'smart_verify', 'smart_rework', 'smart_finish', 'smart_brief', 'smart_brief_confirm']
|
|
274
|
+
|
|
275
|
+
/** Evidence gathering may inspect the world, never mutate it (design §5). */
|
|
276
|
+
const VERIFY_TOOL_DENY = [...SMART_TOOL_NAMES, 'write', 'edit', 'subagent', 'subagent_fork', 'subagent_codex', 'subagent_claude_code']
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* 只读名单分两段:**必备**(智能工具面 + 写文件的工具)与**部署相关**(子代理后端不止一套:
|
|
280
|
+
* 本仓闭包只暴露 `subagent_fork`,`subagent` / `subagent_codex` / `subagent_claude_code`
|
|
281
|
+
* 属于别的后端)。分段的理由见 `verifyToolFilter()`。
|
|
282
|
+
*/
|
|
283
|
+
const VERIFY_TOOL_DENY_CORE = [...SMART_TOOL_NAMES, 'write', 'edit']
|
|
284
|
+
const VERIFY_TOOL_DENY_OPTIONAL = ['subagent', 'subagent_fork', 'subagent_codex', 'subagent_claude_code']
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* 证据面的只读名单要按**本部署的视图**收敛一次。
|
|
288
|
+
*
|
|
289
|
+
* 症状:`smart_verify` 在只暴露 `subagent_fork` 的部署上第一步就死 —— 名单里的
|
|
290
|
+
* `subagent` / `subagent_codex` / `subagent_claude_code` 不存在,而上游 `tools.restrict()`
|
|
291
|
+
* (由 spawn 后端替我们调用)对**不存在的名字直接抛错**:
|
|
292
|
+
* `tools.restrict() names unknown global tools "subagent", …; known global tools: …`。
|
|
293
|
+
* 只读名单的本意是"证据面不许改世界",不是"每种部署都得有这三个后端"。
|
|
294
|
+
*
|
|
295
|
+
* 收敛两条纪律:
|
|
296
|
+
* ① **只丢部署相关的**——`write`/`edit`/智能工具面一个不丢;视图里连必备项都没有,说明这个
|
|
297
|
+
* 视图不可信(不是"本部署没有 write 工具"),此时退回全名单(老行为),**绝不**收敛成
|
|
298
|
+
* `deny: []` 把证据面变成可写;
|
|
299
|
+
* ② 收敛掉的名单走 debug 留痕,不静默。
|
|
300
|
+
*/
|
|
301
|
+
const verifyToolFilter = agent => {
|
|
302
|
+
const known = (() => {
|
|
303
|
+
try { return ctx.tools?.view?.(agent)?.restrictableNames } catch { return undefined }
|
|
304
|
+
})()
|
|
305
|
+
if (known === undefined) return { deny: VERIFY_TOOL_DENY }
|
|
306
|
+
if (VERIFY_TOOL_DENY_CORE.some(name => !known.has(name))) return { deny: VERIFY_TOOL_DENY }
|
|
307
|
+
const dropped = VERIFY_TOOL_DENY_OPTIONAL.filter(name => !known.has(name))
|
|
308
|
+
if (dropped.length > 0) ctx.logger?.debug?.(`smart: smart_verify 只读名单里 ${dropped.join(', ')} 在本部署不存在(视图里没有),已收敛`)
|
|
309
|
+
return { deny: [...VERIFY_TOOL_DENY_CORE, ...VERIFY_TOOL_DENY_OPTIONAL.filter(name => known.has(name))] }
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Run one hand-route child and return its text.
|
|
314
|
+
*
|
|
315
|
+
* 上游契约(`@deepseek-ai/dsh-subagent` 的 `start(name, request)`;spawn 后端是
|
|
316
|
+
* `dsh-subagent-spawn-in-process` + `dsh-subagent-in-process-driver`):`start()`
|
|
317
|
+
* 返回的是 **run 句柄** `{ id, localAgent, result, dispose }`,产出要 `await run.result`
|
|
318
|
+
* 得 `{ output: [{ type: 'text', text }…], stopReason }`,取完按官方 settleRun 的姿势
|
|
319
|
+
* `dispose()` 释放孩子。曾把整个句柄当结果丢进 JSON.stringify —— 句柄上挂着 Cordis
|
|
320
|
+
* 代理(localAgent),序列化摸它 toJSON 直接炸 `cannot get property "toJSON" without
|
|
321
|
+
* inject`;单测的 mock 一直返回 `{ output }`(与真契约不符),所以从没暴露。
|
|
322
|
+
*/
|
|
323
|
+
/**
|
|
324
|
+
* 起一个子代理并等它跑完。
|
|
325
|
+
*
|
|
326
|
+
* **宿主契约在 0.2.x 换过一次**(2026-10-20 现场:`subagents.start is not a function`):
|
|
327
|
+
* 现在是 `startActivation({ provider, label, request, signal, delivery })`,返回
|
|
328
|
+
* `{ childId, result, dispose }`;老的 `start(name, request)` 已从服务面上消失。
|
|
329
|
+
* 两条都留着:新宿主走 startActivation,老宿主走 start(plugin 会被装到别人的 profile 里,
|
|
330
|
+
* 版本不由我们决定)。
|
|
331
|
+
*
|
|
332
|
+
* **代价要如实说**:新请求类型 `SubagentStartRequest` 里**没有** `toolFilter`(它只剩在
|
|
333
|
+
* 冷恢复的描述符里)。所以新宿主上孩子**拿不到**「禁掉 smart_* 工具」这层约束 —— 这条
|
|
334
|
+
* 写在 README 的已知限制里,不再假装它还生效。
|
|
335
|
+
*/
|
|
336
|
+
const startChild = async (exec, { label, prompt, agentOptions, toolFilter }) => {
|
|
337
|
+
const subagents = service('subagents', 'subagent registry')
|
|
338
|
+
if (typeof subagents.startActivation === 'function') {
|
|
339
|
+
const activation = await subagents.startActivation({
|
|
340
|
+
provider: 'spawn',
|
|
341
|
+
label,
|
|
342
|
+
request: {
|
|
343
|
+
prompt: [{ type: 'text', text: prompt }],
|
|
344
|
+
parent: exec.agent,
|
|
345
|
+
...agentOptions === undefined ? {} : { agentOptions },
|
|
346
|
+
},
|
|
347
|
+
signal: exec.signal,
|
|
348
|
+
// caller:只把结果交回调用方,不往父会话塞一条通知 —— 工具调用要的是同步结果。
|
|
349
|
+
delivery: 'caller',
|
|
350
|
+
})
|
|
351
|
+
try {
|
|
352
|
+
return await activation.result
|
|
353
|
+
} finally {
|
|
354
|
+
try { await activation.dispose() } catch { /* 释放失败不顶替结果判定 */ }
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
// 老宿主(0.1.x):start(name, request) → run 句柄。
|
|
358
|
+
const run = await subagents.start('spawn', {
|
|
359
|
+
label,
|
|
360
|
+
prompt: [{ type: 'text', text: prompt }],
|
|
361
|
+
parent: exec.agent,
|
|
362
|
+
...agentOptions === undefined ? {} : { agentOptions },
|
|
363
|
+
...toolFilter === undefined ? {} : { toolFilter },
|
|
364
|
+
signal: exec.signal,
|
|
365
|
+
})
|
|
366
|
+
try {
|
|
367
|
+
return await run.result
|
|
368
|
+
} finally {
|
|
369
|
+
try { await run.dispose() } catch { /* disposal failure is not the caller's verdict */ }
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const runHand = async (exec, { label, prompt, toolFilter, role = 'hand', phase = undefined, attempts = 0 }) => {
|
|
374
|
+
// 每次调用现读(懒读):页面保存的新路由必须在下一次 spawn 就生效。
|
|
375
|
+
// `source`(preset/session/default/*-fallback)随结果带出:review 路由回落 hand 时,
|
|
376
|
+
// 记录要如实标注 fallback,不能让"谁真的看了这份稿子"变成猜(acceptance B5)。
|
|
377
|
+
const found = role === 'review' ? reviewRouteOf(exec) : handRouteOf(exec)
|
|
378
|
+
let route = found.route
|
|
379
|
+
const source = found.source
|
|
380
|
+
// **终审不许回落**(G5 那一关的评审):回落链最坏会跑到执行者用的那条上 —— 自己审自己。
|
|
381
|
+
if (phase === 'reviewing') assertFinalReviewRoute(source)
|
|
382
|
+
// **返工升档**:同一阶段反复修不好的点,让评审那侧看得更用力(提到该模型支持的最高档;
|
|
383
|
+
// 查不到档位表就原样跑并如实标注,不猜、不造档位名)。
|
|
384
|
+
const escalation = escalatedEffort(route, attempts, await supportedEffortsOf(exec, found.route))
|
|
385
|
+
route = escalation.route
|
|
386
|
+
const result = await startChild(exec, { label, prompt, agentOptions: route, toolFilter })
|
|
387
|
+
if (result?.stopReason !== 'completed') {
|
|
388
|
+
const detail = result?.diagnostic === undefined ? '' : `: ${result.diagnostic}`
|
|
389
|
+
throw new Error(`smart: the hand model run "${label}" ended as "${result?.stopReason ?? 'no result'}"${detail}`)
|
|
390
|
+
}
|
|
391
|
+
const body = outputText(result.output)
|
|
392
|
+
if (body.trim().length === 0) throw new Error(`smart: the hand model returned nothing for "${label}" — nothing was recorded`)
|
|
393
|
+
return { route, source, body, escalated: escalation.escalated, attempts: escalation.attempts }
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* The briefing brief: the draft is the whole context, the output is what the user
|
|
398
|
+
* will be walked through in conversation. Structure is pinned so the mechanical
|
|
399
|
+
* completeness check (`missingBriefingSections`) has something to check against.
|
|
400
|
+
*/
|
|
401
|
+
/**
|
|
402
|
+
* 派发上下文卫生(一份答案,五条 prompt 共用)。
|
|
403
|
+
*
|
|
404
|
+
* 源自 superpowers 记的那次事故:*"一次真实派发 42k 字符,99% 是粘贴的历史"* ——
|
|
405
|
+
* 子代理的 prompt 与它回的正文都常驻父会话上下文、每轮重读。两条约束:
|
|
406
|
+
* ① 不自派子代理(评审是**你的活**,不是你雇的人);② 大输出走文件,回正文只给结论与路径。
|
|
407
|
+
*
|
|
408
|
+
* 另有工具层兜底:子会话调用任何 `smart_*` 一律拒绝(`assertEnabled`)。
|
|
409
|
+
*/
|
|
410
|
+
const DISPATCH_HYGIENE = [
|
|
411
|
+
'Working contract (how you work, not what you build): do not dispatch subagents — not helpers, not reviewers; review is your job, not someone you hire. Put bulk output in files, not in your reply: your reply is re-read on every later turn of the parent session, so report conclusions and the paths that prove them, not the transcripts themselves.',
|
|
412
|
+
]
|
|
413
|
+
|
|
414
|
+
const briefingPrompt = ({ state, phase, draft }) => [
|
|
415
|
+
`You are the *review* model in a three-model workflow, writing a **briefing**: what the user needs to know about one phase artifact so the presenting model can walk them through it in plain conversation before their gate. The reader is the USER — a person deciding, not a model executing. Your own verdict belongs in the consult record, not here.`,
|
|
416
|
+
'',
|
|
417
|
+
`Task: ${state.title}`,
|
|
418
|
+
state.goal === '' ? '' : `Goal: ${state.goal}`,
|
|
419
|
+
'',
|
|
420
|
+
`--- ${ARTIFACT_FILE[phase]} ---`,
|
|
421
|
+
draft.trimEnd(),
|
|
422
|
+
'--- end ---',
|
|
423
|
+
'',
|
|
424
|
+
'Write the briefing in the user\'s language (Chinese artifacts ⇒ Chinese briefing), as markdown:',
|
|
425
|
+
'1. Start with a line `覆盖章节:…` naming **every** section heading of the artifact above (## and deeper) — that line is machine-checked, keep it exact. Everything after it is for the user.',
|
|
426
|
+
'2. Then explain the artifact in **user-facing language**: plain sentences a person can follow without having read the artifact and without knowing its vocabulary. Translate artifact-internal shorthand — clause IDs (like A1–A11), file and tool names, test counts, gate names — into what they mean for this decision. Do NOT organize the prose by the artifact\'s internal labels and do NOT write in checklist/audit voice; organize it by what the user must decide.',
|
|
427
|
+
'3. For each part of the story, distinguish 已定 (settled in the draft) / 建议 (the draft recommends, still theirs to accept) / 未决 (open questions only they can answer). Name the critical details — key parameters, the real risks, what would break acceptance — in words that show consequence for the user, not in checklist vocabulary.',
|
|
428
|
+
'4. End with at most 3 questions the user should answer before confirming, phrased as questions to a person.',
|
|
429
|
+
'',
|
|
430
|
+
'Be specific and complete over long: the user decides from this and the conversation, not from the raw draft. No praise.',
|
|
431
|
+
...DISPATCH_HYGIENE,
|
|
432
|
+
].filter(line => line !== '').join('\n')
|
|
433
|
+
|
|
434
|
+
/** optimized:把 plan 与伴随 acceptance 拼成一份给 review 的正文。 */
|
|
435
|
+
const artifactForReview = (rootDir, id, phase) => {
|
|
436
|
+
const main = readArtifact(rootDir, id, phase) ?? ''
|
|
437
|
+
const companion = companionFileOf(phase)
|
|
438
|
+
if (companion === null) return main
|
|
439
|
+
let extra = ''
|
|
440
|
+
try {
|
|
441
|
+
extra = readFileSync(join(taskDir(rootDir, id), companion), 'utf8')
|
|
442
|
+
} catch {
|
|
443
|
+
extra = ''
|
|
444
|
+
}
|
|
445
|
+
return extra === '' ? main : `${main.trimEnd()}\n\n--- ${companion} ---\n${extra.trimEnd()}`
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const BUNDLE_MARKERS = { briefing: '=== BRIEFING ===', consult: '=== CONSULT ===' }
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* 一次 review child 出两段:先给用户的要点,再给模型的可行性评审。
|
|
452
|
+
*
|
|
453
|
+
* 两段用**固定分隔行**隔开,解析时要求两段都非空 —— 缺一段就是格式违约,整次失败
|
|
454
|
+
* (fail closed):宁可让模型重跑一次,也不能把"只有评审、没有要点"的半套结果落盘。
|
|
455
|
+
*/
|
|
456
|
+
const bundlePrompt = ({ state, phase, draft, question }) => [
|
|
457
|
+
`You are the *review* model in a three-model workflow. Produce **two** outputs for the ${phase} artifact below, in one reply.`,
|
|
458
|
+
'',
|
|
459
|
+
`Task: ${state.title}`,
|
|
460
|
+
state.goal === '' ? '' : `Goal: ${state.goal}`,
|
|
461
|
+
'',
|
|
462
|
+
`--- ${ARTIFACT_FILE[phase]} ---`,
|
|
463
|
+
draft.trimEnd(),
|
|
464
|
+
'--- end ---',
|
|
465
|
+
'',
|
|
466
|
+
`Reply with exactly these two sections, each starting with its marker line, in this order:`,
|
|
467
|
+
BUNDLE_MARKERS.briefing,
|
|
468
|
+
'What the USER needs to know, in plain user-facing language: name every section heading of the artifact on a first `覆盖章节:…` line (machine-checked), then explain 已定 / 建议 / 未决 and the details that change the decision. At most 3 questions to the user. No audit voice.',
|
|
469
|
+
BUNDLE_MARKERS.consult,
|
|
470
|
+
question === undefined || question.trim() === '' ? 'Your feasibility review for the model that will execute: blocking problems first, then concrete additions. Be specific.' : question.trim(),
|
|
471
|
+
'',
|
|
472
|
+
'Do not omit either marker. No praise, no restating the artifact.',
|
|
473
|
+
...DISPATCH_HYGIENE,
|
|
474
|
+
'',
|
|
475
|
+
].filter(line => line !== '').join('\n')
|
|
476
|
+
|
|
477
|
+
/** 解析两段;缺段/空段 ⇒ 抛错(调用方保证产物不动、可重试)。 */
|
|
478
|
+
const parseBundle = body => {
|
|
479
|
+
const text = String(body ?? '')
|
|
480
|
+
const briefingAt = text.indexOf(BUNDLE_MARKERS.briefing)
|
|
481
|
+
const consultAt = text.indexOf(BUNDLE_MARKERS.consult)
|
|
482
|
+
if (briefingAt === -1 || consultAt === -1 || consultAt < briefingAt) {
|
|
483
|
+
throw new Error('smart: the review reply is missing one of the two sections (=== BRIEFING === / === CONSULT ===); nothing was recorded — retry')
|
|
484
|
+
}
|
|
485
|
+
const briefingBody = text.slice(briefingAt + BUNDLE_MARKERS.briefing.length, consultAt).trim()
|
|
486
|
+
const consultBody = text.slice(consultAt + BUNDLE_MARKERS.consult.length).trim()
|
|
487
|
+
if (briefingBody === '' || consultBody === '') throw new Error('smart: one section of the review reply is empty; nothing was recorded — retry')
|
|
488
|
+
return { briefingBody, consultBody }
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/** The review brief for the hand model: the draft is the whole context. */
|
|
492
|
+
const consultPrompt = ({ state, phase, draft, question }) => [
|
|
493
|
+
`You are the *review* model in a three-model workflow (brain designs, you review, hand executes). Review the ${phase} artifact below for execution feasibility and say what would break.`,
|
|
494
|
+
'',
|
|
495
|
+
`Task: ${state.title}`,
|
|
496
|
+
state.goal === '' ? '' : `Goal: ${state.goal}`,
|
|
497
|
+
'',
|
|
498
|
+
`--- ${ARTIFACT_FILE[phase]} ---`,
|
|
499
|
+
draft.trimEnd(),
|
|
500
|
+
'--- end ---',
|
|
501
|
+
'',
|
|
502
|
+
question === undefined || question.trim() === '' ? 'Review it: what is missing, wrong, or unexecutable? Be concrete.' : question.trim(),
|
|
503
|
+
'',
|
|
504
|
+
...DISPATCH_HYGIENE,
|
|
505
|
+
'',
|
|
506
|
+
'Answer in markdown. Lead with blocking problems, then concrete additions. No praise, no restating the draft.',
|
|
507
|
+
].filter(line => line !== '').join('\n')
|
|
508
|
+
|
|
509
|
+
/** The executor's briefing: the plan and the acceptance contract, nothing else. */
|
|
510
|
+
const executePrompt = ({ state, plan, acceptance, instructions, base }) => [
|
|
511
|
+
'You are the *hand* model executing an approved plan. The design is settled; your job is to carry it out and report honestly what happened.',
|
|
512
|
+
'',
|
|
513
|
+
`Task: ${state.title}`,
|
|
514
|
+
state.goal === '' ? '' : `Goal: ${state.goal}`,
|
|
515
|
+
'',
|
|
516
|
+
'--- plan.md ---',
|
|
517
|
+
plan.trimEnd(),
|
|
518
|
+
'--- end plan ---',
|
|
519
|
+
'',
|
|
520
|
+
'--- acceptance.md (what will be checked) ---',
|
|
521
|
+
acceptance.trimEnd(),
|
|
522
|
+
'--- end acceptance ---',
|
|
523
|
+
'',
|
|
524
|
+
instructions === undefined || instructions.trim() === '' ? '' : `Extra instructions for this run: ${instructions.trim()}`,
|
|
525
|
+
'',
|
|
526
|
+
// 评审包(S6):代码变更要作为**评审物**交付,而不是只在正文里描述。
|
|
527
|
+
base === undefined || base === null
|
|
528
|
+
? 'Baseline: this is not a git worktree (or HEAD is unreadable), so compare against the working tree — and never `HEAD~1`: it silently drops every commit but the last of a multi-commit task.'
|
|
529
|
+
: `Baseline for this run (recorded before dispatch): \`${base}\` — compare against exactly this, never \`HEAD~1\` (that silently drops every commit but the last of a multi-commit task).`,
|
|
530
|
+
'',
|
|
531
|
+
'Review package (required): before you report, write the code changes to a file at `evidence/changes.md` under this task\'s directory, containing',
|
|
532
|
+
'1. `git log --oneline <BASE>..HEAD` (or the working-tree equivalent if you did not commit),',
|
|
533
|
+
'2. `git diff --stat <BASE>`,',
|
|
534
|
+
'3. `git diff -U10 <BASE>` for every file you touched.',
|
|
535
|
+
'Reply with the file\'s path and a one-line summary; do NOT paste the diff into your reply — it is re-read on every later turn of the parent session. The reviewer reads that file.',
|
|
536
|
+
'',
|
|
537
|
+
'Work through the plan step by step with your tools. Then report in markdown:',
|
|
538
|
+
'1. each plan step with its outcome (done / skipped / failed) and the evidence (paths, commands, output);',
|
|
539
|
+
'2. anything you had to deviate from, and why;',
|
|
540
|
+
'3. what you did NOT do and what remains.',
|
|
541
|
+
'Do not summarise the plan back at me, and do not claim success you did not observe.',
|
|
542
|
+
'',
|
|
543
|
+
'Working contract (these are about how you work, not what you build):',
|
|
544
|
+
...DISPATCH_HYGIENE,
|
|
545
|
+
'- A worker-spawned reviewer is a duplicate seat reviewing the same diff — a defect to flag, not rigor. Review arrives after your report.',
|
|
546
|
+
'- **When the plan is wrong, decide and record, do not stall.** A plan defect, an ambiguity, a conflict with the spec — settle it yourself and keep going. Record each one under a `## Rulings` heading as `- **<what you decided>** — <why> — <what it costs if wrong>`. Three parts or it will be dropped. A wrong ruling is rework your human can see and undo; a session parked on a question costs their day and buys nothing.',
|
|
547
|
+
'- **Four things stop you, and only these:** an irreversible or destructive operation; a security-sensitive action; a side effect outside this worktree that norms say you ask about first; and a plan so broken that every path forward is a guess.',
|
|
548
|
+
].filter(line => line !== '').join('\n')
|
|
549
|
+
|
|
550
|
+
/** The verifier's briefing: an adversarial check against the acceptance contract. */
|
|
551
|
+
const verifyPrompt = ({ state, acceptance, report, focus, reviewPackage }) => [
|
|
552
|
+
'You are the *review* model gathering acceptance evidence — independent of the executor who wrote the report below. You did not write the acceptance criteria and you are not here to be agreeable: your job is to find out whether each criterion is actually met, by inspection.',
|
|
553
|
+
'',
|
|
554
|
+
`Task: ${state.title}`,
|
|
555
|
+
state.goal === '' ? '' : `Goal: ${state.goal}`,
|
|
556
|
+
'',
|
|
557
|
+
'--- acceptance.md (the contract) ---',
|
|
558
|
+
acceptance.trimEnd(),
|
|
559
|
+
'--- end acceptance ---',
|
|
560
|
+
'',
|
|
561
|
+
...DISPATCH_HYGIENE,
|
|
562
|
+
// 评审包(S6):**代码变更**本身是评审物,不只是执行者对它的描述。
|
|
563
|
+
reviewPackage === undefined || reviewPackage === null ? '' : `Review package (the actual code changes): read \`${reviewPackage}\` — the commit list, the stat and the full diff the executor produced. Review the diff against each acceptance criterion; the executor's prose is a claim, the diff is the evidence.`,
|
|
564
|
+
'',
|
|
565
|
+
'--- execution report (what the executor claims) ---',
|
|
566
|
+
report.trimEnd(),
|
|
567
|
+
'--- end report ---',
|
|
568
|
+
'',
|
|
569
|
+
focus === undefined || focus.trim() === '' ? '' : `Concentrate on: ${focus.trim()}`,
|
|
570
|
+
'',
|
|
571
|
+
'You have inspection tools only — no file writes, no edits. Treat the execution report as a claim to be checked, not evidence.',
|
|
572
|
+
'For each acceptance criterion report: id/label · PASS or FAIL · the exact command or file you inspected · what you observed.',
|
|
573
|
+
'End with a list of criteria you could NOT verify and why. If a criterion is unverifiable by inspection, say so instead of guessing.',
|
|
574
|
+
].filter(line => line !== '').join('\n')
|
|
575
|
+
|
|
576
|
+
const summary = (rootDir, id, extra = {}) => {
|
|
577
|
+
const status = taskStatus(rootDir, id, { requireConsult: config.requireConsult, maxRework: config.maxRework })
|
|
578
|
+
return JSON.stringify({ taskId: id, rootDir, ...status, ...extra }, null, 2)
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* Sessions with smart mode switched on (via `/smart`).
|
|
583
|
+
*
|
|
584
|
+
* The mechanical state machine is always available through the tools; this flag
|
|
585
|
+
* is what makes the *workflow discipline* part of the model's brief. Without it
|
|
586
|
+
* the tools are just nine more names in the registry.
|
|
587
|
+
*/
|
|
588
|
+
const smartOn = new WeakMap()
|
|
589
|
+
const isOn = session => session !== undefined && smartOn.get(session) === true
|
|
590
|
+
|
|
591
|
+
const SECTION = [
|
|
592
|
+
'Smart mode is active for this session: run the task as a three-model workflow — design, then execute, then accept.',
|
|
593
|
+
'',
|
|
594
|
+
'Discipline (the tools enforce the mechanical parts; these are the parts only you can honour):',
|
|
595
|
+
'1. Open the task with smart_begin, then work the design phases in order: brainstorm → design → plan → acceptance. Write each artifact with smart_artifact and stop at its gate. **plan.md carries a document contract the gate enforces** (see the `smart-plan` skill): every task block declares its own `**Interfaces:**` with `Consumes` / `Produces` signatures, the document has a `## Global Constraints` section (the project-wide rules the spec fixes, copied verbatim) and a `## Review Focus` section (the input classes the spec implies but no task exercises in its tests). Missing any of them, G3 will not open — that is the floor for "another model can execute this from these files alone". The artifact contract differs per phase: brainstorm is a **decision table** (problem / candidates / current lean / open questions) that is explicitly NOT yet approved; design turns the accepted choice into an **ADR-lite** section (context, options, decision and why, consequences, revisit trigger) — the decision record belongs where the choice is made, not in the phase that was still exploring.',
|
|
596
|
+
'1b. Under the optimized workflow the brainstorm phase spawns no review child: you write its user-facing digest yourself and pass it as smart_artifact\'s `briefing` argument (provenance is recorded as brain/session, never as an independent review). Every later phase keeps the review route.',
|
|
597
|
+
'2. Before every gate, call smart_consult so the review model audits that phase for execution feasibility. Reviews are cheap; discovering an unexecutable plan after execution is not.',
|
|
598
|
+
'3. **Settle what you can decide yourself; put to the user only what only they can decide.** A plan defect, an ambiguity, a conflict with the spec — decide it, record it with `smart_ruling` (`Ruling: what — why — what it costs if wrong`, all three parts), and keep going. A session parked on a question costs the user their day and buys nothing; a wrong ruling is rework they can see and undo. Before a gate, settle what only the user can decide: put those questions to them with smart_decide (the same option card, recorded), fold the answers into the artifact as an 已裁决 section, and re-record it. A draft that still carries a 需要你裁决 section does not open its gate at all — the recommendation you wrote must not silently become their decision.',
|
|
599
|
+
'4. After every artifact lands (including the execution report and the review), a briefing is generated for it (smart_artifact does this; smart_brief only retries a failed one). Walk the user through that briefing **in ordinary conversation** — the key points and the critical details, section by section — and leave room for their questions and requested changes. Discussing is not approving: nothing you say, and nothing they say in chat, opens anything.',
|
|
600
|
+
'5. When the discussion settles, call smart_brief_confirm. It pops one short card and only that card\'s real answer opens the phase gate (or moves execution into review). If the user wants changes, they answer 需要修改 — re-record the artifact and the briefing regenerates; that loop costs no rework budget.',
|
|
601
|
+
'6. Call smart_gate to put the gate to the user, with a short summary of the risks and open questions. The ask card renders that summary as its heading, and a page of it pushes the options off the card — keep it to a few lines (the tool truncates past 300 characters and says so), and put the long version in the artifact, which the panel can show. Never treat your own judgement, or a passing remark, as their approval — the gate records their actual answer.',
|
|
602
|
+
'7. Do not execute before the user passes gate G4. When they do, smart_execute hands the approved plan to the hand model; you stay out of the execution itself. The execution report gets its own briefing and confirmation before the task moves to review.',
|
|
603
|
+
'8. After execution, gather evidence with smart_verify, then write your own verdict as the review artifact and put gate G5 to the user. **Read `evidence/changes.md` yourself before writing that verdict** — the executor\'s prose is a claim, the diff is the evidence; a verdict written only from the report is second-hand. If the executor did not produce that file, say so in the verdict rather than reviewing without it.',
|
|
604
|
+
'9. If the user or the evidence sends the work back, use smart_rework (or the rework option on the gate) — superseded versions are archived, never rewritten in place. Finish with smart_finish once G5 passes.',
|
|
605
|
+
'',
|
|
606
|
+
'Keep the artifacts honest and specific: another model has to execute them from those files alone, with no access to this conversation.',
|
|
607
|
+
].join('\n')
|
|
608
|
+
|
|
609
|
+
if (config.enabled) {
|
|
610
|
+
// 座位在本文件的 `inject` 里声明;未声明的服务属性访问会抛,抛 = 整棵 preset 挂载被拒。
|
|
611
|
+
ctx.systemPrompt.section({
|
|
612
|
+
name: 'fly-smart:policy',
|
|
613
|
+
// 500 = PLAN_POLICY、600 = TEAM_POLICY 之间:模式纪律挨着模式纪律。
|
|
614
|
+
order: 550,
|
|
615
|
+
text: context => (context?.agent !== undefined && isOn(context.agent.session) ? SECTION : ''),
|
|
616
|
+
})
|
|
617
|
+
|
|
618
|
+
// 官方 `/goal` 的形状(`dsh-command-goal/lib/index.js`):宿主平面的 `commands`
|
|
619
|
+
// 注册表 + 本行 ctx 的 scoped layer,handler 从 invocation 拿 agent。
|
|
620
|
+
ctx.commands.register({
|
|
621
|
+
definitionId: 'dsh-plugin-fly-smart',
|
|
622
|
+
name: 'smart',
|
|
623
|
+
description: 'Enter or leave smart mode (design with the brain model, execute with the hand model)',
|
|
624
|
+
input: { hint: '[off|task]', attachments: true },
|
|
625
|
+
handler: ({ agent, rawInput, attachments = [] }) => {
|
|
626
|
+
const message = String(rawInput ?? '').trim()
|
|
627
|
+
if (message === 'off') {
|
|
628
|
+
if (attachments.length > 0) return { kind: 'error', text: 'Attachments cannot accompany /smart off.' }
|
|
629
|
+
if (!isOn(agent?.session)) return { kind: 'success', text: 'Smart mode is already off.' }
|
|
630
|
+
if (agent?.session !== undefined) smartOn.delete(agent.session)
|
|
631
|
+
return { kind: 'success', text: 'Smart mode off. Any task already on disk stays where it is (see smart_status).' }
|
|
632
|
+
}
|
|
633
|
+
// 与 plan mode 互斥:两个模式都要主导"先说清楚再动手",同时开只会互相打架。
|
|
634
|
+
const planMode = ctx.get?.('planMode')
|
|
635
|
+
const planState = planMode?.get?.(agent)
|
|
636
|
+
if (planState === true || planState?.active === true) {
|
|
637
|
+
return { kind: 'error', text: 'Plan mode is active — run /plan off first, then /smart on.' }
|
|
638
|
+
}
|
|
639
|
+
if (agent?.session !== undefined) smartOn.set(agent.session, true)
|
|
640
|
+
if (message !== '' || attachments.length > 0) {
|
|
641
|
+
agent.steer(createUserMessage({
|
|
642
|
+
content: [...attachments, ...message === '' ? [] : [{ type: 'text', text: message }]],
|
|
643
|
+
source: { kind: 'user' },
|
|
644
|
+
}))
|
|
645
|
+
}
|
|
646
|
+
return {
|
|
647
|
+
kind: 'success',
|
|
648
|
+
text: [
|
|
649
|
+
'Smart mode on: design (you) → execute (hand model) → accept (both), with a user gate after every design phase.',
|
|
650
|
+
message === '' ? 'Describe the task, or call smart_begin with the title and goal.' : 'Treating the message above as the task — call smart_begin, then work the design phases.',
|
|
651
|
+
'Use /smart off to leave.',
|
|
652
|
+
].join(' '),
|
|
653
|
+
}
|
|
654
|
+
},
|
|
655
|
+
})
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
ctx.tools.register(defineTool({
|
|
659
|
+
name: 'smart_begin',
|
|
660
|
+
description: 'Open a smart-mode task: create its record and enter the brainstorm phase. Smart mode runs a three-model workflow — this session\'s model designs (brainstorm → design → plan → acceptance, each confirmed by the user), the review route briefs and audits, a subagent on the configured hand route executes, then the pair accepts. Use it when the user asks for a smart-mode run or a task big enough to deserve design-before-execution.',
|
|
661
|
+
parameters: {
|
|
662
|
+
title: { type: 'string', description: 'Short task title (also the default task id slug).' },
|
|
663
|
+
shape: {
|
|
664
|
+
type: 'string',
|
|
665
|
+
enum: ['feature', 'fix', 'review', 'investigation'],
|
|
666
|
+
description: 'What this task is. It decides which phases this task walks: `feature` = the full six (brainstorm → design → plan → acceptance → executing → reviewing); `fix` = brainstorm → plan → executing → reviewing (no design phase — a fix does not need a second exploration); `review` = reviewing only (produce review.md, no G1–G4); `investigation` = brainstorm only (a conclusion, no execution). Default `feature` = the historical behaviour. Recorded with the task: changing the row config later does not re-shape an in-flight task.',
|
|
667
|
+
},
|
|
668
|
+
size: {
|
|
669
|
+
type: 'string',
|
|
670
|
+
enum: ['light', 'standard', 'full'],
|
|
671
|
+
description: 'Cost tier. `light` writes every design phase\'s user-facing digest yourself (the brain) — **zero review children**; `standard` and `full` dispatch one per phase. Orthogonal to `shape`: shape is how many steps, size is what each step costs. Default `full` = the historical behaviour.',
|
|
672
|
+
},
|
|
673
|
+
goal: { type: 'string', description: 'What done looks like, in one or two sentences.' },
|
|
674
|
+
brainstormDepth: { type: 'string', description: 'Optional user-selected depth for brainstorm: light | balanced | deep | exhaustive. Choose based on project type and risk.' },
|
|
675
|
+
taskId: { type: 'string', description: 'Optional explicit id (letters, digits, dot, dash, underscore).' },
|
|
676
|
+
},
|
|
677
|
+
output: textOutput,
|
|
678
|
+
async execute(args, exec) {
|
|
679
|
+
assertEnabled(exec)
|
|
680
|
+
exec.signal?.throwIfAborted?.()
|
|
681
|
+
// 任务根按**本次调用**解析:preset 行(显式 allowGlobalRoot)→ 本会话工作区
|
|
682
|
+
// (session.header.cwd)→ legacy。绝不用 process.cwd():宿主 cwd 不是用户工作区。
|
|
683
|
+
const rootDir = rootOf(exec).root
|
|
684
|
+
const where = workspaceOf(exec)
|
|
685
|
+
const brainstormDepth = args.brainstormDepth ?? config.brainstormDepth ?? 'balanced'
|
|
686
|
+
const supportedEfforts = await supportedEffortsOf(exec)
|
|
687
|
+
const effort = resolveBrainstormEffort(brainstormDepth, supportedEfforts)
|
|
688
|
+
const brainRoute = config.brainRoute === undefined ? undefined : { ...config.brainRoute, reasoningEffort: effort.reasoningEffort }
|
|
689
|
+
const state = createTask(rootDir, args, { ...where ?? {}, maxRework: config.maxRework, workflow: config.workflow, shape: args.shape, size: args.size, brainstormDepth: effort.depth, brainstormEffort: effort.reasoningEffort, brainRoute })
|
|
690
|
+
remember(exec, state.id)
|
|
691
|
+
return summary(rootDir, state.id, {
|
|
692
|
+
created: true,
|
|
693
|
+
rootSource: rootOf(exec).source,
|
|
694
|
+
workspace: where?.workspace ?? null,
|
|
695
|
+
workspaceSource: where?.workspaceSource ?? null,
|
|
696
|
+
})
|
|
697
|
+
},
|
|
698
|
+
}))
|
|
699
|
+
|
|
700
|
+
ctx.tools.register(defineTool({
|
|
701
|
+
name: 'smart_artifact',
|
|
702
|
+
description: 'Record one smart-mode phase artifact (markdown) and open that phase\'s user gate. Phases are ordered and enforced in code: brainstorm → design → plan → acceptance → (executing) → (reviewing). Writing a later phase before finishing the current one, or writing while a gate is open, fails loudly. The user confirms each gate before the workflow moves on.',
|
|
703
|
+
parameters: {
|
|
704
|
+
phase: {
|
|
705
|
+
type: 'string',
|
|
706
|
+
description: 'Which artifact: brainstorm | design | plan | acceptance | executing (execution report) | reviewing (review).',
|
|
707
|
+
enum: ['brainstorm', 'design', 'plan', 'acceptance', 'executing', 'reviewing'],
|
|
708
|
+
},
|
|
709
|
+
markdown: { type: 'string', description: 'The full artifact body, markdown. Per-phase content contract, **enforced at write time** (not only at the gate): brainstorm is a decision table (problem / candidates / current lean / open questions); design is an ADR-lite section; **plan.md must carry the document contract** — an "**Interfaces:**" block in EVERY task (with `Consumes` / `Produces` signatures), a "## Global Constraints" section (the project-wide rules the spec fixes, copied verbatim), and a "## Review Focus" section (the input classes the spec implies but no task exercises). Missing any of these and the write is refused with the list. See the `smart-plan` skill.' },
|
|
710
|
+
acceptanceMarkdown: {
|
|
711
|
+
type: 'string',
|
|
712
|
+
description: 'Required for `phase: "plan"` under the optimized workflow: the acceptance design. The two files are written together and reviewed by one child, so this phase replaces the separate acceptance gate. Leave it out everywhere else.',
|
|
713
|
+
},
|
|
714
|
+
briefing: {
|
|
715
|
+
type: 'string',
|
|
716
|
+
description: 'Optional: the user-facing digest of this artifact, written by you (the brain). Required for brainstorm under the optimized workflow, which spawns no review child for that phase; on every other phase leave it out — the review route writes that digest (and a passing one here would be ignored).',
|
|
717
|
+
},
|
|
718
|
+
taskId: { type: 'string', description: 'Optional; defaults to the session\'s active task.' },
|
|
719
|
+
},
|
|
720
|
+
output: textOutput,
|
|
721
|
+
async execute(args, exec) {
|
|
722
|
+
assertEnabled(exec)
|
|
723
|
+
exec.signal?.throwIfAborted?.()
|
|
724
|
+
const id = pickTaskId(exec, args)
|
|
725
|
+
const rootDir = taskRootOf(id, exec)
|
|
726
|
+
// optimized 的 plan 相位:计划与验收设计是**一次操作**(两文件一起写、一起评)。
|
|
727
|
+
// 相位名仍是 plan,相位表不动;v1 的任务永远走下面那条老路。
|
|
728
|
+
const needsPair = (() => {
|
|
729
|
+
const current = readState(rootDir, id)
|
|
730
|
+
return current?.workflow === 'optimized' && args.phase === 'plan'
|
|
731
|
+
})()
|
|
732
|
+
if (needsPair) {
|
|
733
|
+
if (typeof args.acceptanceMarkdown !== 'string' || args.acceptanceMarkdown.trim() === '') {
|
|
734
|
+
throw new Error('smart: the optimized workflow records plan.md and acceptance.md together — pass `acceptanceMarkdown` with the acceptance design (this phase replaces the separate acceptance gate)')
|
|
735
|
+
}
|
|
736
|
+
const paired = recordPlanAcceptance(rootDir, id, args.markdown, args.acceptanceMarkdown)
|
|
737
|
+
remember(exec, id)
|
|
738
|
+
const briefResult = await (async () => {
|
|
739
|
+
try {
|
|
740
|
+
return await briefArtifact(exec, { rootDir, id, phase: 'plan' })
|
|
741
|
+
} catch (error) {
|
|
742
|
+
return { briefingFailed: true, error: String(error?.message ?? error), nextStep: 'the pair is on disk but its review bundle failed — retry with smart_brief' }
|
|
743
|
+
}
|
|
744
|
+
})()
|
|
745
|
+
return summary(rootDir, id, {
|
|
746
|
+
recorded: 'plan',
|
|
747
|
+
pairedWith: 'acceptance.md',
|
|
748
|
+
awaitingGate: paired.awaitingGate,
|
|
749
|
+
...briefResult.briefingFailed === true
|
|
750
|
+
? { briefingFailed: true, briefingError: briefResult.error, nextStep: briefResult.nextStep }
|
|
751
|
+
: { briefing: briefResult.briefing, briefingRound: briefResult.round, consult: briefResult.consult, bundled: briefResult.bundled === true, nextStep: 'walk the user through the briefing in conversation, then call smart_brief_confirm — one confirmation covers both plan.md and acceptance.md' },
|
|
752
|
+
})
|
|
753
|
+
}
|
|
754
|
+
const state = recordArtifact(rootDir, id, args.phase, args.markdown)
|
|
755
|
+
remember(exec, id)
|
|
756
|
+
// optimized 工作流的 brainstorm **不派 review 子代理**:候选方案阶段还没有可执行性可审,
|
|
757
|
+
// 那一次评审是纯开销。要点由 brain 自己写(`briefing` 参数),记成 `brain/session` 来源。
|
|
758
|
+
const inline = typeof args.briefing === 'string' && args.briefing.trim() !== '' ? args.briefing : undefined
|
|
759
|
+
// 谁写用户要点:`size: 'light'` 的设计相位与 optimized 的 brainstorm 都由 brain 自写
|
|
760
|
+
// (零 review child)—— 判据集中在 `briefingSourceFor`,不散在各处。
|
|
761
|
+
const brainBriefs = briefingSourceFor(state, args.phase) === 'inline'
|
|
762
|
+
// 产物落盘即自动生成 briefing(acceptance A1):这条路是主路径,smart_brief 只做
|
|
763
|
+
// 失败重试/旧任务补生成。生成失败**不冒泡**成落盘失败 —— 产物永远保留(A2)。
|
|
764
|
+
let brief
|
|
765
|
+
if (brainBriefs && inline === undefined) {
|
|
766
|
+
brief = {
|
|
767
|
+
briefingRequired: true,
|
|
768
|
+
nextStep: `这一步(${args.phase})的用户版要点由你(brain)写:把要点放进 \`briefing\` 参数,用同一份 markdown 再调一次 smart_artifact —— 记成 brain/session 来源、不派 review 子代理(${state.size === 'light' ? 'light 成本档' : 'optimized 工作流'})。要点必须逐个点到产物的每个 ## 小节。`,
|
|
769
|
+
}
|
|
770
|
+
} else {
|
|
771
|
+
try {
|
|
772
|
+
brief = await briefArtifact(exec, { rootDir, id, phase: args.phase, inline: brainBriefs ? inline : undefined })
|
|
773
|
+
} catch (error) {
|
|
774
|
+
brief = {
|
|
775
|
+
briefingFailed: true,
|
|
776
|
+
error: String(error?.message ?? error),
|
|
777
|
+
nextStep: `the artifact is on disk but its briefing is incomplete — fix the review output and retry with smart_brief`,
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
const pending = state.pendingDecisions ?? null
|
|
782
|
+
return summary(rootDir, id, {
|
|
783
|
+
recorded: args.phase,
|
|
784
|
+
awaitingGate: state.awaitingGate,
|
|
785
|
+
...brief.briefingFailed === true
|
|
786
|
+
? { briefingFailed: true, briefingError: brief.error, nextStep: brief.nextStep }
|
|
787
|
+
: brief.briefingRequired === true
|
|
788
|
+
? { briefingRequired: true, nextStep: brief.nextStep }
|
|
789
|
+
: {
|
|
790
|
+
briefing: brief.briefing,
|
|
791
|
+
briefingRound: brief.round,
|
|
792
|
+
// bundle 路径同时已经把 consult 落盘了:返回里如实带上,模型不必再调 smart_consult。
|
|
793
|
+
...brief.consult === undefined ? {} : { consult: brief.consult, bundled: brief.bundled === true },
|
|
794
|
+
nextStep: 'walk the user through the briefing in conversation (key points + critical details, leave room for questions), then call smart_brief_confirm — discussion does not open the gate',
|
|
795
|
+
},
|
|
796
|
+
// 没开门前必须说清为什么、下一步做什么:产物里还留着只有用户能定的问题。
|
|
797
|
+
...pending === null ? {} : {
|
|
798
|
+
decisionsPending: pending.items,
|
|
799
|
+
decisionsPendingStep: `${ARTIFACT_FILE[args.phase]} still has ${pending.items} unanswered decision(s) under "${pending.headings.join(' / ')}" — put them to the user with smart_decide (option cards), fold the answers in (rename that section to 已裁决 and write what they chose), then record the artifact again; gate ${GATE_AFTER[args.phase] ?? '?'} opens only after that`,
|
|
800
|
+
},
|
|
801
|
+
})
|
|
802
|
+
},
|
|
803
|
+
}))
|
|
804
|
+
|
|
805
|
+
ctx.tools.register(defineTool({
|
|
806
|
+
name: 'smart_consult',
|
|
807
|
+
description: 'Ask the hand model (the configured handRoute — the model that will execute) to review the current design-phase draft before its gate. The review route audits each design-phase draft for execution feasibility (the briefing is a separate tool, smart_brief): the brain writes brainstorm/design/plan/acceptance, the review model checks each before its gate. Requires that phase\'s artifact to exist; the review is recorded so the gate can prove it happened.',
|
|
808
|
+
parameters: {
|
|
809
|
+
question: { type: 'string', description: 'Optional specific question for the reviewer; defaults to "what is missing, wrong, or unexecutable?".' },
|
|
810
|
+
phase: { type: 'string', description: 'Optional phase to review; defaults to the task\'s current phase.', enum: [...DESIGN_PHASES] },
|
|
811
|
+
taskId: { type: 'string', description: 'Optional; defaults to the session\'s active task.' },
|
|
812
|
+
},
|
|
813
|
+
output: textOutput,
|
|
814
|
+
async execute(args, exec) {
|
|
815
|
+
assertEnabled(exec)
|
|
816
|
+
exec.signal?.throwIfAborted?.()
|
|
817
|
+
const id = pickTaskId(exec, args)
|
|
818
|
+
const rootDir = taskRootOf(id, exec)
|
|
819
|
+
const state = readState(rootDir, id)
|
|
820
|
+
if (state === null) throw new Error(`smart: no such task "${id}"`)
|
|
821
|
+
const phase = args.phase ?? state.phase
|
|
822
|
+
// 前置条件先查完,再花子代理的钱:对一份还没写的草稿做评审没有意义。
|
|
823
|
+
if (!DESIGN_PHASES.includes(phase)) throw new Error(`smart: consults cover the design phases (${DESIGN_PHASES.join(', ')}), not "${phase}"`)
|
|
824
|
+
if (state.phase !== phase) throw new Error(`smart: task "${id}" is in phase "${state.phase}"; the "${phase}" review comes with its own phase`)
|
|
825
|
+
const draft = readArtifact(rootDir, id, phase)
|
|
826
|
+
if (draft === null) throw new Error(`smart: write ${ARTIFACT_FILE[phase]} before asking for a review of it`)
|
|
827
|
+
// optimized:上一次 bundle 若仍绑着**这份**产物,consume 它,不再起第二个 child ——
|
|
828
|
+
// 这正是"两次 review 调用合成一次"的另一半。
|
|
829
|
+
if (state.workflow === 'optimized' && BUNDLE_PHASES.includes(phase)) {
|
|
830
|
+
const bundle = freshReviewBundle(rootDir, state, phase)
|
|
831
|
+
if (bundle !== null) {
|
|
832
|
+
const recorded = readConsult(rootDir, id, phase)
|
|
833
|
+
if (recorded !== null) {
|
|
834
|
+
return summary(rootDir, id, {
|
|
835
|
+
consulted: phase,
|
|
836
|
+
reviewer: `${bundle.provider}/${bundle.model}`,
|
|
837
|
+
review: recorded.body,
|
|
838
|
+
bundleHit: true,
|
|
839
|
+
nextStep: 'this consult came from the same review child as the briefing (no extra spawn); decide whether to fold anything into the artifact',
|
|
840
|
+
})
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
// 没有 fresh bundle(要点生成失败/产物改过):自己跑一次 bundle,两段一起补齐。
|
|
844
|
+
const produced = await runReviewBundle(exec, { rootDir, id, phase, question: args.question })
|
|
845
|
+
return summary(rootDir, id, {
|
|
846
|
+
consulted: phase,
|
|
847
|
+
reviewer: produced.bundleRoute ?? null,
|
|
848
|
+
review: produced.consult,
|
|
849
|
+
bundleHit: false,
|
|
850
|
+
briefingRound: produced.round,
|
|
851
|
+
nextStep: 'walk the user through the briefing in conversation, then record their answer with smart_brief_confirm',
|
|
852
|
+
})
|
|
853
|
+
}
|
|
854
|
+
const { route, body } = await runHand(exec, {
|
|
855
|
+
label: `smart consult · ${phase}`,
|
|
856
|
+
prompt: consultPrompt({ state, phase, draft, question: args.question }),
|
|
857
|
+
toolFilter: { deny: SMART_TOOL_NAMES },
|
|
858
|
+
role: 'review',
|
|
859
|
+
phase,
|
|
860
|
+
attempts: reworkUsedIn(state, phase),
|
|
861
|
+
})
|
|
862
|
+
recordConsult(rootDir, id, phase, { provider: route.provider, model: route.model, question: args.question ?? '', body })
|
|
863
|
+
return summary(rootDir, id, { consulted: phase, reviewer: `${route.provider}/${route.model}`, review: body })
|
|
864
|
+
},
|
|
865
|
+
}))
|
|
866
|
+
|
|
867
|
+
ctx.tools.register(defineTool({
|
|
868
|
+
name: 'smart_decide',
|
|
869
|
+
description: 'Put the decisions only the user can make to them — on the same option card the gate uses — and record their answers in the task tree. Call it whenever a design-phase artifact would otherwise carry open questions (a "需要你裁决" section): those questions are the user\'s to answer, your recommendation must not silently become their decision, and the gate does not open while that section is there. After it returns, fold the answers into the artifact, rename the section to 已裁决 (one line per question: what the user chose), and record the artifact again with smart_artifact.',
|
|
870
|
+
parameters: {
|
|
871
|
+
questions: {
|
|
872
|
+
type: 'array',
|
|
873
|
+
required: true,
|
|
874
|
+
description: 'The decisions to put to the user, in the order they should be answered. Only ask what only they can decide — anything you can settle yourself does not belong here.',
|
|
875
|
+
items: {
|
|
876
|
+
type: 'object',
|
|
877
|
+
additionalProperties: true,
|
|
878
|
+
properties: {
|
|
879
|
+
question: { type: 'string', required: true, description: 'The decision, phrased so the user can answer it by choosing.' },
|
|
880
|
+
options: {
|
|
881
|
+
type: 'array',
|
|
882
|
+
description: 'Optional choices to show. If you recommend one, put it first and append "(Recommended)" to that label.',
|
|
883
|
+
items: {
|
|
884
|
+
type: 'object',
|
|
885
|
+
additionalProperties: true,
|
|
886
|
+
properties: {
|
|
887
|
+
label: { type: 'string', required: true, description: 'Short user-facing option label.' },
|
|
888
|
+
description: { type: 'string', description: 'One sentence explaining the tradeoff.' },
|
|
889
|
+
},
|
|
890
|
+
},
|
|
891
|
+
},
|
|
892
|
+
multiSelect: { type: 'boolean', description: 'Set when the user may choose several options at once.' },
|
|
893
|
+
},
|
|
894
|
+
},
|
|
895
|
+
},
|
|
896
|
+
phase: { type: 'string', description: 'Optional; the phase these decisions belong to (defaults to the task\'s current phase).' },
|
|
897
|
+
taskId: { type: 'string', description: 'Optional; defaults to the session\'s active task.' },
|
|
898
|
+
},
|
|
899
|
+
output: textOutput,
|
|
900
|
+
async execute(args, exec) {
|
|
901
|
+
assertEnabled(exec)
|
|
902
|
+
exec.signal?.throwIfAborted?.()
|
|
903
|
+
const id = pickTaskId(exec, args)
|
|
904
|
+
const rootDir = taskRootOf(id, exec)
|
|
905
|
+
const state = readState(rootDir, id)
|
|
906
|
+
if (state === null) throw new Error(`smart: no such task "${id}"`)
|
|
907
|
+
const phase = typeof args.phase === 'string' && args.phase.trim() !== '' ? args.phase.trim() : state.phase
|
|
908
|
+
if (!DESIGN_PHASES.includes(phase) && phase !== 'reviewing') {
|
|
909
|
+
throw new Error(`smart: phase "${phase}" does not take decisions (the four design phases and reviewing do)`)
|
|
910
|
+
}
|
|
911
|
+
const questions = (Array.isArray(args.questions) ? args.questions : []).filter(item => typeof item?.question === 'string' && item.question.trim() !== '')
|
|
912
|
+
if (questions.length === 0) throw new Error('smart: smart_decide needs at least one question with a non-empty `question`')
|
|
913
|
+
const outgoing = questions.map((item, index) => ({
|
|
914
|
+
id: `smart-decide-${phase}-${index + 1}`,
|
|
915
|
+
header: `smart · ${phase} 裁决`,
|
|
916
|
+
question: item.question.trim(),
|
|
917
|
+
// 选项只收 { label, description? }(schema 就是这么声明的,且 describeTool 会真的校验:
|
|
918
|
+
// 传字符串当场 ToolArgsError)—— 不做"顺手也认字符串"的宽容分支,那是够不到的死代码。
|
|
919
|
+
...Array.isArray(item.options) && item.options.length > 0
|
|
920
|
+
? { options: item.options.map(option => ({ label: option.label, ...option.description === undefined ? {} : { description: option.description } })) }
|
|
921
|
+
: {},
|
|
922
|
+
multi_select: item.multiSelect === true,
|
|
923
|
+
}))
|
|
924
|
+
const userQuestions = service('userQuestions', 'user-question')
|
|
925
|
+
const asked = await userQuestions.ask({
|
|
926
|
+
questions: outgoing,
|
|
927
|
+
...exec.agent === undefined ? {} : { agent: exec.agent },
|
|
928
|
+
signal: exec.signal,
|
|
929
|
+
})
|
|
930
|
+
// 按 id 收回答案:用户可能只答了一部分(引擎允许跳过),缺的照实记成空 —— 不替用户回答。
|
|
931
|
+
const byId = new Map((asked?.answers ?? []).map(answer => [answer?.id, answer]))
|
|
932
|
+
const answers = outgoing.map(item => {
|
|
933
|
+
const answer = byId.get(item.id) ?? {}
|
|
934
|
+
return {
|
|
935
|
+
question: item.question,
|
|
936
|
+
options: (item.options ?? []).map(option => option.label),
|
|
937
|
+
multiSelect: item.multi_select,
|
|
938
|
+
selected: [...answer.selected ?? []],
|
|
939
|
+
custom: (answer.custom ?? '').trim(),
|
|
940
|
+
}
|
|
941
|
+
})
|
|
942
|
+
recordDecisions(rootDir, id, phase, answers, { by: 'user' })
|
|
943
|
+
remember(exec, id)
|
|
944
|
+
const open = openDecisions(readArtifact(rootDir, id, phase))
|
|
945
|
+
return summary(rootDir, id, {
|
|
946
|
+
decided: phase,
|
|
947
|
+
decisions: answers.map(entry => ({ question: entry.question, answer: [...entry.selected, entry.custom].filter(part => part !== '').join(' · ') || '(未作答)' })),
|
|
948
|
+
...open.headings.length === 0 ? {} : {
|
|
949
|
+
nextStep: `把裁决并进 ${ARTIFACT_FILE[phase]}:把「需要你裁决」那一节改成「已裁决」,逐条写下用户的选择,然后重新 smart_artifact 落盘 —— 这一节消失之前门不会开`,
|
|
950
|
+
},
|
|
951
|
+
})
|
|
952
|
+
},
|
|
953
|
+
}))
|
|
954
|
+
|
|
955
|
+
/**
|
|
956
|
+
* Generate one briefing round for a phase artifact (design §briefing).
|
|
957
|
+
*
|
|
958
|
+
* Failure semantics (acceptance A2), exactly three kinds:
|
|
959
|
+
* - review child fails / returns nothing ⇒ `{ briefingFailed }` **returned** (the
|
|
960
|
+
* artifact is fine; generation is retryable) — failure marker is recorded;
|
|
961
|
+
* - completeness violation ⇒ **throws**, listing what the briefing skipped —
|
|
962
|
+
* a digest that skips a section is worse than none (it reads as coverage);
|
|
963
|
+
* - no artifact ⇒ the caller's precondition throw.
|
|
964
|
+
*/
|
|
965
|
+
/**
|
|
966
|
+
* optimized 的哪些阶段把「要点 + consult」合成一次 bundle:
|
|
967
|
+
* 正好是那些**必须有 consult** 的设计阶段。brainstorm 走 brain 自写,executing/reviewing
|
|
968
|
+
* 没有独立 consult,三者都不进 bundle。
|
|
969
|
+
*/
|
|
970
|
+
const BUNDLE_PHASES = ['design', 'plan', 'acceptance']
|
|
971
|
+
|
|
972
|
+
/** 跑一次 bundle child:一次调用同时拿到用户要点与可行性评审。 */
|
|
973
|
+
const runReviewBundle = async (exec, { rootDir, id, phase, question }) => {
|
|
974
|
+
const state = readState(rootDir, id)
|
|
975
|
+
const draft = artifactForReview(rootDir, id, phase)
|
|
976
|
+
const produced = await runHand(exec, {
|
|
977
|
+
label: `smart bundle · ${phase}`,
|
|
978
|
+
prompt: bundlePrompt({ state, phase, draft, question }),
|
|
979
|
+
toolFilter: { deny: SMART_TOOL_NAMES },
|
|
980
|
+
role: 'review',
|
|
981
|
+
phase,
|
|
982
|
+
attempts: reworkUsedIn(state, phase),
|
|
983
|
+
})
|
|
984
|
+
const { briefingBody, consultBody } = parseBundle(produced.body)
|
|
985
|
+
const next = recordReviewBundle(rootDir, id, phase, {
|
|
986
|
+
provider: produced.route.provider,
|
|
987
|
+
model: produced.route.model,
|
|
988
|
+
source: produced.source,
|
|
989
|
+
briefingBody,
|
|
990
|
+
consultBody,
|
|
991
|
+
question: question ?? '',
|
|
992
|
+
})
|
|
993
|
+
return {
|
|
994
|
+
briefing: briefingBody,
|
|
995
|
+
consult: consultBody,
|
|
996
|
+
source: produced.source,
|
|
997
|
+
bundleRoute: `${produced.route.provider}/${produced.route.model}`,
|
|
998
|
+
round: next.briefings[phase].current,
|
|
999
|
+
bundled: true,
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
const briefArtifact = async (exec, { rootDir, id, phase, inline = undefined }) => {
|
|
1004
|
+
const state = readState(rootDir, id)
|
|
1005
|
+
const draft = readArtifact(rootDir, id, phase)
|
|
1006
|
+
if (draft === null) throw new Error(`smart: write ${ARTIFACT_FILE[phase]} before briefing it`)
|
|
1007
|
+
// inline(brain 自写)与 review 生成走**同一条**完整性检查与落轮路径,只在来源上不同:
|
|
1008
|
+
// 没有 provider/model 的轮必须如实标注 brain/session,不能伪装成独立评审(acceptance B5)。
|
|
1009
|
+
if (typeof inline === 'string' && inline.trim() !== '') {
|
|
1010
|
+
const missing = missingBriefingSections(draft, inline)
|
|
1011
|
+
if (missing.length > 0) {
|
|
1012
|
+
const error = new Error(`smart: the briefing for "${phase}" does not cover the artifact — missing: ${missing.join('; ')}. Nothing was recorded; cover every section.`)
|
|
1013
|
+
recordBriefingFailure(rootDir, id, phase, error)
|
|
1014
|
+
throw error
|
|
1015
|
+
}
|
|
1016
|
+
const next = recordBriefing(rootDir, id, phase, { provider: null, model: null, source: 'brain/session', body: inline })
|
|
1017
|
+
return { briefing: inline, source: 'brain/session', round: next.briefings[phase].current, brainAuthored: true }
|
|
1018
|
+
}
|
|
1019
|
+
// optimized:design/plan/acceptance 用**一次** child 同时出要点与 consult。
|
|
1020
|
+
const workflow = readState(rootDir, id)?.workflow
|
|
1021
|
+
if (workflow === 'optimized' && BUNDLE_PHASES.includes(phase)) {
|
|
1022
|
+
try {
|
|
1023
|
+
return await runReviewBundle(exec, { rootDir, id, phase })
|
|
1024
|
+
} catch (error) {
|
|
1025
|
+
const message = String(error?.message ?? error)
|
|
1026
|
+
recordBriefingFailure(rootDir, id, phase, error)
|
|
1027
|
+
return { briefingFailed: true, error: message, nextStep: `the review bundle for "${phase}" failed (${message}) — the artifact is untouched; retry with smart_brief` }
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
let produced
|
|
1031
|
+
try {
|
|
1032
|
+
produced = await runHand(exec, {
|
|
1033
|
+
label: `smart brief · ${phase}`,
|
|
1034
|
+
prompt: briefingPrompt({ state, phase, draft }),
|
|
1035
|
+
// 生成面同样不许动工作流:孩子只读稿、写摘要。
|
|
1036
|
+
toolFilter: { deny: SMART_TOOL_NAMES },
|
|
1037
|
+
role: 'review',
|
|
1038
|
+
phase,
|
|
1039
|
+
attempts: reworkUsedIn(state, phase),
|
|
1040
|
+
})
|
|
1041
|
+
} catch (error) {
|
|
1042
|
+
const message = String(error?.message ?? error)
|
|
1043
|
+
recordBriefingFailure(rootDir, id, phase, error)
|
|
1044
|
+
return {
|
|
1045
|
+
briefingFailed: true,
|
|
1046
|
+
error: message,
|
|
1047
|
+
nextStep: `briefing generation failed for "${phase}" — the artifact is untouched; retry with smart_brief`,
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
const missing = missingBriefingSections(draft, produced.body)
|
|
1051
|
+
if (missing.length > 0) {
|
|
1052
|
+
const error = new Error(`smart: the briefing for "${phase}" does not cover the artifact — missing: ${missing.join('; ')}. Nothing was recorded; regenerate (smart_brief).`)
|
|
1053
|
+
recordBriefingFailure(rootDir, id, phase, error)
|
|
1054
|
+
throw error
|
|
1055
|
+
}
|
|
1056
|
+
const next = recordBriefing(rootDir, id, phase, {
|
|
1057
|
+
provider: produced.route.provider,
|
|
1058
|
+
model: produced.route.model,
|
|
1059
|
+
source: produced.source,
|
|
1060
|
+
escalated: produced.escalated === true,
|
|
1061
|
+
attempts: produced.attempts ?? 0,
|
|
1062
|
+
body: produced.body,
|
|
1063
|
+
})
|
|
1064
|
+
return { briefing: produced.body, source: produced.source, round: next.briefings[phase].current }
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
ctx.tools.register(defineTool({
|
|
1068
|
+
name: 'smart_ruling',
|
|
1069
|
+
description: 'Record one model-side ruling: a plan defect, ambiguity, or spec conflict you settled yourself while working. Use this INSTEAD of stalling; use smart_decide instead when the question is one only the user can answer. Three parts are required — what you decided, why, and what it costs if it turns out wrong. Rulings are kept in state.rulings and reviewed at the end; they never open or close a gate.',
|
|
1070
|
+
parameters: {
|
|
1071
|
+
decision: { type: 'string', description: 'What you decided, in one line.' },
|
|
1072
|
+
why: { type: 'string', description: 'The reasoning you decided it on.' },
|
|
1073
|
+
costIfWrong: { type: 'string', description: 'What it costs if this turns out wrong — this is what makes the ruling reviewable later.' },
|
|
1074
|
+
taskId: { type: 'string', description: 'Optional; defaults to the session\'s active task.' },
|
|
1075
|
+
},
|
|
1076
|
+
output: textOutput,
|
|
1077
|
+
async execute(args, exec) {
|
|
1078
|
+
assertEnabled(exec)
|
|
1079
|
+
const id = pickTaskId(exec, args)
|
|
1080
|
+
const rootDir = taskRootOf(id, exec)
|
|
1081
|
+
const state = recordRuling(rootDir, id, {
|
|
1082
|
+
decision: args.decision,
|
|
1083
|
+
why: args.why,
|
|
1084
|
+
costIfWrong: args.costIfWrong,
|
|
1085
|
+
by: 'model',
|
|
1086
|
+
})
|
|
1087
|
+
return JSON.stringify({
|
|
1088
|
+
recorded: true,
|
|
1089
|
+
total: state.rulings.length,
|
|
1090
|
+
ruling: state.rulings.at(-1),
|
|
1091
|
+
nextStep: 'Keep going — a ruling is a decision you own, not a request for approval. Surface it in the briefing so the user sees what you decided.',
|
|
1092
|
+
})
|
|
1093
|
+
},
|
|
1094
|
+
}))
|
|
1095
|
+
|
|
1096
|
+
ctx.tools.register(defineTool({
|
|
1097
|
+
name: 'smart_brief',
|
|
1098
|
+
description: 'Generate the briefing for the current phase\'s artifact with the review route: a section-complete digest the presenting model walks the user through in conversation. Normally this runs automatically when smart_artifact lands an artifact — use this tool only to retry a failed generation or to backfill an old task that has none. It never opens anything: only smart_brief_confirm\'s card answer does.',
|
|
1099
|
+
parameters: {
|
|
1100
|
+
taskId: { type: 'string', description: 'Optional; defaults to the session\'s active task.' },
|
|
1101
|
+
},
|
|
1102
|
+
output: textOutput,
|
|
1103
|
+
async execute(args, exec) {
|
|
1104
|
+
assertEnabled(exec)
|
|
1105
|
+
exec.signal?.throwIfAborted?.()
|
|
1106
|
+
const id = pickTaskId(exec, args)
|
|
1107
|
+
const rootDir = taskRootOf(id, exec)
|
|
1108
|
+
const state = readState(rootDir, id)
|
|
1109
|
+
if (state === null) throw new Error(`smart: no such task "${id}"`)
|
|
1110
|
+
const phase = state.phase
|
|
1111
|
+
const result = await briefArtifact(exec, { rootDir, id, phase })
|
|
1112
|
+
if (result.briefingFailed === true) return summary(rootDir, id, { briefed: false, phase, ...result })
|
|
1113
|
+
return summary(rootDir, id, {
|
|
1114
|
+
briefed: true,
|
|
1115
|
+
phase,
|
|
1116
|
+
round: result.round,
|
|
1117
|
+
source: result.source,
|
|
1118
|
+
briefing: result.briefing,
|
|
1119
|
+
nextStep: `present the briefing to the user in ordinary conversation (key points + critical details, leave room for their questions), then record their answer with smart_brief_confirm — discussion does not open the gate`,
|
|
1120
|
+
})
|
|
1121
|
+
},
|
|
1122
|
+
}))
|
|
1123
|
+
|
|
1124
|
+
ctx.tools.register(defineTool({
|
|
1125
|
+
name: 'smart_brief_confirm',
|
|
1126
|
+
description: 'Record the user\'s answer to the current phase\'s briefing on one short card and open what their answer opens: 确认 opens the phase gate (or moves the execution report into review), 需要修改 keeps the gate closed for artifact edits at no rework cost, 终止 cancels the task. Call this only after presenting the briefing in conversation and letting the user ask or request changes — the confirmation comes from the card answer alone, so nothing you pass as arguments can stand in for it.',
|
|
1127
|
+
parameters: {
|
|
1128
|
+
taskId: { type: 'string', description: 'Optional; defaults to the session\'s active task.' },
|
|
1129
|
+
},
|
|
1130
|
+
output: textOutput,
|
|
1131
|
+
async execute(args, exec) {
|
|
1132
|
+
assertEnabled(exec)
|
|
1133
|
+
exec.signal?.throwIfAborted?.()
|
|
1134
|
+
const id = pickTaskId(exec, args)
|
|
1135
|
+
const rootDir = taskRootOf(id, exec)
|
|
1136
|
+
const state = readState(rootDir, id)
|
|
1137
|
+
if (state === null) throw new Error(`smart: no such task "${id}"`)
|
|
1138
|
+
const phase = state.phase
|
|
1139
|
+
const entry = state.briefings?.[phase]
|
|
1140
|
+
const round = entry?.current ?? 0
|
|
1141
|
+
if (round === 0) throw new Error(`smart: no briefing round for "${phase}" — generate one first (smart_brief); nothing was asked`)
|
|
1142
|
+
const gateText = phase === 'executing'
|
|
1143
|
+
? '进入验收阶段(reviewing)'
|
|
1144
|
+
: `开门 ${GATE_AFTER[phase] ?? '?'}`
|
|
1145
|
+
const userQuestions = service('userQuestions', 'user-question')
|
|
1146
|
+
const capped = capGateSummary(`briefing(${phase} · 第 ${round} 轮)的要点已经在对话里讲给你听。确认后${gateText}。要改产物请选「需要修改」——这一轮修改不消耗返工额度。`)
|
|
1147
|
+
const briefOptions = [
|
|
1148
|
+
{ kind: 'confirm', label: '确认,进入门禁', description: phase === 'executing' ? '执行报告看懂了,任务进入验收。' : `要点无误,打开 ${GATE_AFTER[phase] ?? '下一阶段'}。` },
|
|
1149
|
+
{ kind: 'revise', label: '需要修改', description: '产物还要改;门保持关,改完重新落盘会生成新一轮 briefing。' },
|
|
1150
|
+
{ kind: 'cancel', label: '终止任务', description: '停止这个任务,不做完。' },
|
|
1151
|
+
]
|
|
1152
|
+
const asked = await userQuestions.ask({
|
|
1153
|
+
questions: [{
|
|
1154
|
+
id: `smart-brief-${phase}`,
|
|
1155
|
+
header: `smart · ${phase} briefing`,
|
|
1156
|
+
question: capped.text,
|
|
1157
|
+
options: briefOptions.map(({ label, description }) => ({ label, description })),
|
|
1158
|
+
multi_select: false,
|
|
1159
|
+
}],
|
|
1160
|
+
...exec.agent === undefined ? {} : { agent: exec.agent },
|
|
1161
|
+
signal: exec.signal,
|
|
1162
|
+
})
|
|
1163
|
+
const answer = asked?.answers?.[0]
|
|
1164
|
+
const choice = (answer?.selected?.[0] ?? '').trim()
|
|
1165
|
+
const note = (answer?.custom ?? '').trim()
|
|
1166
|
+
const audit = [choice, note].filter(part => part !== '').join(' · ')
|
|
1167
|
+
const prefixes = { confirm: ['确认'], revise: ['需要修改', '修改'], cancel: ['终止', '取消'] }
|
|
1168
|
+
const kind = briefOptions.find(option => option.label === choice)?.kind
|
|
1169
|
+
?? Object.entries(prefixes).find(([, list]) => list.some(prefix => choice.startsWith(prefix)))?.[0]
|
|
1170
|
+
?? ''
|
|
1171
|
+
if (kind === 'cancel') {
|
|
1172
|
+
const next = cancelTask(rootDir, id, audit === '' ? '终止自 briefing 确认' : audit)
|
|
1173
|
+
return summary(rootDir, id, { briefingDecision: 'cancel', phase: next.phase, note: audit })
|
|
1174
|
+
}
|
|
1175
|
+
if (kind !== 'confirm' && kind !== 'revise') {
|
|
1176
|
+
throw new Error(`smart: unrecognised briefing answer "${choice}"${note === '' ? '' : ` (note: ${note})`} — expected one of: ${briefOptions.map(option => option.label).join(' / ')} — no state was changed`)
|
|
1177
|
+
}
|
|
1178
|
+
const next = confirmBriefing(rootDir, id, phase, kind, { round })
|
|
1179
|
+
return summary(rootDir, id, {
|
|
1180
|
+
briefingDecision: kind,
|
|
1181
|
+
phase,
|
|
1182
|
+
round,
|
|
1183
|
+
note: audit,
|
|
1184
|
+
awaitingGate: next.awaitingGate,
|
|
1185
|
+
nextPhase: next.phase,
|
|
1186
|
+
nextStep: kind === 'confirm'
|
|
1187
|
+
? next.phase === phase
|
|
1188
|
+
? `briefing confirmed — ask the user to confirm gate ${next.awaitingGate} (smart_gate)`
|
|
1189
|
+
: `briefing confirmed — ${phase} is now ${next.phase}`
|
|
1190
|
+
: 'keep the gate closed: edit the artifact and record it again (smart_artifact) — a new briefing round is generated for the new draft, at no rework cost',
|
|
1191
|
+
})
|
|
1192
|
+
},
|
|
1193
|
+
}))
|
|
1194
|
+
|
|
1195
|
+
ctx.tools.register(defineTool({
|
|
1196
|
+
name: 'smart_gate',
|
|
1197
|
+
description: 'Put the current gate (G1–G4 after each design phase, G5 at the end) to the user and act on their answer. The preconditions are checked in code first: if the artifact or the required hand review is missing, this fails loudly instead of asking. The user\'s answer is recorded with the gate, and only "通过" advances the phase.',
|
|
1198
|
+
parameters: {
|
|
1199
|
+
summary: { type: 'string', description: `Optional short summary of what the user is approving (risks, open questions, what changed). The ask card renders it as the card's heading, so keep it to a few lines — past ${GATE_SUMMARY_LIMIT} characters the tool truncates it and points the user at the artifact.` },
|
|
1200
|
+
taskId: { type: 'string', description: 'Optional; defaults to the session\'s active task.' },
|
|
1201
|
+
},
|
|
1202
|
+
output: textOutput,
|
|
1203
|
+
async execute(args, exec) {
|
|
1204
|
+
assertEnabled(exec)
|
|
1205
|
+
exec.signal?.throwIfAborted?.()
|
|
1206
|
+
const id = pickTaskId(exec, args)
|
|
1207
|
+
const rootDir = taskRootOf(id, exec)
|
|
1208
|
+
const state = readState(rootDir, id)
|
|
1209
|
+
if (state === null) throw new Error(`smart: no such task "${id}"`)
|
|
1210
|
+
const gate = state.awaitingGate
|
|
1211
|
+
if (gate === null) {
|
|
1212
|
+
const pending = state.pendingDecisions ?? null
|
|
1213
|
+
const briefing = state.artifacts[state.phase] === undefined ? [] : briefingGaps(rootDir, state, state.phase)
|
|
1214
|
+
throw new Error(`smart: no gate is open for task "${id}" (phase "${state.phase}")${
|
|
1215
|
+
pending !== null
|
|
1216
|
+
? ` — ${ARTIFACT_FILE[state.phase]} still has ${pending.items} unanswered decision(s) under "${pending.headings.join(' / ')}": put them to the user (smart_decide) and fold the answers into the artifact before the gate opens`
|
|
1217
|
+
: briefing.length > 0
|
|
1218
|
+
? ` — ${briefing[0]}: present the briefing and record the user's answer (smart_brief_confirm) first — discussion does not open the gate`
|
|
1219
|
+
: ''
|
|
1220
|
+
}`)
|
|
1221
|
+
}
|
|
1222
|
+
// 门在代码里:条件不满足就不去打扰用户 —— 不能让"用户点了通过"掩盖产物缺失。
|
|
1223
|
+
const gaps = gateGaps(rootDir, state, { requireConsult: config.requireConsult })
|
|
1224
|
+
if (gaps.length > 0) throw new Error(`smart: gate ${gate} is blocked — ${gaps.join('; ')}`)
|
|
1225
|
+
|
|
1226
|
+
const owner = gateOwner(gate)
|
|
1227
|
+
// 设计阶段退回"上一阶段",终门(G5)退回计划阶段重做 —— 验收不过时能回去的地方
|
|
1228
|
+
// 就是计划(设计稿 §3 的返工回路)。
|
|
1229
|
+
const canStepBack = owner === 'reviewing' || DESIGN_PHASES.includes(owner)
|
|
1230
|
+
const backTo = owner === 'reviewing' ? 'plan' : DESIGN_PHASES[Math.max(0, DESIGN_PHASES.indexOf(owner) - 1)]
|
|
1231
|
+
// 额度耗尽**不是强制收尾**:那一项照样在卡上,只是含义变成"你同意再加一个预算"。
|
|
1232
|
+
// 计数按**目标阶段**算(每阶段各有额度),所以这里问的是 backTo 那一段的余额。
|
|
1233
|
+
const budget = backTo === null ? null : reworkBudget(state, backTo, config.maxRework)
|
|
1234
|
+
const overQuota = budget !== null && budget.used >= budget.cap
|
|
1235
|
+
const userQuestions = service('userQuestions', 'user-question')
|
|
1236
|
+
// 封顶在前、问询在后:卡片拿这段文字当标题,超长会让用户看不到选项(见 GATE_SUMMARY_LIMIT)。
|
|
1237
|
+
const capped = capGateSummary(args.summary ?? '')
|
|
1238
|
+
const truncation = capped.truncated ? { summaryTruncated: true, summaryLimit: GATE_SUMMARY_LIMIT } : {}
|
|
1239
|
+
// 选项是**带 kind 的一等对象**,应答按 kind 分派:早先按标签前缀匹配,而 G5 那条写作
|
|
1240
|
+
// 「验收不通过,退回计划(plan)重做」⇒ 不以「退回」开头 ⇒ 用户一点就抛
|
|
1241
|
+
// "unrecognised gate answer"(钥匙在锁上,锁不认识它)。文案以后怎么改都不会再错位。
|
|
1242
|
+
const reworkLabel = canStepBack
|
|
1243
|
+
? overQuota
|
|
1244
|
+
? `退回重做(「${backTo}」额度已用尽 ${budget.used}/${budget.cap}:选这项=同意再加 ${config.maxRework} 次)`
|
|
1245
|
+
: backTo === owner ? '退回本阶段重做' : owner === 'reviewing' ? `验收不通过,退回计划(${backTo})重做` : `退回上一阶段重做(${backTo})`
|
|
1246
|
+
: null
|
|
1247
|
+
const gateOptions = [
|
|
1248
|
+
{ kind: 'pass', label: '通过,进入下一阶段', description: gate === 'G5' ? '归档任务,交付完成。' : '进入下一阶段(G4 通过即授权执行)。' },
|
|
1249
|
+
...reworkLabel === null ? [] : [{
|
|
1250
|
+
kind: 'rework',
|
|
1251
|
+
label: reworkLabel,
|
|
1252
|
+
description: overQuota
|
|
1253
|
+
? `额度归零代表"机器不再自己加轮次",不代表"你只能收下这版"。选这项我就记下你的加额授权,然后立刻退回「${backTo}」重做。`
|
|
1254
|
+
: '保留历史版本,重做该阶段及其后的产物。',
|
|
1255
|
+
}],
|
|
1256
|
+
{ kind: 'cancel', label: '终止任务', description: '停止这个任务,不做完。' },
|
|
1257
|
+
]
|
|
1258
|
+
const asked = await userQuestions.ask({
|
|
1259
|
+
questions: [{
|
|
1260
|
+
id: `smart-gate-${gate}`,
|
|
1261
|
+
header: `smart · ${owner}`,
|
|
1262
|
+
question: [
|
|
1263
|
+
`阶段「${owner}」的产物与手模型评审都已就绪,是否过门 ${gate}?`,
|
|
1264
|
+
overQuota ? `\n\n本阶段返工额度已用尽(${budget.used}/${budget.cap})。过门或退回都由你定:退回不会白退 —— 你选它我就再加 ${config.maxRework} 次额度。` : '',
|
|
1265
|
+
capped.text === '' ? '' : `\n\n${capped.text}`,
|
|
1266
|
+
].join(''),
|
|
1267
|
+
// 只把 label/description 送进上游(kind 是本进程的账)。
|
|
1268
|
+
options: gateOptions.map(({ label, description }) => ({ label, description })),
|
|
1269
|
+
multi_select: false,
|
|
1270
|
+
}],
|
|
1271
|
+
...exec.agent === undefined ? {} : { agent: exec.agent },
|
|
1272
|
+
signal: exec.signal,
|
|
1273
|
+
})
|
|
1274
|
+
const answer = asked?.answers?.[0]
|
|
1275
|
+
const choice = (answer?.selected?.[0] ?? '').trim()
|
|
1276
|
+
const note = (answer?.custom ?? '').trim()
|
|
1277
|
+
const audit = [choice, note].filter(part => part !== '').join(' · ')
|
|
1278
|
+
// 先认标签(选项是我们自己给的,逐字相等即答案),再退到前缀匹配(用户手打/改写时兜底)。
|
|
1279
|
+
const prefixes = { pass: ['通过'], rework: ['退回', '验收不通过', '继续重做'], cancel: ['终止'] }
|
|
1280
|
+
const kind = gateOptions.find(option => option.label === choice)?.kind
|
|
1281
|
+
?? Object.entries(prefixes).find(([, list]) => list.some(prefix => choice.startsWith(prefix)))?.[0]
|
|
1282
|
+
?? ''
|
|
1283
|
+
|
|
1284
|
+
if (kind === 'pass') {
|
|
1285
|
+
const next = passGate(rootDir, id, gate, { by: 'user', note: audit }, { requireConsult: config.requireConsult })
|
|
1286
|
+
return summary(rootDir, id, { ...truncation, gate, decision: 'passed', nextPhase: next.phase, note: audit })
|
|
1287
|
+
}
|
|
1288
|
+
if (kind === 'rework') {
|
|
1289
|
+
if (backTo === null) throw new Error(`smart: gate ${gate} has no earlier phase to return to`)
|
|
1290
|
+
// 人在门上选了「继续重做」=加额授权:先落授权再返工(reworkTo 按额度判,顺序反了会被自己挡住)。
|
|
1291
|
+
const granted = overQuota ? grantRework(rootDir, id, backTo, { by: 'user', note: audit, maxRework: config.maxRework }) : null
|
|
1292
|
+
const next = reworkTo(rootDir, id, backTo, audit === '' ? `退回自 ${gate}` : audit, { maxRework: config.maxRework })
|
|
1293
|
+
return summary(rootDir, id, {
|
|
1294
|
+
...truncation,
|
|
1295
|
+
gate,
|
|
1296
|
+
decision: 'rework',
|
|
1297
|
+
backTo,
|
|
1298
|
+
reworkCount: next.reworkCount,
|
|
1299
|
+
grantedAnotherBudget: granted === null ? null : config.maxRework,
|
|
1300
|
+
rework: reworkBudget(next, backTo, config.maxRework),
|
|
1301
|
+
note: audit,
|
|
1302
|
+
})
|
|
1303
|
+
}
|
|
1304
|
+
if (kind === 'cancel') {
|
|
1305
|
+
const next = cancelTask(rootDir, id, audit === '' ? `终止自 ${gate}` : audit)
|
|
1306
|
+
return summary(rootDir, id, { ...truncation, gate, decision: 'cancelled', note: audit })
|
|
1307
|
+
}
|
|
1308
|
+
throw new Error(`smart: unrecognised gate answer "${choice}"${note === '' ? '' : ` (note: ${note})`} — expected one of: ${gateOptions.map(option => option.label).join(' / ')} — no state was changed`)
|
|
1309
|
+
},
|
|
1310
|
+
}))
|
|
1311
|
+
|
|
1312
|
+
ctx.tools.register(defineTool({
|
|
1313
|
+
name: 'smart_execute',
|
|
1314
|
+
description: 'Hand the approved plan to the hand model and let it execute. Call this only after gate G4 passed (the design is approved): it runs the plan with the hand route, same capabilities as this session, and records the execution report. The hand model works from plan.md + acceptance.md only — it does not need (or see) the design conversation.',
|
|
1315
|
+
parameters: {
|
|
1316
|
+
instructions: { type: 'string', description: 'Optional extra marching orders for this run (constraints that appeared after the plan was written).' },
|
|
1317
|
+
taskId: { type: 'string', description: 'Optional; defaults to the session\'s active task.' },
|
|
1318
|
+
},
|
|
1319
|
+
output: textOutput,
|
|
1320
|
+
async execute(args, exec) {
|
|
1321
|
+
assertEnabled(exec)
|
|
1322
|
+
exec.signal?.throwIfAborted?.()
|
|
1323
|
+
const id = pickTaskId(exec, args)
|
|
1324
|
+
const rootDir = taskRootOf(id, exec)
|
|
1325
|
+
const state = readState(rootDir, id)
|
|
1326
|
+
if (state === null) throw new Error(`smart: no such task "${id}"`)
|
|
1327
|
+
// G4 未过就不许执行 —— 执行授权是代码判的,不是模型自己觉得可以了。
|
|
1328
|
+
if (state.phase !== 'executing') throw new Error(`smart: task "${id}" is in phase "${state.phase}"; execution starts only after gate G4 passes`)
|
|
1329
|
+
// 重入闸(acceptance B3):执行报告落盘后停在 executing 等 briefing 确认,这个工具
|
|
1330
|
+
// 必须拒绝重跑 —— 否则第二次调用会把第一份报告整个覆盖掉。
|
|
1331
|
+
if (state.artifacts.executing !== undefined) throw new Error(`smart: the execution report of task "${id}" is already on disk — smart_execute runs once; the task now waits for the report's briefing (smart_brief_confirm)`)
|
|
1332
|
+
const plan = readArtifact(rootDir, id, 'plan')
|
|
1333
|
+
const acceptance = readArtifact(rootDir, id, 'acceptance')
|
|
1334
|
+
if (plan === null || acceptance === null) throw new Error('smart: execution needs both plan.md and acceptance.md on disk')
|
|
1335
|
+
|
|
1336
|
+
// **派发前**取基线(superpowers 的警告:派发后再算会漏掉多 commit 任务的前面那些)。
|
|
1337
|
+
const base = gitHeadOf(rootDir)
|
|
1338
|
+
const { route, body } = await runHand(exec, {
|
|
1339
|
+
label: `smart execute · ${id}`,
|
|
1340
|
+
prompt: executePrompt({ state, plan, acceptance, instructions: args.instructions, base }),
|
|
1341
|
+
// 拍板 2:执行与会话同权(只挡住工作流工具,不挡干活的能力)。
|
|
1342
|
+
toolFilter: { deny: SMART_TOOL_NAMES },
|
|
1343
|
+
})
|
|
1344
|
+
const report = `# 执行报告\n\n> 执行模型:${route.provider}/${route.model}\n${base === null ? '' : `> 基线:\`${base}\`(派发前取)\n`}\n${body.trim()}\n`
|
|
1345
|
+
const next = recordArtifact(rootDir, id, 'executing', report)
|
|
1346
|
+
// hand 在执行中自己定下的裁决进 state.rulings(`## Rulings` 小节):拆不出
|
|
1347
|
+
// 「决定 — 为什么 — 错了代价」三段的整条丢弃并照实计数 —— 宁可少记,
|
|
1348
|
+
// 不记不存在的裁决。
|
|
1349
|
+
const { rulings: handRulings, skipped } = rulingsFromReport(report)
|
|
1350
|
+
for (const ruling of handRulings) recordRuling(rootDir, id, { ...ruling, by: 'hand' })
|
|
1351
|
+
const rulings = { recorded: handRulings.length, skipped }
|
|
1352
|
+
// 执行报告同样走 briefing 回路(G2 裁决①):停在 executing,确认后才进 reviewing。
|
|
1353
|
+
let brief
|
|
1354
|
+
try {
|
|
1355
|
+
brief = await briefArtifact(exec, { rootDir, id, phase: 'executing' })
|
|
1356
|
+
} catch (error) {
|
|
1357
|
+
brief = { briefingFailed: true, error: String(error?.message ?? error) }
|
|
1358
|
+
}
|
|
1359
|
+
return summary(rootDir, id, {
|
|
1360
|
+
executed: true,
|
|
1361
|
+
executor: `${route.provider}/${route.model}`,
|
|
1362
|
+
// 叫 rulingsRecorded 而不是 rulings:`summary()` 先铺 status(里面有 rulings 数组),
|
|
1363
|
+
// 同名会把它遮蔽成计数 —— 同一个词在两处指两个东西,是给人添乱。
|
|
1364
|
+
rulingsRecorded: rulings,
|
|
1365
|
+
nextPhase: next.phase,
|
|
1366
|
+
report: body,
|
|
1367
|
+
...brief.briefingFailed === true
|
|
1368
|
+
? { briefingFailed: true, briefingError: brief.error, nextStep: 'the report is on disk; regenerate its briefing with smart_brief, then confirm' }
|
|
1369
|
+
: { briefing: brief.briefing, nextStep: 'walk the user through the execution-report briefing in conversation, then call smart_brief_confirm to move the task into review' },
|
|
1370
|
+
})
|
|
1371
|
+
},
|
|
1372
|
+
}))
|
|
1373
|
+
|
|
1374
|
+
ctx.tools.register(defineTool({
|
|
1375
|
+
name: 'smart_verify',
|
|
1376
|
+
description: 'Gather acceptance evidence with the hand model: it runs the checks in acceptance.md against the execution report and reports each criterion with the exact command and observed result. Read-only in code — the child has no write/edit tools. Record the evidence, then write your own verdict with smart_artifact(phase="reviewing").',
|
|
1377
|
+
parameters: {
|
|
1378
|
+
focus: { type: 'string', description: 'Optional: criteria or doubts to concentrate on.' },
|
|
1379
|
+
taskId: { type: 'string', description: 'Optional; defaults to the session\'s active task.' },
|
|
1380
|
+
},
|
|
1381
|
+
output: textOutput,
|
|
1382
|
+
async execute(args, exec) {
|
|
1383
|
+
assertEnabled(exec)
|
|
1384
|
+
exec.signal?.throwIfAborted?.()
|
|
1385
|
+
const id = pickTaskId(exec, args)
|
|
1386
|
+
const rootDir = taskRootOf(id, exec)
|
|
1387
|
+
const state = readState(rootDir, id)
|
|
1388
|
+
if (state === null) throw new Error(`smart: no such task "${id}"`)
|
|
1389
|
+
if (state.phase !== 'reviewing') throw new Error(`smart: task "${id}" is in phase "${state.phase}"; evidence is gathered in the review phase, after the execution report`)
|
|
1390
|
+
const acceptance = readArtifact(rootDir, id, 'acceptance')
|
|
1391
|
+
const report = readArtifact(rootDir, id, 'executing')
|
|
1392
|
+
if (acceptance === null || report === null) throw new Error('smart: verification needs acceptance.md and the execution report on disk')
|
|
1393
|
+
// 评审包(S6):执行者按契约落的 `evidence/changes.md` —— 在就交给评审者,
|
|
1394
|
+
// 不在就照实说没有(不是"假装有",也不是替它编一份)。
|
|
1395
|
+
const reviewPackagePath = join(taskDir(rootDir, id), 'evidence', 'changes.md')
|
|
1396
|
+
const reviewPackage = existsSync(reviewPackagePath) ? reviewPackagePath : null
|
|
1397
|
+
|
|
1398
|
+
const { route, body } = await runHand(exec, {
|
|
1399
|
+
label: `smart verify · ${id}`,
|
|
1400
|
+
prompt: verifyPrompt({ state, acceptance, report, focus: args.focus, reviewPackage }),
|
|
1401
|
+
toolFilter: verifyToolFilter(exec.agent),
|
|
1402
|
+
role: 'review',
|
|
1403
|
+
phase: 'reviewing',
|
|
1404
|
+
attempts: reworkUsedIn(state, 'reviewing'),
|
|
1405
|
+
})
|
|
1406
|
+
const next = recordVerification(rootDir, id, { provider: route.provider, model: route.model, body, acceptance: 'acceptance.md' })
|
|
1407
|
+
return summary(rootDir, id, { verified: true, verifier: `${route.provider}/${route.model}`, evidence: next.verifications.at(-1)?.file ?? null, findings: body })
|
|
1408
|
+
},
|
|
1409
|
+
}))
|
|
1410
|
+
|
|
1411
|
+
ctx.tools.register(defineTool({
|
|
1412
|
+
name: 'smart_rework',
|
|
1413
|
+
description: 'Send the task back for rework outside a gate: the acceptance run failed, or the user asked for changes. The superseded artifacts move to history/ (never deleted) and the design phase you name must be redone. Bounded by maxRework (per phase); past the bound you cannot buy more — only the user can, by answering 继续重做 at a gate.',
|
|
1414
|
+
parameters: {
|
|
1415
|
+
reason: { type: 'string', description: 'Why it goes back — this is what the history records.' },
|
|
1416
|
+
backTo: { type: 'string', description: 'Design phase to redo (default: plan).', enum: [...DESIGN_PHASES] },
|
|
1417
|
+
taskId: { type: 'string', description: 'Optional; defaults to the session\'s active task.' },
|
|
1418
|
+
},
|
|
1419
|
+
output: textOutput,
|
|
1420
|
+
async execute(args, exec) {
|
|
1421
|
+
assertEnabled(exec)
|
|
1422
|
+
exec.signal?.throwIfAborted?.()
|
|
1423
|
+
const id = pickTaskId(exec, args)
|
|
1424
|
+
const rootDir = taskRootOf(id, exec)
|
|
1425
|
+
const reason = typeof args.reason === 'string' ? args.reason.trim() : ''
|
|
1426
|
+
if (reason === '') throw new Error('smart: smart_rework needs a reason — the history is meant to explain itself')
|
|
1427
|
+
const next = reworkTo(rootDir, id, args.backTo ?? 'plan', reason, { maxRework: config.maxRework })
|
|
1428
|
+
return summary(rootDir, id, { rework: true, backTo: args.backTo ?? 'plan', reworkCount: next.reworkCount, reason })
|
|
1429
|
+
},
|
|
1430
|
+
}))
|
|
1431
|
+
|
|
1432
|
+
ctx.tools.register(defineTool({
|
|
1433
|
+
name: 'smart_finish',
|
|
1434
|
+
description: 'Archive a task whose final gate (G5) passed: write the closing summary into the hidden task tree, promote the deliverables (brainstorm/design/plan/acceptance/execution report/review/summary + README) into <workspace>/docs/smart/<taskId>/, and mark the task archived. The machine refuses to archive while any gate is still open, and a failed promotion leaves the task unarchived so it can be retried.',
|
|
1435
|
+
parameters: {
|
|
1436
|
+
summary: { type: 'string', description: 'Closing summary markdown: what shipped, what is deliberately left out.' },
|
|
1437
|
+
workspace: { type: 'string', description: 'Optional explicit workspace directory for the delivery copy; defaults to the task\'s recorded workspace, then this session\'s working directory.' },
|
|
1438
|
+
taskId: { type: 'string', description: 'Optional; defaults to the session\'s active task.' },
|
|
1439
|
+
},
|
|
1440
|
+
output: textOutput,
|
|
1441
|
+
async execute(args, exec) {
|
|
1442
|
+
assertEnabled(exec)
|
|
1443
|
+
exec.signal?.throwIfAborted?.()
|
|
1444
|
+
const id = pickTaskId(exec, args)
|
|
1445
|
+
const rootDir = taskRootOf(id, exec)
|
|
1446
|
+
const state = readState(rootDir, id)
|
|
1447
|
+
if (state === null) throw new Error(`smart: no such task "${id}"`)
|
|
1448
|
+
// 解析序(acceptance.md Y5):显式 args.workspace → 任务自己记的 → 调用会话的
|
|
1449
|
+
// header.cwd。legacy 任务(state 里没有 workspace)因此提升到**调用者**工作区。
|
|
1450
|
+
const where = workspaceOf(exec)
|
|
1451
|
+
const explicit = typeof args.workspace === 'string' && args.workspace.trim() !== '' ? args.workspace.trim() : null
|
|
1452
|
+
const workspace = explicit ?? (typeof state.workspace === 'string' && state.workspace !== '' ? state.workspace : where?.workspace ?? null)
|
|
1453
|
+
const workspaceSource = explicit !== null ? 'args.workspace' : state.workspace !== undefined ? 'state.workspace' : where?.workspaceSource ?? null
|
|
1454
|
+
// 收尾总结必须**覆盖模型自主裁决**:那是"我替你定的那些事",用户只有在最后这份
|
|
1455
|
+
// 文档里才看得全。判据刻意钝(同 briefing 完整性):有裁决就要求正文里出现
|
|
1456
|
+
// 「裁决」或 `Ruling` —— 一个词都看不见 = 这件事被悄悄吞了。
|
|
1457
|
+
const rulings = Array.isArray(state.rulings) ? state.rulings : []
|
|
1458
|
+
const closing = args.summary ?? ''
|
|
1459
|
+
if (rulings.length > 0 && !/(裁决|Ruling)/iu.test(closing)) {
|
|
1460
|
+
throw new Error(`smart: this task recorded ${rulings.length} model-side ruling(s), and the closing summary never mentions them — cover each one (what you decided, why, and what to watch for if it turns out wrong). They are decisions made on the user's behalf; the closing summary is the only place they all appear together.`)
|
|
1461
|
+
}
|
|
1462
|
+
const next = finishTask(rootDir, id, closing, { workspace, workspaceSource })
|
|
1463
|
+
const promotion = next._promotion
|
|
1464
|
+
return summary(rootDir, id, {
|
|
1465
|
+
archived: true,
|
|
1466
|
+
archivedAt: next.archivedAt,
|
|
1467
|
+
delivery: next.delivery,
|
|
1468
|
+
deliverablePaths: promotion.written,
|
|
1469
|
+
gitHint: `交付副本在 ${next.delivery.dir}(相对工作区)—— 若它应当进版本库,记得 git add 它;隐藏树 .dsh-fly/ 建议留在 .gitignore。`,
|
|
1470
|
+
})
|
|
1471
|
+
},
|
|
1472
|
+
}))
|
|
1473
|
+
|
|
1474
|
+
ctx.tools.register(defineTool({
|
|
1475
|
+
name: 'smart_status',
|
|
1476
|
+
description: 'Includes `rulings` (model-side decisions recorded with `smart_ruling`, distinct from `decisions`, which record what the user chose). Read the smart-mode task state: current phase, recorded artifacts, open user gate, blocking gaps, and the next required action. Call it with no taskId when you need to find the active task, or after a failure to see what the machine expects.',
|
|
1477
|
+
parameters: {
|
|
1478
|
+
taskId: { type: 'string', description: 'Optional; defaults to the session\'s active task, then the most recently touched one.' },
|
|
1479
|
+
},
|
|
1480
|
+
output: textOutput,
|
|
1481
|
+
async execute(args, exec) {
|
|
1482
|
+
assertEnabled(exec)
|
|
1483
|
+
exec.signal?.throwIfAborted?.()
|
|
1484
|
+
const { roots, primary } = candidateTaskRoots({ config, env: process.env, session: exec?.agent?.session })
|
|
1485
|
+
let id
|
|
1486
|
+
try {
|
|
1487
|
+
id = pickTaskId(exec, args)
|
|
1488
|
+
} catch (error) {
|
|
1489
|
+
// 候选根全看一遍:新任务在工作区、老任务在 legacy,只列一个会让"我的任务呢"
|
|
1490
|
+
// 变成找不到。
|
|
1491
|
+
const seen = new Map()
|
|
1492
|
+
for (const entry of roots) for (const row of listTasks(entry.root)) if (!seen.has(row.id)) seen.set(row.id, row)
|
|
1493
|
+
const tasks = [...seen.values()]
|
|
1494
|
+
if (args.taskId === undefined && tasks.length > 0) {
|
|
1495
|
+
return JSON.stringify({ rootDir: primary, roots: roots.map(entry => ({ root: entry.root, source: entry.source })), active: null, tasks }, null, 2)
|
|
1496
|
+
}
|
|
1497
|
+
throw error
|
|
1498
|
+
}
|
|
1499
|
+
const rootDir = taskRootOf(id, exec)
|
|
1500
|
+
const state = readState(rootDir, id)
|
|
1501
|
+
if (state === null) throw new Error(`smart: no such task "${id}"`)
|
|
1502
|
+
return summary(rootDir, id)
|
|
1503
|
+
},
|
|
1504
|
+
}))
|
|
1505
|
+
}
|