agentquad 0.4.5 → 0.4.8

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 (40) hide show
  1. package/README.md +4 -3
  2. package/dist-web/assets/index-DdqC2CwH.css +32 -0
  3. package/dist-web/assets/{index-By--XlP3.js → index-DkI6ZJx_.js} +411 -399
  4. package/dist-web/assets/logo-Cxw7XzHl.png +0 -0
  5. package/dist-web/favicon.png +0 -0
  6. package/dist-web/index.html +2 -2
  7. package/package.json +7 -1
  8. package/src/claude-prompt-detector.js +72 -0
  9. package/src/cli.js +1 -1
  10. package/src/codex-hook-installer.js +1 -1
  11. package/src/codex-prompt-detector.js +104 -13
  12. package/src/config.js +33 -5
  13. package/src/db.js +77 -31
  14. package/src/export/todoMarkdown.js +1 -9
  15. package/src/lark-bot.js +44 -5
  16. package/src/mcp/tools/destructive/index.js +22 -16
  17. package/src/mcp/tools/openclaw/index.js +12 -16
  18. package/src/mcp/tools/read/index.js +7 -7
  19. package/src/mcp/tools/write/index.js +9 -6
  20. package/src/openclaw-bridge.js +176 -28
  21. package/src/openclaw-hook-installer.js +2 -1
  22. package/src/openclaw-hook.js +127 -9
  23. package/src/openclaw-wizard.js +168 -191
  24. package/src/permission-prompt.js +113 -31
  25. package/src/prompt-render.js +0 -8
  26. package/src/pty.js +183 -49
  27. package/src/routes/ai-terminal.js +90 -26
  28. package/src/routes/telegram-sync.js +7 -5
  29. package/src/routes/todos.js +8 -14
  30. package/src/server.js +90 -12
  31. package/src/session-input-dispatcher.js +48 -4
  32. package/src/stats/report.js +1 -6
  33. package/src/telegram-bot.js +82 -15
  34. package/src/telegram-loading-status.js +1 -1
  35. package/src/templates/claude-hooks/notify.js +1 -1
  36. package/src/templates/codex-hooks/notify.js +1 -1
  37. package/src/wiki/index.js +1 -1
  38. package/src/wiki/sources.js +0 -1
  39. package/dist-web/assets/index-8A0oLLcX.css +0 -32
  40. package/dist-web/assets/logo-D4DDtU-r.png +0 -0
Binary file
@@ -5,8 +5,8 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <link rel="icon" type="image/png" href="/favicon.png" />
7
7
  <title>AgentQuad</title>
8
- <script type="module" crossorigin src="/assets/index-By--XlP3.js"></script>
9
- <link rel="stylesheet" crossorigin href="/assets/index-8A0oLLcX.css">
8
+ <script type="module" crossorigin src="/assets/index-DkI6ZJx_.js"></script>
9
+ <link rel="stylesheet" crossorigin href="/assets/index-DdqC2CwH.css">
10
10
  </head>
11
11
  <body>
12
12
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentquad",
3
- "version": "0.4.5",
3
+ "version": "0.4.8",
4
4
  "description": "AgentQuad — local four-quadrant AI task scheduler with embedded Claude Code / Codex terminals",
5
5
  "license": "MIT",
6
6
  "author": "LIUZHENHUA521",
@@ -53,6 +53,10 @@
53
53
  "build:all": "npm run setup && npm run build",
54
54
  "clean": "rm -rf node_modules web/node_modules dist-web web/dist",
55
55
  "ensure-web-deps": "node scripts/ensure-web-deps.js",
56
+ "release": "bash scripts/release.sh patch",
57
+ "release:patch": "bash scripts/release.sh patch",
58
+ "release:minor": "bash scripts/release.sh minor",
59
+ "release:major": "bash scripts/release.sh major",
56
60
  "prepack": "npm run ensure-web-deps && npm run build:web",
57
61
  "postinstall": "chmod +x node_modules/node-pty/prebuilds/*/spawn-helper 2>/dev/null || true"
58
62
  },
@@ -76,6 +80,8 @@
76
80
  "ws": "^8.18.0"
77
81
  },
