From 29ba4eefac76209cd5bc07a951f759adac8b53d7 Mon Sep 17 00:00:00 2001 From: MindIniter Date: Sun, 4 Oct 2026 21:14:32 +0800 Subject: [PATCH 1/2] feat(host): add WorkBuddy as a first-class init agent WorkBuddy has no project-scoped MCP configuration surface. Its only entry is the machine-level ~/.workbuddy/mcp.json, read and written by UserMcpProvider. That file is shared by every project while an aoci MCP server is bound to one --repo, so writing a fixed "aoci" key would silently repoint another repository's integration. - workbuddy.go merges into ~/.workbuddy/mcp.json and never overwrites a foreign aoci entry: it falls back to aoci-, and reports an error when that key is taken as well - IsWorkBuddyMCPInstalled scans every entry for one bound to this repository, so another repository's aoci never reads as installed here - Detect probes the user-level file; a project-root probe would always miss - init --agent workbuddy writes no repository file, so initAgentCandidatePaths returns nil and .gitignore is left alone - the AGENTS.md managed block is prepended for this host only: WorkBuddy injects roughly the first 8000 characters of AGENTS.md into model context, so a trailing block is never read. Every other host keeps appending - --hooks is intentionally inert here; WorkBuddy exposes no pre-write lifecycle hook surface, and the adapter never pretends it installed one --- internal/cli/doctor.go | 1 + internal/cli/init.go | 26 +- internal/cli/init_guide_test.go | 9 +- internal/cli/init_test.go | 2 +- internal/hooks/installer.go | 47 ++- internal/hooks/status.go | 26 ++ internal/hooks/workbuddy.go | 173 ++++++++++ internal/hooks/workbuddy_test.go | 336 ++++++++++++++++++++ internal/ui/server.go | 3 +- internal/ui/snapshot.go | 3 +- textassets/en-US/contracts/ui/messages.json | 14 +- textassets/zh-CN/contracts/ui/messages.json | 14 +- 12 files changed, 638 insertions(+), 16 deletions(-) create mode 100644 internal/hooks/workbuddy.go create mode 100644 internal/hooks/workbuddy_test.go diff --git a/internal/cli/doctor.go b/internal/cli/doctor.go index c319dfa6..0ae19d7c 100644 --- a/internal/cli/doctor.go +++ b/internal/cli/doctor.go @@ -222,6 +222,7 @@ func newDoctorCmd() *cobra.Command { markInstalled(rep, cliMessage("doctor.label.claude_hook"), hooks.IsClaudeHookInstalled(root)) markInstalled(rep, cliMessage("doctor.label.codex_mcp"), hooks.IsCodexMCPInstalled(root)) markInstalled(rep, cliMessage("doctor.label.opencode_mcp"), hooks.IsOpenCodeMCPInstalled(root)) + markInstalled(rep, cliMessage("doctor.label.workbuddy_mcp"), hooks.IsWorkBuddyMCPInstalled(root)) // —— 组三: AI 增强层 —— rep.group(cliMessage("doctor.group.ai")) diff --git a/internal/cli/init.go b/internal/cli/init.go index ee08dd5e..3347902b 100644 --- a/internal/cli/init.go +++ b/internal/cli/init.go @@ -46,6 +46,11 @@ func initAgentCandidatePaths(agent string) []string { return []string{".codex/config.toml"} case "opencode": return []string{"opencode.json"} + case "workbuddy": + // 机器级用户文件(~/.workbuddy/mcp.json),仓库内没有任何候选路径。 + // 返回 nil 让 init 跳过指纹比对与 .gitignore 写入 —— 宿主配置在仓库 + // 外,不该进 .gitignore,也不该被当成仓库资产推进 Baseline。 + return nil default: return nil } @@ -101,13 +106,13 @@ func initAgentGuideCommand( agent string, ) string { switch agent { - case "claude", "codex", "cursor", "opencode": + case "claude", "codex", "cursor", "opencode", "workbuddy": return "aoci index agent guide --agent " + agent + " --json" default: return "aoci index agent guide --agent " + - " --json" + " --json" } } @@ -141,7 +146,7 @@ func init() { } } switch agent { - case "", "claude", "codex", "cursor", "opencode", "all": + case "", "claude", "codex", "cursor", "opencode", "workbuddy", "all": default: return &ExitError{ Code: ExitConfig, @@ -389,9 +394,17 @@ func init() { outputLines = append(outputLines, cliMessage("init.config_ready")) beforeAgents, beforeAgentsExisted := fingerprintInitPath(root, "AGENTS.md") + // 区块落位按宿主能力分:WorkBuddy 只把 AGENTS.md 开头约 8000 字符 + // 注入模型上下文(实测截断),落文末的区块永远读不到,所以该宿主 + // 改插文首;其余宿主保持既有"文末追加"行为不变。 + agentsPlacement := hooks.AgentsBlockAppend + if agent == "workbuddy" { + agentsPlacement = hooks.AgentsBlockPrepend + } agentsMessage, err := - hooks.EnsureAgentsBlock( + hooks.EnsureAgentsBlockAt( root, + agentsPlacement, ) if err != nil { return err @@ -417,6 +430,11 @@ func init() { "codex", "cursor", } + // all 刻意不含 workbuddy: 它的写入面是**机器级**用户文件 + // ~/.workbuddy/mcp.json,对所有项目生效。把它塞进"批量装 + // 项目级宿主配置"的集合里,等于让一次 init 改到仓库之外 + // 的全局配置,超出了 all 的既有语义(帮助文案已承诺 + // "all 保持既有 claude/codex/cursor 集合")。 } for _, agentName := range agents { diff --git a/internal/cli/init_guide_test.go b/internal/cli/init_guide_test.go index f69fba26..2f53c823 100644 --- a/internal/cli/init_guide_test.go +++ b/internal/cli/init_guide_test.go @@ -37,15 +37,20 @@ func TestInitAgentGuideCommand( want: "aoci index agent guide --agent " + "opencode --json", }, + { + agent: "workbuddy", + want: "aoci index agent guide --agent " + + "workbuddy --json", + }, { agent: "", want: "aoci index agent guide --agent " + - " --json", + " --json", }, { agent: "all", want: "aoci index agent guide --agent " + - " --json", + " --json", }, } diff --git a/internal/cli/init_test.go b/internal/cli/init_test.go index 37ecd5cb..d6524686 100644 --- a/internal/cli/init_test.go +++ b/internal/cli/init_test.go @@ -320,7 +320,7 @@ func TestInitInvalidAgentRejected(t *testing.T) { if !errors.As(err, &ee) || ee.Code != ExitConfig { t.Fatalf("应为 ExitConfig 的 ExitError: %v", err) } - if !strings.Contains(ee.Msg, "claude/codex/cursor/opencode/all") { + if !strings.Contains(ee.Msg, "claude/codex/cursor/opencode/workbuddy/all") { t.Fatalf("拒绝文案应列出合法值: %q", ee.Msg) } if _, serr := os.Stat(filepath.Join(root, ".aoci")); !os.IsNotExist(serr) { diff --git a/internal/hooks/installer.go b/internal/hooks/installer.go index 1741d01c..a216f6cd 100644 --- a/internal/hooks/installer.go +++ b/internal/hooks/installer.go @@ -9,6 +9,8 @@ // - claude 全量(MCP 配置 + 可选 hook);codex 写项目级 MCP 配置, // --hooks 时额外安装 compact_prompt + SessionStart(compact) hook; // opencode 严格合并项目级 V1 opencode.json; +// workbuddy 写机器级用户 MCP 配置 ~/.workbuddy/mcp.json(键名退让见 +// workbuddy.go),不写仓库内文件; // cursor 只输出参考片段(诚实占位)。 // // 路径形态(Windows 真机教训): TplData 的 BinPath/RepoRoot 统一转正斜杠 —— @@ -169,10 +171,31 @@ func loadAgentsTemplate() (string, error) { return value, nil } -// EnsureAgentsBlock 在仓库根 AGENTS.md 写入/替换 aoci 标记区块。 +// AgentsBlockPlacement 决定"文件里还没有 aoci 区块"时新区块的落位。 +// 已有区块永远整块替换、位置不动,所以本选项只在首次落块时生效一次。 +type AgentsBlockPlacement int + +const ( + // AgentsBlockAppend 追加到文末(既有行为;宿主会读完整份规则文件时用它)。 + AgentsBlockAppend AgentsBlockPlacement = iota + // AgentsBlockPrepend 插到文首。给"宿主只把规则文件开头一段注入模型 + // 上下文"的场景用: WorkBuddy 实测注入截断在 8000 字符,文末区块永远 + // 进不了模型上下文,只有落在文首才读得到。 + AgentsBlockPrepend +) + +// EnsureAgentsBlock 在仓库根 AGENTS.md 写入/替换 aoci 标记区块(文末落位)。 // 已有区块整块替换,区块外内容一个字节不动;无区块则文末追加;文件不存在则新建。 // 返回动作说明。 func EnsureAgentsBlock(root string) (string, error) { + return EnsureAgentsBlockAt(root, AgentsBlockAppend) +} + +// EnsureAgentsBlockAt 与 EnsureAgentsBlock 同义,但由 placement 指定首次落块位置。 +func EnsureAgentsBlockAt( + root string, + placement AgentsBlockPlacement, +) (string, error) { agentsTemplate, err := loadAgentsTemplate() if err != nil { return "", err @@ -215,6 +238,17 @@ func EnsureAgentsBlock(root string) (string, error) { } return hookMessage("hook.agents_updated"), nil } + // 文首插入:给"宿主只注入规则文件开头一段"的场景,让区块落在可读范围内。 + if placement == AgentsBlockPrepend { + sep := "\n" + if !strings.HasPrefix(text, "\n") { + sep = "\n\n" + } + if err := BackupThenWrite(path, []byte(block+sep+text)); err != nil { + return "", err + } + return hookMessage("hook.agents_prepended"), nil + } // 文末追加 sep := "\n" if !strings.HasSuffix(text, "\n") { @@ -255,6 +289,11 @@ func Detect(root string) []string { fileExists(filepath.Join(root, ".opencode", "opencode.jsonc")) { found = append(found, "opencode") } + // workbuddy: 机器级用户 MCP 文件。WorkBuddy 全局只有这一个入口,项目内 + // 不存在它的任何配置文件,所以这里只能查用户家目录 —— 查项目目录必然漏报。 + if home != "" && fileExists(filepath.Join(home, ".workbuddy", "mcp.json")) { + found = append(found, "workbuddy") + } return found } @@ -263,6 +302,8 @@ func Detect(root string) []string { // codex: 写项目级 .codex/config.toml 的 [mcp_servers.aoci],--hooks 时同时安装 // compact_prompt 与 SessionStart(compact) hook; // opencode: 严格创建/合并项目级 OpenCode V1 opencode.json; +// workbuddy: 合并写入机器级用户文件 ~/.workbuddy/mcp.json(该宿主只有这一个 +// 入口;已有 aoci 键绑别的仓库时退让为 aoci-<项目名>,绝不覆盖); // cursor: 输出参考配置片段(诚实占位,不写文件)。 // 返回面向用户的多行结果说明。 func Install(root, agent string, withHooks bool) (string, error) { @@ -307,6 +348,10 @@ func Install(root, agent string, withHooks bool) (string, error) { return strings.TrimRight(b.String(), "\n"), nil case "opencode": return InstallOpenCodeMCP(root) + case "workbuddy": + // WorkBuddy 无写前生命周期 hook 接入面:withHooks 在此被有意忽略, + // 不静默假装安装(见 workbuddy.go 文件头纪律)。 + return InstallWorkBuddyMCP(root) case "cursor": out, err := renderLocaleTemplate( "codex-cursor-stubs.txt.tmpl", diff --git a/internal/hooks/status.go b/internal/hooks/status.go index f7377c4e..570fd47b 100644 --- a/internal/hooks/status.go +++ b/internal/hooks/status.go @@ -13,6 +13,9 @@ // (aoci 以无关形式出现误报已装)随之消除; // - Codex: 委托 codex.go 的 hasCodexTable(与写入端幂等同一实现,逐行判定 // 注释行免疫,审查事故防线单点承载)。 +// - WorkBuddy: 委托 workbuddy.go 的 workbuddyServerMatches(与写入端选键 +// 同一实现),遍历该文件全部 server 条目找"绑定当前仓库的那一条"; +// 因为该文件是机器级共享的,判据不能只认某个固定键名。 // // 判据取值原则: 任何读取/解析失败一律返回 false(视为未安装)—— 诊断场景宁可 // 报"未装"促使用户检查,绝不误报"已装"给出虚假安全感。 @@ -86,3 +89,26 @@ func IsOpenCodeMCPInstalled(root string) bool { plan, err := prepareOpenCodeMCP(root) return err == nil && plan.Current } + +// IsWorkBuddyMCPInstalled 判断机器级 ~/.workbuddy/mcp.json 里是否存在任一 +// server 条目绑定当前仓库的 aoci binary 与 --repo。 +// 判据: 委托 workbuddyServerMatches(workbuddy.go,与写入端选键同一实现)。 +// 该文件被所有项目共享,所以"已配置"= 存在某条绑定当前仓库,而不是"存在 +// 名为 aoci 的键"——后者会把别的仓库的 aoci 误报成本仓库已装。 +func IsWorkBuddyMCPInstalled(root string) bool { + path, err := workbuddyConfigPath() + if err != nil { + return false + } + doc, err := readWorkBuddyDocument(path) + if err != nil { + return false + } + data := NewTplData(root) + for _, entry := range workbuddyServersOf(doc) { + if workbuddyServerMatches(entry, data) { + return true + } + } + return false +} diff --git a/internal/hooks/workbuddy.go b/internal/hooks/workbuddy.go new file mode 100644 index 00000000..7d01ab2c --- /dev/null +++ b/internal/hooks/workbuddy.go @@ -0,0 +1,173 @@ +// WorkBuddy 适配: 机器级用户 MCP 配置(~/.workbuddy/mcp.json) +// 索引条目: workbuddy.go[Hook.WorkBuddy.8.S] +// +// 纪律: +// - WorkBuddy 只有**机器级**一个 MCP 入口(用户文件 ~/.workbuddy/mcp.json), +// 读面是 UserMcpProvider,写面是同一文件;没有项目级配置面。因此本文件 +// 不写仓库内任何文件,initAgentCandidatePaths 对 workbuddy 返回 nil, +// init 也不会为它写 .gitignore —— 宿主配置在仓库外,无入库问题; +// - JSON 合并写入,绝不覆盖用户既有 servers;写前 BackupThenWrite 备份; +// - 幂等判据单一事实源: 判据端 IsWorkBuddyMCPInstalled(status.go)与写入端 +// 复用 workbuddyServerMatches —— 写入端与判据端绝不各持一份逻辑副本 +// (status.go 判据失配事故的教训); +// - 键名退让: 该文件被**所有项目**共享,而 MCP server 硬绑 --repo。已有 aoci +// 键指向别的仓库时绝不覆盖,改用 aoci-<项目名> 新增一条;两个键名都被别的 +// 仓库占用时报错交人工,不做任何静默改写; +// - hook: WorkBuddy 没有写前生命周期 hook 接入面,故不实现 --hooks 分支, +// `init --hooks --agent workbuddy` 静默忽略而不是假装安装。 +package hooks + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" +) + +// workbuddyServerKey 是默认 server 键名,与其它宿主保持一致。 +const workbuddyServerKey = "aoci" + +// workbuddyConfigPath 返回 ~/.workbuddy/mcp.json。 +// 用户目录不可解析时返回可操作错误,绝不猜路径。 +func workbuddyConfigPath() (string, error) { + home, err := os.UserHomeDir() + if err != nil || strings.TrimSpace(home) == "" { + return "", errors.New(hookMessage("hook.workbuddy_home_missing")) + } + return filepath.Join(home, ".workbuddy", "mcp.json"), nil +} + +// workbuddyServerSpec 构造指向当前仓库的 server 定义(正斜杠路径由 TplData 保证)。 +func workbuddyServerSpec(data TplData) map[string]any { + return map[string]any{ + "command": data.BinPath, + "args": []string{"--repo", data.RepoRoot, "mcp"}, + } +} + +// workbuddyServerMatches 判定某个已解析条目是否就是"当前仓库的 aoci server"。 +// 单条判据的唯一实现:写入端选键前用它识别"同仓库已有条目",判据端用它做 +// 全量匹配 —— 两侧共用,任一侧改动另一侧自动跟随。 +func workbuddyServerMatches(entry any, data TplData) bool { + server, ok := entry.(map[string]any) + if !ok { + return false + } + if command, _ := server["command"].(string); command != data.BinPath { + return false + } + rawArgs, ok := server["args"].([]any) + if !ok || len(rawArgs) != 3 { + return false + } + want := []string{"--repo", data.RepoRoot, "mcp"} + for i, expected := range want { + // json 解析出的元素是 any;逐位比较,类型不符即判不匹配。 + value, isString := rawArgs[i].(string) + if !isString || value != expected { + return false + } + } + return true +} + +// workbuddyKeySuffix 把项目名收敛成可安全用作 server 键名的片段。 +func workbuddyKeySuffix(name string) string { + var b strings.Builder + for _, r := range strings.ToLower(name) { + switch { + case r >= 'a' && r <= 'z', r >= '0' && r <= '9': + b.WriteRune(r) + default: + b.WriteRune('-') + } + } + suffix := strings.Trim(b.String(), "-") + if suffix == "" { + suffix = "repo" + } + return suffix +} + +// workbuddyTargetKey 选定本次写入使用的键名。 +// - aoci 键不存在 → 用它; +// - aoci 键已是当前仓库 → 用它(写入端幂等早退由调用方先行处理); +// - aoci 键绑别的仓库 → aoci-<项目名>;该键空闲就用它,也被别的仓库占用则报错。 +func workbuddyTargetKey(servers map[string]any, data TplData) (string, error) { + existing, taken := servers[workbuddyServerKey] + if !taken || workbuddyServerMatches(existing, data) { + return workbuddyServerKey, nil + } + scoped := workbuddyServerKey + "-" + workbuddyKeySuffix(data.ProjectName) + if occupant, used := servers[scoped]; used && !workbuddyServerMatches(occupant, data) { + return "", errors.New(hookMessage("hook.workbuddy_key_taken", scoped, data.RepoRoot)) + } + return scoped, nil +} + +// readWorkBuddyDocument 读取并解析用户 MCP 整份文件;文件缺失返回空文档。 +// 返回整份文档而不是只有 mcpServers —— 写入必须原样保留用户文档里的其它 +// 顶层字段(与 claude.go 同一纪律:只动 mcpServers,其余字节不碰)。 +// JSON 损坏返回可操作错误,由调用方原样上报,绝不覆盖用户的坏文件。 +func readWorkBuddyDocument(path string) (map[string]any, error) { + doc := map[string]any{} + raw, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return doc, nil + } + return nil, err + } + if len(strings.TrimSpace(string(raw))) == 0 { + return doc, nil + } + if jerr := json.Unmarshal(raw, &doc); jerr != nil { + return nil, errors.New(hookMessage("hook.workbuddy_mcp_invalid", path, jerr)) + } + return doc, nil +} + +// workbuddyServersOf 取出文档里的 mcpServers 表;缺失或类型不符则新建一张。 +func workbuddyServersOf(doc map[string]any) map[string]any { + if servers, ok := doc["mcpServers"].(map[string]any); ok { + return servers + } + return map[string]any{} +} + +// InstallWorkBuddyMCP 合并写入机器级 ~/.workbuddy/mcp.json 的 aoci server。 +// 幂等早退复用判据端 IsWorkBuddyMCPInstalled(单一事实源)。 +func InstallWorkBuddyMCP(root string) (string, error) { + path, err := workbuddyConfigPath() + if err != nil { + return "", err + } + data := NewTplData(root) + + // 幂等早退:当前仓库已在任一 aoci 条目里配置好(判据端同一实现)。 + if IsWorkBuddyMCPInstalled(root) { + return hookMessage("hook.workbuddy_mcp_current", path), nil + } + + doc, err := readWorkBuddyDocument(path) + if err != nil { + return "", err + } + servers := workbuddyServersOf(doc) + key, err := workbuddyTargetKey(servers, data) + if err != nil { + return "", err + } + servers[key] = workbuddyServerSpec(data) + doc["mcpServers"] = servers + + out, err := json.MarshalIndent(doc, "", " ") + if err != nil { + return "", err + } + if err := BackupThenWrite(path, append(out, '\n')); err != nil { + return "", err + } + return hookMessage("hook.workbuddy_mcp_written", path, key), nil +} diff --git a/internal/hooks/workbuddy_test.go b/internal/hooks/workbuddy_test.go new file mode 100644 index 00000000..4fe023b4 --- /dev/null +++ b/internal/hooks/workbuddy_test.go @@ -0,0 +1,336 @@ +// WorkBuddy 机器级用户 MCP 配置安装测试 +// 索引条目: workbuddy_test.go[Hook.WorkBuddyTest.7.S] +// +// 承重判据(每条都有对应的"故意违规"用例): +// - 幂等: 同一仓库连装两次,第二次必须早退且不改动文件字节; +// - 键名退让: 已有 aoci 键绑别的仓库时,必须新增 aoci-<项目名> 而不是覆盖 —— +// 该文件被所有项目共享,覆盖会静默改掉别的仓库的接入; +// - 人工边界: 退让键也被别的仓库占用时报错,不猜、不覆盖; +// - 不毁坏既有配置: 既有 servers、既有顶层字段、损坏的 JSON 都必须原样保留; +// - 判据不含糊: 别的仓库的 aoci 条目不算"本仓库已装";文件损坏判未装。 +package hooks + +import ( + "encoding/json" + "os" + "path/filepath" + "sort" + "strings" + "testing" +) + +// isolateHome 把用户家目录指向临时目录,使测试不碰真实的 ~/.workbuddy。 +func isolateHome(t *testing.T) string { + t.Helper() + home := t.TempDir() + t.Setenv("HOME", home) + return home +} + +// readWorkBuddyConfig 读回测试写入的机器级 MCP 文件。 +func readWorkBuddyConfig(t *testing.T, home string) map[string]any { + t.Helper() + raw, err := os.ReadFile(filepath.Join(home, ".workbuddy", "mcp.json")) + if err != nil { + t.Fatal(err) + } + doc := map[string]any{} + if err := json.Unmarshal(raw, &doc); err != nil { + t.Fatal(err) + } + servers, _ := doc["mcpServers"].(map[string]any) + if servers == nil { + t.Fatal("mcpServers 缺失") + } + return servers +} + +func serverKeys(servers map[string]any) []string { + out := make([]string, 0, len(servers)) + for k := range servers { + out = append(out, k) + } + sort.Strings(out) + return out +} + +func TestInstallWorkBuddyMCPWritesServerAndIsIdempotent(t *testing.T) { + home := isolateHome(t) + root := t.TempDir() + + if _, err := InstallWorkBuddyMCP(root); err != nil { + t.Fatal(err) + } + servers := readWorkBuddyConfig(t, home) + if _, ok := servers[workbuddyServerKey]; !ok { + t.Fatalf("aoci 键未写入: %v", serverKeys(servers)) + } + if !IsWorkBuddyMCPInstalled(root) { + t.Fatal("写入后判据仍为未安装") + } + + // 幂等: 第二次必须早退, 且不得改动文件字节。 + path := filepath.Join(home, ".workbuddy", "mcp.json") + before, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if _, err := InstallWorkBuddyMCP(root); err != nil { + t.Fatal(err) + } + after, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(before) != string(after) { + t.Fatal("第二次安装改动了文件, 幂等被破坏") + } +} + +func TestInstallWorkBuddyMCPKeepsForeignAociEntry(t *testing.T) { + home := isolateHome(t) + root := t.TempDir() + other := t.TempDir() + + // 先装另一个仓库, 占据默认 aoci 键。 + if _, err := InstallWorkBuddyMCP(other); err != nil { + t.Fatal(err) + } + foreign, err := json.Marshal(readWorkBuddyConfig(t, home)[workbuddyServerKey]) + if err != nil { + t.Fatal(err) + } + + if _, err := InstallWorkBuddyMCP(root); err != nil { + t.Fatal(err) + } + servers := readWorkBuddyConfig(t, home) + + // 别人的条目必须逐字未动。 + kept, err := json.Marshal(servers[workbuddyServerKey]) + if err != nil { + t.Fatal(err) + } + if string(kept) != string(foreign) { + t.Fatalf("别人的 aoci 条目被改写:\n before %s\n after %s", foreign, kept) + } + // 本仓库拿到自己的退让键, 判据只对本仓库为真。 + scoped := workbuddyServerKey + "-" + workbuddyKeySuffix(filepath.Base(root)) + if _, ok := servers[scoped]; !ok { + t.Fatalf("未写入退让键 %q: %v", scoped, serverKeys(servers)) + } + if !IsWorkBuddyMCPInstalled(root) { + t.Fatal("退让键写入后判据仍为未安装") + } +} + +func TestWorkBuddyTargetKeyRejectsOccupiedScopedKey(t *testing.T) { + isolateHome(t) + first := t.TempDir() + if _, err := InstallWorkBuddyMCP(first); err != nil { + t.Fatal(err) + } + // 造出第二个仓库: 默认键已被占, 于是落到 aoci-<目录名>。 + second := t.TempDir() + if _, err := InstallWorkBuddyMCP(second); err != nil { + t.Fatal(err) + } + scoped := workbuddyServerKey + "-" + workbuddyKeySuffix(filepath.Base(second)) + if !IsWorkBuddyMCPInstalled(second) { + t.Fatalf("第二个仓库未写入退让键 %q", scoped) + } + // 第三个仓库目录名与第二个相同 ⇒ 退让键撞车 ⇒ 必须报错交人工。 + collide := filepath.Join(t.TempDir(), filepath.Base(second)) + if err := os.MkdirAll(collide, 0755); err != nil { + t.Fatal(err) + } + _, err := InstallWorkBuddyMCP(collide) + if err == nil { + t.Fatal("退让键被别的仓库占用时必须报错, 绝不覆盖") + } + if !strings.Contains(err.Error(), scoped) { + t.Fatalf("报错文案应点明冲突键名 %q, 实际: %v", scoped, err) + } +} + +func TestInstallWorkBuddyMCPRejectsBrokenJSON(t *testing.T) { + home := isolateHome(t) + root := t.TempDir() + dir := filepath.Join(home, ".workbuddy") + if err := os.MkdirAll(dir, 0755); err != nil { + t.Fatal(err) + } + broken := filepath.Join(dir, "mcp.json") + if err := os.WriteFile(broken, []byte("{not json"), 0600); err != nil { + t.Fatal(err) + } + if _, err := InstallWorkBuddyMCP(root); err == nil { + t.Fatal("损坏的 JSON 必须报错, 绝不覆盖用户的坏文件") + } + raw, err := os.ReadFile(broken) + if err != nil { + t.Fatal(err) + } + if string(raw) != "{not json" { + t.Fatalf("坏文件被改写: %q", string(raw)) + } +} + +func TestInstallWorkBuddyMCPPreservesExistingConfig(t *testing.T) { + home := isolateHome(t) + root := t.TempDir() + dir := filepath.Join(home, ".workbuddy") + if err := os.MkdirAll(dir, 0755); err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, "mcp.json") + seed := `{"mcpServers":{"other":{"command":"node","args":["x.js"]}},"note":"keep me"}` + if err := os.WriteFile(path, []byte(seed), 0600); err != nil { + t.Fatal(err) + } + if _, err := InstallWorkBuddyMCP(root); err != nil { + t.Fatal(err) + } + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + doc := map[string]any{} + if err := json.Unmarshal(raw, &doc); err != nil { + t.Fatal(err) + } + servers, _ := doc["mcpServers"].(map[string]any) + if _, ok := servers["other"]; !ok { + t.Fatalf("既有 server 被丢弃: %v", serverKeys(servers)) + } + if doc["note"] != "keep me" { + t.Fatalf("既有顶层字段被丢弃: %v", doc) + } +} + +func TestIsWorkBuddyMCPInstalledRejectsOtherRepoAndBadFile(t *testing.T) { + home := isolateHome(t) + installed := t.TempDir() + if _, err := InstallWorkBuddyMCP(installed); err != nil { + t.Fatal(err) + } + if IsWorkBuddyMCPInstalled(t.TempDir()) { + t.Fatal("别的仓库不得被判为已安装") + } + // 判据原则: 任何解析失败一律 false(宁可报未装, 不给虚假安全感)。 + if err := os.WriteFile(filepath.Join(home, ".workbuddy", "mcp.json"), []byte("[[["), 0600); err != nil { + t.Fatal(err) + } + if IsWorkBuddyMCPInstalled(installed) { + t.Fatal("损坏文件必须判为未安装, 不误报已装") + } +} + +func TestDetectAndDispatchWorkBuddy(t *testing.T) { + home := isolateHome(t) + root := t.TempDir() + + // WorkBuddy 在项目内没有任何配置文件, Detect 只能查用户家目录。 + if err := os.MkdirAll(filepath.Join(home, ".workbuddy"), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(home, ".workbuddy", "mcp.json"), []byte("{}"), 0600); err != nil { + t.Fatal(err) + } + if !containsString(Detect(root), "workbuddy") { + t.Fatalf("Detect 未发现 workbuddy: %v", Detect(root)) + } + // 走 Install 分发; withHooks=true 必须被静默忽略而不是报错或假装安装。 + if _, err := Install(root, "workbuddy", true); err != nil { + t.Fatal(err) + } + if !IsWorkBuddyMCPInstalled(root) { + t.Fatal("Install 分发未写入配置") + } + // 仓库内不得留下任何宿主配置文件。 + for _, name := range []string{".mcp.json", ".workbuddy", ".codex", "opencode.json", ".claude"} { + if _, err := os.Stat(filepath.Join(root, name)); err == nil { + t.Fatalf("仓库内不该出现 %s", name) + } + } +} + +func TestWorkBuddyKeySuffixSanitizes(t *testing.T) { + cases := map[string]string{ + "MindInit": "mindinit", + "My Project": "my-project", + "a.b_c": "a-b-c", + "///": "repo", + "项目": "repo", + "trailing-": "trailing", + "-lead-and-end": "lead-and-end", + } + for in, want := range cases { + if got := workbuddyKeySuffix(in); got != want { + t.Fatalf("workbuddyKeySuffix(%q) = %q, want %q", in, got, want) + } + } +} + +func TestEnsureAgentsBlockAtPrependPutsBlockFirst(t *testing.T) { + isolateHome(t) + root := t.TempDir() + path := filepath.Join(root, "AGENTS.md") + original := "# 项目标题\n\n第一段。\n" + if err := os.WriteFile(path, []byte(original), 0600); err != nil { + t.Fatal(err) + } + if _, err := EnsureAgentsBlockAt(root, AgentsBlockPrepend); err != nil { + t.Fatal(err) + } + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + text := string(raw) + if !strings.HasPrefix(text, agentsBegin) { + t.Fatalf("区块未落在文首(宿主只注入开头一段, 落文末读不到):\n%.120s", text) + } + if !strings.Contains(text, original) { + t.Fatal("原有内容被丢弃") + } + // 幂等: 已有区块时再调用只整块替换, 位置不得变动。 + before, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if _, err := EnsureAgentsBlockAt(root, AgentsBlockPrepend); err != nil { + t.Fatal(err) + } + after, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(before) != string(after) { + t.Fatal("已有区块时重复调用改动了文件") + } +} + +func TestEnsureAgentsBlockAtAppendStaysAtEnd(t *testing.T) { + isolateHome(t) + root := t.TempDir() + path := filepath.Join(root, "AGENTS.md") + original := "# 标题\n" + if err := os.WriteFile(path, []byte(original), 0600); err != nil { + t.Fatal(err) + } + // 默认落位必须仍是文末追加 —— 不得让 workbuddy 适配改变其它宿主行为。 + if _, err := EnsureAgentsBlockAt(root, AgentsBlockAppend); err != nil { + t.Fatal(err) + } + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(string(raw), original) { + t.Fatal("默认落位必须仍是文末追加") + } + if !strings.Contains(string(raw), agentsBegin) { + t.Fatal("区块缺失") + } +} diff --git a/internal/ui/server.go b/internal/ui/server.go index 5fa8075e..80d22d40 100644 --- a/internal/ui/server.go +++ b/internal/ui/server.go @@ -57,7 +57,8 @@ var pageStringKeys = []string{ "ui.page.database_index", "ui.page.none", "ui.page.recovery", "ui.page.recovery_pending", "ui.page.no_action", "ui.page.replaced_on_disk", "ui.page.no_running_server", "ui.page.integration.claude_mcp", "ui.page.integration.claude_hook", "ui.page.integration.codex_mcp", - "ui.page.integration.opencode_mcp", "ui.page.integration.agents_block", + "ui.page.integration.opencode_mcp", "ui.page.integration.workbuddy_mcp", + "ui.page.integration.agents_block", "ui.page.refreshed", "ui.page.up_to_date", "ui.page.disconnected", "ui.page.tab_all", "ui.page.copy_all", "ui.page.overview_hint", "ui.page.refresh_every", "ui.page.refresh_now", "ui.page.interval_10s", "ui.page.interval_30s", "ui.page.interval_1m", diff --git a/internal/ui/snapshot.go b/internal/ui/snapshot.go index 6865e12a..ca416b1e 100644 --- a/internal/ui/snapshot.go +++ b/internal/ui/snapshot.go @@ -89,7 +89,8 @@ func buildSnapshot(root string, options Options, running []Instance) (Snapshot, snapshot.Integrations = map[string]bool{ "claude_mcp": hooks.IsClaudeMCPInstalled(root), "claude_hook": hooks.IsClaudeHookInstalled(root), "codex_mcp": hooks.IsCodexMCPInstalled(root), "opencode_mcp": hooks.IsOpenCodeMCPInstalled(root), - "agents_block": hooks.IsAgentsBlockPresent(root), + "workbuddy_mcp": hooks.IsWorkBuddyMCPInstalled(root), + "agents_block": hooks.IsAgentsBlockPresent(root), } cfg, err := config.Load(root) if err != nil { diff --git a/textassets/en-US/contracts/ui/messages.json b/textassets/en-US/contracts/ui/messages.json index 344f1f83..b7fee74d 100644 --- a/textassets/en-US/contracts/ui/messages.json +++ b/textassets/en-US/contracts/ui/messages.json @@ -257,7 +257,7 @@ "cli.flag.hook_path": "Target file path (mutually exclusive with --stdin-json)", "cli.flag.hook_stdin": "Read Agent hook JSON from stdin (Claude Code channel)", "cli.flag.hook_tool": "Agent-side tool name (Edit, Write, or MultiEdit)", - "cli.flag.init_agent": "Integrate an Agent: claude, codex, cursor, opencode, or all (all keeps the existing claude/codex/cursor set)", + "cli.flag.init_agent": "Integrate an Agent: claude, codex, cursor, opencode, workbuddy, or all (all keeps the existing claude/codex/cursor set and excludes machine-scoped workbuddy)", "cli.flag.init_here": "Initialize in the current directory when repository discovery fails", "cli.flag.init_hooks": "Also install supported host lifecycle hooks (Claude Code pre-write; Codex context compaction)", "cli.flag.init_locale": "Project locale: en-US or zh-CN (default for new projects: en-US)", @@ -676,6 +676,7 @@ "doctor.label.index_file": "Index file", "doctor.label.index_parse": "Index parsing", "doctor.label.opencode_mcp": "OpenCode MCP (opencode.json V1)", + "doctor.label.workbuddy_mcp": "WorkBuddy MCP (~/.workbuddy/mcp.json)", "doctor.label.platform": "Runtime platform", "doctor.label.repo_root": "Repository-root discovery", "doctor.label.secret": "Secret", @@ -1115,10 +1116,11 @@ "hook.agents_appended": "AGENTS.md aoci managed block appended; existing content was preserved", "hook.agents_asset_error": "load the AGENTS template asset: %v", "hook.agents_created": "AGENTS.md created with the aoci managed block", + "hook.agents_prepended": "aoci managed block inserted at the top of AGENTS.md; existing content was preserved", "hook.agents_current": "AGENTS.md aoci managed block is current (skipped)", "hook.agents_updated": "AGENTS.md aoci managed block updated; content outside the block was preserved", "hook.backup_error": "back up %s: %v", - "hook.bad_agent": "unknown Agent %q (available: claude, codex, cursor, opencode, all)", + "hook.bad_agent": "unknown Agent %q (available: claude, codex, cursor, opencode, workbuddy, all)", "hook.claude_hook_current": "hook script ready; .claude/settings.json already contains the aoci hook (skipped)", "hook.claude_hook_installed": "hook installed at %s and registered in .claude/settings.json (PreToolUse: Edit|Write|MultiEdit)", "hook.claude_mcp_current": ".mcp.json already contains the aoci configuration (skipped)", @@ -1151,6 +1153,11 @@ "hook.template_forbidden": "rendered template %s contains forbidden text; refusing to write:\n%s", "hook.template_parse_error": "parse template %s: %v", "hook.template_render_error": "render template %s: %v", + "hook.workbuddy_home_missing": "cannot locate the user home directory, so the WorkBuddy MCP configuration was not written; set HOME and retry", + "hook.workbuddy_key_taken": "%q in ~/.workbuddy/mcp.json is already taken by another repository; %s was not written; rename that key manually and retry", + "hook.workbuddy_mcp_current": "%s already contains the aoci configuration for this repository (skipped)", + "hook.workbuddy_mcp_invalid": "%s exists but is not valid JSON (%v); repair it manually first", + "hook.workbuddy_mcp_written": "%s updated with the aoci MCP configuration (key %q; existing servers were preserved)", "host.executable.absolute_failed": "Could not make the current executable path absolute: %s", "host.executable.control_character": "The current executable path contains a control character.", "host.executable.empty": "The current executable path is empty.", @@ -1161,7 +1168,7 @@ "init.automation_default": "automation.mode set to auto (new-project default; init does not invoke a model)", "init.automation_fallback": "Follow the mode and stopping points returned by Guide", "init.automation_invalid": "automation.mode is invalid; repair the team config.json first", - "init.bad_agent": "unknown Agent %q (available: claude, codex, cursor, opencode, all; all keeps the existing claude/codex/cursor set; omit it to create the skeleton and detect Agents only)", + "init.bad_agent": "unknown Agent %q (available: claude, codex, cursor, opencode, workbuddy, all; all keeps the existing claude/codex/cursor set and excludes machine-scoped workbuddy; omit it to create the skeleton and detect Agents only)", "init.bad_scope_profile": "invalid Managed Scope profile %q (available: production, full, custom)", "init.baseline_advanced": "Baseline advanced for this init operation: %s", "init.baseline_concurrent_suffix": "; concurrent changes were not advanced: %s", @@ -1663,6 +1670,7 @@ "ui.page.integration.codex_mcp": "Codex MCP", "ui.page.integration.opencode_mcp": "OpenCode MCP", "ui.page.integration.agents_block": "AGENTS.md block", + "ui.page.integration.workbuddy_mcp": "WorkBuddy MCP", "ui.page.refreshed": "Refreshed", "ui.page.up_to_date": "Up to date", "ui.page.disconnected": "Disconnected", diff --git a/textassets/zh-CN/contracts/ui/messages.json b/textassets/zh-CN/contracts/ui/messages.json index c6876aab..83405b9d 100644 --- a/textassets/zh-CN/contracts/ui/messages.json +++ b/textassets/zh-CN/contracts/ui/messages.json @@ -257,7 +257,7 @@ "cli.flag.hook_path": "目标文件路径(与 --stdin-json 二选一)", "cli.flag.hook_stdin": "从 stdin 读取 agent hook JSON(Claude Code 通道)", "cli.flag.hook_tool": "agent 侧工具名(Edit/Write/MultiEdit)", - "cli.flag.init_agent": "接入 agent: claude/codex/cursor/opencode/all(all 保持既有 claude/codex/cursor 集合)", + "cli.flag.init_agent": "接入 agent: claude/codex/cursor/opencode/workbuddy/all(all 保持既有 claude/codex/cursor 集合,不含机器级的 workbuddy)", "cli.flag.init_here": "定位失败时允许以当前目录为根就地初始化", "cli.flag.init_hooks": "同时安装宿主支持的生命周期hook(Claude写前;Codex上下文压缩)", "cli.flag.init_locale": "项目Locale: en-US或zh-CN(新项目默认en-US)", @@ -676,6 +676,7 @@ "doctor.label.index_file": "索引文件", "doctor.label.index_parse": "索引解析", "doctor.label.opencode_mcp": "OpenCode MCP(opencode.json V1)", + "doctor.label.workbuddy_mcp": "WorkBuddy MCP (~/.workbuddy/mcp.json)", "doctor.label.platform": "运行平台", "doctor.label.repo_root": "仓库根定位", "doctor.label.secret": "密钥", @@ -1115,10 +1116,11 @@ "hook.agents_appended": "AGENTS.md 已追加 aoci 区块(原有内容未动)", "hook.agents_asset_error": "加载AGENTS模板资产失败: %v", "hook.agents_created": "AGENTS.md 已创建(含 aoci 区块)", + "hook.agents_prepended": "AGENTS.md 已把 aoci 区块插到文首(原有内容未动)", "hook.agents_current": "AGENTS.md aoci 区块已最新(跳过)", "hook.agents_updated": "AGENTS.md aoci 区块已更新(区块外内容未动)", "hook.backup_error": "备份 %s 失败: %v", - "hook.bad_agent": "未知 agent %q(可用: claude/codex/cursor/opencode/all)", + "hook.bad_agent": "未知 agent %q(可用: claude/codex/cursor/opencode/workbuddy/all)", "hook.claude_hook_current": "hook 脚本已就位;.claude/settings.json 已含 aoci hook(跳过)", "hook.claude_hook_installed": "hook 已安装: %s 并注册到 .claude/settings.json(PreToolUse: Edit|Write|MultiEdit)", "hook.claude_mcp_current": ".mcp.json 已含 aoci 配置(跳过)", @@ -1151,6 +1153,11 @@ "hook.template_forbidden": "模板 %s 渲染产物含禁区词,拒绝落盘:\n%s", "hook.template_parse_error": "模板 %s 解析失败: %v", "hook.template_render_error": "模板 %s 渲染失败: %v", + "hook.workbuddy_home_missing": "定位不到用户家目录,无法写入 WorkBuddy MCP 配置: 请设置 HOME 后重试", + "hook.workbuddy_key_taken": "~/.workbuddy/mcp.json 里的 %q 已被另一个仓库占用,本仓库 %s 未写入: 请人工改键名后重试", + "hook.workbuddy_mcp_current": "%s 已含本仓库的 aoci 配置(跳过)", + "hook.workbuddy_mcp_invalid": "%s 已存在但不是合法 JSON(%v): 请先人工修复", + "hook.workbuddy_mcp_written": "%s 已写入 aoci MCP 配置(键 %q;既有 servers 未动)", "host.executable.absolute_failed": "将当前可执行文件路径转为绝对路径失败:%s", "host.executable.control_character": "当前可执行文件路径含控制字符。", "host.executable.empty": "当前可执行文件路径为空。", @@ -1161,7 +1168,7 @@ "init.automation_default": "automation.mode 已设为 auto(新仓默认;init 本次仍不调用模型)", "init.automation_fallback": "请按 Guide 返回的模式和停点执行", "init.automation_invalid": "automation.mode 无效,请先修复团队 config.json", - "init.bad_agent": "未知 agent %q(可用: claude/codex/cursor/opencode/all;all 保持既有 claude/codex/cursor 集合;留空只做骨架并探测)", + "init.bad_agent": "未知 agent %q(可用: claude/codex/cursor/opencode/workbuddy/all;all 保持既有 claude/codex/cursor 集合,不含机器级的 workbuddy;留空只做骨架并探测)", "init.bad_scope_profile": "无效Managed Scope配置%q(可用:production、full、custom)", "init.baseline_advanced": "基线已前移(init 本轮写入): %s", "init.baseline_concurrent_suffix": ";并发变化未前移: %s", @@ -1663,6 +1670,7 @@ "ui.page.integration.codex_mcp": "Codex MCP", "ui.page.integration.opencode_mcp": "OpenCode MCP", "ui.page.integration.agents_block": "AGENTS.md 区块", + "ui.page.integration.workbuddy_mcp": "WorkBuddy MCP", "ui.page.refreshed": "已刷新", "ui.page.up_to_date": "已是最新", "ui.page.disconnected": "连接断开", From b8654817cebaae2c275babadeee5e33ed9739578 Mon Sep 17 00:00:00 2001 From: MindIniter Date: Sun, 4 Oct 2026 21:36:00 +0800 Subject: [PATCH 2/2] docs(host): document the WorkBuddy integration - agent-integrations.md: new WorkBuddy section covering the machine-level configuration shape, the never-overwrite-foreign-entry rule, the inert --hooks, and the prepended managed block; the shared header now names the machine-level file and states why it is the exception to the commit rule - README (en/zh-CN): host lists, prerequisites, host tables, configuration file inventories and the command samples - troubleshooting.md: stale-entry list, the "tools appear in unrelated projects" section (WorkBuddy is the host where scoping cannot be achieved by moving a file), and the .gitignore boundary - CHANGELOG: Unreleased entry --- CHANGELOG.md | 16 ++++++++++++ README.md | 21 ++++++++++----- README.zh-CN.md | 10 +++++--- docs/agent-integrations.md | 52 ++++++++++++++++++++++++++++++++++---- docs/troubleshooting.md | 16 ++++++++++-- 5 files changed, 97 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eb76507c..da2a7b15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,22 @@ All notable public changes to AOCI-CODE will be documented in this file. ## Unreleased +- Add WorkBuddy as a first-class `init --agent` host. WorkBuddy exposes no + project-scoped MCP surface — its only entry is the machine-level + `~/.workbuddy/mcp.json` — so `init --agent workbuddy` merges an entry there + and writes nothing into the repository: no Baseline path, no `.gitignore` + line, no host file for `git status` to report. Because that file is shared by + every project while a server is bound to one `--repo`, the installer never + overwrites a foreign entry: an `aoci` key already pointing at another + repository stays byte-for-byte intact and the new entry is written as + `aoci-`, and a conflict on both keys is reported rather than + resolved. The status predicate scans every entry for one bound to the current + repository, so another repository's `aoci` never reads as installed here. + `--hooks` is inert for this host because it exposes no pre-write lifecycle + hook surface, and the managed `AGENTS.md` block is prepended for this host + alone, because that host injects only the first few thousand characters of the + file into model context and a trailing block is never read; every other host + keeps the existing append behavior, guarded by a regression test. - Fix the repair guidance for a mistyped or self-computed Code binding. Since rc16 the Maintain response no longer repeats `code_plan.candidates`, but the `code_candidate_source_sha256_mismatch`, `code_candidate_id_mismatch`, and diff --git a/README.md b/README.md index 9bd73b50..ea7e13de 100644 --- a/README.md +++ b/README.md @@ -128,7 +128,7 @@ How long the first index takes depends on repository size. A normal integration - A verified release package or a checkout of the canonical AOCI-CODE source repository. - For source builds only: the Go toolchain declared by `go.mod`, `make`, and the other tools the repository requires. -- A supported MCP host, such as Codex, Claude Code, Cursor, or OpenCode. +- A supported MCP host, such as Codex, Claude Code, Cursor, OpenCode, or WorkBuddy. - Normal read and write access to the target repository. AOCI-CODE integrates with the MCP host, not with a model-provider API. DeepSeek @@ -260,7 +260,7 @@ The index and the current managed source should converge back to `aligned`. If t "$AOCI" --repo . doctor ``` -To confirm which AOCI the host is actually connected to, read what the server reports about itself rather than what is on disk. In any `aoci_overview` `check_only` response or any `aoci_maintain` response, `cognition_receipt.mcp_service_version` is the running version and `runtime_repository_root` is the repository it governs. The matching binary path is the `command` in the project's `.mcp.json` or the equivalent host configuration: `.codex/config.toml`, `opencode.json`, or `.cursor/mcp.json`. Replacing bytes on disk does not change a running MCP process, so recheck against those facts after an upgrade or a rollback. +To confirm which AOCI the host is actually connected to, read what the server reports about itself rather than what is on disk. In any `aoci_overview` `check_only` response or any `aoci_maintain` response, `cognition_receipt.mcp_service_version` is the running version and `runtime_repository_root` is the repository it governs. The matching binary path is the `command` in the project's `.mcp.json` or the equivalent host configuration: `.codex/config.toml`, `opencode.json`, `.cursor/mcp.json`, or the machine-level `~/.workbuddy/mcp.json`. Replacing bytes on disk does not change a running MCP process, so recheck against those facts after an upgrade or a rollback. For a one-off walkthrough, use `examples/minimal-repository` in the repository. @@ -310,11 +310,15 @@ aoci.database.txt Database: optional table-level entries; absent by de Initializing a new project creates the Root, Meta, and an empty Code Volume; Database is absent by default. AOCI-CODE does not generate business meaning for the repository or the database on its own. `aoci init --agent ` additionally writes host integration configuration -(`.mcp.json`, `.claude/settings.json`, `.codex/config.toml`, or `opencode.json`) -whose command and repository paths are machine-bound absolute paths. Add those -files to the repository's `.gitignore` and do not commit them: a committed copy -breaks on every other machine, and because the installers detect an existing -entry by key presence, re-running `init` there silently keeps the broken paths. +(`.mcp.json`, `.claude/settings.json`, `.codex/config.toml`, `opencode.json`, or +the machine-level `~/.workbuddy/mcp.json`) +whose command and repository paths are machine-bound absolute paths. Add the +project-level ones to the repository's `.gitignore` and do not commit them: a +committed copy breaks on every other machine, and because the installers detect +an existing entry by key presence, re-running `init` there silently keeps the +broken paths. The WorkBuddy file is machine-level rather than project-level, so +it belongs to no repository and `init` writes nothing into the working tree for +that host. ## How a development task runs @@ -551,6 +555,7 @@ The Code Volume, the Database Volume, and scope can evolve together, but they sh - **Claude Code** can install a `PreToolUse` hook. - **OpenCode V1** gets a strict project-level `opencode.json`. - **Cursor** only returns a reference configuration snippet; nothing is written to the project. +- **WorkBuddy** gets a merged entry in the machine-level `~/.workbuddy/mcp.json` and nothing in the repository. A foreign `aoci` key is never overwritten; the entry is written as `aoci-` instead, and a conflict on both keys is reported rather than resolved. `--hooks` is inert because that host exposes no pre-write hook surface, and the managed agent block is placed at the top of `AGENTS.md` because this host injects only its first few thousand characters into model context. After configuration, check whether the current host session already exposes the AOCI tools. Refresh or reopen that project session only if it has not loaded the new server. A new session normally reads the rules and the Whole-Index once. While the index identity remains valid and no known host compaction has occurred, later tasks reuse what the model already has; the whole index is not injected again mechanically. @@ -560,6 +565,7 @@ After configuration, check whether the current host session already exposes the | **Claude Code** | Project-level MCP; optional thin `PreToolUse` guard | The hook only provides a pre-write reminder or stale guard; it is not the agent runtime | | **OpenCode V1** | Strict project-root `opencode.json` via `--agent opencode` | Continue immediately if tools are loaded; otherwise refresh or reopen the project session | | **Cursor** | Returns an MCP reference configuration snippet | Does not write project configuration; you complete the integration manually for the host | +| **WorkBuddy** | Merged entry in the machine-level `~/.workbuddy/mcp.json` via `--agent workbuddy` | Machine-level, not project-scoped: one file serves every project, and each repository needs its own entry (or its own server key). No pre-write hook surface, so `--hooks` does nothing here | | **Other MCP hosts** | Connect to the standard stdio server | Require manual configuration and host-specific validation | ```bash @@ -568,6 +574,7 @@ aoci --repo /absolute/path/to/repository init --agent codex --hooks aoci --repo /absolute/path/to/repository init --agent claude --hooks aoci --repo /absolute/path/to/repository init --agent opencode aoci --repo /absolute/path/to/repository init --agent cursor +aoci --repo /absolute/path/to/repository init --agent workbuddy ``` Codex `--hooks` limits a compaction handoff to receipt identity, unfinished diff --git a/README.zh-CN.md b/README.zh-CN.md index 6025e8ae..e1fccca8 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -121,7 +121,7 @@ Root、Meta 与参与其中的对象 Volume 共同组成当前 Whole-Index。在 - 经验证的 Release 软件包,或 canonical AOCI-CODE 源码仓库的工作副本; - 从源码构建时,需要 `go.mod` 声明的 Go 工具链、`make` 及仓库要求的其他工具;使用已验证的 Release 二进制本身不需要 Go 或 `make`; -- 一个受支持的 MCP 宿主,例如 Codex、Claude Code、Cursor 或 OpenCode; +- 一个受支持的 MCP 宿主,例如 Codex、Claude Code、Cursor、OpenCode 或 WorkBuddy; - 对目标仓库的正常读写权限。 AOCI-CODE 接入的是 MCP 宿主,不直接接入模型供应商 API。DeepSeek 等模型只有在承载它们的 @@ -250,7 +250,7 @@ Agent 会用 `aoci ui --detach --json` 在后台启动面板并把链接交给 "$AOCI" --repo . doctor ``` -要确认宿主此刻真正连着哪一个 AOCI,看服务端自报的身份,而不是磁盘上的文件:任何 `aoci_overview` 的 `check_only` 响应、或任何 `aoci_maintain` 响应里,`cognition_receipt.mcp_service_version` 是正在运行的版本,`runtime_repository_root` 是它治理的仓库。对应的二进制路径是项目 `.mcp.json` 或等价宿主配置(`.codex/config.toml`、`opencode.json`、`.cursor/mcp.json`)里的 `command`。替换磁盘上的字节不会改变已在运行的 MCP 进程,因此升级或回滚后要按这些事实复核。 +要确认宿主此刻真正连着哪一个 AOCI,看服务端自报的身份,而不是磁盘上的文件:任何 `aoci_overview` 的 `check_only` 响应、或任何 `aoci_maintain` 响应里,`cognition_receipt.mcp_service_version` 是正在运行的版本,`runtime_repository_root` 是它治理的仓库。对应的二进制路径是项目 `.mcp.json` 或等价宿主配置(`.codex/config.toml`、`opencode.json`、`.cursor/mcp.json`,或机器级的 `~/.workbuddy/mcp.json`)里的 `command`。替换磁盘上的字节不会改变已在运行的 MCP 进程,因此升级或回滚后要按这些事实复核。 如需一次性演练,可使用仓库中的 `examples/minimal-repository`。 @@ -299,7 +299,7 @@ aoci.database.txt Database:可选的表级认知;默认不存在 新项目初始化时会创建 Volume Root、Meta 和一个空的 Code Volume;Database 默认不存在。AOCI-CODE 不会自动生成仓库业务语义或 Database 语义。 -`aoci init --agent ` 还会写入宿主集成配置(`.mcp.json`、`.claude/settings.json`、`.codex/config.toml` 或 `opencode.json`),其中的命令与仓库路径是本机绑定的绝对路径。请把这些文件加入仓库的 `.gitignore` 且不要提交:提交后的副本在任何其他机器上都会失效,而安装器按条目是否存在做幂等判断,在那台机器上重跑 `init` 会静默保留坏路径。 +`aoci init --agent ` 还会写入宿主集成配置(`.mcp.json`、`.claude/settings.json`、`.codex/config.toml`、`opencode.json`,或机器级的 `~/.workbuddy/mcp.json`),其中的命令与仓库路径是本机绑定的绝对路径。请把**项目级**的那些文件加入仓库的 `.gitignore` 且不要提交:提交后的副本在任何其他机器上都会失效,而安装器按条目是否存在做幂等判断,在那台机器上重跑 `init` 会静默保留坏路径。WorkBuddy 的文件是机器级而非项目级,不属于任何仓库,`init` 为该宿主不往工作区写任何东西。 ## 🔄 一次完整开发任务如何运行 @@ -533,7 +533,7 @@ Code Volume、Database Volume 和 Scope 可以共同演进,但它们共享同 ## 🔌 宿主集成 -`aoci init` 始终写入托管的 AI Agent 规则,但宿主接入行为不同:Codex 写入项目级 MCP 配置,并可通过 `--hooks` 选择安装上下文压缩prompt与 `SessionStart(compact)`,但仍不安装文件编辑Hook;Claude Code 可以安装 `PreToolUse` Hook;OpenCode V1 使用严格的项目级 `opencode.json`;Cursor 只返回参考配置片段,不写入项目配置。配置完成后,先检查当前宿主会话是否已显示 AOCI 工具;仅在尚未加载新 server 时刷新或重新打开项目会话。新会话通常先读取一次 Rules 与 Whole-Index;只要认知身份仍有效且没有发生已知Host上下文压缩,后续任务会复用当前认知,不会机械地重复注入整个索引。 +`aoci init` 始终写入托管的 AI Agent 规则,但宿主接入行为不同:Codex 写入项目级 MCP 配置,并可通过 `--hooks` 选择安装上下文压缩prompt与 `SessionStart(compact)`,但仍不安装文件编辑Hook;Claude Code 可以安装 `PreToolUse` Hook;OpenCode V1 使用严格的项目级 `opencode.json`;Cursor 只返回参考配置片段,不写入项目配置;WorkBuddy 则合并写入机器级的 `~/.workbuddy/mcp.json`、不往仓库里写任何东西——该文件被所有项目共享而 server 硬绑 `--repo`,所以它**绝不覆盖**指向别的仓库的 `aoci` 键,而是改写 `aoci-<项目名>`,两个键名都被占用时报错交人工;该宿主没有写前 Hook 接口,故 `--hooks` 在此无效;又因它只把 `AGENTS.md` 开头一段注入模型上下文,`init` 会把托管区块放到该文件**最前面**(其它宿主仍保持文末追加)。配置完成后,先检查当前宿主会话是否已显示 AOCI 工具;仅在尚未加载新 server 时刷新或重新打开项目会话。新会话通常先读取一次 Rules 与 Whole-Index;只要认知身份仍有效且没有发生已知Host上下文压缩,后续任务会复用当前认知,不会机械地重复注入整个索引。 | 宿主 | 当前接入方式 | 边界 | | --- | --- | --- | @@ -541,6 +541,7 @@ Code Volume、Database Volume 和 Scope 可以共同演进,但它们共享同 | **Claude Code** | 项目级 MCP;可选 `PreToolUse` 薄守卫 | Hook 只负责写前提示或 Stale 守卫,不是 AI Agent runtime | | **OpenCode V1** | 通过 `--agent opencode` 写入严格的项目根 `opencode.json` | 工具已加载可直接继续;否则刷新或重新打开项目会话 | | **Cursor** | 返回 MCP 参考配置片段 | 不写入项目配置,仍需按宿主手工完成接入 | +| **WorkBuddy** | 通过 `--agent workbuddy` 合并写入机器级 `~/.workbuddy/mcp.json` | 机器级而非项目级:一份文件服务所有项目,每个仓库需要自己的条目(或自己的 server 键名);该宿主没有写前 Hook 接口,`--hooks` 在此无效 | | **其他 MCP Host** | 连接标准 stdio Server | 需要手工配置并完成宿主专项验证 | ```bash @@ -549,6 +550,7 @@ aoci --repo /absolute/path/to/repository init --agent codex --hooks aoci --repo /absolute/path/to/repository init --agent claude --hooks aoci --repo /absolute/path/to/repository init --agent opencode aoci --repo /absolute/path/to/repository init --agent cursor +aoci --repo /absolute/path/to/repository init --agent workbuddy ``` Codex `--hooks` 把压缩handoff限制为receipt身份、未完成write或Recovery状态,以及立即重载指令;不得保留或摘要Whole-Index或Overview/Attestation正文。`PreCompact` Hook不能向宿主压缩输入注入文本,也不能从中删除历史,因此无法单独落实该边界。依赖此能力前,应通过Codex `/hooks` 审查并信任安装的项目Hook。 diff --git a/docs/agent-integrations.md b/docs/agent-integrations.md index 21da91d0..bcee6fb8 100644 --- a/docs/agent-integrations.md +++ b/docs/agent-integrations.md @@ -11,11 +11,14 @@ session only when it has not loaded the new server; hosts that support dynamic MCP reload do not require a blanket application restart. The host configuration files written by `aoci init --agent` (`.mcp.json`, -`.claude/settings.json`, `.codex/config.toml`, `opencode.json`) embed -machine-bound absolute binary and repository paths, and must not be committed: -a committed copy is broken on every other machine, and because each installer -detects an existing entry by key presence, re-running `init` there silently -keeps the broken paths. +`.claude/settings.json`, `.codex/config.toml`, `opencode.json`, +`~/.workbuddy/mcp.json`) embed machine-bound absolute binary and repository +paths. Every project-level one must not be committed: a committed copy is broken +on every other machine, and because each installer detects an existing entry by +key presence, re-running `init` there silently keeps the broken paths. +WorkBuddy's file is the exception that proves the rule: it is machine-level +rather than project-level, so it never belongs to a repository, and `init` +writes nothing into the working tree for that host. `init` adds the files it just wrote to the repository's `.gitignore` under its own marked block, so an ordinary `init` then `scan` leaves them out of Git and @@ -170,6 +173,45 @@ Cursor version before adding it manually: This limitation must remain visible in compatibility claims; a reference template is not native-host validation. +## WorkBuddy + +```bash +aoci --repo /absolute/path/to/repository init --agent workbuddy +``` + +WorkBuddy has no project-scoped MCP configuration surface. Its only entry is the +machine-level `~/.workbuddy/mcp.json`, so `init` merges an entry there and +writes nothing into the repository: no Baseline path, no `.gitignore` line, and +no host file for `git status` to report. The merged entry has the same shape +every other stdio host uses: + +```json +{ + "mcpServers": { + "aoci": { + "command": "/absolute/path/to/aoci", + "args": ["--repo", "/absolute/path/to/repository", "mcp"] + } + } +} +``` + +That file is shared by every project while an aoci server is bound to one +`--repo`, so this installer never overwrites a foreign entry. When an `aoci` +key already points at a different repository it writes `aoci-` instead +and leaves the existing entry byte-for-byte intact; when that scoped key is also +taken by a third repository it reports the conflict and changes nothing. + +Two host facts shape the rest of the behavior: + +- No pre-write lifecycle hook surface exists, so `--hooks` is inert for this + host. The installer ignores it rather than pretending it installed a hook. +- The host injects roughly the first 8000 characters of `AGENTS.md` into model + context, so a managed block appended at the end of that file is never read. + For this host alone, `init` places the managed block at the top of `AGENTS.md`; + every other host keeps the existing append behavior, and a file that already + carries the block still gets an in-place replacement that never moves it. + ## Deterministic offline mode AI is disabled by default. To make that state explicit: diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 4f3ceb00..a180cf93 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -80,7 +80,8 @@ while `aoci doctor` still reports the Claude or Codex integration as installed, because doctor and the installers check entry presence, not path validity. OpenCode instead fails closed with an `mcp.aoci` conflict. Remove the stale `aoci` entry (`mcpServers.aoci` in `.mcp.json`, the `[mcp_servers.aoci]` table -in `.codex/config.toml`, `mcp.aoci` in `opencode.json`, and any stale +in `.codex/config.toml`, `mcp.aoci` in `opencode.json`, the `aoci` key in +`~/.workbuddy/mcp.json`, and any stale `PreToolUse` command in `.claude/settings.json`), then re-run `aoci --repo init --agent ` from the new location. @@ -97,6 +98,14 @@ OpenCode V1, or `.cursor/mcp.json` for Cursor. `aoci init --agent cursor` prints the Cursor configuration but does not write it. After moving the entry, refresh or reopen only the intended project session if its tools have not reloaded. +WorkBuddy is the one host with no project-level surface at all: its only entry +is the machine-level `~/.workbuddy/mcp.json`, so scoping cannot be achieved by +moving a file. `aoci init --agent workbuddy` therefore keeps every repository +in that one file under its own server key (`aoci`, then `aoci-` for +further repositories) instead of overwriting a foreign entry. Projects still +expose the union of those tools, so remove the entry whose `--repo` you no longer +want rather than expecting per-project isolation from the host. + ## MCP closes with EOF stdio MCP is incremental. Keep stdin open, send `initialize`, wait for its response, send `notifications/initialized`, and only then send requests such as `tools/list`. MCP stdout must contain JSON-RPC only; inspect stderr for diagnostics. @@ -310,7 +319,10 @@ asset and the rule that hides it; remove the rule and run `scan` again. cognition meant to be committed, exactly as this repository commits its own. Only the host integration file `init` writes — `opencode.json`, `.mcp.json`, `.codex/config.toml` — carries machine-bound absolute paths and belongs in -`.gitignore`, which `init` arranges by itself. +`.gitignore`, which `init` arranges by itself. WorkBuddy's +`~/.workbuddy/mcp.json` also carries machine-bound absolute paths, but it is +machine-level rather than project-level: it belongs to no repository, and +`init --agent workbuddy` writes nothing into the working tree. ## A Volume reports line-ending-only difference