magic-agent 1.0.49 → 1.0.108

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.
Files changed (94) hide show
  1. package/CLIENT-CONTRACT.md +514 -7
  2. package/README.md +31 -2
  3. package/client-ui/pc/app.css +3 -41
  4. package/client-ui/pc/js/04-task-list.js +6 -2
  5. package/client-ui/pc/js/90-glue.js +23 -4
  6. package/client-ui/pc/js/92-composer.js +11 -4
  7. package/client-ui/pc/js/93-rail.js +178 -19
  8. package/client-ui/shared/conv/03-chat-message.js +145 -25
  9. package/client-ui/shared/conv/09-stream-apply.js +55 -6
  10. package/client-ui/shared/conv/19-attachments.js +70 -6
  11. package/client-ui/shared/conv/27-conv-ledger.js +18 -0
  12. package/client-ui/shared/conv/29-msg-variants.js +10 -0
  13. package/client-ui/shared/conv/README.md +6 -0
  14. package/client-ui/shared/conv/conv.css +77 -0
  15. package/client-ui/shared/conversation.css +229 -43
  16. package/client-ui/shared/conversation.js +1316 -131
  17. package/client-ui/shared/transport-http.js +164 -12
  18. package/client-ui-mobile/index.html +2168 -143
  19. package/internal/agent/capability.go +16 -2
  20. package/internal/agent/claude.go +3 -2
  21. package/internal/agent/codebuddy.go +7 -1
  22. package/internal/agent/codex_test.go +3 -0
  23. package/internal/agent/custom_models_test.go +5 -0
  24. package/internal/agent/dsh.go +18 -2
  25. package/internal/agent/dsh_test.go +53 -2
  26. package/internal/agent/engine_test.go +34 -19
  27. package/internal/agent/env_test.go +14 -9
  28. package/internal/agent/models_test.go +9 -0
  29. package/internal/agent/runcmd_test.go +24 -3
  30. package/internal/agent/stream.go +33 -0
  31. package/internal/cli/ask.go +94 -4
  32. package/internal/cli/contract_test.go +26 -0
  33. package/internal/cli/events.go +18 -7
  34. package/internal/cli/keepalive_test.go +69 -0
  35. package/internal/cli/requirements.go +119 -0
  36. package/internal/cli/requirements_test.go +36 -0
  37. package/internal/cli/root.go +56 -4
  38. package/internal/cli/session.go +64 -25
  39. package/internal/cli/sessionlog.go +76 -7
  40. package/internal/cli/sessionlog_test.go +58 -0
  41. package/internal/cli/team.go +374 -0
  42. package/internal/cli/team_session.go +156 -0
  43. package/internal/cli/team_test.go +360 -0
  44. package/internal/client/plugins.go +7 -4
  45. package/internal/client/requirements.go +309 -0
  46. package/internal/client/requirements_test.go +239 -0
  47. package/internal/client/service.go +545 -19
  48. package/internal/client/service_test.go +47 -2
  49. package/internal/client/session_events_test.go +320 -0
  50. package/internal/client/team_flags.go +204 -0
  51. package/internal/client/team_flags_test.go +254 -0
  52. package/internal/requirements/bootstrap.go +423 -0
  53. package/internal/requirements/consolidate.go +908 -0
  54. package/internal/requirements/doc.go +473 -0
  55. package/internal/requirements/memory.go +324 -0
  56. package/internal/requirements/requirements.go +169 -0
  57. package/internal/requirements/requirements_test.go +530 -0
  58. package/internal/requirements/store.go +289 -0
  59. package/internal/session/delete.go +8 -4
  60. package/internal/session/session.go +194 -17
  61. package/internal/session/session_append.go +4 -1
  62. package/internal/session/session_append_test.go +19 -0
  63. package/internal/session/session_mark_test.go +42 -3
  64. package/internal/session/session_test.go +43 -0
  65. package/internal/session/sessionlog.go +50 -34
  66. package/internal/session/sessionlog_read_test.go +69 -11
  67. package/internal/session/status.go +205 -0
  68. package/internal/session/status_test.go +144 -0
  69. package/internal/team/adaptive.go +293 -0
  70. package/internal/team/board.go +216 -0
  71. package/internal/team/event.go +28 -0
  72. package/internal/team/execute.go +648 -0
  73. package/internal/team/plan.go +903 -0
  74. package/internal/team/planner.go +294 -0
  75. package/internal/team/reduce.go +192 -0
  76. package/internal/team/role.go +334 -0
  77. package/internal/team/route.go +206 -0
  78. package/internal/team/schedule.go +241 -0
  79. package/internal/team/schedule_test.go +286 -0
  80. package/internal/team/subsession.go +27 -0
  81. package/internal/team/team.go +401 -0
  82. package/internal/team/team_test.go +1997 -0
  83. package/npm/bin/magic-client.js +0 -0
  84. package/npm/dist/darwin-arm64/magic-agent +0 -0
  85. package/npm/dist/darwin-arm64/magic-client +0 -0
  86. package/npm/dist/darwin-x64/magic-agent +0 -0
  87. package/npm/dist/darwin-x64/magic-client +0 -0
  88. package/npm/dist/linux-arm64/magic-agent +0 -0
  89. package/npm/dist/linux-arm64/magic-client +0 -0
  90. package/npm/dist/linux-x64/magic-agent +0 -0
  91. package/npm/dist/linux-x64/magic-client +0 -0
  92. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  93. package/npm/dist/win32-x64/magic-client.exe +0 -0
  94. package/package.json +1 -1
