@evandu/super-pi 0.1.164 → 0.1.166

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 (48) hide show
  1. package/agents/coder.md +13 -1
  2. package/agents/critic.md +6 -2
  3. package/agents/patcher.md +6 -2
  4. package/agents/planner.md +14 -2
  5. package/agents/project-leader.md +25 -0
  6. package/agents/reviewer.md +13 -1
  7. package/agents/scout.md +6 -2
  8. package/agents/super-pi-task/core.ts +11 -7
  9. package/agents/verifier.md +6 -2
  10. package/dist/client/.super-pi-client-sources.json +10 -8
  11. package/dist/client/assets/{CodeMirrorEditor-B7hhyG33.js → CodeMirrorEditor-q5ckKYpW.js} +2 -2
  12. package/dist/client/assets/{dist-CdpuBRoy.js → dist-3Qp_-Z3t.js} +1 -1
  13. package/dist/client/assets/{dist-B5xG-jTK.js → dist-B61EUjUz.js} +1 -1
  14. package/dist/client/assets/{dist-BLhPsVFt.js → dist-BN9HZG4O.js} +1 -1
  15. package/dist/client/assets/{dist-CxZb6ATc.js → dist-BOU0i9JW.js} +1 -1
  16. package/dist/client/assets/{dist-BBfDBMAk.js → dist-BT3fZ0wT.js} +1 -1
  17. package/dist/client/assets/{dist-BDy5Nw4_.js → dist-B_l0KBjV.js} +1 -1
  18. package/dist/client/assets/{dist-Foaz_oK2.js → dist-Bb-gT7aj.js} +1 -1
  19. package/dist/client/assets/{dist-D607J8oC.js → dist-CBqBGuBl.js} +1 -1
  20. package/dist/client/assets/{dist-CGO5Bn1x.js → dist-CXXAxAE4.js} +1 -1
  21. package/dist/client/assets/{dist-DJ4sWDSy.js → dist-Crew6BC1.js} +1 -1
  22. package/dist/client/assets/{dist-BjlmPyRl.js → dist-Cs1P7JrO.js} +1 -1
  23. package/dist/client/assets/{dist-CsBiEr54.js → dist-CyTiEqGG.js} +1 -1
  24. package/dist/client/assets/{dist-7Nz91m3a.js → dist-D10kcMrw.js} +1 -1
  25. package/dist/client/assets/{dist-D6nsZZd3.js → dist-DMynScoj.js} +1 -1
  26. package/dist/client/assets/{dist-CrJV_eES.js → dist-DRHWMjx_.js} +1 -1
  27. package/dist/client/assets/{dist-qqDS87aN.js → dist-DSIy9Ers.js} +1 -1
  28. package/dist/client/assets/{dist-BmeBkDNB.js → dist-DdfZJR8e.js} +1 -1
  29. package/dist/client/assets/{dist-CF9zuubZ.js → dist-DiqGp2ie.js} +1 -1
  30. package/dist/client/assets/{dist-ByEzPyEi.js → dist-YFQ3ixZU.js} +1 -1
  31. package/dist/client/assets/{dist-DaUyp_NN.js → dist-_pAt4bRw.js} +1 -1
  32. package/dist/client/assets/{dist-BhouAfNI.js → dist-bLHDT2q5.js} +1 -1
  33. package/dist/client/assets/{dist-C4ql8ak8.js → dist-dbLsO_41.js} +1 -1
  34. package/dist/client/assets/{index-Wtlj0s4R.css → index-C6YWfUu2.css} +1 -1
  35. package/dist/client/assets/{index-1hS3wVqF.js → index-dARLbQ-8.js} +19 -19
  36. package/dist/client/index.html +2 -2
  37. package/dist/server/agents.d.ts +11 -0
  38. package/dist/server/agents.js +50 -6
  39. package/dist/server/application/project-service.d.ts +95 -4
  40. package/dist/server/application/project-service.js +328 -72
  41. package/dist/server/http-server.js +7 -0
  42. package/dist/server/store.d.ts +17 -0
  43. package/dist/server/store.js +97 -1
  44. package/dist/server/subagent-runs.js +19 -1
  45. package/dist/server/task-tool.d.ts +1 -1
  46. package/dist/server/task-tool.js +4 -3
  47. package/dist/server/types.d.ts +44 -1
  48. package/package.json +11 -2
@@ -4,13 +4,22 @@ const stamp = () => new Date().toISOString();
4
4
  * WHAT THE PROJECT LEADER IS TOLD WHEN IT STARTS.
5
5
  *
6
6
  * The two prompts are kept apart on purpose. The standard one is the shape the project flow has
7
- * always had - decompose, delegate, review - and it is not touched. The lightweight one is a
8
- * different set of instructions about a different shape: one session, no decomposition, no
9
- * reviewer. Writing one prompt with holes in it would mean every sentence had to carry a
10
- * condition, and the model would have to work out which shape it was in.
7
+ * always had - decompose, delegate, review. The lightweight one is a different set of
8
+ * instructions about a different shape: one session, no decomposition, no reviewer. Writing one
9
+ * prompt with holes in it would mean every sentence had to carry a condition, and the model would
10
+ * have to work out which shape it was in.
11
+ *
12
+ * THE ESCALATION RULE IS SAID ONCE, IN ONE VOICE. The standard leader used to be told two
13
+ * opposite things about the same event - "your subordinates' escalations come to you first, you
14
+ * decide whether to handle them yourself or report them up" AND "you must not resolve escalations
15
+ * yourself" - and a model reading both has no way to pick, so it hesitates between them. The
16
+ * rule is now one sentence with three parts: handle it at your own level, escalate only what
17
+ * really needs the Boss, and never decide for the Boss or drop an escalation. The lightweight
18
+ * leader has no subordinates at all, so it is told the second half only - it must not decide
19
+ * alone either - and is never told about subordinates it does not have.
11
20
  */
