# internal/ — 目录导览与小包硬约束

> 范围：本文件只讲 `internal/` 下**从代码派生不出来**的东西——职责边界、硬约束、
> 踩过的坑、入口文件、钉住行为的测试。模块列表、文件计数、依赖边由 `internal/blueprint`
> 每轮派生，不要往任何 AGENTS.md 里抄（抄了就是第二份会漂走的事实）。
> 全局架构见仓库根 `AGENTS.md`。

## 哪些目录有局部文档

有局部 `AGENTS.md` 的目录（进去改东西之前先读它）：

| 目录 | 一句话 |
|------|--------|
| `internal/tui/AGENTS.md` | Bubble Tea v2 界面：事件消费、布局高度、共用排版原语 |
| `internal/agents/AGENTS.md` | 子 Agent 与共享 LLM-工具循环（含 `activity/`、`toolbatch/`） |
| `internal/agents/director/AGENTS.md` | Director 编排内核：Planner、窄接口、瞬态索引 |
| `internal/tools/AGENTS.md` | 工具执行器、Surface 装配、工作区守卫、授权回路 |
| `internal/toolspec/AGENTS.md` | 工具元数据唯一声明面（叶子包） |
| `internal/memory/AGENTS.md` | rollout 账本与重放读侧 |
| `internal/compression/AGENTS.md` | 三档上下文压缩与判定口径 |
| `internal/blueprint/AGENTS.md` | 仓库蓝图：派生层 + 落盘层 + fold |
| `internal/blackboard/AGENTS.md` | 跨 Agent 便签与派生索引 |
| `internal/llm/AGENTS.md` | 引擎、路由、故障转移、在飞闸门 |
| `internal/config/AGENTS.md` | TOML 加载、归一化、文本级写回 |
| `internal/app/AGENTS.md` | 一次性装配（顺序本身是契约） |
| `internal/http/AGENTS.md` | REST/WS 与任务生命周期 |
| `internal/messaging/AGENTS.md` | 事件总线与丢弃记账 |
| `internal/browserctl/AGENTS.md` | 外部 browser-control CLI 的执行层 |

**没有局部文档 = 这个目录没有不可派生的知识**，直接读代码。下面收录的是那些
"没有局部文档、但有一条不读代码就会做错"的小包。

## projectid — 算法逐字节冻结

`Compute` 的 base 截断 100 + sha256 前 8 hex 是 rollout / artifact / 蓝图存储共用的主键
（`internal/projectid/projectid.go:24-44`，`internal/blueprint/fold.go:70` 记为 I12）。改算法**不会报错，只会让历史数据
找不到目录**。期望串必须是字面量：`TestComputeGoldenTable`（`internal/projectid/projectid_test.go:15`）。
注意 `internal/app/app.go:942` 的 `getProjectID` 是**另一份已经分叉的实现**（只管 shared_memory），
"顺手统一"会孤儿化既有 KV 数据（正是 R7 待办）。`director.ComputeProjectID` 只是转发，
不许长成第二份（`TestDirectorForwardsToProjectid`）。

## protocol — DO NOT EDIT 的文件里有手写类型

`internal/protocol/agent_events.go:1` 写着生成器产物，但 `InteractionType`（`:46-55`）、
`InferInteractionType`（`:364`）、`isBooleanPair`（`:380`）**生成器不产出**（`scripts/protoc-gen-yagent`
里没有对应发射逻辑）。整档重跑生成器会删掉它们并打断授权对话框
（消费方：`internal/tools/flow_control.go:82`、`internal/tools/user_confirm.go:200`、`internal/tui/components/user_help_dialog.go:80`）。
另外这个包**不是**事件总线的类型登记表：`internal/messaging` 自带 `EventType` 且不 import 它。

## logging — TUI 模式永不写 stdout

`internal/logging/logger.go:84-85`：TUI 下 stdout 归界面，任何 `fmt.Println` 都会撕裂渲染。自带日志器在开文件
失败时必须退回 `GetFallbackWriter()`（`internal/logging/logger.go:236-253`，按模式返回 app 日志 / `io.Discard` /
stderr），下游已把这条写成 CRITICAL 注释（`internal/agents/tool_logger.go:67,94`）。
`YAGENT_LOG_LEVEL` **区分大小写**，只认 `DEBUG|INFO|WARN|ERROR`，小写静默回落 INFO（`:127-141`）。

