Skip to content

Latest commit

 

History

History
335 lines (251 loc) · 22.3 KB

File metadata and controls

335 lines (251 loc) · 22.3 KB

参与 PendingCrew

先说清楚期望:这是一个个人作品,还在快速变形期,接口和数据格式都会变。 欢迎 issue、欢迎 PR,但请别假设有 SLA —— 我不一定接得住,也不一定接得快。

如果你只是想跑起来看看,看 README 就够了,这份不用读。


动手之前

先开一个 issue 说你想干什么。 尤其是想改结构的时候 —— 这个仓库里有好几处 「看着多余、其实是在填某个具体的坑」的写法,注释里通常写了当时的缘由。先聊一句 能省掉双方各写一遍的功夫。

小修(打字错误、明显的空指针、文档笔误)直接发 PR,不用先问。

引一条既有结论当依据之前,先核它成立的条件。 这个仓库里有大量写下了缘由的 注释、docs/tech-debt.md 里的条目、还有 commit 说明 —— 它们大多是对的,但大多 带着一个「如果」。引用时要核的不是它说了什么,是它成立的条件在当下还成不成立

真踩过一次(2026-08-26,Todo #60):tech-debt 里写着「第 4 条要是真出了问题, 正确的方向是走那条未走的路」,被当成预先授权引用了。转述一字不差、读的人也点头 —— 直到把原文调出来,才看见「第 4 条」指的是另一件事(懒行高度回填之后锚点漂不漂), 而那一条恰恰通过了,触发条件根本没成立。结论后来仍然成立,但理由是错的, 换了一个才站得住。

转述无误 ≠ 引用成立。 一条自洽的话最容易被当成授权 —— 它读起来没有任何破绽, 破绽在它前面那个「如果」里,而那个「如果」通常不在被转述的那一句里。

引号里的东西还要核字面 —— 写之前回去翻一次那次跑的原始输出。 引用有两种失败: 引对了字但用错了地方(上面那条),和字就引错了。同一个动作治两种:回去翻原文, 既核它成立的条件,也核它的字面。

真踩过一次(2026-08-26,Todo #68):评估文档里把 claude 的目录信任提示写成 Do you trust the files in this folder? —— 它没这么说,原文是 Quick safety check: Is this a project you created or one you trust?…。是凭印象复述成了一句更顺口的, 而那一整节的题目恰恰就是「把实测到的和推出来的分开」。写的人回去翻输出时自己 发现了,改正那一笔故意没有 amend;紧接着复核的人也照那句复述加了反引号往外发, 同一个错在半小时里连犯两次

规矩要落在动作上,不在态度上。 这个错不挑人,跟仔细不仔细无关,只跟有没有 回去翻有关 —— 犯它的先是刚写完那一整节的人,紧接着是复核的人。所以引文最好锚在 一个入库、可再查的出处上(那段提示后来锚到了 Tests/Fixtures/ 里的真 TUI 录制), 而不是一次跑完就没的临时终端输出。

六条硬规矩

这六条是被踩出来的,不是审美偏好。

1. project.yml 是工程定义的唯一真值,改完必须重新生成并提交 .pbxproj

scripts/gen-project.sh          # 改了 project.yml 之后
scripts/gen-project.sh --fetch  # 本机没装 / 版本不对时,取仓库声明的那一版来用

别直接跑 xcodegen .xcodeproj 是生成物却被提交进仓库,所以生成它的 那个生成器的版本也是仓库的一部分 —— 版本写在 .xcodegen-version 里,CI 装的 就是它。脚本会在生成任何东西之前比对版本并停下,免得你先看到一坨看不懂的 pbxproj diff、再花时间怀疑自己是不是忘了 regen(那是同一种症状)。

要升 XcodeGen 版本,就让它是一次显式提交:改 .xcodegen-version 的数字 + 在 scripts/xcodegen-checksums.txt 补一行 + 同一笔里重新生成 pbxproj。这样历史上 「这次 pbxproj 大改是因为换了生成器」是自解释的,而不是某天某人 brew 升级顺手 带进来的。

PendingCrew.xcodeproj/project.pbxproj 虽然被 git 跟踪,但它是生成物,不要 手改。跟踪它是为了让 clone 下来的人不装 XcodeGen 也能直接开工程。

合并冲突也一样 —— 生成物的冲突不许手解,重新生成。 合并/rebase 时 .pbxproj 撞了,不要去挑 <<<<<<< 两边的行:手解会解出一个谁都没生成过的中间态,而它 多半还编得过,于是没人发现它已经和 project.yml 对不上 —— 直到某个新 worktree 编不过、或者某个新加的文件莫名其妙不进 target。正确做法是先把 project.yml 那边 的冲突解干净(真值在那儿),再 scripts/gen-project.sh 重新生成,然后 git add 生成结果。这是「project.yml 是唯一真值」的直接推论,对任何被跟踪的生成物都成立。

而「没冲突」比冲突更危险 —— 自动合掉的生成物,一样要重生一遍对字节。 冲突会 叫你,自动合不会:git 那把尺子量的是「文本有没有打架」,量不了「生成物的内容还 对不对」。两边各自加了几个文件、行号又恰好不重叠时,git 会一声不吭地合出一份 语法完好但可能少了引用或重了条目.pbxproj,而它只在别人的新 worktree 里 编不过时才现形 —— 那时离现场已经很远了。

所以规矩不是「冲突时重生」,是 .pbxproj(以及任何被跟踪的生成物)只要经过一次 合并/rebase,无论有没有冲突,都跑一次 scripts/gen-project.sh,再看 git status 里它干不干净。干净 = 自动合的结果和生成器的结果一致,这才叫验过;不干净就 git add 生成结果。成本是几十秒,省掉的是「别人 clone 下来编不过」那一整类事故。

(2026-08-26 真走过一次:P3 那七笔合 main 时预判「pbxproj 必冲」,结果 git 自动合掉了, 重生之后逐字相同 —— 这一趟是干净的,但知道它干净靠的是重生那一步,不是 git 的沉默。)

新增 Swift 文件属于「改了工程定义」 —— 源文件是按目录收的,加了文件不 regen, 你本机能编(Xcode 会自己发现),但别人 clone 下来那份 .pbxproj 里没有它, 在别的机器上编不过。这条真的踩过。

CI 会替你查这一条.github/workflows/ci.yml 的「pbxproj 与 project.yml 同步」, 约 12 秒)。它是唯一一条你本机永远看不到红的规矩,所以必须由机器守: Xcode 会自己发现你新加的文件,于是你能编、能跑、能提交,只有别人 clone 下来 才炸。

2. 三端都要编一遍

三端共用一套源码。只编 Mac 会让漏了 #if os(macOS) 的 AppKit 调用把 iOS 端 静默打红。

xcodebuild -project PendingCrew.xcodeproj -scheme PendingCrew \
  -destination 'platform=macOS' build
xcodebuild -project PendingCrew.xcodeproj -scheme PendingCrew \
  -destination 'platform=macOS' test
xcodebuild -project PendingCrew.xcodeproj -scheme PendingCrew \
  -destination 'generic/platform=iOS Simulator' build

单测 bundle 只挂 macOS —— 被测代码基本都在 #if os(macOS) 后面。

这三条 CI 在 PR 上会跑一遍(冷机约 13 分钟),但本机先跑更快:等 CI 告诉你 iOS 端红了,你已经等了十几分钟。

跑测试要留全日志,别只 grep 汇总行。 汇总行(Executed N tests, with M failures) 告诉你红了几条,恰恰不告诉你红的是哪一条 —— 而没有名字的红等于没发生过, 半年后只会变成「这一族偶尔会红」的传说。docs/tech-debt.md 里已经躺着一条这样的 旧账(一次全量里见过一个 failure,从头到尾没定位到)。所以:

xcodebuild ... test > /tmp/test.log 2>&1; grep -E "' failed \(|Executed .* tests, with" /tmp/test.log

还有一条比它更容易骗人的一趟绿是一个样本,不是一个结论。 这个套件里存在 只在满载下现形的竞态用例 —— 单独跑它一百次都绿,全量跑四次能红两次。所以 「我跑过了,绿的」和「这块是干净的」是两句话,改动越靠近并发/时序,两句话之间的 距离越大。真怀疑某条在飘,就连着跑几趟全量,别拿一趟绿去放行发布。

3. 白板的全量读不许出现在 SwiftUI 的 body 求值路径上

LocalWhiteboardStore.list(crewId:) / entries(crewId:)flock + 整份 JSON 全量解码。要时间/末条,取 CrewStore.lastWhiteboardMessages 那份后台按指纹门控 算好的快照;要本 crew 的完整消息,用视图自己在 .task 里订阅的那份。

这条是 2026-08-17「开久了卡」的病根(主线程每秒解析约 11 MB JSON)。判据是 这行代码在 body 里还是在 .task,不是它写成什么样 —— 同样一行 list(crewId:),在 .task(id:) 里合法,在 body 里是违规。

已知未修的一处:CrewSessionWindowView.badgeCount(for:)SessionUnreadStore.unreadCount(..., approvals: .shared, whiteboard: .shared), 切换条那个 ForEach 里每 run 每帧一次、而且是两个账本各一次。它不是换个快照 就能修的(未读数要的是「某时刻之后的一段区间」,而那份快照只存末条/计数), 得先给未读数造一份后台产物 —— 归「让 View 层拿不到这个类型」那条结构性改造。

别给这条红线加文本守卫。 为什么不能加,见第 5 条第一个实例。

4. 目录参数化的 store,别在拿得到实例的地方去够 .shared

LocalWhiteboardStore / LocalTodoStore / LocalApprovalStore / LocalCrewControlStore 都是同一个形状:既有 init(directory:),又有一个 static let shared(吃 LocalWhiteboardStore.defaultDirectory)。凡是自己带着 目录的调用方 —— MCP helper(靠 --dir 定目录)、以及任何注入了 store 的单测 —— 一律用手上那个实例,不许去够 .shared。踩了不会报错,只会读错账本。

最耐久的防线不是记住哪个 store 吃哪个目录,是让同一处的几行共用同一个来源McpServer.blockerState 是范例:

agentTodoExists: { todos.item(crewId: crewId, number: $0) != nil },
humanTodoExists: { humanTodos.item(crewId: crewId, number: $0) != nil })

