@fanchao8609/agent_brain_sync 1.5.7 → 1.6.0

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,14 @@
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 { setUser, getUser, userConfigPath } from '../src/userconfig.js';
7
+ import { runInstall, runUninstall } from '../src/install.js';
7
8
  import { readFileSync } from 'node:fs';
8
9
  import { join, dirname, resolve } from 'node:path';
9
10
  import { fileURLToPath } from 'node:url';
10
11
  import { spawn } from 'node:child_process';
12
+ import { parseArgs } from 'node:util';
11
13
 
12
14
  const ABS_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '..');
13
15
 
@@ -60,28 +62,76 @@ async function cmdUpdate({ yes }) {
60
62
 
61
63
  const [,, cmd, ...rest] = process.argv;
62
64
 
65
+ // 所有 flag 集中声明。parseArgs 只负责「把 argv 切成键值」,形状转换(keep-days → keepDays,
66
+ // no-mcp → mcp:false)在下面一处收口。曾手写了 117 行 if-else 逐 flag 分支。
67
+ const FLAG_SPEC = {
68
+ 'dir': { type: 'string' },
69
+ 'agent': { type: 'string' },
70
+ 'id': { type: 'string' },
71
+ 'section': { type: 'string' },
72
+ 'note': { type: 'string' },
73
+ 'as': { type: 'string' },
74
+ 'payload': { type: 'string' },
75
+ 'tags': { type: 'string' },
76
+ 'keep-days': { type: 'string' },
77
+ 'help': { type: 'boolean' },
78
+ 'dry-run': { type: 'boolean' },
79
+ 'yes': { type: 'boolean' },
80
+ 'repair': { type: 'boolean' },
81
+ 'no-mcp': { type: 'boolean' },
82
+ 'no-skill': { type: 'boolean' },
83
+ };
84
+
63
85
  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);
86
+ // 坑: parseArgs 会把**任何** `-` 开头的 token 当选项,连正文一起吃:
87
+ // `abs note "-X 是个坑"` → values={X:true,' ':true,是:true,…},正文全丢(旧手写版只认 `--` 长选项)。
88
+ // 也不能简单地把所有非 `--` token 剔走 —— 那样 `--dir /x` 的值 `/x` 会被误剔。
89
+ // 解法: 先用 tokens 看清每个 token 的 kind,只把「`--` 开头且 name 在 FLAG_SPEC 里」当真选项,
90
+ // 其余(包括 `-x` 与 `--unknown`)一律按原序交回位置参数,复刻旧手写逻辑。
91
+ const { values: rawValues, tokens } = parseArgs({
92
+ args,
93
+ options: FLAG_SPEC,
94
+ allowPositionals: true,
95
+ strict: false,
96
+ tokens: true,
97
+ });
98
+ const values = {};
99
+ const positionals = [];
100
+ const seenIdx = new Set(); // 同一个 argv 下标可能因 `-X 是个坑` 被拆出多个 token,只收一次
101
+ // tokens 会把 `-X 是个坑` 拆成 5 个短选项(一个字符一个),但原始 argv 里它只是一个 token。
102
+ // 而 token.index 就是原始 argv 的下标 → 直接按下标取回原串,才能拿回未拆的正文。
103
+ for (const t of tokens) {
104
+ const known = t.kind === 'option' && FLAG_SPEC[t.name] !== undefined && t.rawName.startsWith('--');
105
+ if (known) {
106
+ // 同名重复出现时后者胜(旧手写版行为)
107
+ values[t.name] = t.value === undefined ? true : t.value;
108
+ } else if (t.kind === 'positional') {
109
+ positionals.push(t.value);
110
+ } else {
111
+ // `-x` / `--unknown` / 未知长选项: 都不是我们声明的 flag。
112
+ // 旧手写版里 `-x` 落位置参数(→ 被 rejectExtra 拦),`--unknown` 静默收下。
113
+ // 区分: `--` 开头的按旧的「静默收下」当布尔(不进位置参数),其余按 argv 原值整体回位置参数
114
+ // (**不拆**,否则 note 正文会被切成碎片;同一 index 只收一次)。
115
+ if (t.rawName && t.rawName.startsWith('--')) values[t.name] = true;
116
+ else if (!seenIdx.has(t.index)) { seenIdx.add(t.index); positionals.push(args[t.index]); }
117
+ }
118
+ }
119
+ // 坑: parseArgs 对「声明的 string 选项缺值」不报错(值会变 true),
120
+ // 于是 `abs init --dir` 一路传到 resolve(true) 才抛裸栈。在此拦下并给清晰用法。
121
+ const missing = Object.entries(values)
122
+ .filter(([k, v]) => FLAG_SPEC[k]?.type === 'string' && typeof v !== 'string')
123
+ .map(([k]) => `--${k}`);
124
+ if (missing.length) {
125
+ throw new Error(`✗ ${missing.join('、')} 缺少值\n 用法: --<flag> <值>(如 --dir /path/to/project)`);
84
126
  }