12
- const STANDARD_LEADER_PROMPT = "你是直接向 Boss 负责的项目 Leader,兼任秘书。先调用 project(action=get, projectId) 了解最新状态。小项目只拆最少必要任务,大项目按交付边界分工。用 add_task 登记任务(明确目标、交付物、验收标准、依赖);add_task / update_task 时可以带 executorProfile 与 reviewerProfile(支持指定 agentId、cwd、model、thinkingLevel、watchdogTimeoutSeconds),按任务性质将执行与复核委派给最合适的员工,不指定则继承项目默认;需要继续拆解的工作设 role=leader,叶子执行任务设 role=executor,子 Leader 用 parentTaskId 登记自己的子任务,dispatch 启动执行子会话;不要用 session_spawn/session_dispatch 或 task/workflow 绕过项目资源限制。收到交付后用 request_review 启动独立复核。子会话结束会自动回报给你,届时继续派发 ready 任务、复核待验收任务或按 review.summary 返工。不要轮询等待,不要反复无进展重试,并发满时结束当前回复等待回报。用 report 登记关键进展,用 escalate 将重大偏差、权限或预算问题交给 Boss,不得自行解决升级事项。所有任务验收后,用 complete 提交带证据的最终交付总结。上下文有限,明确假设,主动寻找反证。只在本次触发范围内工作。";
13
- const LIGHTWEIGHT_LEADER_PROMPT = "你是直接向 Boss 负责的项目 Leader,这是一个轻量项目:这个项目由你亲自完成,不要拆子任务、不要派发给别人、也没有独立复核。先调用 project(action=get, projectId) 了解最新状态。用 add_task 登记任务(明确目标、交付物、验收标准、依赖),任务一律登记为执行任务:role=leader 或带 parentTaskId 都会被拒绝。dispatch 会把任务交回给你自己,你亲自做完;做完后调用 project(action=submit, projectId, taskId, output, evidenceRefs) 提交交付,提交即完成,不会有人回来复核,证据必须附上。不要用 session_spawn/session_dispatch 或 task/workflow 绕过项目资源限制。不要轮询等待,不要反复无进展重试。用 report 登记关键进展,用 escalate 将重大偏差、权限或预算问题交给 Boss,不得自行解决升级事项。所有任务交付后,用 complete 提交带证据的最终交付总结。上下文有限,明确假设,主动寻找反证。只在本次触发范围内工作。";
21
+ export const STANDARD_LEADER_PROMPT = "你是直接向 Boss 负责的项目 Leader,兼任秘书。先调用 project(action=get, projectId) 了解最新状态。小项目只拆最少必要任务,大项目按交付边界分工。用 add_task 登记任务(明确目标、交付物、验收标准、依赖);add_task / update_task 时可以带 executorProfile 与 reviewerProfile(支持指定 agentId、cwd、model、thinkingLevel、watchdogTimeoutSeconds),按任务性质将执行与复核委派给最合适的员工,不指定则继承项目默认;需要继续拆解的工作设 role=leader,叶子执行任务设 role=executor,子 Leader 用 parentTaskId 登记自己的子任务,dispatch 启动执行子会话;不要用 session_spawn/session_dispatch 或 task/workflow 绕过项目资源限制。收到交付后用 request_review 启动独立复核。子会话结束会自动回报给你,届时继续派发 ready 任务、复核待验收任务或按 review.summary 返工。不要轮询等待,不要反复无进展重试,并发满时结束当前回复等待回报。用 report 登记关键进展。你的下属的升级先到你这里:你在自己这一层处理,能自己解决的自己解决;只有确实需要 Boss 决策的(重大偏差、权限或预算问题)才用 escalate 交给 Boss。你不能替 Boss 做决定,也不要把没处理的升级压下去不理。所有任务验收后,用 complete 提交带证据的最终交付总结。上下文有限,明确假设,主动寻找反证。只在本次触发范围内工作。";
22
+ export const LIGHTWEIGHT_LEADER_PROMPT = "你是直接向 Boss 负责的项目 Leader,这是一个轻量项目:这个项目由你亲自完成,不要拆子任务、不要派发给别人、也没有独立复核。先调用 project(action=get, projectId) 了解最新状态。用 add_task 登记任务(明确目标、交付物、验收标准、依赖),任务一律登记为执行任务:role=leader 或带 parentTaskId 都会被拒绝。dispatch 会把任务交回给你自己,你亲自做完;做完后调用 project(action=submit, projectId, taskId, output, evidenceRefs) 提交交付,提交即完成,不会有人回来复核,证据必须附上。不要用 session_spawn/session_dispatch 或 task/workflow 绕过项目资源限制。不要轮询等待,不要反复无进展重试。用 report 登记关键进展,用 escalate 将重大偏差、权限或预算问题交给 Boss,不得自行解决升级事项。所有任务交付后,用 complete 提交带证据的最终交付总结。上下文有限,明确假设,主动寻找反证。只在本次触发范围内工作。";
14
23
  const text = (value, label) => {
15
24
  if (typeof value !== "string" || !value.trim())
16
25
  throw new Error(`${label} 不能为空`);
@@ -122,6 +131,16 @@ const CACHE_WRITE_WEIGHT = 1.25;
122
131
  const tokens = (usage) => usage ? Math.round(usage.input + usage.output + usage.cacheWrite * CACHE_WRITE_WEIGHT + usage.cacheRead * CACHE_READ_WEIGHT) : 0;
123
132
  /** The budget a project starts with, and the floor the one-click raise falls back to. */
124
133
  const DEFAULT_MAX_TOKENS = 100_000;
134
+ /**
135
+ * HOW MANY PROJECTS ONE LIST CALL MAY CARRY.
136
+ *
137
+ * The list goes into an agent's context, and a workspace that has been alive for a year has
138
+ * hundreds of finished projects. Twenty is enough to answer "how are my projects doing" - the
139
+ * running ones and the ones that just finished are all in there - and small enough that the
140
+ * answer does not arrive wrapped in a year of history. The count that was cut is NOT hidden:
141
+ * the list carries the real total next to the truncated one.
142
+ */
143
+ const PROJECT_LIST_LIMIT = 20;
125
144
  /**
126
145
  * HOW MANY EVENTS THE REPORT'S TIMELINE PRINTS.
127
146
  *
@@ -150,7 +169,7 @@ const EVENT_TEXT = {
150
169
  created: "创建项目", started: "启动执行", task_created: "登记任务", dispatched: "派发执行",
151
170
  submitted: "提交交付", reviewed: "复核", review_requested: "启动复核", task_updated: "任务更新", status: "状态变更",
152
171
  budget: "预算变更", report: "进展汇报", escalated: "例外升级", decision: "决定",
153
- completed: "项目完成", ready: "依赖就绪",
172
+ completed: "项目完成", ready: "依赖就绪", unreachable: "转述对象不可达",
154
173
  };
155
174
  const ESCALATION_STATUS_TEXT = { open: "未决", resolved: "已解决", dismissed: "已忽略" };
156
175
  const SEVERITY_TEXT = { low: "低", medium: "中", high: "高" };
@@ -202,13 +221,24 @@ export class ProjectService {
202
221
  delete t.effectiveReviewerProfile;
203
222
  return t;
204
223
  }
205
- projectView(p) {
224
+ filterEscalations(escalations, actor) {
225
+ return escalations.filter((e) => {
226
+ const audience = e.audience ?? "boss";
227
+ if (audience === "boss")
228
+ return true;
229
+ if (!actor)
230
+ return false;
231
+ return actor === e.targetConversationId || actor === e.requestedByConversationId;
232
+ });
233
+ }
234
+ projectView(p, actor) {
206
235
  const resources = this.resources(p);
207
236
  return {
208
237
  ...p,
209
238
  resources,
210
239
  suggestedMaxTokens: this.suggestedMaxTokens(p, resources.tokens),
211
240
  tasks: p.tasks.map((t) => ProjectService.taskView(t, p)),
241
+ escalations: this.filterEscalations(p.escalations ?? [], actor),
212
242
  };
213
243
  }
214
244
  /**
@@ -241,7 +271,7 @@ export class ProjectService {
241
271
  p.updatedAt = stamp();
242
272
  (p.events ??= []).push({ id: randomUUID(), kind, summary, taskId, actorConversationId, createdAt: p.updatedAt });
243
273
  }
244
- async change(id, workspaceId, work) {
274
+ async change(id, workspaceId, work, actor) {
245
275
  this.workspace(workspaceId);
246
276
  const previous = this.queues.get(id) ?? Promise.resolve();
247
277
  const pending = previous.catch(() => { }).then(async () => {
@@ -253,7 +283,7 @@ export class ProjectService {
253
283
  }
254
284
  await this.store.setProject(p);
255
285
  if (result && typeof result === "object" && "tasks" in result && Array.isArray(result.tasks)) {
256
- return structuredClone(this.projectView(result));
286
+ return structuredClone(this.projectView(result, actor));
257
287
  }
258
288
  return structuredClone(result);
259
289
  });
@@ -268,6 +298,45 @@ export class ProjectService {
268
298
  }
269
299
  live(p) { if (closed(p))
270
300
  throw new Error("项目已结束,不能继续修改或派发"); }
301
+ /**
302
+ * WAKING THE OTHER SIDE MUST NOT TAKE THE REPORT DOWN WITH IT.
303
+ *
304
+ * The report, the escalation or the decision is already written to the store by the time the
305
+ * other side is woken, so a dispatch that fails - a session that was archived, stopped or never
306
+ * existed - must NOT fail the request. It would: the caller would be told the whole thing
307
+ * failed although it succeeded, it would try again, and the same report would be written twice.
308
+ *
309
+ * So the wake is wrapped, the failure is written down, and the record is returned as the
310
+ * success it is.
311
+ *
312
+ * AND THE FAILURE IS NOT SILENT. A report nobody was told about is a report nobody will ever
313
+ * handle, so the project's own timeline gets an event saying exactly that, and the log line
314
+ * names the project and the escalation so the case can be found again later.
315
+ */
316
+ async wake(target, prompt, what, projectId, workspaceId, actor) {
317
+ if (!this.orchestrator)
318
+ return;
319
+ try {
320
+ await this.orchestrator.dispatch(target, prompt, { wait: false });
321
+ }
322
+ catch (error) {
323
+ const message = error instanceof Error ? error.message : String(error);
324
+ console.warn(`Super Pi: 项目 ${projectId} 的${what}已保存,但叫不醒会话 ${target}:${message}`);
325
+ await this.noteUnreachable(projectId, workspaceId, `${what}已保存,但转述对象会话不可达(${target}):${message}`, actor);
326
+ }
327
+ }
328
+ /** THE TIMELINE SAYS THAT NOBODY COULD BE TOLD. Written after the fact, so it never loses the
329
+ * record that was already saved. */
330
+ async noteUnreachable(projectId, workspaceId, summary, actor) {
331
+ if (workspaceId === undefined)
332
+ return;
333
+ try {
334
+ await this.change(projectId, workspaceId, (p) => { this.event(p, "unreachable", summary, undefined, actor); });
335
+ }
336
+ catch (error) {
337
+ console.warn(`Super Pi: 项目 ${projectId} 记录「转述对象不可达」事件失败:${error instanceof Error ? error.message : String(error)}`);
338
+ }
339
+ }
271
340
  leader(p, actor) { if (actor && actor !== p.ownerConversationId)
272
341
  throw new Error("仅项目负责人可以执行此操作"); }
273
342
  topLevelAuthor(p, actor) {
@@ -320,6 +389,78 @@ export class ProjectService {
320
389
  if (actor && actor !== p.ownerConversationId && actor !== p.initiatorConversationId && !p.tasks.some((t) => t.ownerConversationId === actor || t.reviewerConversationId === actor))
321
390
  throw new Error("调用会话不属于项目执行团队");
322
391
  }
392
+ isMember(p, actor) {
393
+ if (!actor)
394
+ return false;
395
+ if (actor === p.initiatorConversationId)
396
+ return true;
397
+ if (actor === p.ownerConversationId)
398
+ return true;
399
+ if (p.tasks?.some((t) => t.ownerConversationId === actor || t.reviewerConversationId === actor))
400
+ return true;
401
+ if (this.projectForSession(actor)?.id === p.id)
402
+ return true;
403
+ return false;
404
+ }
405
+ directSupervisor(p, actor, taskId) {
406
+ if (!actor || actor === p.ownerConversationId || actor === p.initiatorConversationId)
407
+ return undefined;
408
+ const session = this.store.conversation(actor);
409
+ const parentId = session?.parentConversationId;
410
+ if (parentId && this.isMember(p, parentId))
411
+ return parentId;
412
+ const t = taskId
413
+ ? p.tasks.find((item) => item.id === taskId)
414
+ : p.tasks.find((item) => item.ownerConversationId === actor || item.reviewerConversationId === actor);
415
+ const manager = t ? this.manager(p, t) : undefined;
416
+ if (manager && this.isMember(p, manager))
417
+ return manager;
418
+ return undefined;
419
+ }
420
+ /**
421
+ * THE PROJECT A SESSION BELONGS TO RIGHT NOW.
422
+ *
423
+ * `projectForSession` answers a different question - "which project is this session part of" -
424
+ * and it counts the finished projects too, because the history of a project has to stay visible
425
+ * to the people who worked on it. This method answers the narrower question the QUESTION path
426
+ * needs: "is this session inside a project that is still running".
427
+ *
428
+ * WHY THAT DISTINCTION MATTERS. A conversation that took part in a project which has since been
429
+ * completed is not a project member any more - it is a conversation that happens to have a
430
+ * finished project in its history. The old lookup mixed the two, so asking the Boss a question
431
+ * from such a conversation was refused with "a project member may not ask the Boss directly",
432
+ * which is not what was true: the project was over, and nobody was being bypassed. The finished
433
+ * project is still found and still visible in the history; it just does not block the question.
434
+ *
435
+ * A session that is in a running project AND in a finished one belongs to the running one - it
436
+ * is the one that is still in charge of it.
437
+ */
438
+ liveProjectForSession(conversationId) {
439
+ let c = this.store.conversation(conversationId);
440
+ const seen = new Set();
441
+ while (c && !seen.has(c.id)) {
442
+ seen.add(c.id);
443
+ const projects = this.store.projects().filter((p) => p.workspaceId === c.workspaceId
444
+ && (p.ownerConversationId === c.id || p.tasks.some((t) => t.ownerConversationId === c.id || t.reviewerConversationId === c.id)));
445
+ const live = projects.find((p) => !closed(p));
446
+ if (live)
447
+ return live;
448
+ c = c.parentConversationId ? this.store.conversation(c.parentConversationId) : undefined;
449
+ }
450
+ return undefined;
451
+ }
452
+ canAskQuestion(conversationId) {
453
+ const project = this.liveProjectForSession(conversationId);
454
+ if (!project)
455
+ return { ok: true };
456
+ if (conversationId === project.ownerConversationId || conversationId === project.initiatorConversationId) {
457
+ return { ok: true };
458
+ }
459
+ return {
460
+ ok: false,
461
+ reason: "项目成员不能直接向 Boss 提问:请通过直接上级上报,由负责人代为转述",
462
+ };
463
+ }
323
464
  reconcile(p) {
324
465
  if (closed(p))
325
466
  return;
@@ -329,9 +470,15 @@ export class ProjectService {
329
470
  t.updatedAt = stamp();
330
471
  this.event(p, "ready", `前置交付已验收:${t.title}`, t.id);
331
472
  }
332
- const open = p.escalations.filter((e) => e.status === "open");
333
- p.status = open.some((e) => e.severity === "high") ? "awaiting_approval" : open.length || p.tasks.some((t) => t.status === "blocked") ? "blocked"
334
- : p.tasks.some((t) => t.status === "awaiting_review") ? "awaiting_review" : p.tasks.length ? "active" : "planning";
473
+ const isLeader = (convId) => !convId || convId === p.ownerConversationId || convId === p.initiatorConversationId;
474
+ const openBoss = p.escalations.filter((e) => e.status === "open" && (e.audience === "boss" || !e.audience));
475
+ const hasLeaderBlockedTask = p.tasks.some((t) => t.status === "blocked" && isLeader(t.blockedReportedByConversationId));
476
+ const lastLeaderProjectReport = [...p.reports].reverse().find((r) => !r.taskId && isLeader(r.authorConversationId));
477
+ const leaderProjectBlocked = lastLeaderProjectReport?.kind === "blocked";
478
+ p.status = openBoss.some((e) => e.severity === "high") ? "awaiting_approval"
479
+ : openBoss.length || hasLeaderBlockedTask || leaderProjectBlocked ? "blocked"
480
+ : p.tasks.some((t) => t.status === "awaiting_review") ? "awaiting_review"
481
+ : p.tasks.length ? "active" : "planning";
335
482
  }
336
483
  resources(p) {
337
484
  const state = this.store.snapshot();
@@ -374,7 +521,15 @@ export class ProjectService {
374
521
  report zero running agents. The concurrency guard is not touched by this: the in-flight task
375
522
  it counts (`reserved`) already covers the busy leader, so the cap behaves exactly as before. */
376
523
  const leaderIsWorker = lightweight(p) && p.ownerConversationId !== undefined;
377
- return { tokens: sessions.reduce((sum, s) => sum + s.tokens, 0) + runs.reduce((sum, r) => sum + tokens(r.usage), 0),
524
+ /* ONE ACCOUNTING PATH, AND IT IS THE SESSION TREE.
525
+ The number below is the sum of the member sessions' usage, and NOTHING ELSE. A one-shot call
526
+ (a sub-agent) used to be added here from the run records on top of that sum, which was a
527
+ SECOND path to the same money: the run's usage was already inside the conversation's usage
528
+ (the store folds it in when the run settles), so the same tokens were counted twice.
529
+ Now it is counted once - through the session - and a project's budget therefore includes what
530
+ its one-shot calls cost, which is the whole point of a budget a person can predict.
531
+ See `Store.accountSubagentUsage`. */
532
+ return { tokens: sessions.reduce((sum, s) => sum + s.tokens, 0),
378
533
  usageKnown: sessions.length > 0 && sessions.every((s) => Boolean(this.store.conversation(s.id)?.usage)) && runs.every((r) => Boolean(r.usage)),
379
534
  activeWorkers: sessions.filter((s) => (!coordinatorIds.has(s.id) || (leaderIsWorker && s.id === p.ownerConversationId)) && busy(s.status)).length + runs.filter((r) => r.status === "running").length,
380
535
  // THE PROJECT'S OWN MEMORY TREE, named once so the client does not have to infer it from
@@ -385,7 +540,7 @@ export class ProjectService {
385
540
  const scope = this.workspace(workspaceId);
386
541
  return this.store.projects().filter((p) => p.workspaceId === scope).map((p) => this.projectView(p));
387
542
  }
388
- headline(p) {
543
+ headline(p, actor) {
389
544
  const total = p.tasks.length;
390
545
  const completed = p.tasks.filter((t) => t.status === "completed").length;
391
546
  if (p.status === "completed") {
@@ -395,9 +550,10 @@ export class ProjectService {
395
550
  return "项目已取消";
396
551
  if (p.status === "failed")
397
552
  return "项目失败";
398
- const openEscalation = (p.escalations ?? []).find((e) => e.status === "open");
553
+ const visibleEscalations = this.filterEscalations(p.escalations ?? [], actor);
554
+ const openEscalation = visibleEscalations.find((e) => e.status === "open");
399
555
  if (openEscalation) {
400
- return p.status === "awaiting_approval"
556
+ return (openEscalation.audience === "boss" || !openEscalation.audience) && p.status === "awaiting_approval"
401
557
  ? `等待审批:${openEscalation.reason}`
402
558
  : `项目已阻塞:${openEscalation.reason}`;
403
559
  }
@@ -424,26 +580,16 @@ export class ProjectService {
424
580
  return "项目规划中,尚未拆解任务";
425
581
  }
426
582
  isActorRelated(p, actor) {
427
- if (!actor)
428
- return false;
429
- if (actor === p.initiatorConversationId)
430
- return true;
431
- if (actor === p.ownerConversationId)
432
- return true;
433
- if (p.tasks?.some((t) => t.ownerConversationId === actor || t.reviewerConversationId === actor))
434
- return true;
435
- if (this.projectForSession(actor)?.id === p.id)
436
- return true;
437
- return false;
583
+ return this.isMember(p, actor);
438
584
  }
439
- projectSummary(p) {
585
+ projectSummary(p, actor) {
440
586
  const resources = this.resources(p);
441
587
  return {
442
588
  id: p.id,
443
589
  name: p.title,
444
590
  status: p.status,
445
591
  mode: p.mode ?? "standard",
446
- headline: this.headline(p),
592
+ headline: this.headline(p, actor),
447
593
  tasks: {
448
594
  total: p.tasks.length,
449
595
  completed: p.tasks.filter((t) => t.status === "completed").length,
@@ -455,15 +601,32 @@ export class ProjectService {
455
601
  updatedAt: p.updatedAt,
456
602
  };
457
603
  }
458
- listForActor(workspaceId, actor) {
604
+ /**
605
+ * THE PROJECTS AN ACTOR IS PART OF, AND HOW MANY OF THEM THERE ARE.
606
+ *
607
+ * The list is capped, because the list is what goes into an agent's context: a workspace that
608
+ * has been alive for a year has hundreds of finished projects, and handing all of them over
609
+ * answers the question "how are my projects doing" with a wall of history. The cap is on the
610
+ * RECENT ones - the same order the list has always had, newest update first - so what the
611
+ * agent reads first is what actually changed.
612
+ *
613
+ * AND THE COUNT IS TOLD, NOT IMPLIED. `total` is how many projects the session is really
614
+ * related to, counted BEFORE the cut, and `returned` is how many came back. An agent that sees
615
+ * total 57 and returned 20 knows the workspace has more history than it was shown, and says so
616
+ * instead of telling the Boss the workspace only has 20 projects. A silently truncated list
617
+ * would be a lie the agent could not see through.
618
+ */
619
+ listForActor(workspaceId, actor, limit = PROJECT_LIST_LIMIT) {
459
620
  const scope = this.workspace(workspaceId);
460
- return this.store.projects()
621
+ const related = this.store.projects()
461
622
  .filter((p) => p.workspaceId === scope && this.isActorRelated(p, actor))
462
- .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
463
- .map((p) => this.projectSummary(p));
623
+ .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
624
+ const total = related.length;
625
+ const projects = related.slice(0, limit).map((p) => this.projectSummary(p, actor));
626
+ return { projects, total, returned: projects.length };
464
627
  }
465
- get(id, workspaceId) {
466
- return this.projectView(this.raw(id, workspaceId));
628
+ get(id, workspaceId, actor) {
629
+ return this.projectView(this.raw(id, workspaceId), actor);
467
630
  }
468
631
  /**
469
632
  * THE PROJECT AS A REPORT A HUMAN CAN READ.
@@ -524,7 +687,9 @@ export class ProjectService {
524
687
  lines.push(`- 预算上限:${p.budget?.maxTokens ?? DEFAULT_MAX_TOKENS} Token`);
525
688
  // THE USAGE IS REPORTED IN THE SAME BILLING-EQUIVALENT UNITS THE BUDGET IS SPENT IN, and the
526
689
  // formula is printed next to the number: a budget nobody can predict is a budget nobody trusts.
527
- lines.push(`- 已用等效 Token:${this.resources(p).tokens}(口径:输入 + 输出 + 缓存写入×${CACHE_WRITE_WEIGHT} + 缓存读取×${CACHE_READ_WEIGHT},取整)`);
690
+ // The sentence after the formula says what the number INCLUDES - a one-shot call (a sub-agent)
691
+ // is part of it, so a reader who dispatches children can see what that cost them.
692
+ lines.push(`- 已用等效 Token:${this.resources(p).tokens}(口径:输入 + 输出 + 缓存写入×${CACHE_WRITE_WEIGHT} + 缓存读取×${CACHE_READ_WEIGHT},取整;含项目内的一次性调用(subagent))`);
528
693
  lines.push(`- 任务:共 ${p.tasks.length} 个,已完成 ${p.tasks.filter((t) => t.status === "completed").length} 个`);
529
694
  lines.push("", "## 交付与验收", "");
530
695
  if (!p.tasks.length)
@@ -634,7 +799,7 @@ export class ProjectService {
634
799
  }
635
800
  admission(p, checkConcurrency = true, coordinator = false) {
636
801
  this.live(p);
637
- if (p.escalations.some((e) => e.status === "open" && e.severity === "high"))
802
+ if (p.escalations.some((e) => e.status === "open" && e.severity === "high" && (e.audience === "boss" || !e.audience)))
638
803
  throw new Error("等待 Boss 处理升级决策后才能继续派发");
639
804
  const r = this.resources(p);
640
805
  if (p.budget?.maxTokens && r.tokens >= p.budget.maxTokens)
@@ -810,15 +975,16 @@ export class ProjectService {
810
975
  t.status = "in_progress";
811
976
  t.updatedAt = stamp();
812
977
  delete t.blockedReason;
978
+ delete t.blockedReportedByConversationId;
813
979
  this.event(p, "dispatched", `派发执行:${t.title}`, t.id, actor);
814
980
  this.reconcile(p);
815
981
  await this.store.setProject(p);
816
- const delegation = t.role === "leader" ? `你是分支 Leader,负责这项任务的拆解、委派、集成交付。用 project add_task 时必须带 parentTaskId="${t.id}";可按必要性指定 role="leader" 或 "executor";add_task / update_task 时可以带 executorProfile 与 reviewerProfile(支持指定 agentId、cwd、model、thinkingLevel、watchdogTimeoutSeconds),按任务性质将执行与复核委派给最合适的员工,不指定则继承项目默认。用 project dispatch / request_review 管理直属子任务,子会话结束自动回报给你,不要轮询;并发满时结束本轮等待回报。所有必需子任务独立验收后才能 submit 聚合交付,上级还会独立复核你的集成结果。最多 3 层 Leader、8 个分支 Leader;小任务不要继续拆层。` : "你是执行者,不得自行拆解子任务。";
982
+ const delegation = t.role === "leader" ? `你是分支 Leader,负责这项任务的拆解、委派、集成交付。用 project add_task 时必须带 parentTaskId="${t.id}";可按必要性指定 role="leader" 或 "executor";add_task / update_task 时可以带 executorProfile 与 reviewerProfile(支持指定 agentId、cwd、model、thinkingLevel、watchdogTimeoutSeconds),按任务性质将执行与复核委派给最合适的员工,不指定则继承项目默认。用 project dispatch / request_review 管理直属子任务,子会话结束自动回报给你,不要轮询;并发满时结束本轮等待回报。所有必需子任务独立验收后才能 submit 聚合交付,上级还会独立复核你的集成结果。最多 3 层 Leader、8 个分支 Leader;小任务不要继续拆层。你的下属的升级先到你这里;你决定是自己解决还是再向上级转述。` : "你是执行者,不得自行拆解子任务。";
817
983
  /* THE LIGHTWEIGHT TASK SAYS WHAT IS DIFFERENT, because the difference is the point: there is
818
984
  no reviewer coming back, so the evidence that the reviewer would have demanded is the only
819
985
  thing that stands behind the delivery. */
820
986
  const modeNote = lightweight(p) ? "本项目是轻量项目:你就是唯一的执行会话,不会有独立复核人回来,完成后调用 project(action=submit, …) 提交即完成,交付证据必须附上。" : "";
821
- await this.orchestrator.dispatch(t.ownerConversationId, `${this.context(p)}\n${delegation}\n你负责执行任务 ${t.id}:${t.title}\n目标:${t.objective}\n交付物:${t.deliverables.join(";")}\n验收:${t.acceptanceCriteria.join(";")}\n上次复核意见与验证证据:${JSON.stringify(t.review ?? null)}\n已验收前置产物:${JSON.stringify(p.tasks.filter((d) => t.dependencies.includes(d.id)).map((d) => ({ title: d.title, output: d.output, evidenceRefs: d.evidenceRefs })))}\n你不是唯一执行者,遵守分工边界。${modeNote}完成后必须调用 project(action=submit, projectId="${p.id}", taskId="${t.id}", output="交付总结", evidenceRefs=["产物路径或验证记录"]),不能自行标记已完成。复核退回时修复后重新 submit。遇到阻塞用 report 或 escalate,保留证据、假设和未解决问题。不要自行创建其他执行会话或 task/workflow。`, { wait: false });
987
+ await this.orchestrator.dispatch(t.ownerConversationId, `${this.context(p)}\n${delegation}\n你负责执行任务 ${t.id}:${t.title}\n目标:${t.objective}\n交付物:${t.deliverables.join(";")}\n验收:${t.acceptanceCriteria.join(";")}\n上次复核意见与验证证据:${JSON.stringify(t.review ?? null)}\n已验收前置产物:${JSON.stringify(p.tasks.filter((d) => t.dependencies.includes(d.id)).map((d) => ({ title: d.title, output: d.output, evidenceRefs: d.evidenceRefs })))}\n你不是唯一执行者,遵守分工边界。${modeNote}完成后必须调用 project(action=submit, projectId="${p.id}", taskId="${t.id}", output="交付总结", evidenceRefs=["产物路径或验证记录"]),不能自行标记已完成。复核退回时修复后重新 submit。遇到阻塞用 report(阻塞) 或 escalate 上报给你的直接上级,不要直接找 Boss,保留证据、假设和未解决问题。不要自行创建其他执行会话或 task/workflow。`, { wait: false });
822
988
  return ProjectService.taskView(t, p);
823
989
  });
824
990
  }
@@ -938,6 +1104,12 @@ export class ProjectService {
938
1104
  if (patch.status === "ready" && actor && actor !== manager)
939
1105
  throw new Error("仅直属 Leader 可以重新派发任务");
940
1106
  t.status = patch.status;
1107
+ if (t.status === "blocked") {
1108
+ t.blockedReportedByConversationId = actor;
1109
+ }
1110
+ else {
1111
+ delete t.blockedReportedByConversationId;
1112
+ }
941
1113
  }
942
1114
  if (patch.executorProfile !== undefined) {
943
1115
  if (t.ownerConversationId)
@@ -1137,7 +1309,10 @@ export class ProjectService {
1137
1309
  return { ...raised, project };
1138
1310
  }
1139
1311
  async addReport(id, input, workspaceId, actor) {
1140
- return this.change(id, workspaceId, (p) => {
1312
+ let supervisorToWake;
1313
+ let taskForWake;
1314
+ let memberBlockedReport = false;
1315
+ const report = await this.change(id, workspaceId, (p) => {
1141
1316
  this.live(p);
1142
1317
  this.member(p, actor);
1143
1318
  if (!["progress", "blocked", "delivery", "decision_request", "review"].includes(input.kind))
@@ -1151,31 +1326,85 @@ export class ProjectService {
1151
1326
  progress: text(input.progress, "汇报进展"), variance: optional(input.variance, "偏差"), nextStep: optional(input.nextStep, "下一步"), risks: strings(input.risks, "风险"),
1152
1327
  decisionNeeded: optional(input.decisionNeeded, "所需决策"), evidenceRefs: strings(input.evidenceRefs, "证据"), createdAt: stamp() };
1153
1328
  p.reports.push(report);
1154
- if (report.kind === "blocked" && t) {
1155
- t.status = "blocked";
1156
- t.blockedReason = report.progress;
1157
- t.updatedAt = stamp();
1329
+ if (report.kind === "blocked") {
1330
+ if (t) {
1331
+ t.status = "blocked";
1332
+ t.blockedReason = report.progress;
1333
+ t.blockedReportedByConversationId = actor;
1334
+ t.updatedAt = stamp();
1335
+ }
1336
+ if (actor && actor !== p.ownerConversationId && actor !== p.initiatorConversationId) {
1337
+ memberBlockedReport = true;
1338
+ supervisorToWake = this.directSupervisor(p, actor, t?.id);
1339
+ taskForWake = t;
1340
+ }
1158
1341
  }
1159
1342
  this.event(p, "report", report.progress, t?.id, actor);
1160
1343
  this.reconcile(p);
1161
1344
  return report;
1162
- });
1345
+ }, actor);
1346
+ if (supervisorToWake) {
1347
+ const taskInfo = taskForWake ? `任务 ${taskForWake.id}(${taskForWake.title})` : "所属任务";
1348
+ await this.wake(supervisorToWake, `下级成员(会话:${actor})汇报阻塞:${taskInfo}\n原因:${report.progress}\n请评估阻塞并跟进解决,必要时向 Boss 转述或升级。`, "阻塞上报", id, workspaceId, actor);
1349
+ }
1350
+ else if (memberBlockedReport) {
1351
+ /* A MEMBER REPORTED A BLOCK AND NO SUPERVISOR COULD BE FOUND. The report is saved either
1352
+ * way, but nobody has been told, so the case is written down instead of vanishing. */
1353
+ const summary = "阻塞上报已保存,但找不到直接上级会话,上报没有被转述";
1354
+ console.warn(`Super Pi: 项目 ${id} 的${summary}`);
1355
+ await this.noteUnreachable(id, workspaceId, summary, actor);
1356
+ }
1357
+ return report;
1163
1358
  }
1164
1359
  async addEscalation(id, input, workspaceId, actor) {
1165
- return this.change(id, workspaceId, (p) => {
1360
+ let supervisorToWake;
1361
+ let wakeMemberRole;
1362
+ const result = await this.change(id, workspaceId, (p) => {
1166
1363
  this.live(p);
1167
1364
  this.member(p, actor);
1168
1365
  if (!["low", "medium", "high"].includes(input.severity))
1169
1366
  throw new Error("升级风险等级不合法");
1170
- const t = input.taskId ? this.task(p, text(input.taskId, "任务 id")) : undefined;
1171
- const e = { id: randomUUID(), projectId: id, taskId: t?.id, reason: text(input.reason, "升级原因"), severity: input.severity, status: "open", requestedByConversationId: actor, createdAt: stamp() };
1367
+ const t = input.taskId
1368
+ ? this.task(p, text(input.taskId, "任务 id"))
1369
+ : p.tasks.find((task) => task.ownerConversationId === actor || task.reviewerConversationId === actor);
1370
+ const isBossAudience = !actor || actor === p.ownerConversationId || actor === p.initiatorConversationId;
1371
+ const audience = isBossAudience ? "boss" : "supervisor";
1372
+ let targetConversationId;
1373
+ if (!isBossAudience) {
1374
+ targetConversationId = this.directSupervisor(p, actor, t?.id);
1375
+ if (!targetConversationId) {
1376
+ throw new Error("无法确定直接上级会话:上级不在项目执行团队内且无法定位所属任务 Leader,升级已拒绝。");
1377
+ }
1378
+ supervisorToWake = targetConversationId;
1379
+ wakeMemberRole = p.tasks.some((task) => task.role === "leader" && task.ownerConversationId === actor)
1380
+ ? "分支 Leader"
1381
+ : p.tasks.some((task) => task.reviewerConversationId === actor)
1382
+ ? "独立复核"
1383
+ : "执行者";
1384
+ }
1385
+ const e = {
1386
+ id: randomUUID(),
1387
+ projectId: id,
1388
+ taskId: t?.id,
1389
+ reason: text(input.reason, "升级原因"),
1390
+ severity: input.severity,
1391
+ status: "open",
1392
+ requestedByConversationId: actor,
1393
+ audience,
1394
+ ...(targetConversationId ? { targetConversationId } : {}),
1395
+ createdAt: stamp(),
1396
+ };
1172
1397
  p.escalations.push(e);
1173
1398
  this.event(p, "escalated", e.reason, t?.id, actor);
1174
1399
  this.reconcile(p);
1175
1400
  return e;
1176
- });
1401
+ }, actor);
1402
+ if (supervisorToWake) {
1403
+ await this.wake(supervisorToWake, `下级成员(会话:${actor},身份:${wakeMemberRole ?? "执行者"})发起例外升级(ID:${result.id}):\n原因:${result.reason}\n严重级别:${result.severity}\n请及时处理该例外(调用 project action="resolve_escalation" 记录处理意见),或按需向上级转述升级。`, `例外升级(ID:${result.id})`, id, workspaceId, actor);
1404
+ }
1405
+ return result;
1177
1406
  }
1178
- async resolveEscalation(id, escalationId, decision, status, workspaceId) {
1407
+ async resolveEscalation(id, escalationId, decision, status, workspaceId, actor) {
1179
1408
  const result = await this.change(id, workspaceId, (p) => {
1180
1409
  this.live(p);
1181
1410
  if (status !== "resolved" && status !== "dismissed")
@@ -1185,33 +1414,54 @@ export class ProjectService {
1185
1414
  throw new Error("升级事项不存在");
1186
1415
  if (e.status !== "open")
1187
1416
  throw new Error("升级事项已处理");
1417
+ const audience = e.audience ?? "boss";
1418
+ if (audience === "boss") {
1419
+ if (actor)
1420
+ throw new Error("此例外需由 Boss 决策,团队成员无权处理");
1421
+ }
1422
+ else if (audience === "supervisor") {
1423
+ if (!actor)
1424
+ throw new Error("该例外属于直接上级处理范围,Boss 不能直接处理 supervisor 级别的例外");
1425
+ if (actor !== e.targetConversationId)
1426
+ throw new Error("只有指定的直接上级可以处理此例外");
1427
+ e.resolvedByConversationId = actor;
1428
+ }
1188
1429
  e.status = status;
1189
1430
  e.decision = text(decision, "处理意见");
1190
1431
  e.resolvedAt = stamp();
1191
- this.event(p, "decision", `Boss 决策:${e.decision}`, e.taskId);
1432
+ const decider = audience === "boss" ? "Boss" : "直接上级";
1433
+ this.event(p, "decision", `${decider}决策:${e.decision}`, e.taskId, actor);
1192
1434
  this.reconcile(p);
1193
1435
  return e;
1194
- });
1195
- const p = this.get(id, workspaceId);
1196
- const targetTask = result.taskId
1197
- ? p.tasks.find((t) => t.id === result.taskId)
1198
- : p.tasks.find((t) => t.ownerConversationId === result.requestedByConversationId || t.reviewerConversationId === result.requestedByConversationId);
1199
- let directLeaderId;
1200
- if (targetTask) {
1201
- if (targetTask.role === "leader" && targetTask.ownerConversationId) {
1202
- directLeaderId = targetTask.ownerConversationId;
1436
+ }, actor);
1437
+ const p = this.raw(id, workspaceId);
1438
+ if (result.audience === "supervisor") {
1439
+ if (result.requestedByConversationId) {
1440
+ await this.wake(result.requestedByConversationId, `直接上级已处理例外(ID:${result.id}):${result.reason}\n处理意见:${result.decision}\n请按处理意见继续工作。`, `例外处理结果(ID:${result.id})`, id, workspaceId, actor);
1203
1441
  }
1204
- else if (targetTask.parentTaskId) {
1205
- const parent = p.tasks.find((t) => t.id === targetTask.parentTaskId);
1206
- directLeaderId = parent?.ownerConversationId ?? targetTask.accountableConversationId;
1442
+ }
1443
+ else {
1444
+ const targetTask = result.taskId
1445
+ ? p.tasks.find((t) => t.id === result.taskId)
1446
+ : p.tasks.find((t) => t.ownerConversationId === result.requestedByConversationId || t.reviewerConversationId === result.requestedByConversationId);
1447
+ let directLeaderId;
1448
+ if (targetTask) {
1449
+ if (targetTask.role === "leader" && targetTask.ownerConversationId) {
1450
+ directLeaderId = targetTask.ownerConversationId;
1451
+ }
1452
+ else if (targetTask.parentTaskId) {
1453
+ const parent = p.tasks.find((t) => t.id === targetTask.parentTaskId);
1454
+ directLeaderId = parent?.ownerConversationId ?? targetTask.accountableConversationId;
1455
+ }
1456
+ else {
1457
+ directLeaderId = targetTask.accountableConversationId ?? p.ownerConversationId;
1458
+ }
1207
1459
  }
1208
- else {
1209
- directLeaderId = targetTask.accountableConversationId ?? p.ownerConversationId;
1460
+ const recipientId = directLeaderId || p.ownerConversationId;
1461
+ if (recipientId) {
1462
+ await this.wake(recipientId, `Boss 已处理项目 ${p.id} 的例外:${result.reason}\n决策:${result.decision}\n先读取 project 最新状态,按决策继续范围内的工作。`, `例外处理结果(ID:${result.id})`, id, workspaceId, actor);
1210
1463
  }
1211
1464
  }
1212
- const recipientId = directLeaderId || p.ownerConversationId;
1213
- if (recipientId && this.orchestrator)
1214
- await this.orchestrator.dispatch(recipientId, `Boss 已处理项目 ${p.id} 的例外:${result.reason}\n决策:${result.decision}\n先读取 project 最新状态,按决策继续范围内的工作。`, { wait: false });
1215
1465
  return result;
1216
1466
  }
1217
1467
  async complete(id, summary, evidenceRefs, workspaceId, actor) {
@@ -1337,13 +1587,16 @@ export class ProjectService {
1337
1587
  return this.get(project.id, workspaceId);
1338
1588
  }
1339
1589
  if (body.action === "list") {
1590
+ // THE LIST IS CAPPED, AND IT SAYS SO. `total` and `returned` travel with it, so an agent
1591
+ // that sees a short list can tell the difference between "the workspace has three projects"
1592
+ // and "the workspace has fifty and I was shown twenty".
1340
1593
  return this.listForActor(workspaceId, actor);
1341
1594
  }
1342
1595
  const id = text(body.projectId, "projectId");
1343
- const p = this.get(id, workspaceId);
1344
- this.member(p, actor);
1596
+ const rawProject = this.raw(id, workspaceId);
1597
+ this.member(rawProject, actor);
1345
1598
  switch (body.action) {
1346
- case "get": return p;
1599
+ case "get": return this.get(id, workspaceId, actor);
1347
1600
  case "export": return this.exportReport(id, workspaceId);
1348
1601
  case "start": return this.start(id, workspaceId, actor);
1349
1602
  case "add_task": return this.addTask(id, body, workspaceId, actor);
@@ -1354,6 +1607,9 @@ export class ProjectService {
1354
1607
  case "update_task": return this.updateTask(id, text(body.taskId, "taskId"), { ...body, workspaceId }, actor);
1355
1608
  case "report": return this.addReport(id, body, workspaceId, actor);
1356
1609
  case "escalate": return this.addEscalation(id, body, workspaceId, actor);
1610
+ case "resolve_escalation":
1611
+ case "resolveEscalation":
1612
+ return this.resolveEscalation(id, text(body.escalationId, "escalationId"), text(body.decision, "decision"), body.status ?? "resolved", workspaceId, actor);
1357
1613
  case "complete": return this.complete(id, body.summary, body.evidenceRefs, workspaceId, actor);
1358
1614
  case "cancel": return this.cancel(id, body, workspaceId, actor);
1359
1615
  default: throw new Error("未知的项目工具操作");
@@ -2099,6 +2099,13 @@ export function createHttpServer(options) {
2099
2099
  response.writeHead(403, { "content-type": "application/json" }).end(JSON.stringify({ error: verdict.message }));
2100
2100
  return;
2101
2101
  }
2102
+ if (context.projectService) {
2103
+ const check = context.projectService.canAskQuestion(body.conversationId);
2104
+ if (!check.ok) {
2105
+ response.writeHead(403, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: check.reason }));
2106
+ return;
2107
+ }
2108
+ }
2102
2109
  const result = await runService.askQuestion(body.conversationId);
2103
2110
  response.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify(result));
2104
2111
  }