@@ -0,0 +1,903 @@
1
+ package team
2
+
3
+ // plan.go - 计划协议:结构、校验、拓扑排序、PLAN.md / TASK-<id>.md 的落盘。
4
+ //
5
+ // 两种形态,各归各的用途:
6
+ //
7
+ // Plan(Go 结构) 主引擎产出的 JSON 解析而来,程序用;
8
+ // PLAN.md 给人看的计划文档,同时是**全队共享的事实源**(子任务只读它)。
9
+ //
10
+ // 为什么计划文档是 markdown 而不是 JSON:用户要能点开看、也要能看懂「它打算怎么干」。
11
+ // JSON 只有程序读得懂,而团队模式的价值恰恰是过程可见。结构化数据仍留在内存里的
12
+ // Plan 上,落盘只是投影。
13
+
14
+ import (
15
+ "encoding/json"
16
+ "fmt"
17
+ "os"
18
+ "path/filepath"
19
+ "sort"
20
+ "strings"
21
+ "time"
22
+
23
+ "github.com/darren/magic-agent/internal/agent"
24
+ )
25
+
26
+ // Plan 主引擎给出的执行计划。
27
+ type Plan struct {
28
+ // Goal 一句话目标(PLAN.md 的标题)。
29
+ Goal string `json:"goal"`
30
+ // Tasks 子任务;deps 允许为空(无依赖,可立即并发)。
31
+ Tasks []Task `json:"tasks"`
32
+ }
33
+
34
+ // Task 一个子任务。
35
+ type Task struct {
36
+ ID string `json:"id"`
37
+ // Kind 任务类别。**派生字段**:由 Role 决定(见 EffectiveKind),计划 JSON 里不再写它。
38
+ // 保留是为了兼容旧计划 / 直接构造 Task 的调用方——它们只给 Kind 时按该类别的默认角色补 Role。
39
+ Kind string `json:"kind"`
40
+ // Role 执行角色(见 role.go)——任务的**唯一作者字段**,必填。
41
+ // 归一化后一定是内置角色之一;它与 Kind 冲突时以它为准。
42
+ Role string `json:"role"`
43
+ Title string `json:"title"`
44
+ // Prompt 给子引擎的**完整**指令(不含 core 补的那层壳)。
45
+ Prompt string `json:"prompt"`
46
+ // Deps 依赖的任务 id(先跑完才启动本任务)。
47
+ Deps []string `json:"deps"`
48
+ // Engine / Model 指定引擎模型;空 = 按类别路由(见 RouteFor)。
49
+ Engine string `json:"engine"`
50
+ Model string `json:"model"`
51
+ // Deliverables 交付物文件名(相对自己的工作目录)。core 收尾时逐个 stat。
52
+ Deliverables []string `json:"deliverables"`
53
+ // Inputs 本任务需要的上游文件名(必须与上游 deliverables 完全一致)。
54
+ // 由 deps 中的任务声明产出的算**硬依赖**:开跑前不存在就直接失败,不白跑模型。
55
+ Inputs []string `json:"inputs"`
56
+ // Check 验收命令(在工作目录内执行,退出码 0 才通过)。只接受可机器判定的命令。
57
+ Check []string `json:"check"`
58
+ // Required false = 失败不阻断汇总(尽力而为的子任务)。
59
+ Required *bool `json:"required"`
60
+ // Constraints 硬约束(逐条列进任务书)。
61
+ Constraints []string `json:"constraints"`
62
+ // Acceptance 验收标准(逐条列进任务书)。
63
+ Acceptance []string `json:"acceptance"`
64
+ }
65
+
66
+ // IsRequired 未显式声明时按「必需」处理 —— 计划里没写就不该静默降级。
67
+ func (t Task) IsRequired() bool { return t.Required == nil || *t.Required }
68
+
69
+ // EffectiveKind 任务实际生效的类别:角色的类别优先,其次旧的 Kind 字段,最后退 text。
70
+ //
71
+ // 这是**唯一**该被读取的类别口径(路由、校验、渲染、结果字段都用它):Task.Kind 只是
72
+ // 兼容用的回退输入,normalizeTask 会把派生结果写回它,但直接构造的 Task 未必过归一化。
73
+ func (t Task) EffectiveKind() string {
74
+ if k := RoleKind(t.Role); k != "" {
75
+ return k
76
+ }
77
+ if k := NormalizeKind(t.Kind); k != "" {
78
+ return k
79
+ }
80
+ return KindText
81
+ }
82
+
83
+ // ParentID 该子任务在事件流里的委派卡 id。
84
+ //
85
+ // 形状是 `team_<task_id>`:`team_` 前缀让排障时一眼分出「这是团队模式合成的委派」
86
+ // 与引擎原生的 tool_use id(toolu_… / call_…)。
87
+ func (t Task) ParentID() string { return "team_" + t.ID }
88
+
89
+ // InputSpec 一条 `inputs` 解析后的落点。
90
+ //
91
+ // 为什么要解析而不是直接拼路径:计划是模型写的,`inputs` 里可能写 `brief.md`,
92
+ // 也可能写 `t1/brief.md`,还可能写绝对路径。更关键的是——**谁产出它**决定了
93
+ // 「文件不在」意味着什么:由 deps 产出的缺失是硬错(上游没干完),
94
+ // 计划里没人认领的只是个提示(可能就是用户自己提供的素材)。
95
+ type InputSpec struct {
96
+ // Name 计划里声明的原名。
97
+ Name string
98
+ // Path 解析出的绝对路径(空 = 定位不到产出者)。
99
+ Path string
100
+ // From 产出该文件的任务 id(空 = 无来源)。
101
+ From string
102
+ // Hard 硬依赖:deps 中的任务声明了它作为交付物。
103
+ Hard bool
104
+ }
105
+
106
+ // ResolveInputs 把 `inputs` 解析成具体路径。
107
+ //
108
+ // 查找顺序:绝对路径 → deps 里声明的交付物 → 整张计划里声明的交付物。
109
+ // 都找不到就给空 Path(任务书里只作提示,不判失败)。
110
+ func ResolveInputs(runDir string, plan Plan, t Task) []InputSpec {
111
+ out := make([]InputSpec, 0, len(t.Inputs))
112
+ for _, raw := range t.Inputs {
113
+ name := strings.TrimSpace(raw)
114
+ if name == "" {
115
+ continue
116
+ }
117
+ spec := InputSpec{Name: name}
118
+ if filepath.IsAbs(name) {
119
+ spec.Path = name
120
+ out = append(out, spec)
121
+ continue
122
+ }
123
+ base := filepath.Base(name)
124
+ for _, d := range t.Deps {
125
+ dep, ok := findTask(plan, strings.TrimSpace(d))
126
+ if !ok {
127
+ continue
128
+ }
129
+ rel := declaredPath(dep, base)
130
+ if rel == "" {
131
+ continue
132
+ }
133
+ spec.Path = filepath.Join(TaskDir(runDir, dep.ID), rel)
134
+ spec.From = dep.ID
135
+ spec.Hard = true
136
+ break
137
+ }
138
+ if spec.From == "" {
139
+ for _, other := range plan.Tasks {
140
+ if strings.TrimSpace(other.ID) == strings.TrimSpace(t.ID) {
141
+ continue
142
+ }
143
+ rel := declaredPath(other, base)
144
+ if rel == "" {
145
+ continue
146
+ }
147
+ spec.Path = filepath.Join(TaskDir(runDir, other.ID), rel)
148
+ spec.From = other.ID
149
+ break
150
+ }
151
+ }
152
+ out = append(out, spec)
153
+ }
154
+ return out
155
+ }
156
+
157
+ // MissingInputs 硬依赖里**不存在**的文件(带来源说明)。
158
+ //
159
+ // 只核对 Hard:计划里没人认领的名字本来就无从核对,拿它判失败等于让模型的一处
160
+ // 措辞断掉整条链。返回空 = 可以开跑。
161
+ func MissingInputs(runDir string, plan Plan, t Task) []string {
162
+ var out []string
163
+ for _, spec := range ResolveInputs(runDir, plan, t) {
164
+ if !spec.Hard || spec.Path == "" {
165
+ continue
166
+ }
167
+ if _, err := os.Stat(spec.Path); err != nil {
168
+ out = append(out, fmt.Sprintf("%s(应由 %s 产出)", spec.Name, spec.From))
169
+ }
170
+ }
171
+ return out
172
+ }
173
+
174
+ func findTask(plan Plan, id string) (Task, bool) {
175
+ for _, t := range plan.Tasks {
176
+ if strings.TrimSpace(t.ID) == id {
177
+ return t, true
178
+ }
179
+ }
180
+ return Task{}, false
181
+ }
182
+
183
+ func declaresDeliverable(t Task, base string) bool {
184
+ return declaredPath(t, base) != ""
185
+ }
186
+
187
+ // declaredPath 在任务的交付物清单里找与 base 同名的项,返回它**相对任务目录**的路径
188
+ // (保留子目录,如 impl/fizzbuzz.js)。找不到返回 ""。
189
+ //
190
+ // 为什么不能只取 filepath.Base:交付物常写在子目录里(impl/…、test/…),只留文件名
191
+ // 会让 ResolveInputs 拼出 t1/fizzbuzz.js 这种不存在的路径,下游任务于是被判「缺少
192
+ // 上游输入」——与 collectDeliverables 只认 basename 是同一类缺陷(2026-10-22 实测)。
193
+ func declaredPath(t Task, base string) string {
194
+ for _, d := range t.Deliverables {
195
+ rel := cleanRel(d)
196
+ if rel == "" {
197
+ continue
198
+ }
199
+ if filepath.Base(rel) == base {
200
+ return rel
201
+ }
202
+ }
203
+ return ""
204
+ }
205
+
206
+ // cleanRel 把声明里的路径规整成「相对任务目录」的形式:去 ./、统一分隔符;
207
+ // 绝对路径与 ../ 越界写法只保留文件名(子任务不该读写自己目录之外的东西)。
208
+ // 返回空 = 没有有效路径。
209
+ func cleanRel(p string) string {
210
+ name := strings.TrimSpace(p)
211
+ if name == "" {
212
+ return ""
213
+ }
214
+ name = filepath.ToSlash(name)
215
+ for strings.HasPrefix(name, "./") {
216
+ name = name[2:]
217
+ }
218
+ if name == "" {
219
+ return ""
220
+ }
221
+ if strings.HasPrefix(name, "/") || name == ".." || strings.HasPrefix(name, "../") {
222
+ name = filepath.Base(name)
223
+ }
224
+ return filepath.FromSlash(name)
225
+ }
226
+
227
+ // Handoff 子任务的交接单:正文末尾的一个 JSON 对象。
228
+ //
229
+ // 为什么不再只靠正文:下游需要的是「文件在哪、接口是什么、还有什么没解决」,
230
+ // 让每个下游任务去读上游几千字正文里翻这些,等于把上下文成本转嫁给最需要
231
+ // 省 token 的那一端。交接单是 core 能核对的**结构化收尾**。
232
+ type Handoff struct {
233
+ Files []string `json:"files"`
234
+ Summary string `json:"summary"`
235
+ Interface string `json:"interface"`
236
+ Open []string `json:"open"`
237
+ }
238
+
239
+ // parseHandoff 从子任务正文里取**最后一个** JSON 对象当交接单。
240
+ //
241
+ // 取最后一个:任务书本身、代码块里都可能出现 JSON,模型照约定会把交接单放在
242
+ // 最后收尾。解析不出 / 全空字段时返回 nil,调用方回落到正文截断。
243
+ func parseHandoff(text string) *Handoff {
244
+ raw, ok := lastJSONObject(text)
245
+ if !ok {
246
+ return nil
247
+ }
248
+ var h Handoff
249
+ if err := json.Unmarshal([]byte(raw), &h); err != nil {
250
+ return nil
251
+ }
252
+ if len(h.Files) == 0 && len(h.Open) == 0 &&
253
+ strings.TrimSpace(h.Summary) == "" && strings.TrimSpace(h.Interface) == "" {
254
+ return nil
255
+ }
256
+ return &h
257
+ }
258
+
259
+ // lastJSONObject 取文本里最后一个括号配对的 JSON 对象。
260
+ func lastJSONObject(s string) (string, bool) {
261
+ var last string
262
+ found := false
263
+ for {
264
+ raw, ok := firstJSONObject(s)
265
+ if !ok {
266
+ return last, found
267
+ }
268
+ last, found = raw, true
269
+ i := strings.Index(s, raw)
270
+ s = s[i+len(raw):]
271
+ }
272
+ }
273
+
274
+ // handoffMarkdown 把交接单渲染成任务书里的「上游产出」区块。
275
+ func handoffMarkdown(runDir, taskID string, h *Handoff) string {
276
+ var b strings.Builder
277
+ b.WriteString("**摘要**:" + orDash(h.Summary) + "\n\n")
278
+ if files := handoffFiles(runDir, taskID, h); len(files) > 0 {
279
+ b.WriteString("文件(绝对路径):\n")
280
+ for _, f := range files {
281
+ b.WriteString("- " + f + "\n")
282
+ }
283
+ b.WriteString("\n")
284
+ }
285
+ if s := strings.TrimSpace(h.Interface); s != "" {
286
+ b.WriteString("**下游可直接使用的接口**:" + s + "\n\n")
287
+ }
288
+ if len(h.Open) > 0 {
289
+ b.WriteString("未解决:\n")
290
+ for _, o := range h.Open {
291
+ b.WriteString("- " + oneLine(o) + "\n")
292
+ }
293
+ b.WriteString("\n")
294
+ }
295
+ return b.String()
296
+ }
297
+
298
+ // handoffFiles 把交接单里的文件名补成绝对路径(相对路径按产出者自己的工作目录算)。
299
+ func handoffFiles(runDir, taskID string, h *Handoff) []string {
300
+ var out []string
301
+ for _, f := range h.Files {
302
+ f = strings.TrimSpace(f)
303
+ if f == "" {
304
+ continue
305
+ }
306
+ if !filepath.IsAbs(f) {
307
+ f = filepath.Join(TaskDir(runDir, taskID), filepath.Base(f))
308
+ }
309
+ out = append(out, f)
310
+ }
311
+ return out
312
+ }
313
+
314
+ // HandoffSchema 交接单的结构(core 侧校验口径)。
315
+ //
316
+ // ⚠️ 刻意**不**塞进子任务的 agent.Request.JSONSchema:agent 的结构化输出后处理是
317
+ // 「取文本里第一个匹配对象并整体替换正文」(见 agent/jsonextract.go),子任务正文
318
+ // 会因此只剩那个 JSON —— 交接单本来就要和正文共存。planner 的输出是纯 JSON,才
319
+ // 适合走 JSONSchema。所以子任务收尾靠这里的 schema 在 core 侧核对,核不过复用
320
+ // 现有的「一次重试」路径。
321
+ func HandoffSchema() *agent.JSONSchema {
322
+ return &agent.JSONSchema{
323
+ Type: "object",
324
+ Properties: map[string]map[string]any{
325
+ "files": {"type": "array"},
326
+ "summary": {"type": "string"},
327
+ "interface": {"type": "string"},
328
+ "open": {"type": "array"},
329
+ },
330
+ Required: []string{"files", "summary"},
331
+ }
332
+ }
333
+
334
+ // handoffRequired 本任务的产物是否被下游当作**硬输入**。
335
+ //
336
+ // 只有这类任务才强制交接单:叶子任务的交接单没人读,强制只会把一次成功变成
337
+ // 一次无谓重试。
338
+ func handoffRequired(plan Plan, id string) bool {
339
+ self, ok := findTask(plan, id)
340
+ if !ok || len(self.Deliverables) == 0 {
341
+ return false
342
+ }
343
+ for _, t := range plan.Tasks {
344
+ if strings.TrimSpace(t.ID) == id {
345
+ continue
346
+ }
347
+ dep := false
348
+ for _, d := range t.Deps {
349
+ if strings.TrimSpace(d) == id {
350
+ dep = true
351
+ break
352
+ }
353
+ }
354
+ if !dep {
355
+ continue
356
+ }
357
+ for _, in := range t.Inputs {
358
+ if declaresDeliverable(self, filepath.Base(strings.TrimSpace(in))) {
359
+ return true
360
+ }
361
+ }
362
+ }
363
+ return false
364
+ }
365
+
366
+ // handoffProblems 按 HandoffSchema 核对交接单;返回空 = 合格。
367
+ func handoffProblems(plan Plan, t Task, r *TaskResult) []string {
368
+ if !handoffRequired(plan, t.ID) {
369
+ return nil
370
+ }
371
+ if r.Handoff == nil {
372
+ return []string{"缺少交接单:本任务的交付物是下游的硬输入,正文末尾必须给出 JSON 收尾对象"}
373
+ }
374
+ var lack []string
375
+ for _, field := range HandoffSchema().Required {
376
+ switch field {
377
+ case "files":
378
+ if len(r.Handoff.Files) == 0 {
379
+ lack = append(lack, field)
380
+ }
381
+ case "summary":
382
+ if strings.TrimSpace(r.Handoff.Summary) == "" {
383
+ lack = append(lack, field)
384
+ }
385
+ }
386
+ }
387
+ if len(lack) > 0 {
388
+ return []string{"交接单缺少必填字段:" + strings.Join(lack, "、")}
389
+ }
390
+ return nil
391
+ }
392
+
393
+ // Validate 校验计划:非空、id 唯一、类别合法、依赖存在、无环。
394
+ //
395
+ // 任一条不满足都返回错误 —— 调用方(Run)据它降级为单任务,**不硬跑一个坏计划**。
396
+ func (p Plan) Validate() error {
397
+ if len(p.Tasks) == 0 {
398
+ return fmt.Errorf("计划里没有任务")
399
+ }
400
+ seen := make(map[string]bool, len(p.Tasks))
401
+ for _, t := range p.Tasks {
402
+ id := strings.TrimSpace(t.ID)
403
+ if id == "" {
404
+ return fmt.Errorf("有任务缺少 id")
405
+ }
406
+ if seen[id] {
407
+ return fmt.Errorf("任务 id 重复:%s", id)
408
+ }
409
+ seen[id] = true
410
+ if !ValidKind(t.EffectiveKind()) {
411
+ return fmt.Errorf("任务 %s 的类别 %q 不支持(可选:%s)", id, t.EffectiveKind(), strings.Join(Kinds, " / "))
412
+ }
413
+ if strings.TrimSpace(t.Prompt) == "" {
414
+ return fmt.Errorf("任务 %s 没有 prompt", id)
415
+ }
416
+ }
417
+ for _, t := range p.Tasks {
418
+ for _, d := range t.Deps {
419
+ if !seen[strings.TrimSpace(d)] {
420
+ return fmt.Errorf("任务 %s 依赖了不存在的任务 %q", t.ID, d)
421
+ }
422
+ }
423
+ }
424
+ if _, err := p.Order(); err != nil {
425
+ return err
426
+ }
427
+ return nil
428
+ }
429
+
430
+ // Order 拓扑排序,返回「按层」的任务分组:同一层的任务互不依赖。
431
+ //
432
+ // 这是**纯函数视图**:给校验和计划文档用(层 = 并行度的上界),不参与执行调度。
433
+ // 真正跑的时候走 schedule.go 的 ReadyQueue —— 依赖一结束就开跑,不等同层其他任务。
434
+ // 两者语义不同,别拿它当调度器。成环时返回错误(Validate 也靠它兜住环)。
435
+ func (p Plan) Order() ([][]Task, error) {
436
+ byID := make(map[string]Task, len(p.Tasks))
437
+ for _, t := range p.Tasks {
438
+ byID[strings.TrimSpace(t.ID)] = t
439
+ }
440
+ done := make(map[string]bool, len(p.Tasks))
441
+ var layers [][]Task
442
+ for len(done) < len(byID) {
443
+ var layer []Task
444
+ for _, t := range p.Tasks {
445
+ id := strings.TrimSpace(t.ID)
446
+ if done[id] {
447
+ continue
448
+ }
449
+ ready := true
450
+ for _, d := range t.Deps {
451
+ if !done[strings.TrimSpace(d)] {
452
+ ready = false
453
+ break
454
+ }
455
+ }
456
+ if ready {
457
+ layer = append(layer, t)
458
+ }
459
+ }
460
+ if len(layer) == 0 {
461
+ // 剩下的都卡在依赖上 → 环。
462
+ var stuck []string
463
+ for _, t := range p.Tasks {
464
+ if id := strings.TrimSpace(t.ID); !done[id] {
465
+ stuck = append(stuck, id)
466
+ }
467
+ }
468
+ sort.Strings(stuck)
469
+ return nil, fmt.Errorf("任务依赖成环:%s", strings.Join(stuck, " → "))
470
+ }
471
+ // 层内顺序按 id 排,输出稳定(PLAN.md 的表格因此不会每次都不一样)。
472
+ sort.SliceStable(layer, func(i, j int) bool { return layer[i].ID < layer[j].ID })
473
+ for _, t := range layer {
474
+ done[strings.TrimSpace(t.ID)] = true
475
+ }
476
+ layers = append(layers, layer)
477
+ }
478
+ return layers, nil
479
+ }
480
+
481
+ // ValidKind 类别是否受支持。
482
+ func ValidKind(k string) bool {
483
+ k = strings.TrimSpace(k)
484
+ for _, x := range Kinds {
485
+ if x == k {
486
+ return true
487
+ }
488
+ }
489
+ return false
490
+ }
491
+
492
+ // NormalizeKind 把模型可能给出的近义写法收拢到五类;认不出返回空串。
493
+ //
494
+ // 为什么要它:计划是模型产出的,`Text` / `image-generation` / `代码` 这类写法必然出现。
495
+ // 收不拢就降级为 text(而不是整份计划作废)——一张图没画成远好过整轮白跑。
496
+ //
497
+ // code 必须排在 text 之前判断:早先把 "code" 归在 text 分支里,导致代码任务永远
498
+ // 走不到代码引擎。
499
+ func NormalizeKind(k string) string {
500
+ s := strings.ToLower(strings.TrimSpace(k))
501
+ switch {
502
+ case s == "":
503
+ return ""
504
+ case strings.Contains(s, "image"), strings.Contains(s, "img"), strings.Contains(s, "图"):
505
+ return KindImage
506
+ case strings.Contains(s, "video"), strings.Contains(s, "视频"):
507
+ return KindVideo
508
+ case strings.Contains(s, "game"), strings.Contains(s, "游"):
509
+ return KindGame
510
+ case strings.Contains(s, "code"), strings.Contains(s, "coding"),
511
+ strings.Contains(s, "代码"), strings.Contains(s, "编码"),
512
+ strings.Contains(s, "程序"), strings.Contains(s, "编程"):
513
+ return KindCode
514
+ case strings.Contains(s, "text"), strings.Contains(s, "doc"), strings.Contains(s, "文"):
515
+ return KindText
516
+ }
517
+ return ""
518
+ }
519
+
520
+ // parsePlan 从模型输出里取计划 JSON。
521
+ //
522
+ // 模型常见三种包裹:裸 JSON、```json 围栏、一段话里夹一个 JSON 对象。三种都认
523
+ // (agent.firstJSONObject 是包内的,这里按同样的做法自己实现一份,避免为了一个
524
+ // 函数把 team 包和 agent 包耦合得更深)。
525
+ func parsePlan(text string) (Plan, error) {
526
+ raw, ok := firstJSONObject(text)
527
+ if !ok {
528
+ return Plan{}, fmt.Errorf("输出里没有 JSON 对象")
529
+ }
530
+ var plan Plan
531
+ if err := json.Unmarshal([]byte(raw), &plan); err != nil {
532
+ return Plan{}, fmt.Errorf("计划 JSON 解析失败:%w", err)
533
+ }
534
+ for i := range plan.Tasks {
535
+ plan.Tasks[i] = normalizeTask(plan.Tasks[i])
536
+ }
537
+ return plan, nil
538
+ }
539
+
540
+ // normalizeTask 把模型可能写歪的字段收拢到 core 的规范值。
541
+ //
542
+ // 计划轮与再规划轮共用:两条路径都直接吃模型输出,归一化必须一致。
543
+ //
544
+ // **角色是唯一来源**:Role 归一化后,Kind 一律取 RoleKind(Role);模型写的 kind 只在
545
+ // role 缺失/认不出时充当回退线索(旧计划兼容),不会与角色并存互相打架。
546
+ func normalizeTask(t Task) Task {
547
+ t.ID = strings.TrimSpace(t.ID)
548
+ role := NormalizeRole(t.Role)
549
+ if role == "" {
550
+ // 没写角色 / 角色不在表里:按旧的 kind 回退到该类别的默认角色。
551
+ if r := DefaultRoleForKind(NormalizeKind(t.Kind)); r != nil {
552
+ role = r.Name
553
+ }
554
+ }
555
+ if role == "" {
556
+ role = "writer"
557
+ }
558
+ t.Role = role
559
+ t.Kind = RoleKind(role)
560
+ if strings.TrimSpace(t.Title) == "" {
561
+ t.Title = t.ID
562
+ }
563
+ return t
564
+ }
565
+
566
+ // firstJSONObject 从文本里截出第一个**括号配对**的 JSON 对象(含被 ``` 围栏包住的情况)。
567
+ func firstJSONObject(s string) (string, bool) {
568
+ start := strings.Index(s, "{")
569
+ if start < 0 {
570
+ return "", false
571
+ }
572
+ depth, inStr, esc := 0, false, false
573
+ for i := start; i < len(s); i++ {
574
+ c := s[i]
575
+ if inStr {
576
+ switch {
577
+ case esc:
578
+ esc = false
579
+ case c == '\\':
580
+ esc = true
581
+ case c == '"':
582
+ inStr = false
583
+ }
584
+ continue
585
+ }
586
+ switch c {
587
+ case '"':
588
+ inStr = true
589
+ case '{':
590
+ depth++
591
+ case '}':
592
+ depth--
593
+ if depth == 0 {
594
+ return s[start : i+1], true
595
+ }
596
+ }
597
+ }
598
+ return "", false
599
+ }
600
+
601
+ // fallbackPlan 降级用:把用户原话当成唯一一个 text 子任务。
602
+ //
603
+ // 刻意**不**把主引擎再跑一遍 —— 降级的语义是「计划这一步没做成,那就直接干」,
604
+ // 再跑一次只会把失败时间翻倍。
605
+ func fallbackPlan(prompt string) Plan {
606
+ one := true
607
+ return Plan{
608
+ Goal: firstLine(prompt, 60),
609
+ Tasks: []Task{{
610
+ ID: "t1",
611
+ Kind: KindText,
612
+ Role: "writer",
613
+ Title: "直接执行",
614
+ Prompt: prompt,
615
+ Required: &one,
616
+ Deliverables: nil,
617
+ }},
618
+ }
619
+ }
620
+
621
+ // PlanPath PLAN.md 的路径。
622
+ func PlanPath(runDir string) string { return filepath.Join(runDir, "PLAN.md") }
623
+
624
+ // TaskDocPath TASK-<id>.md 的路径。
625
+ func TaskDocPath(runDir, id string) string { return filepath.Join(runDir, "TASK-"+id+".md") }
626
+
627
+ // OutputPath OUT-<id>.md 的路径。
628
+ func OutputPath(runDir, id string) string { return filepath.Join(runDir, "OUT-"+id+".md") }
629
+
630
+ // ArtsPath ARTS-<id>.json 的路径。
631
+ func ArtsPath(runDir, id string) string { return filepath.Join(runDir, "ARTS-"+id+".json") }
632
+
633
+ // HandoffPath HANDOFF-<id>.json 的路径。
634
+ func HandoffPath(runDir, id string) string { return filepath.Join(runDir, "HANDOFF-"+id+".json") }
635
+
636
+ // TaskDir 子任务自己的工作目录。
637
+ func TaskDir(runDir, id string) string { return filepath.Join(runDir, id) }
638
+
639
+ // WritePlanDoc 写 PLAN.md 并置为只读。
640
+ //
641
+ // 只读是刻意的:它是全队共享的事实源,子任务(尤其带 full 权限的那些)不该改动它。
642
+ // 置只读不防恶意,但足以让「子引擎顺手改了几笔」这种事故在第一次 open 时就暴露。
643
+ func WritePlanDoc(runDir string, plan Plan, opts Options) error {
644
+ if err := os.MkdirAll(runDir, 0o755); err != nil {
645
+ return err
646
+ }
647
+ body := PlanMarkdown(plan)
648
+ head := fmt.Sprintf("# 计划 · %s\n\n"+
649
+ "run_id: %s\n"+
650
+ "主引擎: %s%s\n"+
651
+ "创建: %s\n\n"+
652
+ "> 这份文档是全队共享的事实源(只读)。子任务只读它,产物落在各自的工作目录。\n\n",
653
+ orDash(plan.Goal), orDash(opts.RunID), opts.Engine.Name(), modelSuffix(opts.Model),
654
+ time.Now().Format(time.RFC3339))
655
+ return writeReadOnly(PlanPath(runDir), head+body)
656
+ }
657
+
658
+ // PlanMarkdown 把计划渲染成 markdown(不含文件头的 run_id / 创建时间)。
659
+ func PlanMarkdown(plan Plan) string {
660
+ var b strings.Builder
661
+ b.WriteString("## 任务总览\n\n")
662
+ b.WriteString("| id | 角色(类别) | 标题 | 依赖 | 引擎/模型 | 必需 | 交付物 |\n")
663
+ b.WriteString("| --- | --- | --- | --- | --- | --- | --- |\n")
664
+ for _, t := range plan.Tasks {
665
+ eng := orDash(t.Engine)
666
+ if t.Model != "" {
667
+ eng += "/" + t.Model
668
+ }
669
+ req := "是"
670
+ if !t.IsRequired() {
671
+ req = "否"
672
+ }
673
+ b.WriteString(fmt.Sprintf("| %s | %s | %s | %s | %s | %s | %s |\n",
674
+ t.ID, roleText(t.Role)+"("+t.EffectiveKind()+")", oneLine(t.Title), depsText(t.Deps), eng, req, depsText(t.Deliverables)))
675
+ }
676
+ for _, t := range plan.Tasks {
677
+ b.WriteString("\n## " + strings.ToUpper(t.ID) + " · " + oneLine(t.Title) + "\n\n")
678
+ b.WriteString("角色:" + roleDetail(t.Role) + "\n\n")
679
+ b.WriteString("类别:" + t.EffectiveKind() + "(由角色决定)· 必需:" + boolText(t.IsRequired()) + "\n\n")
680
+ b.WriteString(strings.TrimSpace(t.Prompt) + "\n")
681
+ if len(t.Inputs) > 0 {
682
+ b.WriteString("\n输入:" + depsText(t.Inputs) + "\n")
683
+ }
684
+ if len(t.Constraints) > 0 {
685
+ b.WriteString("\n约束:\n")
686
+ for _, c := range t.Constraints {
687
+ b.WriteString("- " + oneLine(c) + "\n")
688
+ }
689
+ }
690
+ if len(t.Acceptance) > 0 {
691
+ b.WriteString("\n验收:\n")
692
+ for _, c := range t.Acceptance {
693
+ b.WriteString("- " + oneLine(c) + "\n")
694
+ }
695
+ }
696
+ if len(t.Check) > 0 {
697
+ b.WriteString("\n验收命令(在工作目录内执行,退出码 0 才通过):\n")
698
+ for _, c := range t.Check {
699
+ b.WriteString("- `" + oneLine(c) + "`\n")
700
+ }
701
+ }
702
+ }
703
+ return b.String()
704
+ }
705
+
706
+ // TaskDoc 渲染一个子任务的任务书(TASK-<id>.md)。
707
+ //
708
+ // 任务书是子任务**唯一的上下文来源**(不共享会话),所以四样东西必须齐:
709
+ // 目标(干到什么算完)、上游产出(前面的人做了什么)、约束(不许做什么)、
710
+ // 交付物(交什么、叫什么名字)。缺一样,子引擎就只能猜。
711
+ func TaskDoc(runDir string, plan Plan, t Task, upstream map[string]TaskResult) string {
712
+ var b strings.Builder
713
+ b.WriteString("# 任务书 · " + strings.ToUpper(t.ID) + " · " + oneLine(t.Title) + "\n\n")
714
+ b.WriteString("你在一个团队任务里承担第 " + t.ID + " 个子任务。\n\n")
715
+ b.WriteString("- 计划文档(只读,全队共享):" + PlanPath(runDir) + "\n")
716
+ b.WriteString("- 你的任务书(只读):" + TaskDocPath(runDir, t.ID) + "\n")
717
+ b.WriteString("- 你的工作目录:" + TaskDir(runDir, t.ID) + "(**只能**在这里写文件)\n")
718
+ b.WriteString("- 全队目标:" + orDash(plan.Goal) + "\n\n")
719
+
720
+ if r := LookupRole(t.Role); r != nil {
721
+ b.WriteString("## 你的角色\n\n")
722
+ b.WriteString("**" + r.Name + "** —— " + orDash(r.Duty) + "\n\n")
723
+ b.WriteString("按这个角色的标准要求自己;任务书里的交付物与验收标准仍是硬要求,不能因为角色而打折。\n\n")
724
+ }
725
+
726
+ b.WriteString("## 任务\n\n" + strings.TrimSpace(t.Prompt) + "\n\n")
727
+
728
+ if len(t.Inputs) > 0 {
729
+ b.WriteString("## 输入\n\n")
730
+ for _, spec := range ResolveInputs(runDir, plan, t) {
731
+ switch {
732
+ case spec.Path == "":
733
+ b.WriteString("- `" + spec.Name + "` —— 计划里没写明谁产出它;先按任务描述自己找,找不到就说明缺什么\n")
734
+ case spec.Hard:
735
+ b.WriteString("- `" + spec.Name + "` → " + spec.Path + "(由 " + spec.From + " 产出,**必须读到**)\n")
736
+ default:
737
+ b.WriteString("- `" + spec.Name + "` → " + spec.Path + "(来自 " + spec.From + ")\n")
738
+ }
739
+ }
740
+ b.WriteString("\n")
741
+ }
742
+
743
+ if len(t.Deps) > 0 {
744
+ b.WriteString("## 上游产出\n\n")
745
+ for _, d := range t.Deps {
746
+ r, ok := upstream[strings.TrimSpace(d)]
747
+ b.WriteString("### " + d + " · " + orDash(r.Title) + "\n\n")
748
+ switch {
749
+ case !ok:
750
+ b.WriteString("(上游没有产出)\n\n")
751
+ case r.State != "done":
752
+ b.WriteString("(上游未完成:" + orDash(r.Reason) + ")\n\n")
753
+ default:
754
+ if r.Handoff != nil {
755
+ rid := r.ID
756
+ if strings.TrimSpace(rid) == "" {
757
+ rid = strings.TrimSpace(d)
758
+ }
759
+ b.WriteString(handoffMarkdown(runDir, rid, r.Handoff))
760
+ } else {
761
+ b.WriteString(Truncate(r.Text, upstreamCap) + "\n\n")
762
+ }
763
+ if len(r.Arts) > 0 {
764
+ b.WriteString("产物:\n")
765
+ for _, a := range r.Arts {
766
+ b.WriteString("- " + a + "\n")
767
+ }
768
+ b.WriteString("\n")
769
+ }
770
+ }
771
+ }
772
+ }
773
+
774
+ if len(t.Constraints) > 0 {
775
+ b.WriteString("## 必须遵守\n\n")
776
+ for _, c := range t.Constraints {
777
+ b.WriteString("- " + oneLine(c) + "\n")
778
+ }
779
+ b.WriteString("\n")
780
+ }
781
+
782
+ b.WriteString("## 交付物\n\n")
783
+ if len(t.Deliverables) == 0 {
784
+ b.WriteString("无强制交付物;把结论写清楚即可。\n\n")
785
+ } else {
786
+ for _, d := range t.Deliverables {
787
+ b.WriteString("- `" + d + "` —— 放在工作目录下,**必须用这个文件名**(汇总时按名字核对)\n")
788
+ }
789
+ b.WriteString("\n")
790
+ }
791
+
792
+ if len(t.Acceptance) > 0 {
793
+ b.WriteString("## 验收标准\n\n")
794
+ for _, c := range t.Acceptance {
795
+ b.WriteString("- " + oneLine(c) + "\n")
796
+ }
797
+ b.WriteString("\n")
798
+ }
799
+
800
+ b.WriteString("## 收尾要求\n\n" +
801
+ "做完(或确认做不到)后,用**一个 JSON 对象**收尾(可以放在 ```json 围栏里):\n\n" +
802
+ "```json\n" +
803
+ "{\"files\":[\"交付物文件名\"],\"summary\":\"做了什么、结论是什么\",\"interface\":\"下游可以直接用的接口/路径\",\"open\":[\"未解决项\"]}\n" +
804
+ "```\n\n" +
805
+ "core 会把这个交接单(而不是整段正文)交给下游任务,所以 summary 要能独立读懂;" +
806
+ "没有的字段给空数组 / 空字符串,不要省略。\n")
807
+ if handoffRequired(plan, t.ID) {
808
+ b.WriteString("\n本任务的交付物是下游的**硬输入**:交接单必须给出非空的 files 与 summary," +
809
+ "缺任一项视为未通过核对(会要求你补齐后重试一次)。\n")
810
+ }
811
+ return b.String()
812
+ }
813
+
814
+ // upstreamCap 上游正文写进任务书时的上限。
815
+ //
816
+ // 8KB ≈ 两三千汉字:够说清「上游做了什么、留了哪些接口」,又不至于把任务书撑成
817
+ // 一整份上下文。超长给首尾两段(中间省略),因为结论常在首尾。
818
+ const upstreamCap = 8192
819
+
820
+ // WriteTaskDoc 写 TASK-<id>.md 并置为只读。
821
+ func WriteTaskDoc(runDir string, plan Plan, t Task) error {
822
+ body := TaskDoc(runDir, plan, t, nil)
823
+ return writeReadOnly(TaskDocPath(runDir, t.ID), body)
824
+ }
825
+
826
+ // EnsureTaskDir 建子任务工作目录。
827
+ func EnsureTaskDir(runDir, id string) error {
828
+ return os.MkdirAll(TaskDir(runDir, id), 0o755)
829
+ }
830
+
831
+ // writeReadOnly 原子写文件后置为 0444(已存在时先去掉只读位再覆盖)。
832
+ func writeReadOnly(path, body string) error {
833
+ if _, err := os.Stat(path); err == nil {
834
+ _ = os.Chmod(path, 0o644)
835
+ }
836
+ if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
837
+ return err
838
+ }
839
+ return os.Chmod(path, 0o444)
840
+ }
841
+
842
+ // Truncate 按 rune 截断,超过上限时保留首尾两段并标注省略了多少。
843
+ func Truncate(s string, max int) string {
844
+ s = strings.TrimSpace(s)
845
+ if max <= 0 {
846
+ return s
847
+ }
848
+ r := []rune(s)
849
+ if len(r) <= max {
850
+ return s
851
+ }
852
+ headN := max * 2 / 3
853
+ tailN := max - headN
854
+ return string(r[:headN]) +
855
+ fmt.Sprintf("\n\n…(中间省略 %d 字)…\n\n", len(r)-headN-tailN) +
856
+ string(r[len(r)-tailN:])
857
+ }
858
+
859
+ func orDash(s string) string {
860
+ if strings.TrimSpace(s) == "" {
861
+ return "—"
862
+ }
863
+ return strings.TrimSpace(s)
864
+ }
865
+
866
+ func boolText(b bool) string {
867
+ if b {
868
+ return "是"
869
+ }
870
+ return "否"
871
+ }
872
+
873
+ func oneLine(s string) string {
874
+ return strings.TrimSpace(strings.ReplaceAll(strings.ReplaceAll(s, "\n", " "), "|", "\\|"))
875
+ }
876
+
877
+ func depsText(xs []string) string {
878
+ var out []string
879
+ for _, x := range xs {
880
+ if s := strings.TrimSpace(x); s != "" {
881
+ out = append(out, s)
882
+ }
883
+ }
884
+ if len(out) == 0 {
885
+ return "—"
886
+ }
887
+ return strings.Join(out, "、")
888
+ }
889
+
890
+ func modelSuffix(model string) string {
891
+ if strings.TrimSpace(model) == "" {
892
+ return ""
893
+ }
894
+ return "/" + strings.TrimSpace(model)
895
+ }
896
+
897
+ func firstLine(s string, max int) string {
898
+ s = strings.TrimSpace(s)
899
+ if i := strings.IndexByte(s, '\n'); i >= 0 {
900
+ s = s[:i]
901
+ }
902
+ return Truncate(s, max)
903
+ }