127
+ const o = {
128
+ ...values,
129
+ _: positionals,
130
+ keepDays: values['keep-days'],
131
+ dryRun: values['dry-run'],
132
+ };
133
+ if (values['no-mcp']) o.mcp = false;
134
+ if (values['no-skill']) o.skill = false;
85
135
  return o;
86
136
  }
87
137
 
@@ -110,6 +160,10 @@ const usage = `abs — agent-brain-sync 记忆工具
110
160
  归档 Done 区旧日期组 → sessions/<日期>-todo归档.md
111
161
  (默认保留近 3 天; 任一天有未完成则整天不归档)
112
162
  abs lint 图谱体检 (死链/孤岛/超尺寸/堆积)
163
+ abs config [show] 查看使用者姓名 (标记作者用)
164
+ abs config set user <名字> 设置使用者姓名 → ~/.abs/config.json
165
+ 未设置时写操作(todo/log/note)会报错要求先设置
166
+ 临时覆盖: ABS_USER=<名字> abs ...
113
167
  abs init [--repair] 建 .brain/ 图谱; 结构不完整时报明细, --repair 只补缺不覆盖
114
168
  abs install [--agent <宿主>] 安装 MCP+hook+skill (宿主: claude-code/codex/opencode/pi)
115
169
  abs uninstall [--agent <...>] 卸载
@@ -189,9 +243,31 @@ const TODO_ACTIONS = {
189
243
  done: 'done',
190
244
  };
191
245
 
246
+ /** abs config —— 读/写用户设置(目前只有 user)。 */
247
+ async function cmdConfig({ sub, value }) {
248
+ const p = userConfigPath();
249
+ if (!sub || sub === 'show' || sub === 'get') {
250
+ const u = await getUser();
251
+ return u
252
+ ? `user = ${u}\n (来源: ${process.env.ABS_USER ? 'ABS_USER 环境变量' : p})`
253
+ : `user 未设置\n 设置: abs config set user <你的名字>\n (或临时: ABS_USER=<名字> abs ...)`;
254
+ }
255
+ if (sub === 'set') {
256
+ const [key, ...rest] = String(value || '').split(/\s+/).filter(Boolean);
257
+ if (key !== 'user') throw new Error(`✗ abs config set 目前只支持 user\n 用法: abs config set user <你的名字>`);
258
+ // 名字含空格时不该静默只取第一个词(会被 setUser 的字符校验拒)。
259
+ // 拼接后交给 setUser 统一判定,报错文案里能看到完整输入。
260
+ const name = rest.join(' ');
261
+ if (rest.length > 1) throw new Error(`✗ 姓名不能含空格(收到 "${name}")\n @标记无法解析带空格的姓名`);
262
+ const u = await setUser(name);
263
+ return `✓ user = ${u}\n → ${p}`;
264
+ }
265
+ throw new Error(`✗ 未知子命令 "${sub}"\n 用法: abs config [show] / abs config set user <名字>`);
266
+ }
267
+
192
268
  async function main() {
193
- const opts = parseArgv(rest);
194
269
  try {
270
+ const opts = parseArgv(rest);
195
271
  if (RENAMED[cmd]) throw new Error(RENAMED[cmd]());
196
272
  switch (cmd) {
197
273
  case 'init': {
@@ -270,7 +346,10 @@ async function main() {
270
346
  }
271
347
  break;
272
348
  }
273
- case 'agents': console.log(installSummary()); break;
349
+ case 'config': {
350
+ console.log(await cmdConfig({ sub: opts._[0], value: opts._.slice(1).join(' ') }));
351
+ break;
352
+ }
274
353
  case 'query': {
275
354
  console.log(await cmdQuery({ dir: opts.dir, terms: opts._ }));
276
355
  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.7",
3
+ "version": "1.6.0",
4
4
  "description": "agent-brain-sync: 跨会话 AI 编码记忆 — hook 纯触发 + CLI/MCP 读写 .brain markdown 图谱, 防并发写保护。",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/skill/SKILL.md CHANGED
@@ -58,6 +58,29 @@ Obsidian 可直接打开的 Markdown 图谱(`.brain/`)做统一落点。
58
58
  > 为什么:方案先过目能省掉整轮返工;登记让跨会话可续;"问开工"把决定权留在用户手里。
59
59
  > **这不是拖延** —— 总结方案本身就是工作,做完再问。
60
60
 
61
+ ## 使用者姓名(作者标记)
62
+
63
+ 图谱需要知道「谁登记的」。写操作(`todo add/note/blocked/done`、`log`、`note`)会检查:
64
+ **未设置姓名则报错并给设置命令**,不默默落盘无名条目。
65
+
66
+ ```bash
67
+ abs config set user <你的名字> # 写入 ~/.abs/config.json,一次即可
68
+ abs config # 查看当前姓名
69
+ ABS_USER=<名字> abs todo add ... # 临时覆盖(CI/多身份),不改落盘配置
70
+ ```
71
+
72
+ 设置后自动标记:
73
+ - `todo.md`:`- [ ] TASK-ID @fanchao — 说明 (认领 2026-09-12)`(作者紧跟 id,扫板先看到人)
74
+ - `log.md`:`## [2026-09-12 13:17] @fanchao dev | 完成 X`(作者前置于 kind)
75
+ - `sources/`:frontmatter `author: fanchao`
76
+
77
+ **只读命令不检查**(`load`/`todo`/`status`/`lint`/`query`/`index`/`log` 无参)——
78
+ hook 在会话结束时非交互调 `abs wrapup`/`abs teardown-check`,那儿拦人会卡断收尾。
79
+
80
+ > `index.md` **不加**作者:index 行是覆盖式更新的,作者会从"创建者"漂成"最后改的人",
81
+ > 语义不固定。要查谁写的,看该页自己的 `author`。
82
+ > 历史条目**不回填**(原文/现场已不在,回填等于编造)。
83
+
61
84
  ## 图谱定位(一个项目一个 `.brain/`,abs 自动定位不用手工指定路径)
