@fanchao8609/agent_brain_sync 1.30.0 → 1.31.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -114,7 +114,8 @@ abs todo note TASK-1 --note "改到 X 文件 L40" # 追加一条子项(
114
114
  abs todo child-done TASK-1 [--n 2] # 勾子项(n=第几条,缺省最后一条)
115
115
  abs todo child-undone TASK-1 [--n 2] # 取消勾(勾错了退回)
116
116
  abs todo memo TASK-1 "随手记一段(可多句,不上看板)" # 任务笔记 → sessions/;无参=查看
117
- abs todo state TASK-1 --note "进行中|讨论中|滞留中" # 改行首状态标记
117
+ abs todo state TASK-1 --note "进行中|讨论中|滞留中|搁置" # 改行首状态标记
118
+ abs todo state TASK-1 --note "滞留中" --n 2 # 改第 2 条子项的状态(子项也能标四态)
118
119
  abs todo done TASK-1
119
120
 
120
121
  abs note "一句话经验" --tags 坑,docker # 经验暂存 → sources/
package/bin/abs.js CHANGED
@@ -450,7 +450,7 @@ const subUsage = {
450
450
  ' abs todo child-done <id> [--n N] 勾子项(缺省最后一条)',
451
451
  ' abs todo child-undone <id> [--n N] 取消勾',
452
452
  ' abs todo memo <id> "随手记(可多句)" 任务笔记 → sessions/,不上看板;无文字=查看',
453
- ' abs todo state <id> --note 进行中|讨论中|滞留中|搁置',
453
+ ' abs todo state <id> --note 进行中|讨论中|滞留中|搁置 [--n N] --n = 改第 N 条子项(缺省改任务本身)',
454
454
  ' abs todo remove <id> 删除任务(连子项,不可恢复;只是先不做请用 state 搁置)',
455
455
  ' abs todo done <id> [--as 落地|否决|仅方案] [结语文字]',
456
456
  ' abs todo archive [--keep-days N] [--dry-run]',
@@ -632,7 +632,7 @@ async function main() {
632
632
  // memo 同:随手记常是多句带空格,位置参数收全段比 --note 顺手。
633
633
  const isDone = action === 'done';
634
634
  const isMemo = action === 'memo';
635
- const usage = `abs todo ${sub} <id>${isDone ? ' [--as 落地|否决|仅方案] [--note "结语"]' : ' --note "…"'}`;
635
+ const usage = `abs todo ${sub} <id>${isDone ? ' [--as 落地|否决|仅方案] [--note "结语"]' : action === 'state' ? ' --note "…" [--n N]' : ' --note "…"'}`;
636
636
  if (isDone) {
637
637
  const note = opts.note || (rest2.length ? rest2.join(' ') : undefined);
638
638
  console.log(await cmdTask({ dir: opts.dir, action, id, section: opts.section, note, as: opts.as, session: opts.session }));
@@ -655,7 +655,10 @@ async function main() {
655
655
  }
656
656
  rejectExtra(rest2, usage);
657
657
  if (!id) throw new Error(`✗ 缺 <id>\n 用法: ${usage}`);
658
- console.log(await cmdTask({ dir: opts.dir, action, id, section: opts.section, note: opts.note, as: opts.as, session: opts.session }));
658
+ // --n:子项位置(state 用它改第 n 条子项,与 child-done 同一套坐标)
659
+ const nKid = opts.n ? Number(opts.n) : undefined;
660
+ if (opts.n && (!Number.isInteger(nKid) || nKid < 1)) throw new Error(`✗ --n 要是正整数(收到 "${opts.n}")`);
661
+ console.log(await cmdTask({ dir: opts.dir, action, id, section: opts.section, note: opts.note, as: opts.as, session: opts.session, n: nKid }));
659
662
  break;
660
663
  }
661
664
  case 'ingest': {
package/bin/mcp.js CHANGED
@@ -113,11 +113,11 @@ tool(
113
113
 
114
114
  tool(
115
115
  'abs_task',
116
- '任务实时落盘(幂等键 = id)。start 登记进 Todo / note 补子项(**追加**,标准 md 嵌套列表,`abs todo note`)/ child_done、child_undone 勾/取消勾子项(n=第几条,缺省最后一条)/ memo 任务随手记(多句/追加/不上看板)/ state 改状态 / done 完成归位 Done(done 时 note=结语文字,as=结语类型:落地|否决|仅方案)。必填: cwd, action, id。多会话并行时,start/add 带上 session(值见 system prompt 的 [会话] 段)以标记「这条任务谁在做」。',
116
+ '任务实时落盘(幂等键 = id)。start 登记进 Todo / note 补子项(**追加**,标准 md 嵌套列表,`abs todo note`)/ child_done、child_undone 勾/取消勾子项(n=第几条,缺省最后一条)/ state 改状态(带 n = 改该任务第 n 条子项,子项也能标四态)/ memo 任务随手记(多句/追加/不上看板)/ done 完成归位 Done(done 时 note=结语文字,as=结语类型:落地|否决|仅方案)。必填: cwd, action, id。多会话并行时,start/add 带上 session(值见 system prompt 的 [会话] 段)以标记「这条任务谁在做」。',
117
117
  {
118
118
  action: z.enum(['add', 'start', 'done', 'note', 'child_done', 'child_undone', 'memo', 'state', 'remove']),
119
119
  id: z.string().describe('任务幂等键,如 TASK-xxx 或子任务名'),
120
- n: z.number().int().positive().optional().describe('child_done/child_undone 用:第几条子项(1-based,缺省=最后一条)'),
120
+ 'n': z.number().int().positive().optional().describe('child_done/child_undone/state 用:第几条子项(1-based,缺省=最后一条;state 不传 n = 改任务本身)'),
121
121
  cwd: z.string().describe('项目根目录(.brain/ 所在处)'), note: z.string().optional().describe('add/start=做什么; note=追加一条子项(改到哪/下一步,一句话); memo=随手记(可多句、不定长,追加式;不传=查看已有笔记); state=进行中|讨论中|滞留中|搁置(搁置=用户改方向/不做了;滞留中=还要做只是卡住); done=结语文字(remove 不用此字段)'),
122
122
  as: z.enum(['落地', '否决', '仅方案']).optional().describe('仅 done:结语类型。默认 落地。做了又撤/评估后不做用 否决,只设计过用 仅方案 —— 别让假【落地】污染看板'),
123
123
  // 会话 id:多会话并行时标记「这条任务是谁在做」。
@@ -20,13 +20,36 @@ export function shortTokens(n: number | null | undefined): string {
20
20
  return String(n)
21
21
  }
22
22
 
23
- /** 拼右上角状态:`8.0%/1.0M 模型名`。缺哪段就省哪段。 */
24
- export function buildStatus(usage: any, modelId: string): string {
23
+ /** 推理档位 → 一个大写字母(用户 2026-11-09 要求:右下角只用一个大写字母表示强度)。
24
+ *
25
+ * 为何不取首字母:minimal / medium / max 三个都是 M。这套是手选的无重写法:
26
+ * off=O、miNimal=N、Low=L、Medium=M、High=H、Xhigh=X、max=MX(用户指定,其余均单字母)。
27
+ * 改映射只改这里,并把测试里的表一起改。 */
28
+ export const LEVEL_LETTER: Record<string, string> = {
29
+ off: 'O',
30
+ minimal: 'N',
31
+ low: 'L',
32
+ medium: 'M',
33
+ high: 'H',
34
+ xhigh: 'X',
35
+ max: 'MX',
36
+ }
37
+
38
+ /** 档位 → 字母;空值或认不得的档位返回空串(那时不显示)。 */
39
+ export function levelLetter(level: string | null | undefined): string {
40
+ return LEVEL_LETTER[String(level || '').trim().toLowerCase()] || ''
41
+ }
42
+
43
+ /** 拼右下角状态:`8.0%/1.0M 模型名 · H`。缺哪段就省哪段。
44
+ * 第三段是推理档位的一个大写字母(见 LEVEL_LETTER);模型不支持推理时不显。 */
45
+ export function buildStatus(usage: any, modelId: string, level = ''): string {
25
46
  const parts: string[] = []
26
47
  if (usage && usage.percent != null) parts.push(`${usage.percent.toFixed(1)}%`)
27
48
  if (usage && usage.contextWindow) parts.push(`/${shortTokens(usage.contextWindow)}`)
28
49
  const model = shortenModel(modelId)
29
- return [parts.join(''), model].filter(Boolean).join(' ')
50
+ const head = [parts.join(''), model].filter(Boolean).join(' ')
51
+ const letter = levelLetter(level)
52
+ return letter ? (head ? `${head} · ${letter}` : letter) : head
30
53
  }
31
54
 
32
55
  export function stripAnsi(s: string): string {
@@ -72,24 +95,28 @@ function dropCols(s: string, n: number): string {
72
95
  }
73
96
 
74
97
  /**
75
- * 推理强度 → 主题色 token。空串表示「不上色,沿用默认色」。
98
+ * 推理强度 → 主色。六档灰蓝(同一色相族、由亮到暗),查不到或 off 时返回空串 = 不上色,用默认色。
99
+ *
100
+ * ⚠ 当前**输入框不再用它**(2026-11-09 用户要求「完全对齐 pi 原版」:边框改用 pi 给的
101
+ * editor.borderColor,即主题那七档 thinking 色)。保留是因为两条:用户在这上面来回改过
102
+ * 两轮(改回去只改 render() 一行),且 todo 面板(abs.pi.ts)另有一份同值副本。
76
103
  *
77
- * 规则:`thinking` + 首字母大写,正好对上主题里的 thinkingMinimal…thinkingMax 七档。
78
- * 为什么 off 返回空串而不是 `thinkingOff`:主题给 thinkingOff 的是接近背景的暗灰
79
- * (dark.json: `okhsl(229 8% 49%)`),拿它去染文字会看不清。用户 2026-11-06 定的是
80
- * 「off 时保持默认色」。
104
+ * 为什么 off 不上色:主题给 thinkingOff 的是接近背景的暗灰(dark.json: `okhsl(229 8% 49%)`),
105
+ * 拿它去染文字会看不清。用户 2026-11-06 定的是「off 时保持默认色」。
106
+ * 色值定于 2026-11-09:先「暗档提亮」把 medium/low/minimal 抬到看得清,试过一版中性灰又还原回蓝调。
107
+ * 对深色背景(#0D1117)对比度 11.84/9.22/7.72/6.53/5.42/4.51。
81
108
  *
82
- * ★ 同一份逻辑在 hooks/abs.pi.ts 里也有一份(`effortColorToken`)。不抽公共模块是故意的:
109
+ * ★ 同一份逻辑在 hooks/abs.pi.ts 里也有一份(`effortAnsi`)。不抽公共模块是故意的:
83
110
  * 安装后 `abs.ts` 与 `abs-rounded-editor/` 是两个可独立删除的扩展,抽成共享文件会让
84
- * 「删掉输入框样式目录」连带把 todo 面板搞崩。
111
+ * 「删掉输入框样式目录」连带把 todo 面板搞崩。改色值时要两边一起改。
85
112
  */
86
113
  export const EFFORT_COLOR: Record<string, { rgb: string; ansi256: number }> = {
87
114
  max: { rgb: '197;205;220', ansi256: 189 },
88
115
  xhigh: { rgb: '168;182;204', ansi256: 152 },
89
116
  high: { rgb: '150;166;198', ansi256: 110 },
90
- medium: { rgb: '122;138;160', ansi256: 110 },
91
- low: { rgb: '94;108;132', ansi256: 103 },
92
- minimal: { rgb: '70;82;106', ansi256: 240 },
117
+ medium: { rgb: '139;153;172', ansi256: 110 },
118
+ low: { rgb: '124;138;162', ansi256: 103 },
119
+ minimal: { rgb: '108;124;157', ansi256: 67 },
93
120
  }
94
121
 
95
122
  export function effortAnsi(level: string | null | undefined, colorMode = 'truecolor'): string {
@@ -98,21 +125,6 @@ export function effortAnsi(level: string | null | undefined, colorMode = 'trueco
98
125
  return colorMode === 'truecolor' ? `38;2;${g.rgb}` : `38;5;${g.ansi256}`
99
126
  }
100
127
 
101
- /** 「搁置」橙 #F59E0B —— 与 todo 面板的 `搁置` 标签同色(abs.pi.ts 的 `C.hold`)。
102
- * 用途(2026-11-08 用户要求):输入框上边框**右上角的状态文字**。
103
- * ★ 左边的 working 状态(`⠼ Working`)**不用**这个色(用户实测要求):
104
- * 它跟框线同色,橙色只标记「上下文占用」。
105
- * ★ 色值在 abs.pi.ts 里也有一份,不抽公共模块是故意的(见 EFFORT_COLOR 的注释)。 */
106
- export const HOLD_COLOR = { rgb: '245;158;11', ansi256: 214 }
107
-
108
- export function holdAnsi(colorMode = 'truecolor'): string {
109
- return colorMode === 'truecolor' ? `38;2;${HOLD_COLOR.rgb}` : `38;5;${HOLD_COLOR.ansi256}`
110
- }
111
-
112
- /** 上边框最左那段(如 `⠼ Working`)由 pi 画、roundBorder 不单独给它上色:
113
- * 它随整行用框线色。试过给它上橙,用户实测要求“跟框线同色”,已改回 ——
114
- * 别再拆一段出来单独染色。 */
115
-
116
128
  /** 用 effortAnsi 的结果包一段文字;空串原样返回(off 时不着色)。 */
117
129
  export function paintAnsi(ansi: string, text: string): string {
118
130
  return ansi ? `\x1b[${ansi}m${text}\x1b[39m` : text
@@ -127,9 +139,12 @@ export function paintAnsi(ansi: string, text: string): string {
127
139
  * leftLabel 贴在左圆角之后;label 贴最右端。哪个放不下就丢掉哪个
128
140
  * (宁可没有,也不让边框溢出折行)。
129
141
  *
130
- * opts.labelFg(2026-11-08 用户要求):只给右边那个状态标签换个色,
131
- * 其余(圆角、虚线、**以及 pi 画在左边的 working 状态**)仍用 fg。
132
- * working 曾经也上橙,用户实测要它跟框线同色,已改回(见下面的注释)。
142
+ * opts.labelFg:只给右边那个标签(右下角的状态文字)换个色,其余(圆角、虚线)仍用 fg。
143
+ * 当前调用方不传(状态文字跟框线同色,2026-11-09 用户定);机制留着,要染色时传一个即可。
144
+ *
145
+ * 历史(别再重走):2026-11-08~09 曾有个 leadFg 开关,用来把 pi 嵌进上边框的
146
+ * `⠼ Working` 那段单独染色(橙)。现在 working 已还原成 pi 原版(不嵌边框),
147
+ * leadSpanOf/leadFg 一并删了 —— 要再嵌回去就照那时候的写法加。
133
148
  */
134
149
  export function roundBorder(
135
150
  line: string,
@@ -166,12 +181,12 @@ export function roundBorder(
166
181
  }
167
182
  }
168
183
  return paintSpans(left + mid + right, fg, [
169
- // 右标签从**末尾**找:万一同一串又出现在左边,不能把左边那个也染成标签色。
184
+ // 标签从**末尾**找:万一同一串又出现在左边,不能把左边那个也染成标签色。
170
185
  [label, opts.labelFg, 'last'],
171
186
  ])
172
187
  }
173
188
 
174
- /** 把 out 里的几个区间换成各自的色,其余整段用 fg。区间按位置排序、重叠则跳过。
189
+ /** 把 out 里的区间换成各自指定的色,其余整段用 fg。区间按位置排序、重叠则跳过。
175
190
  * 为何不用「拼段」实现:标签与 working 段的字符是从 mid 里苹/插进去的,
176
191
  * 只有拿到最终串才能准确定位(直接拼段会在 dropCols 后错位)。 */
177
192
  function paintSpans(
@@ -3,9 +3,14 @@
3
3
  *
4
4
  * 需求(2026-11-04 用户):
5
5
  * ① 输入框左右都有竖线、四角圆角
6
- * ② working 不单独占一行,只在上边框显示运行图标(embedWorkingStatus)
7
- * ③ 上边框最右侧显示「上下文百分比/上限 模型名」,如 `8.0%/1.0M deepseek-v4.1-flash`
8
- * ④ 输入框下方原有 footer 两行全部隐藏
6
+ * ② 右下角显示「上下文百分比/上限 模型名」,如 `8.0%/1.0M deepseek-v4.1-flash`
7
+ * ③ 输入框下方原有 footer 两行全部隐藏
8
+ *
9
+ * 2026-11-09 定案(用户来回试过好几版):
10
+ * ① working 状态交给 pi 嵌在上边框(`embedWorkingStatus: true`)—— 就是 pi 原本那个样子:
11
+ * 左上角 `⠇ Working` + 动画,颜色随整行走框线色。本扩展**不搬不改字**。
12
+ * 试过并作废的:搬到右下角居中、正文最右一列、右上角单字符 —— 用户看完都要回左上角。
13
+ * ② 边框**和框内文字**都用 pi 给的 `editor.borderColor`(即原版那七档 thinking 色)。
9
14
  *
10
15
  * 为什么要覆写 render():
11
16
  * pi 的 Editor.render() 只画上下两条 `─`,每行是「左 padding + 文本 + 右 padding」,
@@ -18,26 +23,16 @@
18
23
  * 注意:自动补全菜单行排在底边之下、且自带内容宽度,不套竖线(否则菜单被框住会错位)。
19
24
  */
20
25
  import { CustomEditor } from '@earendil-works/pi-coding-agent'
21
- import { buildStatus, effortAnsi, holdAnsi, paintAnsi, roundBorder, shortenModel } from './format.ts'
26
+ import { buildStatus, roundBorder, shortenModel } from './format.ts'
22
27
 
23
28
  const SIDE = '│'
24
29
 
25
- /** 右上角状态文本,如 `8.0%/1.0M deepseek-v4.1-flash`。render() 拿不到 ctx,故由事件回调维护。 */
30
+ /** 右下角状态文本,如 `8.0%/1.0M deepseek-v4.1-flash · H`。render() 拿不到 ctx,故由事件回调维护。 */
26
31
  let statusLabel = ''
27
32
 
28
- /** 当前推理强度(off/minimal/low/medium/high/xhigh/max)。render() 拿不到 ctx,同样这里存。
29
- * 用途:输入文字随推理强度变色(2026-11-06 用户需求)。 */
33
+ /** 当前推理档位(off/minimal/…/max)。也只是为了拼状态文字 —— 边框颜色是 pi 自己上的。 */
30
34
  let effortLevel = ''
31
35
 
32
- /** 取当前主题实例的 getter。
33
- *
34
- * ★ 为何不能把主题从构造函数里拿:pi 传给编辑器了的是 `getEditorTheme()`,
35
- * 那对象只有 `{ borderColor, selectList }`,**没有 `fg`** —— 直接用它上色会静默无效
36
- * (不报错,只是没颜色)。真正带 fg 的是 `ctx.ui.theme`(interactive-mode 的 getter)。
37
- * ★ 为何存 getter 而不是存实例:`/theme` 切主题后 ctx.ui.theme 返回**新实例**,
38
- * 存旧实例会継续用旧主题的色板。 */
39
- let themeGetter: (() => any) | null = null
40
-
41
36
  export class RoundedEditor extends CustomEditor {
42
37
  render(width: number): string[] {
43
38
  if (width < 4) return super.render(width)
@@ -45,70 +40,64 @@ export class RoundedEditor extends CustomEditor {
45
40
  const lines = super.render(width - 2)
46
41
  if (lines.length < 2) return lines
47
42
 
48
- const borderColor = (this as any).borderColor?.bind(this)
49
- // 现代暗色方案(2026-11-08):文字 #F8FAFC,边框 #444C56,不再跟推理强度变色。
50
- // 光标色 / 输入框背景 / 聚焦外发光是 pi 编辑器内部画的,扩展接管不了,先不动。
51
- const colorMode = (() => { try { return String(themeGetter?.().getColorMode()) } catch { return 'truecolor' } })()
52
- const textFg = (s: string) => paintAnsi(effortAnsi(effortLevel, colorMode), s)
53
- const lineFg = textFg
54
- const wrap = lineFg
55
- // 右上角状态文字用「搁置」橙 #F59E0B(2026-11-08 用户要求):
56
- // 只染那一段 —— 圆角、虚线、以及 pi 画的 working 状态都跟框线同色
57
- // (working 曾也上橙,用户实测「要跟输入框线条颜色一致」,已改回)。
58
- const holdFg = (s: string) => paintAnsi(holdAnsi(colorMode), s)
43
+ // 边框颜色直接用 pi 给编辑器设好的那个(2026-11-09 用户要求「完全对齐原版」):
44
+ // pi 的 interactive-mode 在 updateEditorBorderColor() 里把 editor.borderColor 设成
45
+ // theme.getThinkingBorderColor(档位),bash 模式另给一套 —— 直接复用就等于原版那套
46
+ // (含 off 的暗灰),而且 /theme、切档、切 bash 模式时 pi 自己会换并请求重绘。
47
+ // 光标色 / 输入框背景 / 聚焦外发光是 pi 内部画的,接管不了。
48
+ const border = (this as any).borderColor
49
+ const wrap = typeof border === 'function' ? border.bind(this) : (s: string) => s
59
50
  const out: string[] = []
60
51
 
61
- // 上边框:原本是整行 `─`(含 working 图标与滚动提示),两端换圆角,右端贴状态。
62
- out.push(roundBorder(lines[0], wrap, '╭', '╮', statusLabel, '', { labelFg: holdFg }))
52
+ // 上边框:原本是整行 `─`(含 pi 嵌进来的 working 状态与滚动提示),两端换圆角。
53
+ // working 就是 pi 嵌的那个样子(左上角 `⠇ Working` + 动画),不搬不改字(2026-11-09 定稿)。
54
+ // 整行会被 roundBorder 重上一次框线色,所以它跟边框同色。
55
+ out.push(roundBorder(lines[0], wrap, '╭', '╮'))
63
56
  // 下边框在原结果的倒数第 (1 + 自动补全行数) 位 —— 补全菜单排在它后面。
64
57
  const autocompleteHeight = (this as any).renderedAutocompleteHeight ?? 0
65
58
  const bottomIdx = lines.length - 1 - autocompleteHeight
66
59
 
67
- for (let i = 1; i < bottomIdx; i++) {
68
- // 剥掉 pi 原生文字色,再包 #F8FAFC —— 否则内层色码赢。
69
- const body = lines[i].replace(/\x1b\[(?!7m)[0-9;]*m/g, '')
70
- out.push(wrap(SIDE) + textFg(body) + wrap(SIDE))
60
+ // 正文行:两端各包一根竖线,文字也上推理色(2026-11-09 用户要求「输入框文字颜色与推理一致」)。
61
+ // 先剥掉 pi 原生文字色再包 —— 否则内层色码会提前收色,看着像只有前半行有颜色。
62
+ for (const body of lines.slice(1, bottomIdx)) {
63
+ out.push(wrap(SIDE) + wrap(body.replace(/\x1b\[[0-9;]*m/g, '')) + wrap(SIDE))
71
64
  }
72
65
  // 下边框(2026-11-07 还原:左下角的 fanchao·昵称已去掉 —— 用户嫌难看,
73
66
  // 回 todo 面板标题;roundBorder 的 leftLabel 参数保留备用)。
74
- out.push(roundBorder(lines[bottomIdx], wrap, '╰', '╯'))
67
+ // 状态文字(`31.8%/1.0M 模型名`)2026-11-09 从右上角搬到右下角,颜色跟框线一致
68
+ // (用户当日先要搁置橙、看完又要改回推理色 —— 以最新为准)。
69
+ out.push(roundBorder(lines[bottomIdx], wrap, '╰', '╯', statusLabel))
75
70
  // 自动补全行原样透传(不套竖线)。
76
71
  for (let i = bottomIdx + 1; i < lines.length; i++) out.push(lines[i])
77
72
  return out
78
73
  }
79
74
  }
80
75
 
81
- /** 刷新右上角状态文本(每次渲染前由事件与定时器调用)。 */
76
+ /** 刷新右下角状态文本(每次渲染前由事件与定时器调用)。 */
82
77
  function refreshStatus(ctx: any): void {
83
78
  try {
84
- statusLabel = buildStatus(ctx?.getContextUsage?.(), ctx?.model?.id ?? '')
79
+ effortLevel = String(ctx?.thinkingLevel || '')
85
80
  } catch {
86
- statusLabel = shortenModel(ctx?.model?.id ?? '')
81
+ effortLevel = ''
87
82
  }
88
- refreshEffort(ctx)
89
- }
90
-
91
- /** 读出当前推理强度。`ctx.thinkingLevel` 是 pi 官方给扩展的入口(effective level)。 */
92
- function refreshEffort(ctx: any): void {
93
83
  try {
94
- effortLevel = String(ctx?.thinkingLevel || '')
84
+ statusLabel = buildStatus(ctx?.getContextUsage?.(), ctx?.model?.id ?? '', effortLevel)
95
85
  } catch {
96
- effortLevel = ''
86
+ statusLabel = shortenModel(ctx?.model?.id ?? '')
97
87
  }
98
88
  }
99
89
 
100
90
  export default function piRoundedEditor(pi: any): void {
101
91
  pi.on('session_start', (_event: unknown, ctx: any) => {
102
92
  if (!ctx?.ui?.setEditorComponent) return
103
- // 主题 getter(带 fg 的那个)—— 见 themeGetter 注释。
104
- themeGetter = () => ctx.ui?.theme
105
93
  refreshStatus(ctx)
106
- // 第四个参数 embedWorkingStatus: true —— working 状态进上边框,不再单独占一行。
94
+ // 第四个参数 embedWorkingStatus: true —— working 状态进上边框,不单独占一行(2026-11-09 用户定案)。
95
+ // 颜色不用管:pi 嵌入时用 editor.borderColor,本扩展又把整行重上一次框线色,两边同为档位色。
107
96
  ctx.ui.setEditorComponent(
108
97
  (tui: any, theme: any, keybindings: any) =>
109
98
  new RoundedEditor(tui, theme, keybindings, { embedWorkingStatus: true }),
110
99
  )
111
- // ④ 隐藏输入框下方的 footer 两行(路径/git/token、MCP/扩展状态),信息并入右上角。
100
+ // ③ 隐藏输入框下方的 footer 两行(路径/git/token、MCP/扩展状态),信息并入右下角。
112
101
  ctx.ui.setFooter?.(() => ({ render: () => [], invalidate() {} }))
113
102
  })
114
103
 
@@ -120,11 +109,9 @@ export default function piRoundedEditor(pi: any): void {
120
109
  ctx?.ui?.requestRender?.()
121
110
  })
122
111
 
123
- // 切推理强度时重刷输入框文字色(2026-11-06 用户需求)。
124
- // 为何不能只靠 session_start 那一次:/model 的强度档位、Alt+T 之类的切换不改会话,
125
- // 不重建编辑器也就不会重新读 effortLevel —— 文字会停在旧色。
112
+ // 切推理强度:档位字母要跟着变(边框色是 pi 自己换并重绘的,不用管)。
126
113
  pi.on('thinking_level_select', (_event: any, ctx: any) => {
127
- refreshEffort(ctx)
114
+ refreshStatus(ctx)
128
115
  ctx?.ui?.requestRender?.()
129
116
  })
130
117
 
package/hooks/abs.pi.ts CHANGED
@@ -780,13 +780,13 @@ export function pickNickname(rnd: () => number = Math.random): string {
780
780
  * 子项行(缩进的 `- [ ]`)归属到它上一条任务,不单独成条。 */
781
781
  export function parseOpenTasks(md: string, max = PANEL_MAX_ROWS, who = ''): {
782
782
  total: number
783
- rows: { state: string; id: string; desc: string; kids: { done: boolean; text: string }[]; sid: string }[]
783
+ rows: { state: string; id: string; desc: string; kids: { done: boolean; text: string; state: string | null }[]; sid: string }[]
784
784
  hidden: number
785
785
  } {
786
786
  // 两阶段:先无过滤地按序解析(子项归属需要“上一条”是原文里真正的前一条),
787
787
  // 再按作者过滤。若边解析边过滤,被滤掉的条目下方的子项会挂到它上面那一条
788
788
  // —— 实测:别人任务的子项会显示在我的任务下面(2026-10-03 审查发现)。
789
- type Row = { state: string; id: string; desc: string; kids: { done: boolean; text: string }[]; authors: string[]; sid: string }
789
+ type Row = { state: string; id: string; desc: string; kids: { done: boolean; text: string; state: string | null }[]; authors: string[]; sid: string }
790
790
  const parsed: Row[] = []
791
791
  let inTodo = false
792
792
  for (const raw of String(md || '').split('\n')) {
@@ -861,12 +861,20 @@ async function currentUser(): Promise<string> {
861
861
  * 为何不 import src:面板是宿主扩展,单独装到 ~/.pi/extensions/,与包内 src/ 不同目录
862
862
  * (装完就断了)。故两端各一份 —— 规则简单(两步正则),且两边测试都盖。
863
863
  * 标准 md:缩进的 `- [ ] 内容`。 */
864
- function parseChildLine(l: string): { done: boolean; text: string; indent: number } | null {
864
+ /** 行首状态标记的四个词(任务行与子项共用;与 CLI 侧 common.js 的 TASK_STATES 同值)。
865
+ * 两个扩展各自独立安装,所以这里再拄一份,不跨包 import。 */
866
+ const STATE_WORDS = ['进行中', '讨论中', '滞留中', '搁置']
867
+
868
+ function parseChildLine(l: string): { done: boolean; text: string; indent: number; state: string | null } | null {
865
869
  // 只认标准 md 缩进子项(2026-10-20 旧箭头写法已废弃)。
866
870
  // 旧行由 CLI 侧 migrateLegacyChild 在读写时迁移掉,面板拿到的已是新格式。
867
871
  const m = String(l || '').match(/^(\s+)-\s*\[([ x])\]\s*(.*)$/)
868
872
  if (!m) return null
869
- return { done: m[2] === 'x', text: m[3].trim(), indent: m[1].length }
873
+ // 子项也能带状态标记(2026-11-09 用户要求,与任务行同形):只认四个词,
874
+ // 别的方括号(如 `[WIP]`)是正文的一部分,原样留在 text 里(同 CLI 侧 parseChild)。
875
+ const s = m[3].match(/^\[([^\]]+)\]\s*(.*)$/)
876
+ const state = s && STATE_WORDS.includes(s[1]) ? s[1] : null
877
+ return { done: m[2] === 'x', text: (state ? s![2] : m[3]).trim(), indent: m[1].length, state }
870
878
  }
871
879
 
872
880
  function dispWidth(s: string): number {
@@ -939,73 +947,65 @@ function clipToWidth(s: string, width: number): string {
939
947
  * 拿不到 ctx,只能由事件回调先存好。2026-11-06 用户需求:面板主色跟推理强度走。 */
940
948
  let effortLevel = ''
941
949
 
942
- /**
943
- * 推理强度 → 主题色 token。空串表示「不上推理色,用调用方给的兼底色」。
944
- *
945
- * 规则:`thinking` + 首字母大写,正好对上主题里的 thinkingMinimal…thinkingMax 七档。
946
- * 为什么 off 返回空串而不是 `thinkingOff`:主题给 thinkingOff 的是接近背景的暗灰
947
- * (dark.json: `okhsl(229 8% 49%)`),拿它去染文字会看不清。用户 2026-11-06 定的是
948
- * 「off 时保持默认色」。
950
+ /** 档位 → pi 主题里那档 thinking 色(与输入框边框同一个来源)。
949
951
  *
950
- * ★ 同一份逻辑在 hooks/abs-rounded-editor/format.ts 里也有一份(`thinkingColorToken`)。
951
- * 不抽公共模块是故意的:安装后 `abs.ts` 与 `abs-rounded-editor/` 是两个可独立删除的扩展,
952
- * 抽成共享文件会让「删掉输入框样式目录」连带把 todo 面板搞崩。
953
- */
954
- export const EFFORT_COLOR: Record<string, { rgb: string; ansi256: number }> = {
955
- max: { rgb: '197;205;220', ansi256: 189 },
956
- xhigh: { rgb: '168;182;204', ansi256: 152 },
957
- high: { rgb: '150;166;198', ansi256: 110 },
958
- medium: { rgb: '122;138;160', ansi256: 110 },
959
- low: { rgb: '94;108;132', ansi256: 103 },
960
- minimal: { rgb: '70;82;106', ansi256: 240 },
961
- }
962
-
963
- export function effortAnsi(level: string | null | undefined, colorMode = 'truecolor'): string {
964
- const g = EFFORT_COLOR[String(level || '').trim().toLowerCase()]
965
- if (!g) return ''
966
- return colorMode === 'truecolor' ? `38;2;${g.rgb}` : `38;5;${g.ansi256}`
952
+ * 为何要向主题要(2026-11-09 用户要求「todo 上的颜色也跟着 pi 输入框的颜色」):
953
+ * 那七档色写在主题里(dark.json 的 okhsl),自己拄一份就会跟着主题改而跑偏。
954
+ * 为何要抠一层正则:getFgAnsi() 返回的是整段 `\x1b[38;2;…m`,而面板内部用的是
955
+ * 「色码串 + paintAnsi 自己包转义」那套,只能取里面的 `38;2;…`。
956
+ * 空档位或主题不认这个 token → 返回空串,调用处回落到 C.desc。 */
957
+ export function thinkingAnsiOf(theme: any, level: string | null | undefined): string {
958
+ const lvl = String(level || '').trim().toLowerCase()
959
+ if (!lvl) return ''
960
+ const token = `thinking${lvl[0].toUpperCase()}${lvl.slice(1)}`
961
+ try {
962
+ const m = String(theme?.getFgAnsi?.(token) || '').match(/\x1b\[([0-9;]+)m/)
963
+ return m ? m[1] : ''
964
+ } catch {
965
+ return ''
966
+ }
967
967
  }
968
968
 
969
- /** 用 effortAnsi 的结果包一段文字;空串原样返回(off 时不着色)。 */
969
+ /** 用色码串包一段文字(如 `paint('38;2;16;185;129', 'x')`);空串原样返回。 */
970
970
  export function paintAnsi(ansi: string, text: string): string {
971
971
  return ansi ? `\x1b[${ansi}m${text}\x1b[39m` : text
972
972
  }
973
973
 
974
974
  export function renderPanelLines(
975
- data: { total: number; rows: { state: string; id: string; desc: string; kids?: { done: boolean; text: string }[] }[]; hidden: number },
975
+ data: { total: number; rows: { state: string; id: string; desc: string; kids?: { done: boolean; text: string; state?: string | null }[] }[]; hidden: number },
976
976
  who: string,
977
977
  width: number,
978
978
  fg: (color: string, s: string) => string,
979
979
  nickname = '',
980
- effort = '',
980
+ effortColor = '',
981
981
  ): string[] {
982
982
  // ★ 空看板不再直接返回空数组(2026-10-06 用户要求:「即便 todo 没有列表,也要在 pi 上显示」)。
983
983
  // 理由与看板段一致:面板是「机制在不在」的常驻指示,进去就看不见 = 不知道机制活着。
984
984
  // 下面仍要画框和标题,所以这里只是不再提前 return。
985
985
  //
986
- // ★ 主色跟推理强度走(2026-11-06 用户需求):标题、[状态]、树线 ├─└─、复选框 [ ]、
987
- // 进行中动画同用一色,与 pi 输入框描边/文字同色系。
988
- // ★ off 时整块面板用灰阶(同日用户补提:「当推理关闭的时候 todo 面板文字应该全是灰色」)——
989
- // 高亮色只在真的开着推理时给,否则「推理关了面板还在发光」让人误以为还在深度思考。
990
- // 主色用 muted(中灰),描述与子项降一阶到 dim(深灰),两阶灰保留层次,不一整片平。
991
- // 现代暗色方案(2026-11-08 用户给定),不再跟推理强度走。
992
- // 标题色(2026-11-08 用户实测「太亮了」):从纯金 #FFD700 换成与「搁置」同一个橙 #F59E0B
993
- // —— 纯金在暗底上抢眼,标题只是分区标签,不该比当天要做的事更跳。
994
- // 代价:标题与 `[搁置]` 标签同色;两者一个在最左(标题)一个在行中(状态),位置分得开。
986
+ // ★ 文字色跟推理强度走(2026-11-09 用户要求「面板上的文字颜色要与输入框推理强度一致」):
987
+ // 标题、树线、任务 id、描述、子项、`+N more` 全用档位色(effortColor,由调用处从主题取)。
988
+ // ★ 四个状态标签(进行中/讨论中/滞留中/搁置)**保留各自语义色**,不跟档位走 ——
989
+ // 用户选的方案:否则四态只能靠文字看,一眼分不出。别再顺手把它们也统一了。
990
+ // 描述色 C.desc 降为「取不到档位色时的兼底」(主题没这个 token / 模型不支持推理)。
991
+ // 历史(勿重走):2026-11-08 曾定过「现代暗色方案:标题橙、树线蓝灰、描述次灰」,已作废。
995
992
  const C = {
993
+ // 面板大标题回到擱置橙 #F59E0B(2026-11-09 用户试过灰白,看完要回橙;沿用 2026-11-08 的定)。
994
+ // 任务行的 id/描述**不用**这个色 —— 它们跟档位色跑。
996
995
  title: '38;2;245;158;11',
997
996
  task: '38;2;226;232;240',
998
997
  desc: '38;2;148;163;184',
999
- tree: '38;2;150;170;198',
1000
998
  doing: '38;2;16;185;129',
1001
999
  talk: '38;2;59;130;246',
1002
1000
  hold: '38;2;245;158;11',
1003
1001
  stuck: '38;2;139;92;246',
1004
- done: '38;2;100;116;139',
1005
1002
  }
1006
1003
  const stateAnsi = (st: string): string =>
1007
1004
  st === '进行中' ? C.doing : st === '讨论中' ? C.talk : st === '搁置' ? C.hold : st === '滞留中' ? C.stuck : C.task
1008
1005
  const paint = (ansi: string, t: string) => paintAnsi(ansi, t)
1006
+ // 面板文字色 = 调用处从主题取的档位色;取不到(主题没这个 token / 模型不支持推理)回落描述灰。
1007
+ // 提到循环外:标题与 `+N more` 也在用它。
1008
+ const eff = effortColor || C.desc
1009
1009
  const lines: string[] = []
1010
1010
  // 上描边行已删除(2026-11-04 用户要求「去掉上边框」)。
1011
1011
  // 历史:那行曾用 `thinkingOff` 对齐 pi 输入框描边色(排查三轮才定色)。
@@ -1028,35 +1028,38 @@ export function renderPanelLines(
1028
1028
  const branch = isLast ? '└─' : '├─'
1029
1029
  // 进行中的行曾带圆圈动画(2026-11-08 用户定:「这个动画没啥用」,整个去掉)。
1030
1030
  // 去掉后 [进行中] 靠状态色区分,不再额外消耗一次 200ms 重绘。
1031
- const hasState = ['进行中', '讨论中', '搁置', '滞留中'].includes(t.state)
1031
+ const hasState = STATE_WORDS.includes(t.state)
1032
1032
  // 复选框 `[ ]`(2026-10-16 用户要求):面板是 todo.md 的只读视图,
1033
1033
  // 就该长成 todo 原貌 —— 摆成与文件里 `- [ ]` 同形,读到就认得出对应哪一行。
1034
- const eff = effortAnsi(effort, 'truecolor') || C.desc
1035
1034
  const box = hasState ? '' : paint(eff, '[ ] ')
1036
1035
  const stPart = hasState
1037
1036
  ? paint(stateAnsi(t.state), `[${t.state}]`)
1038
1037
  : paint(eff, '[待办]')
1039
- const head = `${paint(C.tree, branch)} ${box}${stPart} ${paint(eff, t.id)}`
1040
- const desc = t.desc ? ' ' + paint(C.desc, `— ${t.desc}`) : ''
1038
+ const head = `${paint(eff, branch)} ${box}${stPart} ${paint(eff, t.id)}`
1039
+ const desc = t.desc ? ' ' + paint(eff, `— ${t.desc}`) : ''
1041
1040
  lines.push(clipToWidth(head + desc, width))
1042
1041
  if (t.kids && t.kids.length) {
1043
1042
  // 子项:标准 md 嵌套列表 → 画成树
1044
1043
  // ├─ [ ] 子项一
1045
1044
  // └─ [x] 子项二
1046
1045
  // 树线前缀 = 父任务的 │(非末条)或空格(末条),与父任务分支对齐。
1047
- // 勾过的用 dim 压暗(一眼看出哪些已完成),未勾用正常色。
1048
- const stem = isLast ? ' ' : paint(C.tree, '│ ')
1046
+ // 勾过的用删除线区分(颜色已统一为档位色),未勾不着修饰。
1047
+ const stem = isLast ? ' ' : paint(eff, '│ ')
1049
1048
  const n = t.kids.length
1050
1049
  t.kids.forEach((k, ki) => {
1051
1050
  const kb = ki === n - 1 ? '└─' : '├─'
1052
- const kbox = paint(eff, k.done ? '[x]' : '[ ]')
1053
- const ktext = k.done ? `\x1b[9m${paint(C.done, k.text)}\x1b[29m` : paint(C.desc, k.text)
1054
- lines.push(clipToWidth(`${stem} ${paint(C.tree, kb)} ${kbox} ${ktext}`, width))
1051
+ // 有状态的子项跟任务同形:不画 `[ ]`,改画状态标签(2026-11-09 用户要求子项也能标四态)。
1052
+ const kbox = k.state
1053
+ ? paint(stateAnsi(k.state), `[${k.state}]`)
1054
+ : paint(eff, k.done ? '[x]' : '[ ]')
1055
+ // 勾过的子项靠删除线区分(颜色已统一为档位色,不再降灰)
1056
+ const ktext = k.done ? `\x1b[9m${paint(eff, k.text)}\x1b[29m` : paint(eff, k.text)
1057
+ lines.push(clipToWidth(`${stem} ${paint(eff, kb)} ${kbox} ${ktext}`, width))
1055
1058
  })
1056
1059
  }
1057
1060
  })
1058
1061
 
1059
- if (data.hidden > 0) lines.push(clipToWidth(paint(C.title, ` +${data.hidden} more`), width))
1062
+ if (data.hidden > 0) lines.push(clipToWidth(paint(eff, ` +${data.hidden} more`), width))
1060
1063
  return lines
1061
1064
  }
1062
1065
  /** 隐藏面板。**必须主动 requestRender** —— 否则数据清了、屏幕还留着旧画面。
@@ -1103,7 +1106,8 @@ async function refreshTodoPanel(ui: any, cwd: string): Promise<void> {
1103
1106
  width,
1104
1107
  (c: string, s: string) => theme.fg(c, s),
1105
1108
  sessionNickname,
1106
- effortLevel,
1109
+ // 面板主色跟 pi 输入框一致:从主题现取当前档位的 thinking 色(2026-11-09 用户要求)。
1110
+ thinkingAnsiOf(theme, effortLevel),
1107
1111
  ),
1108
1112
  invalidate: () => {},
1109
1113
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fanchao8609/agent_brain_sync",
3
- "version": "1.30.0",
3
+ "version": "1.31.1",
4
4
  "description": "agent-brain-sync: 跨会话 AI 编码记忆 — hook 纯触发 + CLI/MCP 读写 .brain markdown 图谱, 防并发写保护。",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -61,7 +61,8 @@ Then run `abs lint` (dead links, over-size, NO-TAIL).
61
61
  abs todo start <id> --note "做什么" # register (note required)
62
62
  abs todo note <id> --note "到哪一步" # append child (one sentence)
63
63
  abs todo done <id> --as 落地|否决|仅方案 "结语"
64
- abs todo state <id> --note 进行中|讨论中|滞留中|搁置
64
+ abs todo state <id> --note 进行中|讨论中|滞留中|搁置 # 任务本身
65
+ abs todo state <id> --note 滞留中 --n 2 # 第 2 条子项(子项也带四态)
65
66
  abs todo remove <id> # delete incl. children, no undo
66
67
  ```
67
68
 
@@ -127,6 +127,19 @@ export function isChildLine(l) {
127
127
  * 存量靠 migrateLegacyChild 一次性迁移(那是全仓唯一还认得 `↳` 的地方)。 */
128
128
  export const CHILD_INDENT = ' ';
129
129
 
130
+ /** 行首状态标记的四个词(任务行与子项共用;2026-11-09 起子项也能标)。
131
+ * 放在本文件而不是 spec.js:parseChild / renderChild 要用它判「行首 [xx] 是不是状态」,
132
+ * 而 common.js 是不 import 任何模块的叶子(spec.js 反向 re-export),放 spec 会成环。 */
133
+ export const TASK_STATES = ['进行中', '讨论中', '滞留中', '搁置'];
134
+
135
+ /** 把 `[xx] 正文` 拆成 { state, text }:只认 TASK_STATES 里的词,
136
+ * 别的方括号(如 `[WIP]`)是正文的一部分,原样留在 text 里。 */
137
+ function splitStateMark(rest) {
138
+ const raw = String(rest ?? '');
139
+ const m = raw.match(/^\[([^\]]+)\]\s*(.*)$/);
140
+ return m && TASK_STATES.includes(m[1]) ? { state: m[1], text: m[2] } : { state: null, text: raw };
141
+ }
142
+
130
143
  /** 唯一还认得旧 `↳` 写法的地方 —— 存量迁移。
131
144
  *
132
145
  * 为何保留这一个函数(2026-10-20 用户定:彻底去掉断点写法,但存量要自动迁移):
@@ -155,13 +168,15 @@ export function migrateLegacyChild(l) {
155
168
  export function parseChild(l) {
156
169
  const m = String(l || '').match(/^(\s+)-\s*\[([ x])\]\s*(.*)$/);
157
170
  if (!m) return null;
158
- return { done: m[2] === 'x', text: m[3].trim(), indent: m[1].length };
171
+ const { state, text } = splitStateMark(m[3]);
172
+ return { done: m[2] === 'x', text: text.trim(), indent: m[1].length, state };
159
173
  }
160
174
 
161
175
  /** 生成子项行(标准 md,统一入口 —— 格式只有这一个真源)。
162
- * indent 默认 2(挂在顶层任务下);多层嵌套时传更深的值。 */
163
- export function renderChild(text, { done = false, indent = CHILD_INDENT } = {}) {
164
- return `${indent}- ${done ? '[x]' : '[ ]'} ${text}`;
176
+ * indent 默认 2(挂在顶层任务下);多层嵌套时传更深的值。
177
+ * state 传四态之一则写成 ` - [ ] [滞留中] 正文`(与任务行同形)。 */
178
+ export function renderChild(text, { done = false, indent = CHILD_INDENT, state = null } = {}) {
179
+ return `${indent}- ${done ? '[x]' : '[ ]'} ${state ? `[${state}] ` : ''}${text}`;
165
180
  }
166
181
 
167
182
  /** Todo 分区里「合法行」以外的东西 = 裸文本(总结/报告/清单)。
package/src/todo/spec.js CHANGED
@@ -199,7 +199,9 @@ export function indexLinkSectionNeeds(section) {
199
199
  * 搁置 = 不做了 / 用户改方向了 → 下会话不该捡,除非用户又提
200
200
  * 混在一起会让下会话分不清「该不该接着干」。 */
201
201
 
202
- export const TASK_STATES = ['进行中', '讨论中', '滞留中', '搁置'];
202
+ // 四个状态词已挪到 common.js(2026-11-09:子项也能标状态,parseChild/renderChild 要用它)——
203
+ // 这里只做转发,别处的 import 路径不变。
204
+ export { TASK_STATES } from './common.js';
203
205
 
204
206
  /** 结构重建(B 档):以标准分区表为准重排整个文件。
205
207
  *
@@ -4,7 +4,7 @@
4
4
  import { brainPath, requireBrain } from '../index.js';
5
5
  import { editFile, SKIP } from '../lock.js';
6
6
  import { requireUser, atTag } from '../userconfig.js';
7
- import { stripStateMark, checkTaskBox, stateOfTaskLine, upsertTask, today, setBreakpoint, setBreakpointDone, setMemo, readMemo, setStateMark, TASK_STATES, insertDoneGrouped, idOfTaskLine, DONE_KINDS, withDoneKind, sessionOf, parseChild, migrateLegacyChild, SEC } from '../todo.js';
7
+ import { stripStateMark, checkTaskBox, stateOfTaskLine, upsertTask, today, setBreakpoint, setBreakpointDone, setMemo, readMemo, setStateMark, setChildStateMark, TASK_STATES, insertDoneGrouped, idOfTaskLine, DONE_KINDS, withDoneKind, sessionOf, parseChild, migrateLegacyChild, SEC } from '../todo.js';
8
8
  import { ensurePersonPage } from '../note.js';
9
9
 
10
10
  // 纪律: task 过程动作(start/done/note/blocked)只改 todo.md, 不写 log.md。
@@ -131,8 +131,9 @@ export async function cmdTask({ dir, action, id, section, note, as, session, n }
131
131
  return done ? `✓ 任务改名 ${id} → ${newId}` : `[NO_MATCH] 未找到含 "${id}" 的未完成任务行`;
132
132
  }
133
133
  if (action === 'state') {
134
- // 改状态标记(原地,不搬区):进行中 / 讨论中 / 滞留中
135
- if (!note) throw new Error(`✗ 用法: abs todo state <id> --note "${TASK_STATES.join('|')}"`);
134
+ // 改状态标记(原地,不搬区):进行中 / 讨论中 / 滞留中 / 搁置。
135
+ // 带 n = 改某任务的第 n 条子项(2026-11-09 用户要求给子项加状态);不带给 n = 改任务本身。
136
+ if (!note) throw new Error(`✗ 用法: abs todo state <id> --note "${TASK_STATES.join('|')}" [--n N]`);
136
137
  if (!TASK_STATES.includes(note)) {
137
138
  throw new Error(`✗ 状态只接受: ${TASK_STATES.join(' | ')}(收到 "${note}")`);
138
139
  }
@@ -140,11 +141,16 @@ export async function cmdTask({ dir, action, id, section, note, as, session, n }
140
141
  let cc = [];
141
142
  await editFile(f, (cur) => {
142
143
  if (cur === null) return SKIP;
143
- const r = setStateMark(cur, id, note);
144
+ const r = n ? setChildStateMark(cur, id, n, note) : setStateMark(cur, id, note);
144
145
  cc = r.changed;
145
146
  return r.changed.length ? { text: r.text } : SKIP;
146
147
  });
147
- return cc.length ? `✓ ${id} 状态 → [${note}]` : `[NO_MATCH] 未找到含 "${id}" 的未完成任务行,或状态未变`;
148
+ if (!cc.length) {
149
+ return n
150
+ ? `[NO_MATCH] "${id}" 下没有第 ${n} 条子项(或任务不存在),或状态未变`
151
+ : `[NO_MATCH] 未找到含 "${id}" 的未完成任务行,或状态未变`;
152
+ }
153
+ return `✓ ${id} ${n ? `第 ${n} 条子项` : ''}状态 → [${note}]`;
148
154
  }
149
155
  // 删除任务(2026-11-05 用户要求「每次删除任务可烦人了」)。
150
156
  // 与 state=搁置 的区别(别混):
package/src/todo/tasks.js CHANGED
@@ -198,13 +198,43 @@ export async function setBreakpointDone(brainRoot, { id, n, done = true }) {
198
198
  if (target === undefined) return SKIP;
199
199
  const child = parseChild(lines[target]);
200
200
  picked = child;
201
- lines[target] = renderChild(child.text, { done });
201
+ // 保留印过的状态标记(2026-11-09):只翻复选框,不能把 [滞留中] 这类标记抹掉。
202
+ lines[target] = renderChild(child.text, { done, indent: ' '.repeat(child.indent), state: child.state });
202
203
  return { text: lines.join('\n'), ok: true };
203
204
  });
204
205
  if (res === SKIP || !picked) {
205
206
  return { ok: false, msg: `[NO_MATCH] "${id}" 下没有第 ${n || '(最后)'} 条子项(或任务不存在)` };
206
207
  }
207
- return { ok: true, msg: `✓ 子项 ${done ? '已勾' : '已取消勾'} → ${id}\n ${renderChild(picked.text, { done })}` };
208
+ return { ok: true, msg: `✓ 子项 ${done ? '已勾' : '已取消勾'} → ${id}\n ${renderChild(picked.text, { done, state: picked.state })}` };
209
+ }
210
+
211
+ /** 改**子项**的状态标记(2026-11-09 用户要求:子项也要能标四态)。
212
+ *
213
+ * 为何不复用 setStateMark:子项没有自己的 id,findTaskLine 只能定位任务行
214
+ * (idOfTaskLine 取的是行首第一个 token,子项那行取到的是正文首字),
215
+ * 所以子项只能按「某任务的第 n 条」定位(与 child-done 同一套坐标)。
216
+ * 任务行:`- [ ] [进行中] TASK-1 …`
217
+ * 子项行:` - [ ] [滞留中] 改到哪一步`
218
+ * n 必填(1-based):不带 n 的「改状态」= 改任务本身(setStateMark),
219
+ * 子项路径只由带 n 的调用进来。非正整数或越界 → 当没匹配,不静默改到别的行。 */
220
+ export function setChildStateMark(text, id, n, state) {
221
+ if (!TASK_STATES.includes(state)) return { text, changed: [] };
222
+ if (!Number.isInteger(n) || n < 1) return { text, changed: [] };
223
+ const lines = String(text ?? '').split('\n');
224
+ const idx = findTaskLine(lines, id);
225
+ if (idx === -1) return { text, changed: [] };
226
+ const kids = childRange(lines, idx);
227
+ const target = kids[n - 1];
228
+ if (target === undefined) return { text, changed: [] };
229
+ const child = parseChild(lines[target]);
230
+ if (!child || child.state === state) return { text, changed: [] };
231
+ const ord = kids.indexOf(target) + 1;
232
+ lines[target] = renderChild(child.text, {
233
+ done: child.done,
234
+ indent: ' '.repeat(child.indent),
235
+ state,
236
+ });
237
+ return { text: lines.join('\n'), changed: [`${id} 第 ${ord} 条子项状态 → ${state}`] };
208
238
  }
209
239
 
210
240
  // ---------- 任务随手记(memo)----------
package/src/todo.js CHANGED
@@ -7,5 +7,5 @@ export { LEGACY_SECTION_RENAMES, TODO_SECTIONS, TODO_SUBSECTIONS, isAllowedTodoS
7
7
  export { todoTemplate, rebuildStructure, dropRetiredSections, enforceBrainFormat, normalizeTodo, ensureStateMark, stateOfTaskLine, stripStateMark, checkTaskBox } from './todo/structure.js';
8
8
  export { checkFileShape, violationSignatures, assertNoStrayBlank, assertTodoContent } from './todo/validate.js';
9
9
  export { doneDateOf, doneKindOf, withDoneKind, collapseDone, archiveDoneInText, renderArchiveBody, upsertArchiveSection, groupDoneSection, insertDoneGrouped, archiveBrief } from './todo/archive.js';
10
- export { readTodo, ensureTodo, setStateMark, addTask, upsertTask, idOfTaskLine, findTaskLine, setBreakpoint, setBreakpointDone, setMemo, readMemo, boardText } from './todo/tasks.js';
10
+ export { readTodo, ensureTodo, setStateMark, setChildStateMark, addTask, upsertTask, idOfTaskLine, findTaskLine, setBreakpoint, setBreakpointDone, setMemo, readMemo, boardText } from './todo/tasks.js';
11
11
  export { migrateLegacyChild, isTaskLine, isChildLine, CHILD_INDENT } from './todo/common.js';