两行视觉对称,谁也没法只改一行而不显眼。顺带:正确的写法通常也是更短的写法humanTodos 已由 sibling(.human) 默认好,用它不需要任何额外构造)。

例外只有一种:startWatching() / directoryChanged 这类变更信号(不是数据读) 今天确实走 .shared。最坏情况是收不到 tick、什么都不刷新 —— 看得见的失败, 不是悄悄读错人。

这条为什么在 CI 上抓不到,见第 5 条第二个实例。

5. 先证明它会红,再信它的绿

一把从原理上就看不见你要防的那个东西的尺子,它给出的绿是无内容的。

所以:加一把新尺子(守卫、探针、断言、给人念的清单)之前,先让它在一个已知的 坏例子上真的红一次。 红不出来,说明你要防的东西不在它的量程里 —— 那它以后每 一次绿都不构成证据,而且比没有尺子更糟:它会留下一句「已经有东西看着了」。

下面每一行都是同一件事的一种长相,不是几条独立经验;每一条都真跑过,数字都是 实测的。以后再遇到新的长相,作为一行追加进这张表,不要新开一条规矩 —— 新长相 是无穷的,这条判据不是。表按长相排,不按时间排:你是拿自己手上那把尺子来对号的。

长相 一句现场 全文在哪
量不到:违规和合法在文本上一模一样 grep "shared.list(crewId" Sources/Mac/Views/ → 9 命中、真阳性 0;跳注释的「聪明」版在一个已知还在违规的文件上扫出 0 —— 扫描器越聪明,那个文件看起来越干净 提交 d3d4392e70b9a1
量不到:要读的那个文件在 CI 上根本不存在 断言写的是「没有」,而 CI 是干净机器 → 恒定读出「没有」。一个从来没读过 temp 目录的用例,会以一条稳定的绿一直活着 提交 99e2bc7
量错东西:量的是文本打不打架,不是语义还在不在 整层删除之后 rebase 自动合、零冲突。一次是「逐处核了、没事」,一次是把 MARKETING_VERSION 悄悄退回 0.1.15 —— near-miss 配上真命中才看得出它是差一点 提交 cdf801b140cb0f
量错对象:性质量对了,对象不是你以为的那个 发版脚本取两次 main:闸门跑的/真被构建的/tag 指向的可以是三棵树,没有一步会报警。共享目录 checkout -b 之后,闸门四条读数一条都不会响 提交 1bf3fa4只有梗概,配套动作见表下)
量错对象:指针的形式一直有效,指的东西早就走了 docs/tech-debt.mdCONTRIBUTING.md第 271 行(这里写成散文形式、不写成 文件:行号:它是反面例子、不是活引用,写成那个形状会被将来的检测器当假阳性捞出来),而全文只有 268 行 —— 271 > 268,越过了文件末尾。零判断就能判死,而它在那儿躺了很久没人发现:271 行"一直存在",跳过去只会读到一句不相干的话。全仓同类 6 处,其中 2 处(271 > 268、640 > 514)是靠 N > wc -l 抓的,另有 1 处从写下那天就指错了文件 提交 1f8cc7f + 7523044(两笔合起来才是全部六处)
量不全:只量首尾,不量路径 探针量到不补偿跳 680pt、补偿后 14pt/1pt,首尾一致 → 绿。人类装上之后说「像是又从上面滑下来的感觉,位置倒是一样」;写给人念的四条清单继承了探针的盲区,一条都没问到 docs/tech-debt.md「加载更早」那条、提交 f45d4cb
量不全:省略是你自己加的 汇总行只说红了几条、恰恰不说是哪条;head -20 把 skip 构成压成 11-1=10 这个减法;grep -czh 0 / en 4,回去逐条看那 4 行全是英文里的普通词 提交 1bf3fa4只有梗概,推论见表下)