62
85
 
63
86
  `.brain/` 放项目根,一个项目只建一份。所有 `abs` 命令(`abs load/todo/note/task/query/lint...`)
@@ -204,11 +227,12 @@ monorepo 若多个子包各自独立交付,可各建一份 `.brain/`。`abs st
204
227
 
205
228
  ## 知识页格式(concepts/entities/syntheses)
206
229
 
207
- 所有页统一 frontmatter 三项:`tags / updated / status`(无额外字段)。
230
+ 所有页统一 frontmatter:`tags / author / updated / status`。
208
231
 
209
232
  ```markdown
210
233
  ---
211
234
  tags: [concept, 领域] # 首标签 ∈ entity|concept|source|synthesis|session-log
235
+ author: fanchao # 作者(abs note 自动写;手写页也须填)
212
236
  updated: YYYY-MM-DD
213
237
  status: draft # 或 reviewed(仅指知识冲突裁决结案)
214
238
  ---
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/store.js CHANGED
@@ -2,11 +2,11 @@
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';
6
+ import { requireUser, atTag } from './userconfig.js';
7
7
  import { addTask, upsertTask, boardText, readTodo, ensureTodo, todoTemplate, today, localStamp, setBreakpoint, moveBlocked, insertDoneGrouped, idOfTaskLine, archiveDoneInText, renderArchivePage, renderArchiveBody, DONE_KINDS, withDoneKind, doneKindOf } from './todo.js';