## embedbin — `dist/bin/1` 不能删

`dist/` 被 `.gitignore` 忽略，但 `dist/bin/1`（0 字节）和 `dist/data/models.json` 是被跟踪的
（`git ls-files dist/`）。`//go:embed dist/bin/*`（`embed.go:5`）在空目录上**直接编译失败**
（实测 `pattern dist/bin/*: no matching files found`），那个单字符文件就是占位符。
它对提取不可见只因 `isExecutableName` 拒绝长度 1（`internal/embedbin/embedbin.go:154-156`）——改名成两字符会
解出一个 1 字节的假可执行文件。是否重写磁盘由 `version.json` 决定，**不校验内容**
（`:66-89`）：手工替换 `~/.yagent/bin/codeseek` 不会被发现。提取失败只 `slog.Debug`
（`internal/app/app.go:175`），默认级别下不可见，症状是"退化成纯文件工具"。
`rg` 不在 `dist/bin` 里（git 树），`internal/tools/search_operations.go:16-23` 回落系统 PATH。

## globalctx — 没有版本符号

`globalctx.Version/BuildTime/GitCommit` **不存在**（`grep -rn` 在 `*.go` 里零命中），
而 `build.sh:164-166` 与 `.github/workflows/release.yml` 仍在 `-X` 注入它们；`go build -ldflags
"-X pkg.NoSuch=1"` 静默成功，所以没人发现。别按根 AGENTS.md 的旧说法加 `--version`。
读运行时可变字段必须走 `EnvView`（`internal/globalctx/env_view.go:3-5` 的动态读取语义）。跨 Agent 共享的可变值
走 `RepoContextStore`（RWMutex，`internal/globalctx/repo_context_store.go:5-12`），它是原先无锁共享字符串的替代品。

## dict — 没有热加载

补全词典只在 TUI 构造时加载一次（`internal/tui/tui_model.go:734-736`），改文件必须重启。
`fsnotify` 只活在 `go.mod`，Go 代码里零引用。根 AGENTS.md 的"fsnotify 热加载（词典）"是过期描述。
`Complete` 的 20 条上限写死（`internal/dict/completion.go:82`），而 TUI 包装层的 `limit` 形参被丢弃
（`internal/tui/completion.go:4-10`）。源文件读失败一律静默跳过（`internal/dict/completion.go:42-49`）。

## skills — 有一条启动即崩的路径

`LoadSkills` 在目录**存在但不可读**时返回 `(nil, err)`（`internal/skills/skills.go:37-40`），而两个调用点
拿到就 `.Count()`（`main.go:230-234`、`:380-384`），`Count` 没有 nil 接收者守卫
（`internal/skills/skills.go:116-120`）→ nil 解引用。"目录不存在"返回空注册表 + nil error，所以这条只在
权限异常时响。项目技能**整体替换**用户级而非合并：回落条件是 `Count()==0`（`main.go:234`），
项目里只要有一条技能，`~/.yagent/skills` 就永远看不见。

## tokenutil — 降级是一次性粘滞的

`sync.Once` 只加载一次编码器（`internal/tokenutil/tokenutil.go:20,31-37`），首次失败（离线）后 `encoder` 永远为
nil，此后全进程走 `len(text)/4`，warn 只打一次（`:39-49`）。对中文这是约 4 倍低估，会让压缩
触发点系统性偏晚（口径见根 AGENTS.md §6.5）。
`internal/blueprint` 刻意不 import 本包（`check_blueprint_leaf.sh`），计数器由装配层作参数传入。

## gitinfo — ctx 必须 `WithoutCancel`