78
82
  "devDependencies": {
83
+ "@xterm/xterm": "^5.5.0",
84
+ "jsdom": "^29.1.1",
79
85
  "supertest": "^7.0.0",
80
86
  "vitest": "^2.1.1"
81
87
  },
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Claude Code stdout 提示词检测器:兜底 Notification hook 不 fire 的场景。
3
+ *
4
+ * 背景:Claude Code 2.x 引入 settings.json `permissions.defaultMode='auto'` 后,
5
+ * 由 model classifier 决定是否要弹权限提示。实测命中这条路径时 Notification hook
6
+ * 不 fire(社区 issue 也复现),导致 AgentQuad 完全收不到信号——状态条仍显示"运行中",
7
+ * IM 也不会推权限卡片。
8
+ *
9
+ * 实现:与 codex-prompt-detector 同构(环形缓冲区 + 防抖),命中
10
+ * extractPermissionPrompt 的锚点 + 至少 2 个数字选项 + Claude 独有页脚时上报。
11
+ *
12
+ * 误触保护:
13
+ * - 同一段文本只 emit 一次(lastEmittedText 去重)
14
+ * - 选项数 < 2 不算 Claude 标准 1/2/3 权限框,跳过(光锚点会被 assistant 自述误触)
15
+ * - 必须命中 CLAUDE_PERMISSION_FOOTER("Esc to cancel · Tab to amend" 或同源
16
+ * "Tab to select")——AI 自由回复里可能恰好出现"Do you want to" + 数字列表,
17
+ * 但绝不会带这些 TUI 控件提示。这条页脚是把"真权限框"跟"普通 markdown 列表"
18
+ * 区分开的唯一可靠信号
19
+ * - 调用方(server.js / openclaw-hook 走 cooldown)负责跟真 Notification hook 的去重
20
+ */
21
+ import { extractPermissionPrompt } from './permission-prompt.js'
22
+
23
+ const DEFAULT_DEBOUNCE_MS = 1500
24
+ const RING_MAX = 32
25
+
26
+ // Claude TUI 真权限框/选择器底部固定 footer(cleanPtyTail 不会过滤掉这行)。
27
+ // "Esc to cancel"(permission prompt)、"Tab to amend"(permission prompt)、
28
+ // "Tab to select"(slash command picker / model picker)。
29
+ // AI 自由回复里出现这种字面文本的概率近乎 0。
30
+ const CLAUDE_PERMISSION_FOOTER = /Esc\s+to\s+cancel|Tab\s+to\s+amend|Tab\s+to\s+select/i
31
+
32
+ export function createClaudePromptDetector({ pty, onMatch, debounceMs = DEFAULT_DEBOUNCE_MS } = {}) {
33
+ if (!pty || !onMatch) throw new Error('pty, onMatch required')
34
+ const ring = []
35
+ let timer = null
36
+ let stopped = false
37
+ let lastEmittedText = null
38
+
39
+ function onData(chunk) {
40
+ if (stopped) return
41
+ ring.push({ ts: Date.now(), text: String(chunk) })
42
+ while (ring.length > RING_MAX) ring.shift()
43
+ if (timer) clearTimeout(timer)
44
+ timer = setTimeout(maybeMatch, debounceMs)
45
+ }
46
+
47
+ function maybeMatch() {
48
+ timer = null
49
+ if (stopped) return
50
+ const tail = ring.map(c => c.text).join('')
51
+ const { text, options } = extractPermissionPrompt(tail)
52
+ if (!text || options.length < 2) return
53
+ // 防 AI 自由回复假阳性:必须带真权限框的 footer,否则不动手
54
+ if (!CLAUDE_PERMISSION_FOOTER.test(text)) return
55
+ // 用尾部 200 字符做去重 key:连续 TUI redraw 会让 extractor 的 window 上下文不一样,
56
+ // 但 prompt 末尾("Do you want to proceed?\n1. Yes\n...")总是稳定的——用它判同。
57
+ const sig = text.slice(-200)
58
+ if (sig === lastEmittedText) return
59
+ lastEmittedText = sig
60
+ onMatch({ promptText: text, options })
61
+ }
62
+
63
+ // 一轮结束(Stop hook / jsonl turn-done)后调,让下一轮新的 prompt 不被 lastEmittedText 卡掉
64
+ function reset() {
65
+ lastEmittedText = null
66
+ }
67
+
68
+ function start() { pty.onData(onData) }
69
+ function stop() { stopped = true; if (timer) { clearTimeout(timer); timer = null } }
70
+
71
+ return { start, stop, reset, _maybeMatch: maybeMatch, _ring: ring }
72
+ }
package/src/cli.js CHANGED
@@ -667,7 +667,7 @@ export async function runStart(cmdOpts = {}) {
667
667
  const program = new Command()
668
668
  program
669
669
  .name('agentquad')
670
- .description('Local four-quadrant todo CLI with embedded Claude Code / Codex terminal')
670
+ .description('Local status-board todo CLI with embedded Claude Code / Codex / Cursor terminal')
671
671
  .version(loadPkgVersion())
672
672
 
673
673
  program.command('start')
@@ -59,7 +59,7 @@ function parseHookVersion(content) {
59
59
  }
60
60
 
61
61
  function buildHookEntry(event, hookScriptPath) {
62
- const eventLower = event === 'UserPromptSubmit' ? 'notification' : 'stop'
62
+ const eventLower = event === 'UserPromptSubmit' ? 'user-prompt-submit' : 'stop'
63
63
  return {
64
64
  matcher: '',
65
65
  hooks: [
@@ -1,7 +1,35 @@
1
+ /**
2
+ * Codex CLI stdout 提示词检测器:把 Codex 在 PTY 里弹出来的"是否允许运行命令?"
3
+ * 兜底转成 markPendingConfirm + IM 卡片,跟 Claude detector 一对的。
4
+ *
5
+ * 新版 Codex (gpt-5-codex / codex-cli 0.2+) 的 prompt 形态:
6
+ * Would you like to run the following command?
7
+ * Reason: ...
8
+ *
9
+ * $ <command>
10
+ *
11
+ * 1. Yes, proceed (y)
12
+ * 2. Yes, and don't ask again for commands that start with `<cmd>` (p)
13
+ * 3. No, and tell Codex what to do differently (esc)
14
+ *
15
+ * 旧 Codex 的 `[y/N]` / `apply patch?` 单行问句已经被淘汰;老的 PATTERNS 一个都不命中
16
+ * 真权限框,结果是 PTY 卡住但 IM 收不到卡片。
17
+ *
18
+ * 新检测规则(与 claude-prompt-detector 同思路):
19
+ * 1) anchor: "Would you like to" + "?" 出现在尾部
20
+ * 2) 末尾 5 行内有 `(esc)` 选项(Codex 真权限框第 3 个选项一定是 "...(esc)")
21
+ * 3) anchor → 末尾之间有 ≥2 个 "N. xxx (y/p/esc/n)" 形式的数字选项
22
+ *
23
+ * 三个信号一起到位才认;AI 自由回复里偶尔出现 "Would you like to" + 数字列表
24
+ * 不会有 `(esc)` 那种 hotkey 后缀,照常不命中。
25
+ *
26
+ * 兼容老 Codex:保留旧的 single-line `[y/N]` / `apply patch?` 形式作为 fallback。
27
+ */
1
28
  const DEFAULT_DEBOUNCE_MS = 1500
2
29
  const RING_MAX = 32
3
30
 
4
- const PATTERNS = [
31
+ // Codex 的单行问句(保留兜底,1.x 老版本还能用)
32
+ const LEGACY_SINGLE_LINE_PATTERNS = [
5
33
  /(approve|allow|continue|proceed)\??\s*\(\s*y\/n\s*\)\s*$/i,
6
34
  /\?\s*\[\s*y\/N\s*\]\s*$/i,
7
35
  /\?\s*\[\s*Y\/n\s*\]\s*$/i,
@@ -10,15 +38,56 @@ const PATTERNS = [
10
38
  /apply patch\?\s*\[[^\]]*\]\s*$/i,
11
39
  ]
12
40
 
41
+ // 新版 Codex 多行权限框的三个识别信号
42
+ const CODEX_NEW_ANCHOR = /Would\s+you\s+like\s+to\s+(?:run|apply|approve|continue)/i
43
+ const CODEX_NEW_OPTION = /^\s*([1-9])\.\s+(\S.{0,120}?)\s+\(\s*(y|p|n|esc|N|Y)\s*\)\s*$/i
44
+ const CODEX_NEW_ESC_OPTION = /\(\s*esc\s*\)\s*$/i
45
+
13
46
  function stripAnsi(s) {
14
47
  return String(s || '').replace(/\x1b\[[0-9;?]*[A-Za-z~]/g, '').replace(/\x1b\][^\x07]*\x07/g, '')
15
48
  }
16
49
 
50
+ /**
51
+ * 新版 Codex prompt 严格匹配。
52
+ * - 末尾 5 行里有 `(esc)` 结尾的选项行
53
+ * - 它上面 15 行内有 anchor ("Would you like to ...")
54
+ * - anchor → esc-option 之间 ≥2 个带 hotkey 后缀的数字选项
55
+ */
56
+ function matchCodexNewPrompt(cleaned) {
57
+ const lines = cleaned.split('\n')
58
+ let escIdx = -1
59
+ for (let i = lines.length - 1; i >= Math.max(0, lines.length - 5); i--) {
60
+ if (CODEX_NEW_ESC_OPTION.test(lines[i])) { escIdx = i; break }
61
+ }
62
+ if (escIdx < 0) return null
63
+ const start = Math.max(0, escIdx - 15)
64
+ let anchorIdx = -1
65
+ for (let i = escIdx - 1; i >= start; i--) {
66
+ if (CODEX_NEW_ANCHOR.test(lines[i])) { anchorIdx = i; break }
67
+ }
68
+ if (anchorIdx < 0) return null
69
+ // 收集 anchor → esc 之间的数字选项行(用作 dedup signature 的稳定核心)
70
+ const optionLines = []
71
+ for (let i = anchorIdx + 1; i <= escIdx; i++) {
72
+ if (CODEX_NEW_OPTION.test(lines[i])) optionLines.push(lines[i].trim())
73
+ }
74
+ if (optionLines.length < 2) return null
75
+ return {
76
+ startIdx: anchorIdx,
77
+ endIdx: escIdx,
78
+ text: lines.slice(anchorIdx, escIdx + 1).join('\n'),
79
+ // signature:所有选项行(含 hotkey 后缀)拼起来——同一个 prompt 复用同一份
80
+ // 选项;连续 TUI redraw / ring buffer 重复都映射到同一 sig,dedup 稳。
81
+ sig: optionLines.join('|'),
82
+ }
83
+ }
84
+
17
85
  export function createCodexPromptDetector({ pty, onMatch, debounceMs = DEFAULT_DEBOUNCE_MS, emitter = null } = {}) {
18
86
  if (!pty || !onMatch) throw new Error('pty, onMatch required')
19
87
  const ring = []
20
88
  let timer = null
21
89
  let stopped = false
90
+ let lastEmittedSig = null
22
91
 
23
92
  function onData(chunk) {
24
93
  if (stopped) return
@@ -28,26 +97,48 @@ export function createCodexPromptDetector({ pty, onMatch, debounceMs = DEFAULT_D
28
97
  timer = setTimeout(maybeMatch, debounceMs)
29
98
  }
30
99
 
100
+ function ifNotSelfQuotedByAi(text) {
101
+ const resolvedEmitter = typeof emitter === 'function' ? emitter() : emitter
102
+ if (!resolvedEmitter?.getLatestAssistantContent) return text
103
+ const ai = resolvedEmitter.getLatestAssistantContent() || ''
104
+ const trimmed = text.slice(-200).trim()
105
+ if (trimmed && (ai.includes(trimmed) || ai.endsWith(trimmed))) return null
106
+ return text
107
+ }
108
+
31
109
  function maybeMatch() {
32
- const tail = ring.slice(-4).map(c => c.text).join('')
110
+ timer = null
111
+ if (stopped) return
112
+ const tail = ring.map(c => c.text).join('')
113
+
114
+ // 1) 新版多行权限框优先(gpt-5-codex / codex-cli 0.2+)
115
+ const m = matchCodexNewPrompt(tail)
116
+ if (m) {
117
+ const checked = ifNotSelfQuotedByAi(m.text)
118
+ if (!checked) return
119
+ if (m.sig === lastEmittedSig) return
120
+ lastEmittedSig = m.sig
121
+ onMatch({ promptText: m.text, matchedPattern: 'codex_new_multiline' })
122
+ return
123
+ }
124
+
125
+ // 2) 老版单行问句兜底
33
126
  let matchedPattern = null
34
- for (const re of PATTERNS) {
127
+ for (const re of LEGACY_SINGLE_LINE_PATTERNS) {
35
128
  if (re.test(tail)) { matchedPattern = re.source; break }
36
129
  }
37
130
  if (!matchedPattern) return
38
- const resolvedEmitter = typeof emitter === 'function' ? emitter() : emitter
39
- if (resolvedEmitter?.getLatestAssistantContent) {
40
- const ai = resolvedEmitter.getLatestAssistantContent() || ''
41
- const trimmed = tail.slice(-200).trim()
42
- if (trimmed && (ai.includes(trimmed) || ai.endsWith(trimmed))) {
43
- return // AI self-quoted prompt; not a real Codex permission ask
44
- }
45
- }
131
+ const checked = ifNotSelfQuotedByAi(tail)
132
+ if (!checked) return
133
+ const sig = tail.slice(-200)
134
+ if (sig === lastEmittedSig) return
135
+ lastEmittedSig = sig
46
136
  onMatch({ promptText: tail.slice(-200), matchedPattern })
47
137
  }
48
138
 
139
+ function reset() { lastEmittedSig = null }
49
140
  function start() { pty.onData(onData) }
50
- function stop() { stopped = true; if (timer) clearTimeout(timer) }
141
+ function stop() { stopped = true; if (timer) { clearTimeout(timer); timer = null } }
51
142
 
52
- return { start, stop }
143
+ return { start, stop, reset }
53
144
  }
package/src/config.js CHANGED
@@ -126,6 +126,7 @@ const DEFAULT_LARK_CONFIG = {
126
126
  requireThreadGroup: true,
127
127
  eventSubscribeEnabled: true,
128
128
  autoCreateTopic: true,
129
+ autoCreateTodo: true,
129
130
  defaultPermissionMode: "bypass",
130
131
  notificationCooldownMs: 600_000,
131
132
  };
@@ -313,6 +314,9 @@ function defaultConfig() {
313
314
  // 新建待办时是否默认勾选「创建后自动启动 AI 终端」。
314
315
  // Drawer 上的开关仍可单次覆盖;这里只是默认值。
315
316
  defaultAutoStartAi: false,
317
+ // 新建待办时默认套用的 Prompt 模板 ID 列表(多选)。空数组 = 不预选。
318
+ // 用户在 SettingsDrawer 里维护;创建任务时 TodoManage 读取此值作为表单初值。
319
+ defaultAppliedTemplateIds: [],
316
320
  // 自动启动 / dispatch / 顶栏 ⌘K 等场景下使用的默认 AI 工具。
317
321
  defaultAiTool: "claude",
318
322
  tools: resolveToolsConfig(),
@@ -381,6 +385,9 @@ export function normalizeConfig(cfg = {}) {
381
385
  ...cfgRest,
382
386
  defaultPermissionMode: normalizePermissionMode(cfg.defaultPermissionMode, "default"),
383
387
  defaultAutoStartAi: !!cfg.defaultAutoStartAi,
388
+ defaultAppliedTemplateIds: Array.isArray(cfg.defaultAppliedTemplateIds)
389
+ ? cfg.defaultAppliedTemplateIds.map((x) => String(x).trim()).filter(Boolean)
390
+ : [],
384
391
  defaultAiTool,
385
392
  tools: {
386
393
  ...mergedTools,
@@ -470,15 +477,38 @@ function backupCorruptConfig(file) {
470
477
  }
471
478
  }
472
479
 
480
+ // Atomic write: write to a sibling tmp file then rename over the target.
481
+ // POSIX rename is atomic on the same filesystem — readers always see either
482
+ // the old or the new file, never a half-written one. Eliminates the
483
+ // truncated-write → JSON.parse-fail → reset-to-defaults loop.
484
+ function atomicWriteFile(file, contents) {
485
+ const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
486
+ writeFileSync(tmp, contents);
487
+ renameSync(tmp, file);
488
+ }
489
+
473
490
  function tryWriteConfig(file, cfg) {
474
491
  try {
475
- writeFileSync(file, JSON.stringify(cfg, null, 2));
492
+ atomicWriteFile(file, JSON.stringify(cfg, null, 2));
476
493
  return true;
477
494
  } catch {
478
495
  return false;
479
496
  }
480
497
  }
481
498
 
499
+ // In-process write serialization. Concurrent PUT /api/config calls (and any
500
+ // other server-side read-modify-write sequence) used to interleave their
501
+ // load/save and lose each other's changes. withConfigLock chains operations
502
+ // onto a single Promise queue so reads + writes within `fn` are atomic
503
+ // against other queued operations. Out-of-process writers (CLI, hooks) are
504
+ // NOT protected — see design doc R2/F4 deferred scope.
505
+ let configWriteQueue = Promise.resolve();
506
+ export function withConfigLock(fn) {
507
+ const run = configWriteQueue.then(() => fn(), () => fn());
508
+ configWriteQueue = run.catch(() => {});
509
+ return run;
510
+ }
511
+
482
512
  export function loadConfig({ rootDir = DEFAULT_ROOT_DIR } = {}) {
483
513
  ensureRoot(rootDir);
484
514
  const file = join(rootDir, "config.json");
@@ -488,9 +518,7 @@ export function loadConfig({ rootDir = DEFAULT_ROOT_DIR } = {}) {
488
518
  return cfg;
489
519
  }
490
520
  try {
491
- const cfg = normalizeConfig(JSON.parse(readFileSync(file, "utf8")));
492
- tryWriteConfig(file, cfg);
493
- return cfg;
521
+ return normalizeConfig(JSON.parse(readFileSync(file, "utf8")));
494
522
  } catch {
495
523
  backupCorruptConfig(file);
496
524
  const cfg = normalizeConfig();
@@ -501,7 +529,7 @@ export function loadConfig({ rootDir = DEFAULT_ROOT_DIR } = {}) {
501
529
 
502
530
  export function saveConfig(cfg, { rootDir = DEFAULT_ROOT_DIR } = {}) {
503
531
  ensureRoot(rootDir);
504
- writeFileSync(
532
+ atomicWriteFile(
505
533
  join(rootDir, "config.json"),
506
534
  JSON.stringify(normalizeConfig(cfg), null, 2),
507
535
  );
package/src/db.js CHANGED
@@ -1110,37 +1110,82 @@ export function openDb(file = ':memory:') {
1110
1110
  return r.changes
1111
1111
  }
1112
1112
 
1113
- function seedBuiltinTemplatesIfEmpty() {
1114
- if (ptStmts.countAll.get().n > 0) return
1113
+ // Canonical list of builtin templates. On startup we ensure each one exists
1114
+ // in the DB (matched by exact name + builtin=1). Missing ones get inserted;
1115
+ // user-edited copies are left alone. Adding a new entry here will surface
1116
+ // for existing users on next restart.
1117
+ const BUILTIN_TEMPLATE_SEEDS = [
1118
+ {
1119
+ name: '方案顾问(脑爆)',
1120
+ description: '先脑爆方向,不急着动手',
1121
+ content: '请先不要直接动手实现。先针对下面的任务 brainstorm:\n- 列出 2-3 种可选方案,说明优缺点\n- 指出风险点与需要用户拍板的关键决策\n- 明确验收标准\n\n在我确认方案后再进入实现。',
1122
+ },
1123
+ {
1124
+ name: '全自动工程师(自动驾驶)',
1125
+ description: '内心脑爆 → 自选最优 → 跑完 → 最后报告',
1126
+ content: '按"自动驾驶"模式处理下面的任务,不要停下来问我。\n\n1. 先在心里 brainstorm 2-3 种实现思路,挑出最合理的一种,但不需要列出来征求我的同意。\n2. 直接执行:理解 → 实现 → 自测(跑相关测试 / 编译 / lint) → 提交。\n3. 遇到不可避免必须我决策的歧义点(例:要不要删数据、要不要对外发版),才停下来问;普通选型不要问。\n4. 跑完后输出一份三段式报告:\n - 变更摘要:改了什么、为什么这么改\n - 验证结果:跑了哪些自测、是否通过、有没有遗留\n - 仍需我确认的事项:列出来 / 没有就写"无"\n5. 我考虑过哪些方案、为什么选这个,请在"变更摘要"里用一两句说明。',
1127
+ },
1128
+ {
1129
+ name: '守稳派开发(稳定优先)',
1130
+ description: '改动面最小、不引新依赖、不动公共 API',
1131
+ content: '本任务执行时请优先保证"稳定",含义:\n- 改动面尽量小,不顺手重构、不删看似无用的代码\n- 不引入新依赖、不升级现有依赖\n- 不改公共 API / 接口签名 / 数据库 schema(除非任务本身就是改这个)\n- 优先补测试覆盖现有行为;改动 hot path 时尽量保留旧路径作为兜底\n- 选型偏保守:用已经在项目里用过的库 / 写法\n如果"稳定"和任务目标冲突,告诉我,让我决定。',
1132
+ },
1133
+ {
1134
+ name: '前瞻派架构师(未来发展优先)',
1135
+ description: '允许重构 / 引入抽象 / 留扩展点',
1136
+ content: '本任务执行时请优先考虑"长期可扩展性",含义:\n- 允许并鼓励顺手重构邻近代码,让结构更清晰\n- 可以引入新抽象 / 接口,为可预见的下一步需求留扩展点\n- 公共 API / 类型 / 数据结构允许调整,但要在报告里列出影响面(调用方 / 测试 / 文档)\n- 选型可以挑当前社区主流而非项目已有的旧写法,但要说明替换原因\n- 不要为完全假想的需求过度设计 —— 只服务"已经看到苗头"的下一步\n完成后在报告里说明:哪些是为长期留的扩展点,分别服务什么场景。',
1137
+ },
1138
+ {
1139
+ name: 'Bug 侦探',
1140
+ description: '复现 → 定位 → 最小用例 → 修复 → 回归',
1141
+ content: '按 bug 修复流程处理下面的问题:\n1. 先复现(给出复现步骤和实际 vs 预期)\n2. 定位根因(不要过早修改代码)\n3. 写一个能复现该 bug 的最小用例(如果有测试框架)\n4. 修复根因,不是修现象\n5. 回归:跑相关测试;考虑同类 bug 是否还存在',
1142
+ },
1143
+ {
1144
+ name: '重构师',
1145
+ description: '先读懂 → 列出影响面 → 小步重构',
1146
+ content: '按照小步重构原则处理下面的任务:\n1. 先通读相关代码,复述你的理解\n2. 列出此次重构的影响面(调用方 / 测试 / 类型)\n3. 每一步只改一件事,保持可运行\n4. 每步后跑一次测试(如果有)\n5. 不要顺手加功能、不要引入新抽象,除非当前任务要求',
1147
+ },
1148
+ {
1149
+ name: '测试工程师',
1150
+ description: 'TDD:红 → 绿 → 重构',
1151
+ content: '用 TDD 的方式处理下面的任务:\n1. 先列出测试矩阵(输入 × 场景)\n2. 先写一个最简失败用例(红)\n3. 用最小改动让它通过(绿)\n4. 重构(保持绿)\n5. 重复 2-4 直到覆盖矩阵\n不 mock 真实依赖(除非跨网络/支付等)。',
1152
+ },
1153
+ {
1154
+ name: '代码评审员',
1155
+ description: '只评审,不改代码',
1156
+ content: '请只做代码评审,不要修改代码。按下面的维度给出具体反馈:\n- 可读性:命名、结构、注释\n- 正确性:边界、错误处理、并发\n- 安全性:注入、鉴权、敏感数据\n- 性能:明显的 N+1 / 无谓复制\n- 简洁性:是否有过度设计 / 可删除的冗余\n每条反馈给出文件:行号 + 建议。',
1157
+ },
1158
+ ]
1159
+ // 把老用户库里旧名字的 builtin 行就地改名成新名字,避免新种子重复插入。
1160
+ // 仅在新名尚未占位时改名;用户复制版(builtin=0)不动。
1161
+ const BUILTIN_RENAMES = [
1162
+ ['Brainstorm(脑爆)', '方案顾问(脑爆)'],
1163
+ ['自动驾驶(Autopilot)', '全自动工程师(自动驾驶)'],
1164
+ ['稳定优先(Stability First)', '守稳派开发(稳定优先)'],
1165
+ ['未来发展优先(Future First)', '前瞻派架构师(未来发展优先)'],
1166
+ ['Bug 修复', 'Bug 侦探'],
1167
+ ['重构', '重构师'],
1168
+ ['写测试', '测试工程师'],
1169
+ ['代码评审', '代码评审员'],
1170
+ ]
1171
+ const findBuiltinByName = db.prepare(
1172
+ `SELECT id FROM prompt_templates WHERE builtin = 1 AND name = ? LIMIT 1`,
1173
+ )
1174
+ const renameBuiltin = db.prepare(
1175
+ `UPDATE prompt_templates SET name = ?, updated_at = ? WHERE builtin = 1 AND name = ?`,
1176
+ )
1177
+ function migrateBuiltinNames() {
1178
+ const now = Date.now()
1179
+ for (const [oldName, newName] of BUILTIN_RENAMES) {
1180
+ if (findBuiltinByName.get(newName)) continue // 新名已存在则跳过
1181
+ if (!findBuiltinByName.get(oldName)) continue // 旧名也不在就没事
1182
+ renameBuiltin.run(newName, now, oldName)
1183
+ }
1184
+ }
1185
+ function ensureBuiltinTemplates() {
1115
1186
  const now = Date.now()
1116
- const seeds = [
1117
- {
1118
- name: 'Brainstorm(脑爆)',
1119
- description: '先脑爆方向,不急着动手',
1120
- content: '请先不要直接动手实现。先针对下面的任务 brainstorm:\n- 列出 2-3 种可选方案,说明优缺点\n- 指出风险点与需要用户拍板的关键决策\n- 明确验收标准\n\n在我确认方案后再进入实现。',
1121
- },
1122
- {
1123
- name: 'Bug 修复',
1124
- description: '复现 → 定位 → 最小用例 → 修复 → 回归',
1125
- content: '按 bug 修复流程处理下面的问题:\n1. 先复现(给出复现步骤和实际 vs 预期)\n2. 定位根因(不要过早修改代码)\n3. 写一个能复现该 bug 的最小用例(如果有测试框架)\n4. 修复根因,不是修现象\n5. 回归:跑相关测试;考虑同类 bug 是否还存在',
1126
- },
1127
- {
1128
- name: '重构',
1129
- description: '先读懂 → 列出影响面 → 小步重构',
1130
- content: '按照小步重构原则处理下面的任务:\n1. 先通读相关代码,复述你的理解\n2. 列出此次重构的影响面(调用方 / 测试 / 类型)\n3. 每一步只改一件事,保持可运行\n4. 每步后跑一次测试(如果有)\n5. 不要顺手加功能、不要引入新抽象,除非当前任务要求',
1131
- },
1132
- {
1133
- name: '写测试',
1134
- description: 'TDD:红 → 绿 → 重构',
1135
- content: '用 TDD 的方式处理下面的任务:\n1. 先列出测试矩阵(输入 × 场景)\n2. 先写一个最简失败用例(红)\n3. 用最小改动让它通过(绿)\n4. 重构(保持绿)\n5. 重复 2-4 直到覆盖矩阵\n不 mock 真实依赖(除非跨网络/支付等)。',
1136
- },
1137
- {
1138
- name: '代码评审',
1139
- description: '只评审,不改代码',
1140
- content: '请只做代码评审,不要修改代码。按下面的维度给出具体反馈:\n- 可读性:命名、结构、注释\n- 正确性:边界、错误处理、并发\n- 安全性:注入、鉴权、敏感数据\n- 性能:明显的 N+1 / 无谓复制\n- 简洁性:是否有过度设计 / 可删除的冗余\n每条反馈给出文件:行号 + 建议。',
1141
- },
1142
- ]
1143
- seeds.forEach((s, i) => {
1187
+ BUILTIN_TEMPLATE_SEEDS.forEach((s, i) => {
1188
+ if (findBuiltinByName.get(s.name)) return
1144
1189
  ptStmts.insert.run({
1145
1190
  id: randomUUID(),
1146
1191
  name: s.name,
@@ -1153,7 +1198,8 @@ export function openDb(file = ':memory:') {
1153
1198
  })
1154
1199
  })
1155
1200
  }
1156
- seedBuiltinTemplatesIfEmpty()
1201
+ migrateBuiltinNames()
1202
+ ensureBuiltinTemplates()
1157
1203
 
1158
1204
  const wikiStmts = {
1159
1205
  insertRun: db.prepare(`
@@ -1,13 +1,6 @@
1
1
  import { parseTranscriptFile } from '../transcripts/scanner.js'
2
2
  import { estimateCost, DEFAULT_PRICING } from '../pricing.js'
3
3
 
4
- const QUADRANT_LABEL = {
5
- 1: 'Q1 紧急且重要',
6
- 2: 'Q2 重要不紧急',
7
- 3: 'Q3 紧急不重要',
8
- 4: 'Q4 不紧急不重要',
9
- }
10
-
11
4
  const STATUS_LABEL = {
12
5
  todo: '待办',
13
6
  ai_pending: 'AI 待确认',
@@ -30,7 +23,7 @@ export async function buildTodoExport(db, todoId, { turns = 'summary', turnLimit
30
23
  if (!todo) return null
31
24
  const comments = db.listComments(todoId)
32
25
  const aiSessions = Array.isArray(todo.aiSessions) ? todo.aiSessions : []
33
- const subtodos = todo.parentId ? [] : db.listTodos({ quadrant: todo.quadrant }).filter(item => item.parentId === todo.id)
26
+ const subtodos = todo.parentId ? [] : db.listTodos().filter(item => item.parentId === todo.id)
34
27
 
35
28
  const sessionRows = []
36
29
  for (const s of aiSessions) {
@@ -122,7 +115,6 @@ export function renderTodoMarkdown(report) {
122
115
  lines.push(`# ${todo.title}`)
123
116
  lines.push('')
124
117
  const meta = [
125
- `**象限**:${QUADRANT_LABEL[todo.quadrant] || todo.quadrant}`,
126
118
  `**状态**:${STATUS_LABEL[todo.status] || todo.status}`,
127
119
  ]
128
120
  if (todo.dueDate) meta.push(`**截止**:${fmtDateTime(todo.dueDate)}`)
package/src/lark-bot.js CHANGED
@@ -126,6 +126,38 @@ export function normalizeEvent(raw = {}) {
126
126
  * action.value 是按钮的 value(构卡片时塞的 JSON),约定字段:
127
127
  * { callback_data: 'qt:perm:abcd:allow' } // 跟 telegram 的 callback_data 同字符串格式
128
128
  */
129
+ /**
130
+ * 把 wizard 风格的 callback 返回值({toast: '字符串', chosenLabel, action, editOriginal})
131
+ * 适配成 Lark 卡片回调期望的 schema({toast: {type, content}})。
132
+ *
133
+ * 背景:Lark SDK 的 WSClient.handleEventData 把 handler 的返回值 base64 编码后回写到
134
+ * WebSocket frame;Lark 服务端反序列化时按文档校验 toast 必须是 `{type, content}` 对象,
135
+ * wizard 直传 string 会失败 → 飞书 UI 弹「出错了,请稍后重试 code: 200340」。
136
+ * 文档:https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/feishu-cards/card-callback/card-callback-communication
137
+ *
138
+ * 已经是 Lark 格式的({toast: {type, content}})直接放过;undefined → 不显 toast。
139
+ */
140
+ export function adaptWizardResponseToLark(r) {
141
+ if (!r || typeof r !== 'object') return undefined
142
+
143
+ // 已是 Lark 形态({toast: {type?, content}}) → 透传,保留 caller 想自己控的 type
144
+ if (r.toast && typeof r.toast === 'object' && (typeof r.toast.content === 'string' || r.toast.i18n)) {
145
+ const out = { toast: { ...r.toast } }
146
+ if (!out.toast.type) out.toast.type = 'info'
147
+ return out
148
+ }
149
+
150
+ // wizard 形态(toast: string)→ 按 action 字段推断 type
151
+ const content = typeof r.toast === 'string' ? r.toast.trim() : ''
152
+ if (!content) return undefined // 没有 toast 文本就别 emit,Lark UI 默认无提示
153
+ let type = 'info'
154
+ const action = String(r.action || '')
155
+ if (action.includes('allow') || action.includes('sent')) type = 'success'
156
+ else if (action.includes('stale') || action.includes('invalid')) type = 'warning'
157
+ else if (action.includes('failed') || action.includes('error') || action.includes('unavailable')) type = 'error'
158
+ return { toast: { type, content } }
159
+ }
160
+
129
161
  export function normalizeCardAction(raw = {}) {
130
162
  const event = raw.event || raw
131
163
  const action = event.action || {}
@@ -418,20 +450,24 @@ export function createLarkBot({
418
450
 
419
451
  async function handleCardAction(raw) {
420
452
  const ev = normalizeCardAction(raw)
453
+ logger.info?.(`[lark-bot] card action received: callbackData=${ev.callbackData || 'null'} chatId=${ev.chatId || 'null'} from=${ev.fromUserId || 'null'}`)
421
454
  if (!ev.chatId || !ev.callbackData) {
422
- return { ok: false, reason: 'invalid_card_action' }
455
+ const r = adaptWizardResponseToLark({ toast: '⚠️ 无效的卡片回传', action: 'invalid' })
456
+ logger.info?.(`[lark-bot] card action → Lark resp: ${JSON.stringify(r)}`)
457
+ return r
423
458
  }
424
459
  const configuredChatId = getConfig()?.lark?.chatId
425
460
  if (configuredChatId && ev.chatId !== String(configuredChatId)) {
426
461
  logger.warn?.(`[lark-bot] ignored card_action from other chat: ${ev.chatId}`)
427
- return { ok: true, action: 'ignored_chat' }
462
+ // 跨群点的卡片:返回最小合法响应(带个 info toast),避免 Lark UI 弹 200340 generic 错误
463
+ return { toast: { type: 'info', content: '已忽略(非本群)' } }
428
464
  }
429
465
  if (typeof wizard.handleCallback !== 'function') {
430
466
  logger.warn?.(`[lark-bot] wizard.handleCallback unavailable; dropping lark card action`)
431
- return { ok: false, reason: 'no_handler' }
467
+ return adaptWizardResponseToLark({ toast: '⚠️ 服务未就绪', action: 'failed' })
432
468
  }
433
469
  try {
434
- return await wizard.handleCallback({
470
+ const wizardR = await wizard.handleCallback({
435
471
  channel: 'lark',
436
472
  chatId: ev.chatId,
437
473
  threadId: ev.threadId,
@@ -439,9 +475,12 @@ export function createLarkBot({
439
475
  callbackData: ev.callbackData,
440
476
  fromUserId: ev.fromUserId,
441
477
  })
478
+ const adapted = adaptWizardResponseToLark(wizardR)
479
+ logger.info?.(`[lark-bot] card action → wizard returned ${JSON.stringify(wizardR)} → Lark resp: ${JSON.stringify(adapted)}`)
480
+ return adapted
442
481
  } catch (e) {
443
482
  logger.warn?.(`[lark-bot] card action handler failed: ${e.message}`)
444
- return { ok: false, reason: 'handler_failed', detail: e.message }
483
+ return adaptWizardResponseToLark({ toast: `⚠️ 处理失败:${e.message}`, action: 'failed' })
445
484
  }
446
485
  }
447
486