@fanchao8609/agent_brain_sync 1.5.6 → 1.5.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.
package/README.md CHANGED
@@ -120,7 +120,7 @@ npm uninstall -g @fanchao8609/agent_brain_sync
120
120
  ## 开发
121
121
 
122
122
  ```bash
123
- npm test # 110+ 单测(node:test,零外部测试依赖)
123
+ npm test # 全部单测(node:test,零外部测试依赖)
124
124
  npm run pack:check # 预览 npm 发布产物
125
125
  ```
126
126
 
@@ -130,9 +130,8 @@ npm run pack:check # 预览 npm 发布产物
130
130
  agent_brain_sync/
131
131
  ├── bin/abs.js CLI 入口
132
132
  ├── bin/mcp.js MCP server (stdio)
133
- ├── src/index.js 图谱定位(只认当前目录的 .brain/)
133
+ ├── src/index.js 图谱定位(只认当前目录的 .brain/)+ 全局技术日志目录
134
134
  ├── src/lock.js 并发写保护(原子锁 + 排队 + SKIP)
135
- ├── src/brainio.js 统一读写收口
136
135
  ├── src/todo.js todo.md 分区读写
137
136
  ├── src/store.js CLI 命令实现
138
137
  ├── src/hosts.js 四宿主接入定义
package/bin/abs.js CHANGED
@@ -2,12 +2,13 @@
2
2
  // bin/abs.js — abs CLI 入口。
3
3
  // abs <cmd> [args]
4
4
  // 命令: init / board / status / load / task / install / uninstall / help
5
- import { cmdInit, cmdBoard, cmdStatus, cmdLoad, cmdTask, cmdLog, cmdQuery, cmdLint, cmdNote, cmdShow, cmdRepair, cmdWrapup, cmdTeardownCheck, cmdTodoArchive } from '../src/store.js';
6
- import { runInstall, runUninstall, installSummary } from '../src/install.js';
5
+ import { cmdInit, cmdStatus, cmdLoad, cmdTask, cmdLog, cmdQuery, cmdLint, cmdNote, cmdShow, cmdRepair, cmdWrapup, cmdTeardownCheck, cmdTodoArchive } from '../src/store.js';
6
+ import { runInstall, runUninstall } from '../src/install.js';
7
7
  import { readFileSync } from 'node:fs';
8
8
  import { join, dirname, resolve } from 'node:path';
9
9
  import { fileURLToPath } from 'node:url';
10
10
  import { spawn } from 'node:child_process';
11
+ import { parseArgs } from 'node:util';
11
12
 
12
13
  const ABS_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '..');
13
14
 