8
8
  import { editFile, SKIP } from './lock.js';
9
- import { appendWrapup, strandedFor, wrapupLogPath } from './wrapup.js';
9
+ import { appendWrapup, strandedFor } from './wrapup.js';
10
10
 
11
11
  // ---------- init: 建 .brain/ 骨架 ----------
12
12
  const BRAIN_DIRS = ['entities', 'concepts', 'sources', 'syntheses', 'sessions'];
@@ -97,8 +97,10 @@ export async function cmdRepair({ dir }) {
97
97
  }
98
98
 
99
99
  function resolveProjectDir(dir) {
100
- if (!dir) return process.cwd();
101
- return resolve(dir);
100
+ // 必须显式回退 cwd: resolve(undefined) 会抛 ERR_INVALID_ARG_TYPE,
101
+ // 并不像看上去那样「自动回退」。曾误删此分支为 resolve(dir),把 abs init/load/board
102
+ // 不带 --dir 全部变成裸栈崩溃。
103
+ return resolve(dir || process.cwd());
102
104
  }
103
105
 
104
106
  export function indexTemplate() {
@@ -308,7 +310,7 @@ export async function cmdTeardownCheck({ dir, payload }) {
308
310
  // 绝不"无节流"——否则一旦宿主张不到 id, decision:block 就会无限自激。
309
311
  const sid = String(ev.session_id || ev.sessionId || '').replace(/[^\w-]/g, '');
310
312
  const key = sid || 'nosession-' + day + '-' + root.replace(/[^\w]/g, '_');
311
- const mark = join(homedir(), '.abs', 'log', `teardown-${key}.mark`);
313
+ const mark = join(absLogDir(), `teardown-${key}.mark`);
312
314
  try {
313
315
  await fs.access(mark);
314
316
  return '{}'; // 本会话(或本项目今日)已推过
@@ -382,12 +384,15 @@ function slugOf(text, n = 24) {
382
384
  // ---------- log: 追加工作成果沉淀摘要(用户/AI 主动 abs log "..." 记, 不收工具动作流水) ----------
383
385
  export async function cmdLog({ dir, title, kind = 'dev' }) {
384
386
  const root = await requireBrain(dir || process.cwd());
387
+ const who = await requireUser(); // 写操作守卫
385
388
  const p = brainPath(root, 'log.md');
386
389
  const stamp = localStamp();
387
390
  // 不硬切: log.md 是人类读的成果摘要, 也是 abs load 的开机入口。600 码点够一条完整小结,
388
391
  // 超出才在语义边界收口(曾 slice(0,100) → 34/85 条断在词中间)
389
392
  const clean = clip(String(title || '').replace(/\n/g, ' '), 600);
390
- const line = `## [${stamp}] ${kind} | ${clean}`;
393
+ // 作者前置于 kind:`## [时间] @name dev | 内容`。
394
+ // 一眼先看到谁做的(与 todo 行 `ID @name — 说明` 排版对齐)。
395
+ const line = `## [${stamp}] ${atTag(who)} ${kind} | ${clean}`;
391
396
  await editFile(p, (cur) => {
392
397
  const text = cur ?? '# 🗒 操作日志\n';
393
398
  // 倒序:新行插在标题后(若已是模板占位行则替换它)
@@ -404,13 +409,14 @@ export async function cmdLog({ dir, title, kind = 'dev' }) {
404
409
  // log.md 是「工作成果沉淀摘要」(用户/AI 主动 abs log "..." 记), 不收工具动作流水。
405
410
  export async function cmdTask({ dir, action, id, section, note, as }) {
406
411
  const root = await requireBrain(dir || process.cwd());
412
+ // 写操作守卫:无姓名不落盘(hook 调的 wrapup/teardown-check 不经过这里,不受影响)
413
+ const who = await requireUser();
407
414
  if (action === 'start') {
408
- const text = `${id}${note ? ' — ' + note : ''}`;
409
415
  const r = await upsertTask(root, {
410
416
  section: section || 'Today / In Progress',
411
- text: `${text} (认领 ${today()})`,
417
+ text: `${id} ${atTag(who)}${note ? ' — ' + note : ''} (认领 ${today()})`,
412
418
  });
413
- return `✓ 任务${r.updated ? '更新(幂等)' : '登记'} → ${brainPath(root, 'todo.md')}\n ${id}${note ? ' — ' + note : ''}`;
419
+ return `✓ 任务${r.updated ? '更新(幂等)' : '登记'} → ${brainPath(root, 'todo.md')}\n ${id} ${atTag(who)}${note ? ' — ' + note : ''}`;
414
420
  }
415
421
  if (action === 'blocked') {
416
422
  const r = await moveBlocked(root, { id, reason: note });
@@ -546,6 +552,7 @@ export async function cmdNote({ dir, text, tags }) {
546
552
  } catch {
547
553
  return `未找到 .brain/ 图谱。先在项目根运行: abs init`;
548
554
  }
555
+ const who = await requireUser(); // 写操作守卫
549
556
  const srcDir = brainPath(root, 'sources');
550
557
  await fs.mkdir(srcDir, { recursive: true });
551
558
  // 幂等: 同文本 60s 内只落一份
@@ -564,6 +571,7 @@ export async function cmdNote({ dir, text, tags }) {
564
571
  const body = [
565
572
  '---',
566
573
  `tags: [${fmTags}]`,
574
+ `author: ${who}`,
567
575
  `updated: ${today()}`,
568
576
  'status: draft',
569
577
  '---',
@@ -599,7 +607,7 @@ export async function cmdNote({ dir, text, tags }) {
599
607
  return { text: next };
600
608
  });
601
609
  await cmdLog({ dir: root, title: clean, kind: 'note' });
602
- return `✓ 经验暂存 → sources/${file}\n ${clean}`;
610
+ return `✓ 经验暂存 → sources/${file}\n ${clean} ${atTag(who)}`;
603
611
  }
604
612
  // ---------- lint: 体检(与 scripts/lint.sh 同规则的 Node 版,供 CLI/MCP 直调) ----------
605
613
  export async function cmdLint({ dir }) {
package/src/todo.js CHANGED
@@ -390,10 +390,12 @@ export async function upsertTask(brainRoot, { section, text }) {
390
390
  // 在所有分区中找该 id 对应的未完成任务行(Done 的已完成行不重复动);全等比对
391
391
  const idx = findTaskLine(lines, id);
392
392
  if (idx !== -1) {
393
- // 原位更新:保留断点附属行,只换任务行本体
393
+ // 原位更新:保留断点附属行 + 原@作者,只换任务行本体
394
+ const author = extractAuthor(lines[idx].replace(/^- \[ \] /, ''));
394
395
  const oldNote = extractNote(lines[idx].replace(/^- \[ \] /, ''));
395
396
  const merged = note && oldNote && oldNote.startsWith(note) ? `${note}${oldNote.slice(note.length)}` : note || oldNote;
396
- const newLine = `- [ ] ${id}${merged ? ' — ' + merged : ''} (认领 ${today()})`;
397
+ const at = author ? ` @${author}` : '';
398
+ const newLine = `- [ ] ${id}${at}${merged ? ' — ' + merged : ''} (认领 ${today()})`;
397
399
  lines[idx] = newLine;
398
400
  return { text: lines.join('\n'), updated: true };
399
401
  }
@@ -410,10 +412,18 @@ export function extractId(text) {
410
412
  return m ? m[1] : null;
411
413
  }
412
414
 
413
- /** 提取 " — " 后的 note 部分。 */
415
+ /** 提取 " — " 后的 note 部分(剥掉紧跟在 id 后的 `@author` 标记及尾部 `(认领 ...)`)。
416
+ * 格式: `<id> @author — <note> (认领 date)`。@author 可选(旧行/未设置姓名时无)。 */
414
417
  export function extractNote(text) {
415
418
  const i = String(text).indexOf(' — ');
416
- return i === -1 ? '' : String(text).slice(i + 3).replace(/\s*\(认领[^)]*\)\s*$/, '');
419
+ if (i === -1) return '';
420
+ return String(text).slice(i + 3).replace(/\s*\(认领[^)]*\)\s*$/, '').trim();
421
+ }
422
+
423
+ /** 提取 id 后的 `@author` 标记(无则空)。供 upsertTask 原位重建行时保留作者。 */
424
+ export function extractAuthor(text) {
425
+ const m = String(text).match(/^\S+\s+@([\w\u4e00-\u9fff.-]+)/);
426
+ return m ? m[1] : '';
417
427
  }
418
428
 
419
429
  /**
@@ -0,0 +1,68 @@
1
+ // src/userconfig.js — 用户身份(作者名)配置。
2
+ // 用途:todo/log/生成的文档标 @name,让跨会话图谱能看出"谁登记的"。
3
+ //
4
+ // 配置源(后者优先):
5
+ // 1. ~/.abs/config.json 的 { "user": "fanchao" } —— abs config set user <name> 写入
6
+ // 2. 环境变量 ABS_USER —— 临时/CI 覆盖,不改落盘配置
7
+ //
8
+ // 纪律:**只在写操作检查**。hook 会在会话结束时非交互调 abs wrapup / abs teardown-check,
9
+ // 只读命令若也拦人,hook 路径会卡住或报错。故 requireUser 只被写命令调用。
10
+ import { promises as fs } from 'node:fs';
11
+ import { join } from 'node:path';
12
+ import { homedir } from 'node:os';
13
+
14
+ /** 配置文件路径(ABS_CONFIG_DIR 可覆盖,测试隔离用)。 */
15
+ export function userConfigPath() {
16
+ const dir = process.env.ABS_CONFIG_DIR || join(homedir(), '.abs');
17
+ return join(dir, 'config.json');
18
+ }
19
+
20
+ /** 读配置的 user 字段(不存在/坏 JSON 一律返回 null,不抛)。 */
21
+ export async function getUser() {
22
+ const env = String(process.env.ABS_USER || '').trim();
23
+ if (env) return env; // 环境变量优先:临时覆盖不必改落盘配置
24
+ try {
25
+ const raw = await fs.readFile(userConfigPath(), 'utf8');
26
+ const u = JSON.parse(raw).user;
27
+ return typeof u === 'string' && u.trim() ? u.trim() : null;
28
+ } catch {
29
+ return null;
30
+ }
31
+ }
32
+
33
+ /** 写配置的 user 字段(保留其它键)。 */
34
+ export async function setUser(name) {
35
+ const clean = String(name || '').trim();
36
+ if (!clean) throw new Error('✗ 姓名不能为空');
37
+ // 合法性:允许字母数字中文下划线连字符点,禁空白(空白会破坏 @name 标记的解析)
38
+ if (!/^[\w\u4e00-\u9fff.-]+$/.test(clean)) {
39
+ throw new Error(`✗ 姓名 "${clean}" 含不支持的字符(只允许字母/数字/中文/._-,且不含空格)`);
40
+ }
41
+ const p = userConfigPath();
42
+ await fs.mkdir(join(p, '..'), { recursive: true });
43
+ let cfg = {};
44
+ try { cfg = JSON.parse(await fs.readFile(p, 'utf8')); } catch { /* 首次或坏 JSON → 重建 */ }
45
+ cfg.user = clean;
46
+ await fs.writeFile(p, JSON.stringify(cfg, null, 2) + '\n', 'utf8');
47
+ return clean;
48
+ }
49
+
50
+ /**
51
+ * 写操作入口守卫:拿到作者名,拿不到就抛带指引的错误。
52
+ * 错误文案给出两条路(持久设置 / 临时覆盖),别让用户猜。
53
+ */
54
+ export async function requireUser() {
55
+ const u = await getUser();
56
+ if (u) return u;
57
+ throw new Error(
58
+ '✗ 尚未设置使用者姓名 —— 图谱需要标记每条记录的作者。\n' +
59
+ ' 请任选其一设置后重试:\n' +
60
+ ' abs config set user <你的名字> (写入 ~/.abs/config.json, 一次即可)\n' +
61
+ ' ABS_USER=<你的名字> abs ... (仅本次生效)'
62
+ );
63
+ }
64
+
65
+ /** 标记串:`@name`。用于 todo 行 / log 行 / 页面 frontmatter。 */
66
+ export function atTag(name) {
67
+ return `@${name}`;
68
+ }
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
- }