`Collect`/`Status` 都从 `context.WithoutCancel(ctx)` 派生预算（`internal/gitinfo/gitinfo.go:65`、`:118`）。直接拿
调用方**已取消**的 ctx 跑 git，得到的是"全空身份、不报错"和"dirty=false 但其实没跑过"
（`:57-60`、`:110-113`）。闸门：`TestCollect_SurvivesCancelledContext`（`internal/gitinfo/gitinfo_test.go:130`）。
时间上界整批 2s / 单次 1s，脏文件清单截断 200 条（`:26,29,102`）——否则检查点比它保护的会话还大。
本包必须是叶子包：两个 `session_meta` 写入点分属 `agents` 与 `agents/director`，后者不得 import 前者。

## util — 原子写只准有一处

`internal/util/atomic.go:39-41` 是全仓唯一的 tmp+rename 内核，由 `check_no_inline_atomic_write.sh` 守着。
守卫有两个盲区（脚本 `:29,37-38`）：判定要求同文件同时出现 `os.Rename(` 与写 `*.tmp`，
所以临时名不含字面 `.tmp` 的第二份实现抓不到；扫描范围只有 `internal/` 非测试文件。
选项有牙齿：`TmpSuffix` 结果不得命中调用方目录的列举扩展（`internal/util/atomic.go:19-22`，blackboard 只认
`*.md`）；`DirPerm=0` 保持"目录不存在就失败"（`:12-14`）；`InheritPerm` 是 config 的 chmod 保留规则。
`numeric.go` 与 `error_utils.go` 的 `NewErrorWithContext` 是死代码（全仓零调用）。

## knowledge — 注入当前是关的

`Inject` 开头有一段恒真 if 提前返回（`internal/knowledge/injector.go:74-78`，注释写明"临时 hardcode 禁用"，恢复时
删掉整块）。在此之前调试"知识注入没生效"是白费——注意根 AGENTS.md §6.4 写的 `:67-71` 已行号漂移。
fail-safe 是铁律：任何"不该检索"的输入返回 `("", nil)` 且不发布任何事件（`:72,294-313`）。
预算常量内联在本包（`:35-43`），因为原 `[codeseek.knowledge]` 五键全部不可达已删。

## recovery — 查询本身就是那次试探

`Allow()` 带 open→half-open 迁移副作用，half-open 只放一个探针（`internal/recovery/circuit_breaker.go:33-66`），
而消费方把查询写成 `return !r.breaker.Allow()`（`internal/agents/director/recovery.go:74-78`）。加新查询点前先数有几个调用点在读它（根 AGENTS.md §11 坑 36）。
`probeAt` 必须在 Success 与 Failure 两处都清零（`:69-88`）；判据是 `failures >= threshold`（`:100`）；
`Success()` 在 open 态也要闭合（`:80-84`，并行委派下另一条链可能先返回）；`Reset()` 同时清 state 与
failures（`:119-125`）。`SetLastFailure`（`:127-132`）注释写明"用于测试"，生产不许调。闸门：`TestCircuitBreaker_HalfOpenAdmitsExactlyOneProbe`（`internal/recovery/circuit_breaker_test.go:208`）。

## pause / supervise — 见根 AGENTS.md §6.2 与 §9.2

`internal/pause`：闸门不取消 ctx、`Wait` 对 nil receiver 安全、`AbandonedError` 必须同时
`errors.As` 且 `errors.Is` 到 `context.Canceled`（`internal/pause/gate.go:30-37`、`:83-107`）。
`internal/supervise`：叶子包（`check_supervise_leaf.sh` 判 `go list -deps` 里不许出现任何 `yagent/` 包）、
裸 `go` 全仓硬禁且**没有 PENDING 名单**（`scripts/check_no_bare_go.sh:1-12`）、`Drain` 不制造取消（`internal/supervise/supervise.go:156-158`）。
两条使用纪律（批次仍用本地 WaitGroup、执行体不进自己的域）见 `internal/agents/AGENTS.md` 与
`internal/http/AGENTS.md`。

## artifact — ID 形态是安全边界