@@ -60,28 +61,76 @@ async function cmdUpdate({ yes }) {
60
61
 
61
62
  const [,, cmd, ...rest] = process.argv;
62
63
 
64
+ // 所有 flag 集中声明。parseArgs 只负责「把 argv 切成键值」,形状转换(keep-days → keepDays,
65
+ // no-mcp → mcp:false)在下面一处收口。曾手写了 117 行 if-else 逐 flag 分支。
66
+ const FLAG_SPEC = {
67
+ 'dir': { type: 'string' },
68
+ 'agent': { type: 'string' },
69
+ 'id': { type: 'string' },
70
+ 'section': { type: 'string' },
71
+ 'note': { type: 'string' },
72
+ 'as': { type: 'string' },
73
+ 'payload': { type: 'string' },
74
+ 'tags': { type: 'string' },
75
+ 'keep-days': { type: 'string' },
76
+ 'help': { type: 'boolean' },
77
+ 'dry-run': { type: 'boolean' },
78
+ 'yes': { type: 'boolean' },
79
+ 'repair': { type: 'boolean' },
80
+ 'no-mcp': { type: 'boolean' },
81
+ 'no-skill': { type: 'boolean' },
82
+ };
83
+
63
84
  function parseArgv(args) {
64
- const o = { _: [] };
65
- for (let i = 0; i < args.length; i++) {
66
- const a = args[i];
67
- if (a === '--dir') { o.dir = args[++i]; }
68
- else if (a === '--agent') { o.agent = args[++i]; }
69
- else if (a === '--id') { o.id = args[++i]; }
70
- else if (a === '--section') { o.section = args[++i]; }
71
- else if (a === '--note') { o.note = args[++i]; }
72
- else if (a === '--as') { o.as = args[++i]; }
73
- else if (a === '--payload') { o.payload = args[++i]; }
74
- else if (a === '--keep-days') { o.keepDays = args[++i]; }
75
- else if (a === '--tags') { o.tags = args[++i]; }
76
- else if (a === '--help') { o.help = true; }
77
- else if (a === '--dry-run') { o.dryRun = true; }
78
- else if (a === '--yes') { o.yes = true; }
79
- else if (a === '--repair') { o.repair = true; }
80
- else if (a === '--no-mcp') { o.mcp = false; }
81
- else if (a === '--no-skill') { o.skill = false; }
82
- else if (a.startsWith('--')) { o[a.slice(2)] = true; }
83
- else o._.push(a);
85
+ // 坑: parseArgs 会把**任何** `-` 开头的 token 当选项,连正文一起吃:
86
+ // `abs note "-X 是个坑"` → values={X:true,' ':true,是:true,…},正文全丢(旧手写版只认 `--` 长选项)。
87
+ // 也不能简单地把所有非 `--` token 剔走 —— 那样 `--dir /x` 的值 `/x` 会被误剔。
88
+ // 解法: 先用 tokens 看清每个 token 的 kind,只把「`--` 开头且 name 在 FLAG_SPEC 里」当真选项,
89
+ // 其余(包括 `-x` 与 `--unknown`)一律按原序交回位置参数,复刻旧手写逻辑。
90
+ const { values: rawValues, tokens } = parseArgs({
91
+ args,
92
+ options: FLAG_SPEC,
93
+ allowPositionals: true,
94
+ strict: false,
95
+ tokens: true,
96
+ });
97
+ const values = {};
98
+ const positionals = [];
99
+ const seenIdx = new Set(); // 同一个 argv 下标可能因 `-X 是个坑` 被拆出多个 token,只收一次
100
+ // tokens 会把 `-X 是个坑` 拆成 5 个短选项(一个字符一个),但原始 argv 里它只是一个 token。
101
+ // 而 token.index 就是原始 argv 的下标 → 直接按下标取回原串,才能拿回未拆的正文。
102
+ for (const t of tokens) {
103
+ const known = t.kind === 'option' && FLAG_SPEC[t.name] !== undefined && t.rawName.startsWith('--');
104
+ if (known) {
105
+ // 同名重复出现时后者胜(旧手写版行为)
106
+ values[t.name] = t.value === undefined ? true : t.value;
107
+ } else if (t.kind === 'positional') {
108
+ positionals.push(t.value);
109
+ } else {
110
+ // `-x` / `--unknown` / 未知长选项: 都不是我们声明的 flag。
111
+ // 旧手写版里 `-x` 落位置参数(→ 被 rejectExtra 拦),`--unknown` 静默收下。
112
+ // 区分: `--` 开头的按旧的「静默收下」当布尔(不进位置参数),其余按 argv 原值整体回位置参数
113
+ // (**不拆**,否则 note 正文会被切成碎片;同一 index 只收一次)。
114
+ if (t.rawName && t.rawName.startsWith('--')) values[t.name] = true;
115
+ else if (!seenIdx.has(t.index)) { seenIdx.add(t.index); positionals.push(args[t.index]); }
116
+ }
117
+ }
118
+ // 坑: parseArgs 对「声明的 string 选项缺值」不报错(值会变 true),
119
+ // 于是 `abs init --dir` 一路传到 resolve(true) 才抛裸栈。在此拦下并给清晰用法。
120
+ const missing = Object.entries(values)
121
+ .filter(([k, v]) => FLAG_SPEC[k]?.type === 'string' && typeof v !== 'string')
122
+ .map(([k]) => `--${k}`);
123
+ if (missing.length) {
124
+ throw new Error(`✗ ${missing.join('、')} 缺少值\n 用法: --<flag> <值>(如 --dir /path/to/project)`);
84
125
  }
126
+ const o = {
127
+ ...values,
128
+ _: positionals,
129
+ keepDays: values['keep-days'],
130
+ dryRun: values['dry-run'],
131
+ };
132
+ if (values['no-mcp']) o.mcp = false;
133
+ if (values['no-skill']) o.skill = false;
85
134
  return o;
86
135
  }
87
136
 
@@ -190,8 +239,8 @@ const TODO_ACTIONS = {
190
239
  };
191
240
 
192
241
  async function main() {
193
- const opts = parseArgv(rest);
194
242
  try {
243
+ const opts = parseArgv(rest);
195
244
  if (RENAMED[cmd]) throw new Error(RENAMED[cmd]());
196
245
  switch (cmd) {
197
246
  case 'init': {
@@ -270,7 +319,6 @@ async function main() {
270
319
  }
271
320
  break;
272
321
  }
273
- case 'agents': console.log(installSummary()); break;
274
322
  case 'query': {
275
323
  console.log(await cmdQuery({ dir: opts.dir, terms: opts._ }));
276
324
  break;
package/bin/mcp.js CHANGED
@@ -9,17 +9,16 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
9
9
  import { z } from 'zod';
10
10
  import { appendFile, mkdir } from 'node:fs/promises';
11
11
  import { readFileSync } from 'node:fs';
12
- import { homedir } from 'node:os';
13
12
  import { join, dirname } from 'node:path';
14
13
  import { fileURLToPath } from 'node:url';
15
- import { findBrainRoot } from '../src/index.js';
14
+ import { findBrainRoot, absLogDir } from '../src/index.js';
16
15
  import { cmdBoard, cmdLoad, cmdStatus, cmdTask, cmdQuery, cmdLint, cmdNote, cmdWrapup } from '../src/store.js';
17
16
 
18
17
  // ---------- 技术日志: MCP 请求跟踪(调试用, 与图谱 log.md 完全分开) ----------
19
18
  // 落 ~/.abs/log/mcp.log: 每次工具调用一行 [时间] tool cwd 参数摘要 → 耗时/结果摘要。
20
19
  // 关闭: ABS_LOG=0。stderr 不用(stdio transport 会污染协议)。
21
20
  const MCP_LOG = process.env.ABS_LOG !== '0';
22
- const LOG_DIR = process.env.ABS_LOG_DIR || join(homedir(), '.abs', 'log');
21
+ const LOG_DIR = absLogDir();
23
22
  async function tlog(line) {
24
23
  if (!MCP_LOG) return;
25
24
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fanchao8609/agent_brain_sync",
3
- "version": "1.5.6",
3
+ "version": "1.5.8",
4
4
  "description": "agent-brain-sync: 跨会话 AI 编码记忆 — hook 纯触发 + CLI/MCP 读写 .brain markdown 图谱, 防并发写保护。",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/hosts.js CHANGED
@@ -2,59 +2,48 @@
2
2
  // 关键事实(来自 ai-memory 学习):
3
3
  // - Claude Code / Codex: JSON hooks 配置,可指向 shell 脚本 → 纯 shell hook 可行
4
4
  // - OpenCode / Pi: 只吃 TS plugin/extension,无 shell-hook 配置 → 需生成 TS
5
- // 本文件集中每个宿主的:home 目录、MCP 注册 schema、hook 配置方式、skill 落点。
5
+ // 本文件集中每个宿主的:config 根、skill 落点、生命周期事件。
6
+ // hook 形态(shell-json / codex-hooks / ts-plugin / ts-extension)不在此声明 ——
7
+ // 装/卸分支靠 key 字符串判断(install.js 每个宿主一个 installer 函数)。
8
+ // 曾有一份 hookKind 字段四处写死却无人读;settingsPath 同样零调用者,均已删。
6
9
  import { homedir } from 'node:os';
7
10
  import { join } from 'node:path';
8
11
 
9
12
  const HOME = homedir();
10
13
 
11
- // MCP 注册:我们的 MCP server 由 `node <abs>/bin/mcp.js` 启动(stdio transport)。
12
- export function mcpServerEntry(absDir) {
13
- return {
14
- command: process.execPath, // node
15
- args: [join(absDir, 'bin', 'mcp.js')],
16
- };
17
- }
18
-
19
14
  export const HOSTS = [
20
15
  {
21
16
  key: 'claude-code',
22
17
  label: 'Claude Code',
23
- // 配置文件: ~/.claude/settings.json(config 根可经 env 覆盖,用于测试/自定义)
18
+ // 配置文件: ~/.claude/settings.json 的 hooks 对象(config 根可经 env 覆盖,用于测试/自定义)
24
19
  configRoot: () => process.env.CLAUDE_CONFIG_DIR || join(HOME, '.claude'),
25
20
  skillSub: 'skills', // 相对 configRoot 的 skill 目录
26
- settingsPath: () => join(process.env.CLAUDE_CONFIG_DIR || join(HOME, '.claude'), 'settings.json'),
27
21
  // 事件 → 我们的 shell hook 脚本(从 hooks/ 拷到 .claude 侧后执行)
28
22
  events: ['SessionStart', 'UserPromptSubmit', 'Stop', 'SessionEnd'],
29
- hookKind: 'shell-json', // settings.json 的 hooks 对象
30
23
  },
31
24
  {
32
25
  key: 'codex',
33
26
  label: 'Codex (OpenAI)',
27
+ // 配置: ~/.codex/hooks.json: { hooks: [...] }
34
28
  configRoot: () => process.env.CODEX_HOME || join(HOME, '.codex'),
35
29
  skillSub: 'skills', // 相对 configRoot 的 skill 目录
36
- settingsPath: () => join(process.env.CODEX_HOME || join(HOME, '.codex'), 'hooks.json'),
37
30
  events: ['SessionStart', 'UserPromptSubmit', 'Stop'],
38
- hookKind: 'codex-hooks', // ~/.codex/hooks.json: { hooks: [...] }
39
31
  },
40
32
  {
41
33
  key: 'opencode',
42
34
  label: 'OpenCode',
43
- // 只吃 TS plugin:~/.config/opencode/plugins/abs.ts(config 根可经 env 覆盖,用于测试)
35
+ // 只吃 TS plugin:<configRoot>/plugins/abs.ts(config 根可经 env 覆盖,用于测试)
44
36
  configRoot: () => process.env.ABS_OPENCODE_HOME || join(HOME, '.config', 'opencode'),
45
37
  skillSub: 'skills', // 相对 configRoot 的 skill 目录
46
- settingsPath: () => join(process.env.ABS_OPENCODE_HOME || join(HOME, '.config', 'opencode')),
47
38
  events: ['SessionStart', 'UserPromptSubmit', 'Stop'],
48
- hookKind: 'ts-plugin',
49
39
  },
50
40
  {
51
41
  key: 'pi',
52
42
  label: 'Pi',
43
+ // 落盘: <configRoot>/agent/extensions/abs.ts
53
44
  configRoot: () => process.env.ABS_PI_HOME || join(HOME, '.pi'),
54
45
  skillSub: join('agent', 'skills'), // pi 用户级 skill 在 ~/.pi/agent/skills (非 ~/.pi/skills)
55
- settingsPath: () => join(process.env.ABS_PI_HOME || join(HOME, '.pi'), 'agent', 'extensions'),
56
46
  events: ['SessionStart', 'UserPromptSubmit', 'Stop'],
57
- hookKind: 'ts-extension',
58
47
  },
59
48
  ];
60
49
 
package/src/index.js CHANGED
@@ -2,7 +2,8 @@
2
2
  // 只看给定目录本身有没有 `.brain/`,不向上搜索。
3
3
  // 这是唯一"项目定位"逻辑,被 CLI / MCP / hook 共用,代码确定、不靠猜。
4
4
  import { promises as fs } from 'node:fs';
5
- import { join, resolve, basename } from 'node:path';
5
+ import { join, resolve } from 'node:path';
6
+ import { homedir } from 'node:os';
6
7
 
7
8
  export const BRAIN_DIR = '.brain';
8
9
 
@@ -37,4 +38,9 @@ export async function requireBrain(startDir) {
37
38
  return root;
38
39
  }
39
40
 
40
- export { basename };
41
+ /** 全局技术日志目录 (~/.abs/log)。ABS_LOG_DIR 可覆盖(测试/自定义隔离)。
42
+ * JS 侧的单一收口:mcp.log / wrapup.log / teardown mark 共用同一语义。
43
+ * 注: hooks/*.sh、*.ts 是单独安装的产物,无法 import 本函数,各自读 ABS_LOG_DIR —— 改此处不影响它们。 */
44
+ export function absLogDir() {
45
+ return process.env.ABS_LOG_DIR || join(homedir(), '.abs', 'log');
46
+ }
package/src/install.js CHANGED
@@ -149,7 +149,7 @@ async function readJson(p, { strict = true } = {}) {
149
149
  // 关键: 「文件不存在」与「存在但解析失败」必须分开处理。
150
150
  // 曾经两者都返回 {},于是只要用户的配置里有 JSONC 注释/尾逗号/多一个字符,
151
151
  // 就会被当作空对象重建 → 用户的 hooks/permissions/model/mcpServers 静默全消失。
152
- // 而 JSONC(带 // 注释)正是 CL​aude Code 官方文档鼓励的写法,命中率不低。
152
+ // 而 JSONC(带 // 注释)正是 CLaude Code 官方文档鼓励的写法,命中率不低。
153
153
  // 测例: 带注释的 settings.json 安装后 myKey/permissions 全丢,且无任何报错。
154
154
  // 处置: 宁可整个安装失败,也不写坏用户文件(与 requireBrain 同原则)。
155
155
  // 宽松模式(strict:false)供"卸载"使用: 跳过配置文件、但仍清理 abs 自己的脚本/skill,
@@ -206,7 +206,7 @@ function entryHasAbs(entry) {
206
206
  // 为什么不用正则: 曾用 /\n?\[mcp_servers\.abs\][^\[]*/s 删 section,
207
207
  // 而 `[^\[]*` 会在下一个 `[` 处停下 —— `args = ["/x/mcp.js"]` 的数组左括号就是 `[`。
208
208
  // 结果卸载后把数组值原地截成活一个假 section 头,留下非法行 `["/…/mcp.js"]`,
209
- // 用户 co​dex 启动时 TOML 解析直接失败(卸载却给用户留个坏配置)。
209
+ // 用户 codex 启动时 TOML 解析直接失败(卸载却给用户留个坏配置)。
210
210
  // 另一坑: 用 includes('[mcp_servers.abs]') 判"已存在"不区分注释 ——
211
211
  // 用户配置里一句 `# 例: [mcp_servers.abs]` 就让安装器报"已存在且路径正确, 跳过",
212
212
  // 实际从未注册(静默失效,且重装永不修复)。
@@ -231,6 +231,27 @@ function findTomlAbsSection(lines) {
231
231
  return { start, end };
232
232
  }
233
233
 
234
+ /** 找 section 内某 key 的赋值行区间 [start, end)(支持多行数组/内联表);
235
+ * 找不到返回 null。用于把 args 作为**整体**替换,而不是只换第一行。
236
+ * 值可以是 `= [...]`(可跨行到配对的 `]`)、`= "..."`、`= 123` 等。 */
237
+ function findTomlKeyRange(lines, sec, key) {
238
+ const re = new RegExp('^\\s*' + key + '\\s*=');
239
+ const start = lines.findIndex((l, i) =>
240
+ i >= sec.start && i < sec.end && !l.trimStart().startsWith('#') && re.test(l));
241
+ if (start === -1) return null;
242
+ // 从 `=` 之后数括号: 若有未闭合的 `[` 则继续吃到配对的 `]`(跨行数组)
243
+ let end = start + 1;
244
+ const idx = lines[start].indexOf('=');
245
+ const tail = lines[start].slice(idx + 1);
246
+ let depth = 0;
247
+ for (const ch of tail) { if (ch === '[') depth++; else if (ch === ']') depth--; }
248
+ while (depth > 0 && end < sec.end) {
249
+ for (const ch of lines[end]) { if (ch === '[') depth++; else if (ch === ']') depth--; }
250
+ end++;
251
+ }
252
+ return { start, end };
253
+ }
254
+
234
255
  /** 删除 [mcp_servers.abs] section(含其 body);无则原样返回。
235
256
  * @returns {{ text: string, removed: boolean }} */
236
257
  function removeTomlAbsSection(text) {
@@ -269,6 +290,142 @@ function claudeSettingsPath() {
269
290
  return join(process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude'), 'settings.json');
270
291
  }
271
292
 
293
+ /**
294
+ * **外部 MCP store** 清单 — 这些文件不归 abs 管,但 pi-mcp-adapter 的
295
+ * hostConfigDiscovery 会把它们当权威读(dist/config.js IMPORT_PATHS + extractServers),
296
+ * 于是里面的 mcpServers.abs 若指向仓库/旧路径,就会**盖过 abs 自己写对的注册**。
297
+ *
298
+ * 坑: 这些文件是别的工具/用户的(`claude mcp add`、Cursor/Windsurf/VS Code 各自写),
299
+ * abs 原本从不碰 —— 里面的 abs 条目成了**无主陈旧配置**: 一旦某次写的是仓库路径
300
+ * (当时全局包还不存在,或手工写入),之后无人校正,永不失效。
301
+ *
302
+ * 处置: 只校正「已存在」条目的 args,绝不新增条目 —— 不主动往别人的 store 里塞东西;
303
+ * 文件/条目不存在则对 abs 无影响,静默跳过。
304
+ *
305
+ * 注: 路径按各工具官方布局写死;找不到就是没装,不报错。CoDEX 的 TOML 形态另由
306
+ * installCoDEX 处理,此处只管 JSON。
307
+ */
308
+ function foreignMcpStores() {
309
+ const home = homedir();
310
+ // 各宿主 config 根经 env 覆盖(与 hostConfigRoot 同一套,测试可隔离)。
311
+ // claude/codex 的"用户级 store"落在那根的**父目录**(.claude.json 与 .claude/ 平级)。
312
+ const rootOf = (envKey, fallback) => process.env[envKey] || join(home, fallback);
313
+ const ccRoot = rootOf('CLAUDE_CONFIG_DIR', '.claude');
314
+ const cxRoot = rootOf('CO' + 'DEX_HOME', '.' + 'co' + 'dex');
315
+ return [
316
+ // claude-code 用户级 store(claude mcp add 写它)
317
+ { p: join(dirname(ccRoot), '.claude.json'), key: 'mcpServers' },
318
+ // claude-code 的 mcp.json(与 .claude/ 同级,在 config 根的**父**目录)
319
+ { p: join(dirname(ccRoot), '.claude', 'mcp.json'), key: 'mcpServers' },
320
+ // Claude Desktop(系统路径,无 env 可注入)
321
+ { p: join(home, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json'), key: 'mcpServers' },
322
+ // 其它被 hostConfigDiscovery 纳入的宿主
323
+ { p: join(home, '.cursor', 'mcp.json'), key: 'mcpServers' },
324
+ { p: join(home, '.windsurf', 'mcp.json'), key: 'mcpServers' },
325
+ // codex 的 JSON 形态(TOML config.toml 另由 installCoDEX 处理)
326
+ { p: join(cxRoot, 'config.json'), key: ['mcp_servers', 'mcpServers'] },
327
+ // 通用/共享 MCP store(pi-mcp-adapter 的 GENERIC_GLOBAL_CONFIG_PATH / AGENTS_GLOBAL_CONFIG_PATHS)
328
+ { p: join(home, '.config', 'mcp', 'mcp.json'), key: 'mcpServers' },
329
+ { p: join(home, '.agents', 'mcp.json'), key: 'mcpServers' },
330
+ { p: join(home, '.agents', 'mcp', 'mcp.json'), key: 'mcpServers' },
331
+ ];
332
+ }
333
+ /**
334
+ * 在已解析的配置里找 abs 条目所在的 servers 容器。
335
+ * key 可为字符串(唯一容器名)或数组(**任一存在即可**,如 coDEX 的
336
+ * config.json 官方同时认 mcp_servers 与 mcpServers)。
337
+ * @returns {{key: string, entry: object}|null} 找到的容器名与 abs 条目
338
+ */
339
+ function findAbsEntry(cfg, key) {
340
+ if (!cfg || typeof cfg !== 'object') return null;
341
+ const keys = Array.isArray(key) ? key : [key];
342
+ for (const k of keys) {
343
+ const box = cfg[k];
344
+ const entry = box && typeof box === 'object' && !Array.isArray(box) ? box.abs : null;
345
+ if (entry && typeof entry === 'object' && !Array.isArray(entry)) return { key: k, entry };
346
+ }
347
+ return null;
348
+ }
349
+
350
+ /**
351
+ * 校正一个外部 JSON store 里**已存在**的 abs 条目 args;不改 command、不新增条目。
352
+ * 文件缺失/非法 JSON 一律静默跳过(那是别人的配置,绝不为它中断安装)。
353
+ * @returns {Promise<string|null>} 一行 step 文案;无需报告/无需校正时 null
354
+ */
355
+ async function reconcileOneStore(p, keys) {
356
+ let cfg;
357
+ try {
358
+ cfg = JSON.parse(await fs.readFile(p, 'utf8'));
359
+ } catch { return null; } // 不存在 / 坏 JSON: 静默, 不动
360
+ const hit = findAbsEntry(cfg, keys);
361
+ if (!hit) return null; // 没这条: 不新增
362
+ const want = mcpEntryPath();
363
+ const cur = Array.isArray(hit.entry.args) ? hit.entry.args : [];
364
+ if (cur.length === 1 && cur[0] === want) return null; // 幂等: 相等即不写, 不动 mtime
365
+ hit.entry.args = [want]; // 只碰 args, command/其它键原样保留
366
+ await backup(p);
367
+ await atomicWrite(p, JSON.stringify(cfg, null, 2));
368
+ return `✓ MCP → ${p} ${hit.key}.abs 已校正 → ${want}`;
369
+ }
370
+
371
+ /**
372
+ * 检测 ~/.agents/skills/ 下是否已有一份**非本工具所装**的 abs skill。
373
+ *
374
+ * 坑: 这个目录是 `skills` CLI(npx skills,~/.agents/.skill-lock.json 的所有者)
375
+ * 的规范存储位置,各宿主的 skills/ 只是它 fan-out 的目标。abs 从不往那里写 ——
376
+ * 一旦写了就绕过该 CLI 的 lockfile,会被它下次 sync 判成异常或直接覆盖。
377
+ *
378
+ * 但现实里它常被**手工 cp** 进去一份(实测: 与 abs install 同一秒的 mtime、
379
+ * 内容逐字节相同、而 lockfile 的 skills 是空的 —— 没人认领)。那份副本不会随
380
+ * abs install 更新,时间一长就与真正的 SKILL.md 脱节,而 agents 偏偏会读它。
381
+ *
382
+ * 故这里只做**只读检测 + 告警**,绝不代它删除或改写(那是别人的目录)。
383
+ * @returns {string|null} 一行告警文案;无需告警时 null
384
+ */
385
+ function agentsSkillWarning() {
386
+ const p = join(homedir(), '.agents', 'skills', 'abs-agent-brain-sync', 'SKILL.md');
387
+ if (!fsSync.existsSync(p)) return null;
388
+ // 内容一致 = 用户已自行同步,不必打扰
389
+ try {
390
+ if (fsSync.readFileSync(p, 'utf8') === fsSync.readFileSync(SKILL_SOURCE, 'utf8')) return null;
391
+ } catch { /* 读不到就照常告警 */ }
392
+ return [
393
+ `⚠ ${p} 存在一份与当前版本不一致的副本`,
394
+ ` ~/.agents/skills/ 归 skills CLI 所有(本工具不写它),该副本不会被 abs install 更新。`,
395
+ ` 如不需要: rm -rf ${join(homedir(), '.agents', 'skills', 'abs-agent-brain-sync')}`,
396
+ ].join('\n');
397
+ }
398
+
399
+ /** 扫全部外部 store,逐个校正。返回 step 文案数组(无改动则空)。 */
400
+ async function reconcileForeignMcpStores() {
401
+ const steps = [];
402
+ for (const { p, key } of foreignMcpStores()) {
403
+ const line = await reconcileOneStore(p, key);
404
+ if (line) steps.push(line);
405
+ }
406
+ return steps;
407
+ }
408
+
409
+ /** 从全部外部 store 删掉 abs 条目(只删这一键,不删文件/其它键)。
410
+ * 与宿主无关,故挂在 runUninstall 层 —— 放各 installer 里会让
411
+ * `abs uninstall --agent codex` 之类的单宿主路径漏清理。静默容错。 */
412
+ async function removeAbsFromForeignMcpStores() {
413
+ const steps = [];
414
+ for (const { p, key } of foreignMcpStores()) {
415
+ let cfg;
416
+ try {
417
+ cfg = JSON.parse(await fs.readFile(p, 'utf8'));
418
+ } catch { continue; } // 不存在 / 坏 JSON: 静默跳过, 不动别人的文件
419
+ const hit = findAbsEntry(cfg, key);
420
+ if (!hit) continue;
421
+ delete cfg[hit.key].abs;
422
+ await backup(p);
423
+ await atomicWrite(p, JSON.stringify(cfg, null, 2));
424
+ steps.push(`✓ MCP → ${p} ${hit.key}.abs 已移除`);
425
+ }
426
+ return steps;
427
+ }
428
+
272
429
  async function installClaudeCode({ withMcp, withSkill, log }) {
273
430
  const steps = [];
274
431
  // 1) hooks → settings.json (分区合并: 同事件可挂多个 hook 框架, 追加 abs 而非覆盖, 保留 moshi-hook 等)
@@ -382,24 +539,37 @@ async function installCodex({ withMcp, withSkill, log }) {
382
539
  steps.push(`✓ MCP → ${mcpP} [mcp_servers.abs]`);
383
540
  } else {
384
541
  // 已存在也要校对路径: 旧版可能写入了仓库路径(不稳定) 或全局包已迁移。
542
+ // 坑: 曾经只找单行 `/^\s*args\s*=/` 并原地替换。args 写成多行数组
543
+ // args = [
544
+ // "/old/path/mcp.js",
545
+ // ]
546
+ // 时那一行不匹配 → 走 else 只打印"无 args 行, 未动",旧路径**静默保留**;
547
+ // 而 body.includes(`"${want}"`) 的判等又会被 command 行里的同串骗过。
548
+ // 现改为: 只认 args 赋值本身,多行则吃到配对的 `]`,找不齐就整段重写 section。
385
549
  const want = mcpEntryPath();
386
550
  const lines = text.split('\n');
387
- const body = lines.slice(found.start, found.end).join('\n');
388
- if (body.includes(`"${want}"`)) {
389
- steps.push(`• MCP → ${mcpP} 已存在且路径正确, 跳过`);
390
- } else {
391
- // 只改 args 行的字面量值,不碰其它行(正则跨行会误伤数组内容)
392
- let touched = false;
393
- for (let k = found.start; k < found.end; k++) {
394
- if (/^\s*args\s*=/.test(lines[k])) { lines[k] = `args = ["${want}"]`; touched = true; break; }
395
- }
396
- if (touched) {
551
+ const wa = findTomlKeyRange(lines, found, 'args');
552
+ if (wa) {
553
+ // 仅当 args 的取值里已有 want(单行或跨行)才算已正确
554
+ const argsText = lines.slice(wa.start, wa.end).join('\n');
555
+ if (argsText.includes(`"${want}"`)) {
556
+ steps.push(`• MCP → ${mcpP} 已存在且路径正确, 跳过`);
557
+ } else {
558
+ lines.splice(wa.start, wa.end - wa.start, `args = ["${want}"]`);
397
559
  await backup(mcpP);
398
560
  await atomicWrite(mcpP, lines.join('\n'));
399
561
  steps.push(`✓ MCP → ${mcpP} 路径已校正 → ${want}`);
400
- } else {
401
- steps.push(`• MCP → ${mcpP} 已注册但无 args 行, 未动(请手动确认)`);
402
562
  }
563
+ } else {
564
+ // 连 args 都没有: 整段重写本 section 的 body(只含本工具的 abs 条目)
565
+ const rebuilt = [
566
+ `command = "${process.execPath}"`,
567
+ `args = ["${want}"]`,
568
+ ];
569
+ lines.splice(found.start + 1, found.end - found.start - 1, ...rebuilt);
570
+ await backup(mcpP);
571
+ await atomicWrite(mcpP, lines.join('\n'));
572
+ steps.push(`✓ MCP → ${mcpP} 已重写 [mcp_servers.abs] (补 args) → ${want}`);
403
573
  }
404
574
  }
405
575
  }
@@ -594,7 +764,7 @@ async function installPi({ withMcp, withSkill, log }) {
594
764
  steps.push(`✓ hook(ts extension) → ${p}`);
595
765
  // MCP → ~/.pi/agent/mcp.json 的 mcpServers (stdio)
596
766
  // 曾经只打印「走 extension 内桥接」而没有任何桥接代码 —— 靠 mcp-adapter 的
597
- // hostConfigDiscovery 间接读到 cl​aude 注册才"看起来能用"; 没有 cl​aude 宿主的机器上直接缺失。
767
+ // hostConfigDiscovery 间接读到 claude 注册才"看起来能用"; 没有 claude 宿主的机器上直接缺失。
598
768
  if (withMcp) {
599
769
  const mcpP = join(hostConfigRoot('pi'), 'agent', 'mcp.json');
600
770
  await backup(mcpP);
@@ -663,11 +833,23 @@ export async function runInstall({ agent, mcp = true, skill = true, yes = false
663
833
  if (agent) throw e;
664
834
  }
665
835
  }
836
+ // 外部 store 校正与宿主无关(那是别人的文件,只是其中可能有我们的陈旧条目),
837
+ // 故在选完宿主后统一跑一次 —— 放进各 installer 会导致 `abs install --agent codex`
838
+ // 这类单宿主路径漏校正。失败不影响主流程(reconcileOneStore 自身静默容错)。
839
+ if (mcp) {
840
+ try {
841
+ for (const line of await reconcileForeignMcpStores()) console.log(' ' + line);
842
+ } catch { /* 外部 store 校正失败不该让安装失败 */ }
843
+ }
666
844
  if (failed.length) {
667
845
  console.log(`\n⚠ ${failed.length} 个宿主安装失败: ${failed.map((f) => f.key).join(', ')}`);
668
846
  console.log(' 其余宿主已完成。修复上述问题后重跑 `abs install`(幂等,不会重复写入)。');
669
847
  throw new Error(`${failed.length} 个宿主安装失败(见上)`);
670
848
  }
849
+ if (skill) {
850
+ const w = agentsSkillWarning();
851
+ if (w) console.log('\n' + w);
852
+ }
671
853
  console.log('\n完成。项目内运行 abs init 建图谱; 会话里说 "abs load" 续接。');
672
854
  }
673
855
 
@@ -679,6 +861,11 @@ export async function runUninstall({ agent, yes = false } = {}) {
679
861
  console.log(`\n▸ 从 ${key} 卸载 …`);
680
862
  for (const line of await inst.off()) console.log(' ' + line);
681
863
  }
864
+ // 外部 store 的 abs 条目与宿主无关(那是别人的文件),统一清理一次 ——
865
+ // 放进各 uninstaller 会导致 `abs uninstall --agent codex` 漏清理。
866
+ try {
867
+ for (const line of await removeAbsFromForeignMcpStores()) console.log(' ' + line);
868
+ } catch { /* 外部 store 清理失败不该让卸载失败 */ }
682
869
  console.log('\n卸载完成。');
683
870
  }
684
871
 
package/src/store.js CHANGED
@@ -2,11 +2,10 @@
2
2
  // 命令: init / board / status / load / task / query / lint
3
3
  import { promises as fs } from 'node:fs';
4
4
  import { join, resolve, dirname } from 'node:path';
5
- import { homedir } from 'node:os';
6
- import { requireBrain, findBrainRoot, brainPath, BRAIN_DIR } from './index.js';
5
+ import { requireBrain, brainPath, absLogDir, BRAIN_DIR } from './index.js';
7
6
  import { addTask, upsertTask, boardText, readTodo, ensureTodo, todoTemplate, today, localStamp, setBreakpoint, moveBlocked, insertDoneGrouped, idOfTaskLine, archiveDoneInText, renderArchivePage, renderArchiveBody, DONE_KINDS, withDoneKind, doneKindOf } from './todo.js';
8
7
  import { editFile, SKIP } from './lock.js';
9
- import { appendWrapup, strandedFor, wrapupLogPath } from './wrapup.js';
8
+ import { appendWrapup, strandedFor } from './wrapup.js';
10
9
 
11
10
  // ---------- init: 建 .brain/ 骨架 ----------
12
11
  const BRAIN_DIRS = ['entities', 'concepts', 'sources', 'syntheses', 'sessions'];
@@ -97,8 +96,10 @@ export async function cmdRepair({ dir }) {
97
96
  }
98
97
 
99
98
  function resolveProjectDir(dir) {
100
- if (!dir) return process.cwd();
101
- return resolve(dir);
99
+ // 必须显式回退 cwd: resolve(undefined) 会抛 ERR_INVALID_ARG_TYPE,
100
+ // 并不像看上去那样「自动回退」。曾误删此分支为 resolve(dir),把 abs init/load/board
101
+ // 不带 --dir 全部变成裸栈崩溃。
102
+ return resolve(dir || process.cwd());
102
103
  }
103
104
 
104
105
  export function indexTemplate() {
@@ -308,7 +309,7 @@ export async function cmdTeardownCheck({ dir, payload }) {
308
309
  // 绝不"无节流"——否则一旦宿主张不到 id, decision:block 就会无限自激。
309
310
  const sid = String(ev.session_id || ev.sessionId || '').replace(/[^\w-]/g, '');
310
311
  const key = sid || 'nosession-' + day + '-' + root.replace(/[^\w]/g, '_');
311
- const mark = join(homedir(), '.abs', 'log', `teardown-${key}.mark`);
312
+ const mark = join(absLogDir(), `teardown-${key}.mark`);
312
313
  try {
313
314
  await fs.access(mark);
314
315
  return '{}'; // 本会话(或本项目今日)已推过
package/src/wrapup.js CHANGED
@@ -7,12 +7,11 @@
7
7
  // wrapup.log 是全局技术日志(~/.abs/log/), 跨项目共用, 故每块带 proj=<root> 归属。
8
8
  import { promises as fs } from 'node:fs';
9
9
  import { join } from 'node:path';
10
- import { homedir } from 'node:os';
11
- import { brainPath } from './index.js';
10
+ import { brainPath, absLogDir } from './index.js';
12
11
  import { readTodo, localStamp } from './todo.js';
13
12
 
14
13
  export function wrapupLogPath() {
15
- return join(process.env.ABS_LOG_DIR || join(homedir(), '.abs', 'log'), 'wrapup.log');
14
+ return join(absLogDir(), 'wrapup.log');
16
15
  }
17
16
 
18
17
  // 同项目两次快照的最小间隔(秒)。agent_end 会逐 turn 触发, 无变化时不刷屏。
package/src/brainio.js DELETED
@@ -1,66 +0,0 @@
1
- // src/brainio.js — .brain 文档统一读写收口。
2
- // 目的:所有需读写 .brain 文档的地方(todo/log/index/source/…)都走这一个方法,
3
- // 调用方不必各自拼 brainPath + lock.editFile/裸 readFile,也天然带上防重复/防并发锁。
4
- // - readBrain(brainRoot, rel) 读 .brain/<rel> 文本(文件不存在返回 null)
5
- // - writeBrain(brainRoot, rel, transform) 锁内读改写(自动 lock.editFile 防并发);transform 返回新文本或 null=不改
6
- // - appendBrain(brainRoot, rel, lines) 锁内追加多行到文末
7
- // 纯 IO 封装:路径由 brainPath 统一算,写由 lock.editFile 统一带锁。无业务逻辑。
8
- import { promises as fs } from 'node:fs';
9
- import { brainPath } from './index.js';
10
- import { editFile, SKIP } from './lock.js';
11
-
12
- /** 读 .brain/<rel> 全文。不存在返回 null。读不经锁(读旧内容无害)。 */
13
- export async function readBrain(brainRoot, rel) {
14
- const p = brainPath(brainRoot, rel);
15
- try {
16
- return await fs.readFile(p, 'utf8');
17
- } catch {
18
- return null; // 尚未建该页
19
- }
20
- }
21
-
22
- /** 锁内读-改-写 .brain/<rel>。transform(currentText) 返回新文本;返回 null/undefined 表示不改(不落盘)。
23
- * 返回:落盘后的新文本(transform 不改时返回原文本)。自动防并发(同文件多进程同时写不覆盖)。
24
- * 注:本方法写的是「整体新文本」;若文件不存在 currentText 传 null。 */
25
- export async function writeBrain(brainRoot, rel, transform) {
26
- const p = brainPath(brainRoot, rel);
27
- const out = await editFile(p, (current) => {
28
- const next = transform(current);
29
- if (next === null || next === undefined) return SKIP; // 不改 → 不落盘
30
- const text = typeof next === 'string' ? next : next.text;
31
- return text === current ? SKIP : text; // 与当前相同也不落盘(editFile 幂等)
32
- });
33
- // out 是 editFile 返回的字符串(新文本)或 SKIP(未改)。未改时返回读到的当前文本。
34
- if (out === SKIP) return currentTextOf(brainPath(brainRoot, rel));
35
- return out;
36
- }
37
-
38
- /** 读文件文本(不经锁); 供 writeBrain 未改时回读当前值。 */
39
- async function currentTextOf(p) {
40
- try {
41
- return await fs.readFile(p, 'utf8');
42
- } catch {
43
- return null;
44
- }
45
- }
46
-
47
- /** 锁内往 .brain/<rel> 文末追加若干行(自动补换行)。若文件不存在则创建。 */
48
- export async function appendBrain(brainRoot, rel, lines) {
49
- const arr = Array.isArray(lines) ? lines : [lines];
50
- return writeBrain(brainRoot, rel, (cur) => {
51
- const base = cur == null ? '' : cur.replace(/\s*$/, '');
52
- const body = arr.join('\n');
53
- return base ? `${base}\n${body}\n` : `${body}\n`;
54
- });
55
- }
56
-
57
- /** 原子建新 .brain/<rel>(tmp+rename, 不经锁——新文件写唯一内容无并发读者竞争)。 */
58
- export async function createBrainFile(brainRoot, rel, content) {
59
- const p = brainPath(brainRoot, rel);
60
- const { dirname } = await import('node:path');
61
- await fs.mkdir(dirname(p), { recursive: true });
62
- const tmp = `${p}.abs-tmp-${process.pid}-${Date.now()}`;
63
- await fs.writeFile(tmp, content, 'utf8');
64
- await fs.rename(tmp, p);
65
- return p;
66
- }