表里第 4、6 行的全文没有别的家,所以这三句留在这儿 —— 它们不是故事,是动作:

  • 断言要对着失败本身,不是对着这次的现场:写 git merge-base --is-ancestor "$COMMIT" main || 退出(这个 commit 在不在 main 上), 不要写「断言共享目录在 main 上」—— 后者更直觉、离故障更近,但下次换个方式拿错 commit 它照样不响。
  • 共享目录恒在 main,要开分支就开 worktree;跑判据用的全量开一个钉死在目标 commit 的 detached worktreegit worktree add --detach),让 HEAD 在物理上没法在 脚下移动 —— 事后检查靠的是「在正确的时刻想起来做一件正确的事」,那恰恰是最不可靠的一环。
  • 任何在你和原始输出之间做省略的东西(head / tail / -c / 汇总行 / 摘要), 一旦它的输出要被当成判据,就必须摘掉。 推论:记构成别记数字,而构成必须是逐条 枚举出来的、带名字 —— 任何靠总数减出来的分项都不算构成,哪怕算术是对的。

6. 绕过约束的临时方案要留痕

如果你为了让 A 跑通而把代价转嫁给了 B(关掉某个校验、塞个 placeholder、双写、 用 #if 整块屏蔽、只验最容易过的那条路径),docs/tech-debt.md 里记一条, 或者加一道会响的断言。这个仓库靠那本账活着,不靠记性。

写清三件事:为什么这么改、代价转嫁到了哪、失败时长什么样。

7. 批量改动:把「绝不能动的那样东西」算成一个指纹

一次批量改动通常只有一个真正的危险动作,其余都是安全的。把那个危险动作对应的 不变量算成一个指纹,改前改后各算一次 —— 别去「逐处核对一遍确认没动它」。

现场(9585514,给 104 处文档引用补路径,规则是一个行号都不许改):

# 把所有变动文件里的 :N / :N-M 抓出来排序取指纹,改前改后各一次
git diff --name-only 9585514^ 9585514 | while read f; do git show "$SIDE:$f"; done \
  | grep -oE ':[0-9]+(-[0-9]+)?' | sort | md5

9585514^   8c20212594fc042e4328a48adb78b55b   358 个
9585514    8c20212594fc042e4328a48adb78b55b   358 个

为什么它比人工复核硬:

  • 「我核过了」的覆盖面等于真正打开看过的那几行,置信度随规模衰减;指纹不衰减。
  • 它直接回答唯一的那个风险(行号有没有被动过),不是一个相关但更容易测的东西 —— 「diff 只有 90 行」「改动都在路径那一段」都是代理量,指纹不是。
  • 天生会红:真改到一个行号,两侧立刻不同,不需要谁去制造坏例。

用的时候只有一个要求:指纹要盖住那个不变量的全部,而不是它的一个样本。 上面那条抓的是全部变动文件的全部行号,不是抽查几份。

文档里的「文件:行」引用

scripts/doc-ref-check.sh 会扫 docs/ 和仓库根的 *.md,判两条不需要读懂那一行 写了什么的:行号越界、文件不存在。它跟着发版闸门一起跑(scripts/release-gate.sh 第⑤条读数)。它刻意不判「这一行是不是真的讲那件事」—— 那要语义,尺子一有语义 就开始误报,然后被人关掉。零误报是它唯一的卖点。

写引用时三件事:

  • 快照文档在头部声明基准提交<!-- doc-ref-base: <sha> -->。一次性清点、事后 报告、采样分析描述的是过去某一刻的树;把它的行号改成今天的,等于把它改成假的。 声明了之后检测器就对那棵树数行。别为了让它变绿去改行号 —— 那是这批活里唯一 真正危险的动作。基准填哪个:文档正文自己写了「基线:main@xxxxxxx」就用那个, 没写就用创建提交(git log --follow ... | tail -1),不要用「文档末次改动」, 它常常只是一次无关的目录移动。
  • 引用第三方包里的文件,必须写明包名与版本,例如 「swift-markdown-ui 0.5.x 的 Sources/MarkdownUI/Views/Blocks/BlockNode+View.swift 第 16 行」。只写一个裸文件名,读者在本仓库里永远找不到它 —— 那不是引用腐烂,是 引用没写清它在哪棵树上,同一个病换了一棵树。检测器不管这一类,也别为它去扩: 要判它就得解析每个依赖的版本、拉下源码再数行,那是把零判断的尺子变成有判断的, 然后它会开始误报、被人关掉。这条靠人写清楚,不靠尺子。
  • 反面例子写成散文,别写成 <路径>:<行号> 的形状(本文件上面那张表就是这么写的)。 写成那个形状会被检测器当成一条真引用捞出来。

代码风格

没有 linter,跟着周围的代码写就行。几条本仓库的习惯:

  • 注释写「为什么」,不写「是什么」。 这个仓库注释密度偏高,因为很多地方 的形状是被具体的坑逼出来的 —— 不写下来,下一个人(包括三个月后的作者)会把它 「优化」掉,然后重新踩一遍。带日期和现象的注释是资产,不是噪音。
  • 中文注释是常态,不用改成英文。
  • 主线程很敏感。这个 app 同时挂着 N 个 PTY,往 @MainActor 上加同步工作之前 先想一下它会不会随 session 数线性放大 —— docs/tech-debt.md 第一条就是这个。

提交与 PR

  • 一个提交一件事。提交信息说清为什么,别只说改了什么。
  • PR 里贴出你跑过的验证(哪几条命令、什么结果)。「应该没问题」不算验证。
  • 不要在 PR 里夹带无关的格式化改动。

目录速查

project.yml             XcodeGen 工程定义(唯一真值)
Config/Config/Signing.xcconfig 签名默认值(ad-hoc);本机覆盖写 Config/Local.xcconfig
.xcodegen-version       生成 .xcodeproj 用哪一版 XcodeGen(唯一真值)
Sources/
  Mac/                  macOS 专有:LocalRunner(agent 子进程)、Mac 界面
  Mcp/                  crew-comms MCP server —— agent 通过它读写白板、@ 人、请示
  Stores/               本地持久化:白板、Todo、审批、唤醒、crew 树
  Chat/                 群聊 UI
  Remote/               跨端 WS 协议与 viewer 客户端(**尚未接通**,见 README「状态」)
  Views/ Services/ Models/ Support/
Tests/PendingCrewTests/ XCTest(macOS)
Shared/AppUpdate/       Sparkle 自动更新 + 构建版本戳
scripts/                本地小工具 + release/
docs/                   release-macos.md(发版)、tech-debt.md(债)
docs/internal/          开发过程记录,写完即冻结,**不随代码更新**

数据根突然全是 EPERM(工具集体瞎掉时先跑这个)

~/Library/Application Support/PendingCrew/周期性变成「建得了、看得见、 删得掉,但只要文件已经存在就打不开」。这时 agent 那一侧的全部 crew 工具都瞎了, 而 app 因为存盘走的是整份原子写(临时文件 + rename),看起来还活着

sh scripts/diagnose-data-dir.sh        # 退出码 0 = 读得动,1 = 就是这一族

它不猜成因、不要 sudo、不碰任何既有文件,一次量完:这棵树 / 是不是整棵子树 / 三个别人的 Application Support 做横向对照 / daemon 日志末尾 / 排不空的机长命令。 现场与已排除的十条成因:docs/internal/2026-09-12-eperm-cause-found.md

解只有人能给:系统设置 → 隐私与安全性 → App 管理 / 完全磁盘访问。

有几个测试需要现取 fixture

CrewChatOpenCostTests 用的是真实群聊数据(不入版本历史,见 .gitignore)。 没有 fixture 时这些用例会 skip 并打出取数据的命令 —— 干净 clone 上那是预期的, 不是失败。要真跑的话:

scripts/make-chat-fixtures.sh <crew-id>

安全问题不要开 issue

SECURITY.md