`^\d{8}-\d{6}-[0-9a-f]{8}$` 在 `Load` **和** `write` 两处强校验防路径穿越（`internal/artifact/store.go:20-22,113-118,147-150`）；
反序列化后必须校验 `schema_version == 1`（`:130-132`）。摘要必须带 ID，而 ID 要 `Save` 之后才有
→ `Save` 后 `UpdateSummary` 回写是既定两步（`:101-110` + `internal/agents/result.go:168-178`）。
`EstTokens` 是确定性纯函数（ASCII/4 + 非 ASCII×1，`internal/artifact/summary.go:9-27`），判定与截断同一口径。
本包无 TTL/GC：全文只增不删。kill-switch 是 `YAGENT_ARTIFACT_DISABLE=1`（`internal/artifact/summary.go:87-91`）。

## datamanager — 不是 /resume 的事实来源

任务账本 `~/.yagent/tasks/*.jsonl`，5s 防抖；**rollout 才是断点续跑的事实来源**（见
`internal/memory/AGENTS.md`）。`FlushAll` 不许持 `dm.mu` 做 IO（锁内只拿 writer 快照，
`internal/datamanager/data_manager.go:270-273`），且**首错不得提前 return**（`:273`，否则后面 N-1 条任务记忆凭空蒸发）。
增量判断与 pending 收集必须来自同一次 `Snapshot()`（`:198-207`）。索引只描述磁盘状态，文件没写成就
不更新索引（`:127-129`）。`persistIndex` 只走 `util.AtomicWrite` 且**不建目录**（`internal/datamanager/index.go:274-276`）。
计时 seam：`taskFlushDebounce`（`:24-27`）、`flushHook`（`:140-142`）。

## thinklink — 第一条用户输入永不淘汰

`KindUserInput` 的第一条是终极压缩重建的底线信息（`internal/thinklink/thinklink.go:57-60`，淘汰实现 `:255-270`）。
`Snapshot()` 必须深拷贝（`:114-120`）。`ParseKind` 认不出来必须返回 `false`，不得回落默认值
（`:37-41`——把 thought_plan 错认成 user_input 会让重建输出看起来完全合理）。
`RebuildPrompt` 的 `[TP-n]` 全局序号要用淘汰偏移换算（`:204-234`），`keepPlans<=0` 是"全部保留"。
`Seed` 不可省：未 seed 的重放会话走到第三档只能拿原始任务输入重建（`:132-146`）。
`<thought>` 解析失败**不报错只静默丢失**（`internal/thinklink/thoughtplan.go:61-63`）。

## mcp — 从不调 tools/list

发送点只有 `initialize` / `notifications/initialized` / `tools/call` / `shutdown`
（`internal/mcp/client.go:135,164,202,247`），所以 MCP 侧工具**不会自动暴露**，全靠 Go 手写桥接。
`notifications/tools/list_changed` 只打日志（`:417-426`）。每条请求的 deadline 取调用方 ctx 与
`RequestTimeout` 的较小值，`<=0 → 30s` 的兜底**只在 `NewMCPClient` 一处**（`:29-38,68-80`）。
`IsAlive()` 只读两个原子位，不得再读 `cmd.ProcessState`（`:219-226`）。`sendJSON` 必须持 `writeMu`
（`:50-52`，超过 PIPE_BUF 的并发写会交错成非法 JSON-RPC）。假服务器 = 重新执行测试二进制
（`internal/mcp/client_process_test.go:22-33` 认 `YAGENT_TEST_FAKE_MCP_SERVER`），不要改成 `go run`。

## models — 查不到就返回 0

绝不兜底成"看着差不多的那个"；三级顺序 完整 id → 裸名 → 相似度（`internal/models/catalog.go:104-136`），
模糊命中首次必 warn。`fuzzyMinSimilarity = 0.6` 是实测钉住的两头（`:23-26`）。先按 id 排序再建索引，
并列固定取 id 字典序更小者（`:81-83,186-203`）——否则 map 遍历顺序让压缩阈值每次启动都抖。
`ClassifyTiers` 的可打分候选 <3 或基准交集为空时必须 `ok=false` 且给 `Reason`（`internal/models/score.go:72-100`）。
`dist/data/models.json` 是被 git 跟踪的产物，改完要 `git add -f`（根 AGENTS.md §11 坑 2）。
