关于 plan-docs
plan-docs 是一套基于技能和脚本的结构化计划文件管理工作流,使用状态元数据追踪计划。它支持在代码仓库和配置的存储区管理计划,并提供创建、改状态、引用及生命周期管理命令。
主要功能
- 01带 YAML 状态元数据的计划文件
- 02通过命令创建和更新计划
- 03将计划路由至仓库或存储区
- 04管理引用、标签和状态
- 05使用 doctor 检查配置
适用场景
- 01按状态追踪想法
- 02为无法存放计划的仓库保存计划
- 03查找多个配置位置中的未完成计划
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ theodoread/ agent-skills --skill plan-docs安装后在 Claude Code 中输入/plan-docs确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
references/design-rationale.mdprior-art-task-trackers.mdscripts/plans.py详细介绍
结构化、有状态的计划文件
plans/YYYY-MM-DD-topic.md 约定——每个想法或设计对应一个文件;YAML frontmatter 中包含 status 字段,无需打开文件即可查看其生命周期;并明确规定 plans/ 应保持为工作集,而非永久归档。
设计理由、先例和示例:
references/design-rationale.md。是否应该改用现有工具——Markdown 任务跟踪器、git-bug、beads,以及跨仓库场景为何是它们都无法以低成本解决的问题——详见
references/prior-art-task-trackers.md。
此技能读取、运行和写入的内容
- 读取:会话仓库的
plans/、两个计划存储($PLANS_HOME,默认为~/plans,以及其敏感信息同级目录)、自身配置($PLAN_DOCS_CONFIG,否则为~/.config/plan-docs/config.toml,Windows 上则为%APPDATA%\plan-docs\)、projects_root下的目录名(用于推导scan要检查的私有术语——仅名称,绝不读取内容)、会话仓库和存储的 git 历史;在 Claude Code 中,还会读取由$CLAUDE_CODE_SESSION_ID指定的 transcript 路径,以锚定跨仓库防护。 - 运行:
git——可在任何位置运行只读命令;仅在存储中通过commit运行git commit。“绝不让客户端身份进入你要发布的仓库”中的历史清除步骤会打印出来供你运行;脚本绝不会运行这些步骤。 - 写入:仅通过
install、config set、describe和uninstall写入自身配置。通过new、set-status、move、absorb --apply、graduate,以及你手动执行的退役操作,写入会话仓库plans/中的计划文件和两个存储中的计划文件。存储目录及其 README 会以0700权限创建。通过attach:复制你指定的每个文件,副本位于计划文件旁的目录或存储的_attachments/中;写入计划文件自身的## Attachments行,以及存储.git/info/exclude中的一行。通过commit提交到存储;绝不在会话仓库中提交,也绝不写入其他仓库的工作树文件——new会拒绝此类操作并指出--for。archive、list、tags、refs、doctor、scan和where不会写入任何内容。 - 网络:无。推送存储由你在扫描之后执行。
运行脚本,不要重新推导
scripts/plans.py(使用标准库,除非另有说明,否则只读)负责以下每个机械步骤:计划应放入哪个目录、如何创建并添加正确的 frontmatter、状态索引、锚定标签的 grep、晋升和删除门槛,以及入站引用。运行它,而不是打开文件自行推导答案——读取文件的成本很高,下面的每条命令都只需读取一次。
每次调用都要写出完整路径。 Shell 变量不会在 Agent 的 Bash 调用之间保留——只有工作目录会保留——因此 P=… 赋值在下一条命令中就会变为空,下面的每次调用都必须写出完整路径:
python3 ~/.agents/skills/plan-docs/scripts/plans.py list
status: 和 updated: 是 set-status 的输出。绝不要手动输入这两行。 这条规则针对的是这两行,而不是某种特定的状态转换——无论是 idea -> in-progress、单独刷新 updated:,还是删除前的 landed,都适用。无论你还在编辑文件中的什么内容,frontmatter 都必须通过 python3 <path> set-status <file> <status> 来修改,且只能通过它修改,因为只有这条命令会运行门槛检查,也只有它知道今天的日期。
在两个仓库、两次会话中统计:2026-08-30 和 2026-09-01:手动编辑了四次,一次门槛检查都没运行。 两次 idea -> in-progress,一次更新 updated: 时间戳时还删除了 depends_on:,还有一次 idea -> landed,其计划随后在接下来的三个提交中被彻底移除——甚至用了 git rm。每次都通过文件编辑工具完成。两次会话都没有绕过拒绝,也都没有触发可能拒绝操作的代码路径。当时两次会话的上下文中都有这项技能,这正是上面那句话强调两行规则,而不是强调门槛重要性的原因。
[PITFALL:绕过操作不会留下任何痕迹,其结果与正确晋升无法区分。 frontmatter 格式正确,list 会显示新的状态,也没有任何记录能说明门槛是通过了,还是根本没检查。四种情况下门槛检查都会通过——因为标签此前已解决——但那只是操作顺序正确,并不代表流程合规。]
从这里开始。以下三条命令能回答大多数会话的问题,在生命周期推进到相应阶段之前,无需查看下文:
| 问题 | 命令 |
|---|---|
| 任何仓库中会话的首次调用 | absorb — 没有待处理事项时不输出任何内容 |
| 有哪些开放计划?我该做什么? | list — 参见“询问有哪些开放计划” |
| 新计划应放在哪里,又该如何编写? | new <topic>,或 new … --for <repo> |
| 这台机器是否已配置好,以及如何配置? | doctor |
其余命令,按需要使用的时机排列
# writing one down
python3 <path> where # which directories this repo reads and writes
python3 <path> repos --search <words> # what each repo is for, to route a plan by
python3 <path> new <topic> --for <repo> # something belonging to a repo you are not in
python3 <path> new <topic> --to store # this repo's plan, kept out of a tree another session holds
python3 <path> commit <file>... -m "<msg>" # commit these plans alone, whatever else is staged
python3 <path> new <topic> --unscoped # an idea with no repo yet
python3 <path> graduate <file> --to <repo> # …once it has one
# working on it
python3 <path> set-status <file> planned # refuses if the gate for that status fails
python3 <path> tags --tag DEFERRED # anchored, across every plan this repo can see
python3 <path> move <file> --to store # a repo switching where it keeps plans
python3 <path> attach <plan> <file>... # copy evidence somewhere stable, and record it
# retiring it, and getting it back
python3 <path> refs <file> # inbound references, before retiring
python3 <path> archive --search <words> # a retired plan, back out of git history
# keeping the machine right
python3 <path> scan # no private name reaches a repo you publish
python3 <path> orgs # whose repo each directory is, from its own remote
python3 <path> install --explain # set the machine up, one decision at a time
每个读取类命令都支持 --json,因此无需解析这里提到的文本输出。
需要文件参数的命令,参数的位置各有不同: set-status、move、refs、graduate 使用位置参数;用于将默认范围从所有计划缩小到特定文件的命令,则使用 --file:tags、archive。2026-08-30 的一次会话刚运行完 tags --file <name> --tag DEFERRED,接着便沿用了相同的参数形式,在一个循环中对七个文件运行 refs --file <name>,连续七次收到 unrecognized arguments: --file,最后只好重新运行整个循环。
计划文件放在哪里
计划文件通常保存在它所描述的代码仓库中。但大多数雇主和客户的代码仓库无法这样做——你不能在那里自行添加 plans/ 目录——因此有三种方案,而代码仓库使用哪种方案属于配置,绝不能按会话临时判断:
| 方案 | 计划文件存放位置 | 适用情况 |
|---|---|---|
| repo | <repo>/plans/,与代码一同提交 | 你拥有的代码仓库 |
| store | <该层级对应的存储目录>/<projects 根目录下的路径> | 无法存放自身计划文件的代码仓库 |
| both | 同时读取两处,只写入一处 | 正在切换方案的代码仓库,可双向切换 |
存储目录会按每个代码仓库所在的深度镜像其路径,因此 <root>/<project>/<repo> 克隆会对应到 <store>/<root>/<project>/<repo>——没有 slug,也不会让两个客户的 api 冲突。路径根据代码仓库根目录计算,而不是根据工作目录计算。
从 git 工作树操作时,镜像路径属于代码仓库,而不是该工作树——同一代码仓库的所有工作树共用一个镜像和一个 absorb 队列,因此在功能分支上编写的计划文件也能在 main 中看到。采用 repo 方案的计划文件不会被重定向:该文件会随其提交所在的分支移动,这本来就是正确的处理方式,因此它会保留在工作树自己的 plans/ 中。适用时,where 会输出一行 worktree:,因为此时 rel 指向的目录并非当前所在目录,若不作说明,看起来就像个 bug。此前,无论哪个方向,计划文件都会悄无声息地丢失:从工作树写入的计划文件会落在主检出目录永远不会查看的位置;而为该代码仓库存档的计划文件则会落在工作树永远不会查看的位置。
首先:这是什么类型的机器?
配置中的 device 决定存储是否拆分:
device | 机器上存放的内容 | 存储 |
|---|---|---|
contractor(默认) | 多方的工作以及你自己的公开仓库 | 两个仓库,按敏感度拆分 |
work | 一个组织的内容——雇主发放的设备或公司设备 | 一个,完全不分层 |
工作设备没有可供分层划定边界的对象,因此拆分只会产生一个空目录,而每条命令仍要考虑它。sensitive_store 和 shareable_roots 不再适用,install 也不会询问这两项。
这里不会输出任何层级信息,这正是有意为之,而不是为了整洁。“敏感”是一种_相对_分类——意思是“不得放到另一层所在的位置”——所以在只有一个存储的机器上,没有可供比较的对象,输出该信息就等于声称材料具有没人确认过的属性。在工作设备上,doctor、list、where 和 config 都不会提及层级,--json 会报告 tier: null,而不是输出一个需要读者自行解读的词。
它也有默认规则,而承包商设备则刻意没有。 公司机器上的所有内容都属于同一个组织,而组织有自己的工作跟踪器——因此,未匹配的仓库会进入存储,例外情况则是用户自己的仓库,按 own_accounts 与远程地址匹配,而不是按主机匹配。这样才能在 GitHub Enterprise 实例上正常工作,因为你的仓库和雇主的仓库位于同一主机名下,只有账户能区分它们。where 会指出使用了哪个回退规则(work device default 或 work device, your own account),因此结果不会含糊不清。设置 default 可覆盖此规则。
不会放宽的是远程地址检查——但检查的问题有所不同。 将雇主的内部工作推送到个人远程地址,不会因为机器上只有一个组织的工作就变得可以接受,因此单一存储仍是受保护的存储。改变的是检查会询问_是哪个_远程地址,而不是_是否存在_远程地址:已记录的目标不会提示,其他目标则会报告。
python3 <path> config set sanctioned_remotes '["<host>/<owner>"]' # where the store may push
条目可以是 <host>/<owner>、<host>/<owner>/<repo>、表示整个实例的 <host>/,或以 / 或 ~ 开头、指向驱动器或 NAS 的路径——匹配时会检查远程地址解析出的身份,因此同一目标的 ssh、类 scp 和 https 写法都会得到相同结果。裸账户名会被代码拒绝:own_accounts 会刻意在任意主机上匹配名称,因此一条记录就能同时覆盖 github.com 和企业实例;而在这里复用这种形式,恰恰会留下漏洞——它无法区分你在公司主机上的账户与在公共主机上的同名账户,而只有前者位于边界之内。
如果未设置此键,与之前一样,受保护存储上的每个远程地址都会被报告;现在,提示信息会附上对应规则所在的行,因此这是一项一次性决策,而不是先收到警告、之后再将其跳过。检查仅读取 URL,不读取其他信息——你推送到的仓库是否为私有仓库需要进行网络调用,而此脚本不会执行这种调用,因此将其设为私有仍是你的责任。
[PITFALL:**默认值是 contractor,因为两种错误的代价不同。**在单一雇主的机器上误判为 contractor,代价只是多出一个未使用的目录和一行输出。在存放多方工作内容的机器上误判为 work,则会把客户计划放进用户误以为可以安全推送的存储。默认值遵循不会造成泄漏的错误类型,因此未配置的机器永远不会成为危险的那一台。]
在承包商设备上,存储分为两个仓库
| 层级 | 内容 | 远程 |
|---|---|---|
| 可共享 | _unscoped/ 和 shareable_roots 中的根目录 | 允许 — 通常为私有 |
| 敏感 | 其他所有根目录:雇主和客户的工作 | 无 |
两者都是拥有完整历史记录的普通 git 仓库,因此退役、archive 和立即提交规则在两者中的运作方式完全相同。计划格式、状态词汇或标签都不会因层级而改变。
两者创建时的权限模式都是 0700——这是免费的默认设置,而不是可以依赖的保护措施。 层级决定哪些内容可以离开这台机器;权限模式只限制机器上哪些人可以读取它,而设置此模式是因为不需要付出任何代价:umask 只能缩小传给 mkdir 的权限模式,而 Windows 会忽略该参数,因此无需分支处理,也无需配置任何内容。
之后不会检查权限模式,这是有意为之。这个资料库假设机器只有一个用户,没有第二个人需要权限模式来防范——而且在 Windows 上每次运行都会触发检查,因为那里没有这个概念,导致用户被要求运行一个自己没有的命令。你移动、从备份恢复或手动创建的存储库会保留其原有权限模式;如果你使用的是共享机器,并且在意这一点,就自行运行 chmod 700,因为这项技能无论如何都不会发现权限模式。
你永远不需要选择层级。 根目录的层级取决于 shareable_roots(默认值为 public_roots),每条命令都会为你解析层级——where 会打印层级,new --for 会打印层级以及用于提交的准确 git -C 命令,archive 会搜索两者。请根据命令的提示操作,而不要自行推断路径。
这种划分是结构上的,不是安全机制。 风险在于任意文件中出现客户名称,而不在于文件是否位于客户目录中——一个未归类的想法或个人仓库的计划,也很容易提到你无权披露的工作。因此,可共享层级需要通过内容检查,就像任何要公开发布的仓库一样:
python3 <path> scan --mode history --path <the shareable store> # before the FIRST push
python3 <path> scan --mode staged --path <the shareable store> # before each commit after that
两条命令命中时都会以非零状态退出。层级边界只是为了从一开始就避免将整个客户根目录推送到远程端。
--mode tree 不是推送前的关卡,把它当成关卡正是需要避免的错误。 推送发送的是历史记录,而不是工作树;两者恰恰会在关键之处出现差异:一个提到客户的计划经改写后再次提交,会留下干净的工作树和不干净的历史记录,而推送发布的是历史记录。2026-08-29,在为这个存储库接入自己的远程端时确认了这一点——这一节曾将 --mode tree 写作关卡,而第一次实际推送证明了这个问题问错了。
第一次推送是通过历史模式来处理最省事的时机:此时尚未发布任何内容,因此命中仍然只是编辑问题,而不是清理历史的决策。之后,每次提交都使用 --mode staged,以确保后续历史记录保持干净;--mode history 则成为定期审计手段,而不是关卡。
如果你确实要重写历史,在删除 refs/original/ 前,扫描会一直报错。 --mode history 会读取 git log --all -p,而 git filter-branch 会将重写前的引用留在 refs/original/ 下,--all 仍然会遍历这些引用——因此,即使重写已经成功,扫描报告的命中数也会与开始时完全相同,看起来就像什么都没做。2026-08-29,在为这个存储库进行首次推送时确认了这一点。在得出结论前先检查分支本身,然后删除备份引用并释放旧对象:
git -C <store> log <branch> -p | grep -i <term> # the real answer
git -C <store> update-ref -d refs/original/refs/heads/<branch>
git -C <store> reflog expire --expire=now --all && git -C <store> gc --prune=now
请先复制整个目录。这是这些计划的唯一副本,而在这套约定中,重写是唯一可能导致计划丢失的操作。
shareable_roots 作为独立键存在,默认值为 public_roots,因为这两个问题几乎总是答案相同,但并非完全相同:某个根的名称可能可以公开,但其计划不能;反之亦然。在它们的答案确实不同时,再设置此项。
将根移到其他层级不会移动任何文件。 doctor 会报告镜像根位于错误的存储库中,并指出它应该放在哪里;迁移它意味着要在两个历史记录中执行 git mv,并决定哪些内容要发布,因此绝不会自动执行。
仓库归谁所有?询问远程端,而不是目录
目录名表示克隆仓库存放在哪里;它的远程端才表示仓库归谁所有,而且只有后者在克隆仓库被移走后仍然成立。因此,[orgs] 根据从仓库自身远程端读取的 <host>/<owner> 路由,并且检查顺序为在 [repos] 之后、[roots] 之前——完整顺序如下:
| 检查顺序 | 键 | 含义 |
|---|---|---|
| 第 1 个 | [repos] 精确路径 | 此仓库 |
| 第 2 个 | [orgs] 远程所有者 | 属于该账户或组织的每个克隆仓库 |
| 第 3 个 | 最长的 [roots] 前缀 | 存放在该目录下的每个仓库 |
| 第 4 个 | default | 对所有未匹配项的机器级设置 |
| 第 5 个 | 设备回退项 | 仅适用于 work 设备,见上文 |
[orgs] 的优先级高于 [roots],因为两者回答的问题不同,且只有一个与仓库本身有关。如果两者不一致——某个克隆来自他人的组织,却位于路由为 repo 的根目录下——以远程端为准,目录位置只是巧合。
对于归属组织尚未决定的仓库,不允许使用 plans/ 目录。 组织有自己的跟踪工具,因此将 plans/ 目录提交到组织仓库中,是一种未经组织成员认可的约定;提交一经推送,组织内的人就能看到它。where 会以退出码 3 退出,并指出组织名称以及记录相关选择的行。以下两种情况刻意不会触发此检查:没有远程端的仓库(这是本地仓库,因此无人能够同意或反对——没有证据不等于证据不足),以及未设置 own_accounts 的机器,因为没有可供比较的对象时,每个所有者都会被视为外部所有者,检查就会一次性针对所有仓库触发;这种情况下,人们通常会禁用检查,而不是作出选择。
python3 <path> orgs # every owner on this machine, its route, and who decided it
python3 <path> orgs --repos # …with each organisation's clones listed
当外部组织的仓库被路由为 repo,但无人作出选择时,它会以退出码 3 退出,因此它不仅能列出信息,也能执行检查。与 doctor 和 repos 一样,它的输出会列出雇主和客户:该命令用于决定计划放在哪里,绝不要将其输出粘贴到你要发布的仓库中。
where 以退出码 3 退出,表示这是个问题,而不是失败。 这意味着没有规则涵盖此仓库,或该仓库属于一个尚未作出选择的组织。询问用户应该使用哪种路由,然后记录答案。绝不要默默替用户选择:猜测使用 "repo" 会将目录写入他人的仓库,猜测使用 "store" 则会把计划藏到用户从未指定的位置。
使用 config set 记录,绝不要自行编辑 TOML 文件:
python3 <path> config set roots.<root-name> repo # a whole root
python3 <path> config set repos.<root>/<repo> store # one repo, beats any root rule
python3 <path> config set orgs.<host>/<owner> store # every clone of theirs, beats a root rule
python3 <path> config set own_accounts '["<account>"]' # the accounts that are yours
python3 <path> config set sanctioned_remotes '["<host>/<owner>"]' # where the guarded store may push
python3 <path> config set default store # everything unmatched
python3 <path> config set view.idea_limit 20 # how many ideas a listing shows
在命令行中编写 [orgs] 键时不要加引号。 引号是 TOML 文件中键的写法,因此从配置中复制带引号的键,会写入一个首字符为 " 的键——它是格式正确的 TOML,但永远匹配不到任何组织。脚本会去掉带引号名称的引号,而不会将其存储下来,因为这种错误原本不会给出任何提示。
它会保留文件中的所有注释——这些注释记录了每个键的设置理由——会原地替换掉已注释的示例,并拒绝配置自身架构不接受的值,同时恢复文件,而不是让文件处于损坏状态。键所属的表由第一个点之前的部分决定,因此,包含多个点的仓库路径仍会作为一个键处理。
projects_root = "~/projects"
store = "~/plans" # the shareable tier
# sensitive_store = "~/plans-sensitive" # defaults to <store>-sensitive
default = "store" # omit it and an unmatched repo asks instead
public_roots = ["github.com-personal"] # names that may appear in a published repo
# shareable_roots = ["github.com-personal"] # the tier boundary; defaults to public_roots
own_accounts = ["your-account"] # unset = no repo's ownership is checked at all
# sanctioned_remotes = ["git.corp.example/your-account"] # where the guarded store may push
[roots]
"github.com-personal" = "repo" # longest matching prefix wins
[repos] # an exact repo entry beats any root entry
"github.com-acme/legacy-api" = { mode = "both", write = "store" }
[orgs] # by remote owner; beats [roots], loses to [repos]
"github.com/acme-corp" = "store"
环境假设及设置
$PLANS_HOME(默认为 ~/plans)是可共享存储,而 $PLANS_SENSITIVE_HOME(默认为
<store>-sensitive)是另一部分——固定前者也会固定后者,因为后者由前者派生;
projects_root(默认为 ~/projects)是镜像路径所基于的根目录;
$PLAN_DOCS_CONFIG 可覆盖配置文件的位置。
配置按机器而非按用户设置,并且刻意不纳入版本控制。 它将_这_台机器上恰好克隆的仓库映射到路由,因此在其他任何地方都没有实际意义——另一台克隆集合不同的机器需要不同的文件,而不是复制这份文件。不要提议将其提交到仓库或同步;丢失它只需重新运行一次 install 和一轮 config set,这比维护一份除一台机器外、对其他机器都不正确的共享文件更省事。
设置机器是一套引导流程,由你来执行。 脚本从不提示——它必须在人手动运行时也能正常工作,而在 Agent 的 Bash 调用中进行交互式提示会因无人可输入而挂起。因此,决策会以数据形式打印出来,交互环节由你负责:
- 运行
python3 <path> install --explain——它会说明将创建什么,然后针对每项决策输出一个区块,分别说明它是什么、当前设置是什么、建议值是什么,以及选错的代价。不会写入任何内容。 - 使用
AskUserQuestion将每项决策交给用户,并将suggest行作为推荐选项、cost行作为说明。不要直接采用默认值:default、own_accounts、public_roots和shareable_roots的答案决定了计划是否会存入用户并不拥有的仓库、scan是否会捕获客户名称,以及哪些根目录可以发送到远程服务。own_accounts必须确认,而不是直接接受:建议值根据机器上最常见的远程仓库所有者生成;在公司电脑上,这通常是雇主——记录该答案会将其组织标记为用户自己的组织,从而使下游的所有权检查都悄无声息地通过。 - 使用
config set(见上文)记录每项答案。切勿手动编辑 TOML。 - 运行
python3 <path> install——该操作可幂等执行:如果不存在配置文件骨架,就写入一个(绝不覆盖现有文件);将两个存储都创建为 git 仓库;不添加任何远程服务;创建不属于任何仓库的区域。 - 运行
python3 <path> doctor——确认设置已生效,且没有遗留问题。
只有在没有 default 覆盖未配置路由的根目录时,它才会针对每个此类根目录询问一个问题;如果已设置默认值,该答案就已经给出,因此引导流程会很简短。
python3 <path> uninstall 会撤销这些设置:它会移除配置,但保留两个存储,因为存储是这些计划的唯一副本;删除它们需要使用 --purge-store --force 并经过审慎决定。触发拒绝删除的文件数量会在操作任一存储之前,统计两个层级中的文件总数。
项目目录树必须是什么样子
系统通过遍历 projects_root 并在每个 .git 处停止来发现仓库,因此遍历过程假定目录树具有特定结构。集合目录是通往某个仓库路径上的任意目录——projects_root 本身、其下的每个根目录,以及 <root>/<project>/<repo> 层级中的每个中间层级。集合属性是推导出来的,不需要配置:如果某个目录不是仓库,且其下有仓库,它就是集合目录。
projects_root不能是 git 仓库。 这一情况会导致致命错误,程序会直接拒绝,而不是报告:如果那里有.git,遍历会返回一个名为.的仓库,所有真实仓库都会变得不可见,scan几乎不会推导出任何术语——保密检查因此通过,只是因为它再也看不到任何内容。- 绝不跟随符号链接。 Git 会解析符号链接,因此指向根目录内某个仓库的链接会让同一仓库以两个路径重复登记;指向外部仓库的链接则会被发现过程计入,但被
where拒绝。请在仓库的真实路径下规划。 - 裸仓库既不是仓库,也不是集合目录,程序会将其报告为这种情况,而不是进入其中遍历。
- 关联工作树不是第二个仓库。 它不会被登记,
doctor会指出它所属的检出目录。否则,一个仓库会被列为三个,分别路由到三个独立的存储镜像,而且它的分支名称会进入私密术语列表——而分支名称都是普通词(feat、main、docs),扫描也因此会产生大量噪声,最终导致功能被关闭。两种相邻目录结构都会造成这种情况:VS Code 默认的<repo>.worktrees/<name>和扁平的<repo>-<branch>。从外部看,子模块也一样——两者都会在检出目录中放置.git文件,而不是目录——但子模块仍然是仓库,因为只有工作树会在其中命名worktrees。 - 不包含任何仓库的目录会被直接忽略——
doctor会统计这些目录,--strict会列出它们。 - 直接克隆到
projects_root的仓库必须通过[repos]路由,绝不能通过[roots]。[roots]键是一个路径_前缀_,而深度为 1 的仓库没有前缀,因此不会查询命名该仓库的条目,该仓库会落入default——即使你仍然写了这样的条目,doctor也会通过inert_root_rules报告它。git 仓库绝不会是候选根目录,因此doctor和install --explain都不会要求你对其进行分类:在常见得多的扁平~/projects/<repo>布局中,否则每个克隆都将被列为尚未决定的根目录,机器上的每个仓库都会产生一条警告。default会处理所有这些仓库,而确实需要单独设置的仓库,则会在首次写入计划时添加[repos]条目。
明确分类每个根目录,即使 default 会给出相同的结果也不例外。这样,落入 default 的根目录就确切表示“自上次做出任何决定以来新出现的根目录”,doctor 会将其列为等待决定——没有已见标记,没有注册表,只把读取的配置作为已回答事项的记录。这里的“根目录”指的是包含仓库的目录,因此,如果某台机器的项目根目录只包含仓库,就没有需要分类的内容,doctor 也不会对此作出提示。如果不进行这一步,新克隆的根目录就会被静默路由;对于客户端根目录来说这是正确的,但对于个人根目录来说则悄悄地错了:其计划会永远累积在存储镜像中,因为路由到存储的仓库,其镜像_就是_它的归属地,而 absorb 正确地什么也不做。
这台机器配置好了吗?其中有什么
python3 <path> doctor
一次调用即可了解全貌:配置位置、每个存储库及其 Git 状态,还有远程地址是否已获准、哪些根目录已登记、依据哪条规则、归入哪个层级、哪些仓库实际存有计划、按状态和未关闭标签统计的数量,以及一份问题列表——存储库不是 Git 仓库或已丢失 Git 身份、受保护存储库有未经批准的远程地址、镜像根目录被归入错误层级、PLANS_HOME 未设置、某个仓库存有计划但没有规则将其路由、某个不属于你的组织的仓库被路由为 repo,以及某个外国组织的克隆被归入可共享根目录,而其存储库中的计划会落入可能配置了远程地址的层级。如果出现异常,请运行此命令;在信任 archive 之前也请运行,因为它无法从没有 Git 历史记录的存储库中检索任何内容。
它按根目录汇总,且只在某个仓库存有计划时才点名该仓库——按仓库逐项列出会为机器上的每个克隆各列一行,这相当于列出雇主和客户名册。其输出仅用于配置机器,绝不可粘贴到你要发布的仓库中。
受保护存储库的远程地址规则是设计如此,并非疏忽——它可以是承包商设备上的敏感层级,也可以是工作设备上的唯一存储库。除非 sanctioned_remotes 包含该远程地址,否则 doctor 会报告它:保留本地历史记录是好处,而避免让一个个人远程地址汇集多个客户的内部架构才是目的。记录目标地址时必须依据该雇主的实际政策做出决定,绝不可图方便;在记录之前,应将该存储库视为未备份。切勿将任一存储库或其子目录符号链接到工作仓库中——这样会让内容重新进入按仓库限定范围的目录树,而 Agent 会遍历读取该目录树。
切勿让客户身份信息出现在你发布的仓库中
设立存储库的原因是工作仓库不能存放计划。与之相对应、且更重要的规则是:提交到你发布的仓库中的计划,不得提及其来源客户、雇主、项目或仓库。 不能提组织名称、内部项目名称、工作邮箱地址或工单前缀。你仍然可以编写关于为他人工作的计划——描述其形态(“采用 <project>/<repo> 层级结构的工作根目录”、“处于审查压力下的客户仓库”),并将具体信息留在该存储库中。
在提交到任何公开或可能公开的仓库之前,运行 python3 <path> scan,并在提交前立即运行 --mode staged。如果命中,该命令会以非零状态码退出。扫描词取自这台机器——projects_root 下不属于 public_roots 条目的每个根目录、项目和仓库名称;每个根目录名称也会拆分出其组织名称(这样,<org>.com-<host>-<team> 背后的客户也会通过 @<org>.com 地址被捕获);再加上 [private] extra——因此新克隆的客户仓库无需维护任何额外内容即可纳入扫描,且扫描词列表本身不必写入公开仓库。
[private] extra 不是可有可无的装饰:如果某个雇主在这台机器上没有仓库,就没有目录可供提取信息;除非有人将其添加进去,否则扫描不会发现它。
只有集合名称才会拆分出组织名称。projects_root 下若有一个目录本身就是仓库,则只会将其完整名称作为扫描词,不会进一步拆分——拆分它会让普通词语进入扫描词列表,而一个会在每份文档中都标记“repo”的拦截机制,最终只会被关闭。
绝不要手动编写审计模式。 scan --list-terms 会打印扫描器推导出的列表;scan --mode history 才是审计。手写的正则表达式覆盖范围更窄,你看不到它的边界,而且扫描结果看起来会很干净。2026-08-29 实际确认:有人用手写的交替模式调查了一个仓库,只覆盖了已知地址,宣布除此之外都干净,然后清理了仓库——接着扫描器就在该仓库 2021 年的前两个提交中发现了雇主名称,涉及四个分支,因为手写模式漏掉了一个工作根目录,而推导出的列表一直包含它。
需要正确处理的失败模式:
- 通用词命中。 名为
tools或settings的工作仓库会匹配普通英语。将这个名称放入配置的[private] ignore列表——绝不要扩大public_roots,为了修复一个词而屏蔽整个组织的名称。 - 写到命中的内容会让它重新出现。 解释泄露内容的计划、用真实名称举例的说明页面、引用问题行的提交消息——每一项都会直接把该词重新放回刚清理过的扫描目标树中。2026-08-29 的同一场会话中测了三次,每次都在提交前被
--mode staged捕获。通过指出它所在的位置来引用该内容——打印出它的提交和scan --mode history命令——而不是直接引用它。 - 命中出现在已推送的历史记录中。
--mode history会扫描每个提交。清理工作树不会从已发布的仓库中移除任何内容;清除历史记录意味着强制推送并提出支持请求,这应由用户决定,而不是悄悄修改。报告问题,指出相关提交,然后停止。 - 扫描无法读取的路径。
--mode tree使用git ls-files枚举文件,而嵌套检出——例如.claude/worktrees/下的链接工作树或子模块——只会作为一个目录条目返回,而不是其中的文件。这些路径会列在命中数量下方,不包含在该数量内;每个路径都是独立的仓库,应通过将--path指向它来扫描。--mode staged和--mode history会读取 git 自身的输出,不受影响,因此上面的提交前规则使用 staged,而不是 tree。
2026-08-28 实际确认:该仓库此前已经发布了一份计划,其中的测量表列出了六个雇主/客户的根目录名称,以及一个客户的内部 <project>/<repo> 路径——这是一个没有任何规则告知它不能这样做的 Agent 写入的,写入时该仓库自己的 README 还在宣称它是公开仓库。
属于另一个仓库的事项
属于另一个仓库的工作,应作为该仓库的计划归档,而不是实际执行——尤其不能只是新建计划文件,但这条规则针对的是工作本身,而非文件类型。在同一台机器上并行运行的会话会共享该目录,因此任何内容若出现在某个会话正在处理的仓库中,就是这条规则旨在防止的失败情况;跨仓库提交也是该仓库里无人请求的操作。
只针对计划文件阐述这条规则,正是会话绕过它的方式。 会话读到规则,正确使用 --for,认为自己已经遵守规则,之后却照样编辑该仓库的源文件——因为正在编写的内容不是计划。2026-08-29 实际确认:不到一小时内,有个会话就这么做了,建议作为首选方案去编辑另一个仓库中的两个文件,并运行该仓库的部署任务。编辑恐怕是更糟的情况:多出来的计划文件不会生效,而且很显眼;而编辑某个文件时,该仓库的会话可能正拿着它工作,这就会造成真正的冲突。
例外范围很窄,值得直接引用,而不是改述——“除非这是一个需要大量来回沟通才能修复的非常复杂的流程”。这不是“除非事情很小”:单行代码片段的编辑正是那种感觉太小、不值得归档的情况,但实际上并非如此。这里说的是一种无法比共同完成更快地描述清楚的变更,而且希望由所属仓库中的专属会话来处理,而不是通过转交来完成。 读取其他仓库仍然没问题,而且这样才能把已归档的计划写得足够准确,以便据此采取行动。 禁止的是写入。
对于计划文件,脚本会强制执行这条规则,而不是指望任何人记得:new 拒绝在会话所在仓库以外创建计划,并会在错误信息中指出 --for。
对于操作已有文件的命令——graduate,以及通过 --path 指定的任何操作——脚本会改为发出警告,因为这些操作有合理用途;看到警告时,最好在该仓库内的会话中完成工作;如果仍要继续,请明确告知用户具体写入了什么内容、写到了哪里。
防护机制锚定在会话启动时所在的仓库,而非工作目录,因为 cwd 两方面都不可靠——2026-08-29,同一会话中观察到了重置和持久化的 cd。如果防护机制拿 cwd 和 cwd 比较,那么当 cwd 发生变化时,它就无法触发,因为两边会一起变化;而锚点能让比较的两边彼此独立。
共有三个层级,可信度从高到低。除第 2 层外,这里没有任何内容是 Claude 专属的,使用此技能也不需要任何一层:
| 层级 | 信号 | 适用情况 |
|---|---|---|
| 1 | $PLAN_DOCS_SESSION_REPO | 任何运行环境,只要它导出此变量 |
| 2 | $CLAUDE_CODE_SESSION_ID → 会话的转录文件路径 | Claude Code,无需设置 |
| 3 | cwd | 回退方案;无法检测目录偏移 |
如果运行环境不是 Claude Code,请在会话开始时导出 PLAN_DOCS_SESSION_REPO——
export PLAN_DOCS_SESSION_REPO="$(git rev-parse --show-toplevel)"——这样防护机制就和 Claude Code 下完全一样强。不设置也仍然能正常工作;只是防护机制会降级到第 3 层,不再捕捉目录偏移。doctor 会报告当前使用的层级,并将回退方案列为问题,因此没人会在不知情的情况下处于较弱的层级。
无论处于哪个层级,都要遵循以下两条习惯:
- **阅读每次创建操作打印的
repo:行。**它会指出计划刚刚归属的仓库。该信息根据文件的写入位置得出,而非来自任何比较,因此无论cwd或锚点如何变化,它都准确无误。 - 未经用户同意,绝不
cd进入其他仓库;执行任何跨仓库命令后,在某次调用重新确认cwd之前,都应将其视为未知。锚点让意外的cd不至于造成问题,但并不意味着可以随意切换——会话中的其他所有操作仍然相对于cwd执行。
python3 <path> new <topic> --for github.com-personal/<repo> # or an absolute path
它会根据该仓库的路由设置,将内容写入仓库对应的存储镜像,该镜像位于所有工作树之外。目标仓库不会有任何更改。在该仓库中工作的会话可以看到这些内容——仓库范围的 list 无论路由如何,都会读取存储镜像——并可以按自己的计划通过 move <file> --to repo 吸收它,只提交到自己的仓库。
**当该仓库自身的工作树正被占用时,同一个镜像也可供它使用。**如果计划属于你当前所在的仓库,但另一个会话正在写入该工作树,new <topic> --to store 会将计划放入该仓库自己的存储镜像,之后 absorb 会在此处提供它。对你自己的仓库使用 --for 会被拒绝,而 --unscoped 是错误的回退方案:absorb 不会查找未限定范围的区域。2026-09-13 已确认:一个出于此原因而被归档到未限定范围区域的计划,直到三小时后一次收集操作发现 new 支持 --to 并将其移出,期间一直没有被提供。
这些文件没有 frontmatter 标记。对于自行保存计划的仓库,存储镜像中的文件按定义就是传输中;对于路由到存储的仓库,同一个文件则位于其永久存放位置。路由和位置已经说明了一切,因此无需设置任何内容,也不会产生状态偏移。
如果存储库有未提交的更改,请添加新计划,而不是编辑现有计划,并引用其关联的计划。另一个会话可能正在使用该文件;新文件不会冲突,而编辑被占用的文件会造成无法挽回的损失。检查 git -C <store> status --porcelain——检查创建命令指定的目标存储库。检查另一个层级,只会回答另一个仓库的问题。
这项检查之所以有效,是因为两个层级都是真正的 Git 仓库。这就是敏感根目录要作为第二个仓库,而不是加入可共享仓库的 .gitignore 的原因:2026-08-29 验证过,写入 Git 忽略路径根本不会出现在 git status --porcelain 中,因此这项检查对它看不到的层级会报告干净——这比没有检查更糟,因为人们会相信这个结果。
存储库计划一写完就立即提交,绝不要拖到会话结束时。 存储库每脏一分钟,另一个会话就得多考虑一分钟是否该改为添加一个原本更想编辑的文件。因此,上述规则和这条规则其实是同一条规则的两面:脏状态持续时间短,回退操作才便宜;而脏状态持续时间短,是因为没人会一直搁置未提交的计划。new 会生成一个空骨架,因此提交的时机是在内容写完之后,而不是创建时。
python3 <path> commit <the plan> -m "<repo>: <what it is>"
使用该命令,而不是 git add && git commit,因为存储库是一个只有一棵工作树、一个索引的仓库,而机器上的每个会话都会写入其中。 commit 会通过私有索引,用你指定的文件从 HEAD 构建提交,因此并行会话暂存的工作既不会夹带在你的提交说明下,也不会受到你的提交影响。2026-08-29,在该命令出现之前进行的测量显示:一次 git add 在同一段工作时间内两次被纳入另一个会话的提交,每次都报告 nothing added to commit——这听起来就像添加失败了,但实际上添加已经成功,只是内容已被别人提交。内容从未出错;提交说明描述的更改与提交所包含的差异不符,而只有运行 git log -- <path> 才能查出文件最终落在了哪里。
在这台机器上,手动操作是重复次数最多的单一模式——2026-09-01 测得,23 个会话中调用了 142 次——因此才值得做成命令,而不是再写一条更长的说明。
一次调用中列出同一逻辑更改涉及的所有文件;一次吸收就是一个提交,不是 N 个。 直到 2026-09-09,该命令一次只接受一个文件,四个会话因此付出了代价:两个会话自行推导出 git -C <store> commit -- <dir>——这确实可行,因为路径规格提交不会提交索引;一个会话为一次吸收付出了两次提交、两条说明的代价;还有一个会话在七个文件时才碰到单数限制,此前五个会话已经在三个仓库中排入了同一个队列。每次偏离规则的情况都在各自的提交说明中完整陈述,因此这反映的是措辞问题,而非那些会话的问题:只给出机制的规则,任何接受该机制的人都能设法绕过去。列出多个路径只是让同一个私有索引循环处理,因此不再有可争辩之处。
指定多个文件时必须使用 -m,因为自动生成的说明只描述单个计划的主题,无法用默认说明描述一组文件。这里有意不提供整个目录的形式:目录参数很容易把本会话从未改动的文件一并纳入;而就在其中一次提交执行时,存储库的另一个镜像中正暂存着另一个会话删除的文件——当时使用 <dir> 参数只差一级目录才算正确,而命令本身不会说明这一点。
如果你提到的路径已经消失,命令会说明它是否被 absorb 吸收。 你编辑过的存储计划可能在编辑和提交之间被另一个会话吸收,随后提交就会在宣告新增的消息下,仅包含删除内容。此事于 2026-09-04 确认,依据的是 session-harvest 步骤 2 中的“已归属”规则:删除 76 项、插入 0 项,花了八分钟阅读目标仓库,才确认内容并未丢失——它确实还在,已被完整吸收。提示会列出目标位置和添加该内容的提交,并且只有在实际解析出目标位置时才会打印,因此普通的退役不会被误告知其计划已被吸收。
[PITFALL:Edit 会更早捕获此问题,而 Write 不会;这是工具本身的差异,并非约定使然。 对已吸收的路径执行 Edit 会明确报错 File does not exist;对同一路径执行 Write 则会重新创建文件,随后提交会在一条为追加内容撰写的消息下记录一次复活——某个会话已经取走的计划又回到了存储中,absorb 还会再次提供它。此事于 2026-09-05 从无害的一面得到确认:一个会话在本轮早些时候归档了计划,之后继续扩展该计划时发现存储路径已消失,并从 Edit 得知了这一点。因此,若要添加到可能正被其他人吸收的存储计划中,请使用 Edit;遇到失败时应把它视为新消息,而不是用 Write 绕过去。]
推送是单独的受控步骤,只有可共享层有可推送的远程位置。 推送前先扫描,且只有结果干净时才推送——对即将创建的提交使用 --mode staged,而在存储首次推送前使用 --mode history,因为推送实际发送的就是这些内容,而且这是命中内容仍可编辑、尚未变成清除决策的最后时机。层级边界会阻止整个客户端根目录被推送到远程,但它无法识别个人仓库计划或未限定范围的想法中出现的客户端名称。敏感层没有远程位置,因此无内容可推送。
吸收为此仓库归档的内容
在会话中首次调用 plan-docs 时,先运行 absorb:
python3 <path> absorb # report only — silent when nothing is waiting and nothing is owed
python3 <path> absorb --apply # move them into this repo's plans/
[PITFALL:如果某个仓库的计划存放在存储中,就没有内容可供 absorb 吸收,但它仍然是正确的首个调用。 镜像_就是_该仓库的永久存放位置,因此内容从不会处于传输途中——但这是唯一会带有退役提示的命令,而存储中的计划和其他计划一样,最终都会进入终止状态。在每个仓库都路由到存储的机器上(这正是企业机器的常见情况),该提示就是它唯一会打印的内容。这个命令以另一种情况命名;如果运行后没有输出,应理解为“此处没有待处理事项”,而不是命令不适用。]
如果存储中没有此仓库的内容,也没有待处理的退役事项,命令就会保持静默,因此在不适用的会话中不会造成任何成本。如果它打印出已归档计划,请先用 AskUserQuestion 向用户展示这一组计划,再进行应用——整组只问一个问题,不要每个计划问一次——然后应用计划、运行仓库的质量检查,并提交两次:此仓库提交新增内容,存储提交删除内容。--apply 会打印已填入路径的存储提交命令;请连同提交消息一起运行,不要自行拼装路径。还要注意,commit 会解析路径所指向的位置,绝不会按基本名称解析——直到 2026-09-13,使用相对于存储的路径提交吸收操作的删除内容时,还曾在错误的仓库中产生空提交。然后继续处理本次会话的其他工作。
每个会话运行一次,而不是每条命令运行一次。脚本是无状态的,无法分辨这是第一次调用还是第五次;你能分辨,因此规则写在这里。如果会话跳过了提议,有待处理计划时,list 也会打印一行页脚。
在此处同时提交到两个仓库,并不属于本节禁止的跨仓库提交。 你是在此仓库中工作的会话:新增内容会进入你自己的树,而 store 只会移除你刚取走的文件。禁止的是写入不属于你的树——而 absorb --apply 会严格执行这一点:如果目标仓库不是你的会话所属的仓库,它就会拒绝。为另一个仓库生成报告(absorb --path <other>,不带 --apply)只是无害的查询,仍然允许。
名称冲突意味着合并,不是重命名。 如果归档计划的名称已存在于 plans/ 中,absorb 就会拒绝操作,以非零状态码退出,并且不会销毁任一副本——两个计划同名意味着它们都涉及该主题,这时就应该手动将它们合并。
吸收是协调脏 store 拆分的环节——而 absorb 只能告诉你存在引用,无法说明原因。 harvest 发现 store 处于脏状态时,会添加一个引用现有计划的新计划,而不是编辑另一个会话可能正在持有的文件;这两部分需要合并。但一个计划引用另一个计划的常见情况,只是两者相关,而且有好几个计划明确这么说——“与其一同归档”“这是一个新问题,而非对那个问题的补充”“此处不再提出”。因此,absorb 会在每对计划旁打印 references …,判断由你来做:阅读两份计划,仅当它们是同一主题的两部分时才合并。
在 2026-08-29 和 2026-08-30 的两次吸收中进行统计:十二组配对里,有七组是有意区分开的。失败的代价并不对称,这就是报告不说明原因的原因——漏掉真正的拆分,会造成一个之后有人才会注意到的重复主题;而错误地合并一对计划,会破坏某人经过书面推理得出的区分,连同推理本身一起毁掉。吸收之后,没有任何机制会再次提醒你真正相关的一对计划,因为这种配对信息只存在于文字中,所以此处漏掉真正的拆分,就意味着以后再也不会有人被提醒。
把收到的标题作为一个整体来读,并询问是否有任意两个标题实际指向同一主题——因为名称相同是唯一能被检测到的冲突,而主题相同但名称不同的两个计划,对所有检测机制来说都不可见。这里实际发生的情况正是如此:并行会话各自选择描述性文件名,因此,通常把计划命名得恰当,反而会让检查失效。
2026-09-03 确认了这一点,而这对计划很典型,因为两份计划都没有错。一份计划归档在 store 中,讨论某项 skill 对其配置和变更做了什么声明;另一份计划则在几小时内由并行会话提交到目标仓库,讨论 skill 可以把内容放在哪里——XDG 配置、数据还是状态目录,以及两个 store 环境变量。两者是互为补充,而非重复,并且依赖关系是单向的:披露内容会列出位置,因此在确定位置之前,无法敲定披露格式。 如果某个会话在没有第二份计划的情况下吸收了第一份,就会基于即将变更的位置设计披露格式。absorb 没有报告这两点,因为两份计划没有相互引用——两位作者都不知道对方的计划存在。
[陷阱:冲突最不显眼的时候,恰恰最容易发生。 两个会话会因为用户在同一时期向它们都提出了同一个主题而聚焦于此——而这也正是双方都看不到对方文件名的时候,因为另一份计划在归档时可能还不存在。能够发现这种冲突的机制(引用)需要作者不可能掌握的信息;而实际运行的机制(名称相同)则会被描述性文件名绕过。] [决定:用于促使思考的提示词,而不是检测器。 经权衡两种方案后,于 2026-09-09 敲定。文件名 词语重合判断很粗略——2026-09-03 的那对只共有「skills」一词。Frontmatter 毫无帮助:两者都没有 topic 字段,而添加该字段只会带来新的维护负担和出错风险。一个被人信任的弱检测器,比一个促使思考的提示词更糟,因为它会把「我应该看看」变成「工具本来会提醒我的」——而本节的核心论点本来就是,错误合并一对计划,会破坏某人曾以文字推敲出的区分。基于同样的理由,也不把检查放在_归档_会话中:只有在另一份计划已经存在时它才有用,而这两份计划相隔数小时,因此它只能捕捉一个方向,无法捕捉另一个方向。]
同一个缺口也存在于单个仓库自己的 plans/ 中——没有任何机制阻止两个会话在那里针对同一主题分别打开两份计划;而 list 按状态而非主题分组,因此读者浏览时会看到它们分列两处。目前只观察到存储库的情况;仓库内的情况机制相同,只是不涉及存储库,而且每当你把列表当作列表来阅读时,同一提示也适用。
如果确实要合并,则遵循四条规则,每条都是在需要时推导出来的:
- 保留能描述合并后主题的名称,而不是其中某个案例的名称。「较早的文件名」在两份计划日期相同时并无明确定义,而这恰恰是常见情况。
- 修正内容应就地合并,而不是追加——否则错误的论点会排在正确的论点上方,读者还得自行判断。
- 在保留下来的计划中注明被合并移除的文件名,否则
archive无法找到它:已删除的计划还在 git 中,而这一行是唯一能指向要搜索的文件名的内容。 - 已实施的集群成员应标记为已完成,而不是继续保留,这样合并就不会排入已经存在的编辑工作。
引用的文件名若找不到实际文件,就不构成一对——计划正文中可以合理地引用已弃用和外部的计划,只有与任一侧实际文件名匹配的名称才算数。
吸收处理只适用于自行维护计划的仓库。对于被路由到存储库的仓库,镜像就是永久存放位置,任何内容都不会处于中转状态。
退休提示词 absorb 也涵盖
plans/ 被定义为一个会逐渐清空的工作集,而在 2026-09-02 之前,从来没有人要求任何人清空它。缺少的从来不是这套机制——五步流程、删除门槛、入站引用检查、通过 archive 读回已退休计划——缺少的是触发条件。截至 2026-08-29,全机器范围内:五个仓库中有九个已到终态的计划,其中两个在同一天达到该状态,因此待处理积压增长得比消化得更快,而 list 的页脚始终在打印。九个计划中,有一个不仅尚未退休,而且在流程中途停滞了。
因此,absorb 会分成两组说明,这代表两种不同的请求:
STALLED mid-retirement— 计划包含## Migrated to部分,因此第 4 步已完成,第 5–6 步却从未执行。这类计划最容易完成,也最容易丢失,因为它与无人着手的计划没有任何其他区别。不论计划已有多长时间,都会提示。优先完成这些计划。- 待退休 — 状态已终结
RETIREMENT_PROMPT_AFTER_DAYS(3)天或更久,按时间从早到晚排列;最多显示五行,其余合并为一个数量。
把它作为一个问题提给用户,说明所需成本,并让“现在不做”成为真正的选项。 退休是一套判断流程,而不是一条命令:按生命周期对内容进行分类,为理由找到归处,写入 ## Migrated to,修正入站引用,然后删除。因此,这个提示是在提议把当前会话的一部分时间花在用户本来不是为此而来的事情上。“退休这两个计划会花掉大半个会话”和“要不要整理一下?”是两个不同的问题,只有前者可以得到诚实的回答。处理一个停滞计划只需几分钟,把它和一个尚未着手的计划一起询问,会让前者被忽略。
三个已经敲定的选择,每一个此前都是待决问题:
- 限流依据是计划的存续时间,这样选择是因为它不需要状态记录。 记录询问内容和时间的标记文件会成为第二个生命周期存储,而它自身却没有退休机制——这与本约定反对将任何内容存放在
plans/之外的理由相同。设为三天而非一天,是为了避免周五加入的计划在周六就发出提醒。限流是为了避免反复提醒,而不是判断是否已准备就绪——它决定何时把计划交由_其他人_接手,并不说明创建该计划的会话该如何处理;按照上面的规则,该会话应在当天退休计划。 - 三个界面,三个不同的触发条件,不分散职责。
absorb在会话开始时提示_已积压一段时间_的计划。session-harvest已经会在“待处理决策”下报告_本次会话_使其达到终态的计划——它必须这样做,因为absorb运行时,本次会话还没有提交任何内容。对于正在查看计划状态的会话,list保留被动的一行页脚。每个界面回答的问题,另外两个都无法看到。 - 提示只信任它读取的状态字段,这一点是已知的局限。 在这里,手动绕过门槛修改状态的计划看起来毫无区别——这也是为什么“
status:和updated:是set-status的输出”是文件开头的规则,而不是一条备注。
这并不是提议退休现有的待处理积压。 那是需要投入实际会话时间的实际工作,应当有计划地安排;把它合并进新增提示的改动中,会让提示首次运行时就变成最糟糕的情况。
尚未归属任何仓库的计划
尚未达到归属某个仓库条件的想法、任务或探索,应放到 $PLANS_HOME/_unscoped/:
python3 <path> new <topic> --unscoped # works anywhere, including outside any git repo
python3 <path> list --scope unscoped # the repo-less backlog on its own
python3 <path> graduate <file> --to <path inside the new repo>
graduate 会按照目标仓库自己的规则路由文件——放入该仓库已提交的 plans/,或放入该仓库的存储目录;如果目标是存储目录,还会写入 repo: frontmatter。仓库一出现就立即处理,不要拖到之后:工作已转移到仓库的未归属计划,会成为无人再找得到的计划。
查看有哪些待办
用一次 list 调用回答“我们有哪些计划”和“我该做什么”,不要打开计划文件。 范围会为你选好:
python3 <path> list # auto: this repo when in one, the machine when not
python3 <path> list --scope family # every repo, always
python3 <path> list --stale 14 # only what nobody has touched in a fortnight
python3 <path> list --since 2026-08-01 # only what moved on or after a date
python3 <path> list --tag DEFERRED # only plans carrying that open tag
python3 <path> list --all # include landed, abandoned and superseded
python3 <path> list --limit 0 # every idea, uncapped
--scope auto 是默认值,在路由到的仓库内会解析为 repo,在任何仓库之外且位于存储库自身内部时会解析为 family——因此,在没有切换到任何特定目录的会话中,无需额外询问,就能得到整台机器范围内的答案。
仓库范围会读取仓库的 plans/、其存储库镜像,以及未指定范围的区域,不受路由设置影响。 路由决定_写入_落在哪里;若让它决定_读取_能看到什么,就会导致未指定范围的计划在机器上所有根目录均为 mode = "repo" 时,对每个仓库都不可见(已于 2026-08-29 确认)——那些没有其他途径重新引起关注的计划,正是从未显示出来的计划。
--stale 和 --since 查询的是相反的情况,并且对未标记时间的计划采取相反处理:--stale 会保留它(没有信息表明它被处理过),--since 会排除它(没有信息表明它有变动)。两者都不会限制默认输出;下方的上限会限制输出,因为日期窗口的大小无法预测。
输出有上限,且只有 idea 层级会被限制。 in-progress、blocked 和 planned 的数量受实际可同时进行的工作量限制;idea 会不断增长,因此只有这一组的开销没有上限。上限由 [view] idea_limit 设置(默认值为 10),--limit 可按调用覆盖该值,页脚总会说明省略了多少行。切勿把受限列表当作完整集合——如果你准备对所有计划得出结论,请先传入 --limit 0。
只有 --scope family 会报告一项内容,因为任何单仓库命令都无法看到它:以阻塞来源视图展示的 depends_on,并汇总为计数。计划本身会在被等待的仓库中以 --scope repo 显示,标为“等待此仓库”——等待情况在这里才有可操作性;而在整台机器范围内列出每一条依赖关系,会像 idea 层级一样随着计划库增长而变长(2026-08-29 测量:81 行中有 22 行)。
所有范围都会显示状态漂移,而漂移后的状态并非表面看起来那样。 不在词汇表中的状态——例如定义了 landed 却写成 done,或是在应该使用枚举值的地方写了一段自由格式段落——会以自身名称作为分组标题显示,因此在仓库范围下,它看起来像是有意设置的 blocked on …,而不是错误。家庭范围仍然是唯一能发现无人处理的仓库中状态漂移的视图;它首次发现了两个实例(2026-08-29),之后又发现了两个(2026-09-02)——但不应只在这里报告,因为不能向其他仓库写入,所以每个发现都不得不登记为计划,并等待永远不会看到它的那个会话。检查本身不需要家庭范围:词汇表是固定枚举,因此单个仓库就具备所需的一切。
[陷阱:看起来像同义词的漂移,比看起来像一段文字的漂移更隐蔽。 在应该使用枚举值的地方写一段文字,对任何读者来说都明显不对。而 done 是一个看似合理的单词,会呈现为整齐的标题——它的后果也更严重,因为它悄悄宣称计划已经完成,而词汇表中真正的终结状态 landed 受门控限制:当有人尝试设置该状态时,set-status landed 因同一个计划带有未关闭的 UNVERIFIED 标签而拒绝了操作。这个同义词不只是错误标注了状态,还绕过了检查。因此,漂移要从_未过滤_的集合中读取——手动设为 landed 的计划会归入终结状态组,否则在检查看到它之前就会从列表行中被过滤掉。]
终结状态的计划会从列表行中隐藏,但绝不会悄无声息地消失。 plans/ 是一个会逐渐清空的工作集,因此仍留在其中的 landed 计划意味着该归档却尚未归档;页脚会统计这些计划,--all 会显示它们。这个计数往往是输出中最有用的一行。
和 repos 一样,家庭范围的输出会列出工作仓库:用它来决定要做什么,但绝不要把它粘贴到你要发布的仓库中。
计划属于哪个仓库?
当内容可能合理地属于多个仓库时,不要用 grep 搜索仓库来决定,也不要猜测:
python3 <path> repos --search "<the words the plan is about>"
它会列出项目根目录下的所有仓库,并显示每个仓库的路由和一行描述——配置中的 [about] 条目;若没有,则显示该仓库 README 的首行——再根据搜索词排序。选出排名前两三位的仓库,将这些描述作为选项文本,通过 AskUserQuestion 提供给用户确认:这是一个有依据的猜测,而不是开放式问题。如果 README 自带的描述不合适,使用 python3 <path> describe <repo> "<what belongs there>" 记录更准确的描述。该列表会列出工作仓库,因此仅用于选择目标位置——绝不要将其粘贴到要发布的仓库中。
创建计划
python3 <path> new <topic> — 使用 kebab-case 格式的主题,每个主题对应一个文件。它会在路由对应的写入目录中创建 YYYY-MM-DD-topic.md,并自动填入正确的 frontmatter:
---
status: idea
updated: YYYY-MM-DD
repo: <origin URL> # store-held plans only — location no longer names the repo
---
正文:## Context → ## Open questions(每个未解决的问题都标记内联的 [NEEDS CLARIFICATION: ...] 标签)→ ## Recommended direction(粗略建议,不作硬性规定)。
这适用于偶然发现的任何事项,而不仅仅是有意提出的设计请求。 值得日后修复的 bug、值得集思广益的想法、值得缓解的风险——每项都应有自己的文件。绝不要将其作为未来工作写在 README.md、AGENTS.md、文档页面或代码注释中:这些内容描述的是当前状态,而纯文本没有状态字段,因此永远不会有人收到提醒回来处理。(2026-08-23 已确认:一个已知的测试覆盖率缺口被写在 README.md 中,称为“一个实际存在的缺口……尚未修复”,而不是计划文件,因此任何扫描 plans/ 查找待处理工作的机制都无法发现它。)
提交计划文件
每次提交 plans/*.md 前都要运行仓库的质量检查——无论是创建、更新还是归档。“只是 Markdown”并不构成例外:格式化工具会重新排版文本,而在采用此约定的仓库中,跳过质量检查的纯文档提交是 CI 变红最常见的原因。
存放在 store 中的计划应改为提交到 store 自己的 git 仓库,不受任何工作仓库的质量检查要求约束。但仍应在写入计划的同一会话中在那里提交:store 的历史记录是该计划唯一的记录,而一个放在无人查看的目录里、尚未提交的文件,就等同于没有计划。
将证据附加到计划
计划引用 Downloads、临时目录中的日志,或其 source_session 中提到的对话记录,实际上是在引用很快就会消失的内容——对话记录会在计时器到期时消失,其他文件则会在下次有人清理时消失。attach 会将文件复制到稳定的位置,并在计划中写入一行,说明它保存了什么:
python3 <path> attach <plan> <file>... # size decides where each one goes
python3 <path> attach <plan> <file>... --commit # evidence someone will read
python3 <path> attach <plan> <file>... --local # bulk output, or something that must stay exact
有两个存放位置,而只有你能判断哪个方面更重要。 大小不超过 [attachments] commit_limit_kb(默认为 1024)的文件会被复制到计划旁边、以计划命名的目录中,并与计划一起提交;更大的文件则会放到存储区的 _attachments/ 目录中,git 永远不会看到它。大小是脚本能够判断的那一半,所以它会选择默认值——标志参数则留给另一半:
| 文件 | 存放位置 |
|---|---|
| 另一个 Agent 的调查报告、屏幕截图 | 提交 |
| 数 MB 的日志、数据转储 | 本地 |
| 必须保持字节完全一致的任何内容 | 本地 |
[陷阱:已提交的附件是该仓库中的普通文件,也会经过仓库的校验流程。 dprint 会重新排版 Markdown,因此以 .md 格式提交的调查报告在校验流程首次运行时就不再保持字节完全一致——而且这个过程不会发出提示,因为校验流程会重写文件并通过检查。如果证据是精确措辞,请使用 --local。]
本地附件是该文件唯一的副本,每次写入本地附件时,attach 都会说明这一点。它不在任何 git 历史记录中,所以 archive 无法将它恢复;在承包商的设备上,它还位于刻意不提供远程服务的存储层中。计划中的记录带有 sha256,因此后续读者可以将它与同名的其他文件区分开来;是否也保留原文件,由你决定。
记录绝不会写明文件的来源。 复制文件的目的就是不依赖临时路径,而源路径可能会将客户端目录或账户名称带入你要发布的仓库中。请在正文中说明文件是什么、展示了什么;记录中会包含名称、大小和摘要。
scan 会像查看其他受版本控制的文件一样查看已提交的附件,但看不到本地附件。这种不对称是刻意设计的,而非功能缺失——本地部分绝不会离开这台机器。
附件会随计划一起移动。 absorb、move 和 graduate 都会带上该目录,而 commit <plan> 会将计划及其附件作为一次提交一并提交。如果目标位置已经存在目录,就会阻止移动,处理方式与名称冲突完全相同:两个对象占用同一个名称时,需要合并,而不是重命名。
退休时,已提交的部分会随计划一起删除,但仍可在 archive 读取的历史记录中访问。本地部分则由 refs 标记并保留原样——删除它是在一个旨在可逆的流程中唯一不可逆的步骤,因此必须交由用户决定。
标签
五种行内标记,格式均为 [SHOUTY-WORD: text]。这样,下方需要判断的事项都可以通过 grep 找到,无需重新阅读;即使文件被删除,也不会丢失任何重要信息。
| 标签 | 含义 | 退役时 |
|---|---|---|
[NEEDS CLARIFICATION: …] | 尚待解决的问题 | 阻止进入 planned 和 landed |
[DECISION: …] | 已确定的选择,以及它胜过其他方案的原因 | → 设计依据 |
[PITFALL: …] | 不明显的陷阱,已通过实际碰到它确认 | → 设计依据 |
[DEFERRED: …] | 有意暂不纳入范围,但仍有需求 | → 未完成计划;阻止删除 |
[UNVERIFIED: …] | 已设计或实现,但尚未实际验证 | → 验证或推迟;阻止进入 landed |
这五种就是全部词汇。 不要增加第六种——标记应用不一致比没有标记更糟,因为 grep 仍会返回结果,且人们会信任这些结果,但它们并不完整。特意不设 [VERIFIED:];没有 [UNVERIFIED: 才是信号。
set-status 会强制检查上面两列所列的标记,并根据你要进入的状态进行门控,而不是根据你要离开的状态。 in-progress 刻意不设门槛:带着未解决的问题开始工作很正常;如果不是这样,还有 blocked on <reason> 可用。landed 会检查 UNVERIFIED 和 NEEDS CLARIFICATION,因为它是删除前的状态,而且 idea -> landed 会完全跳过 planned。最后这条是一次修复,而非描述:直到 2026-09-02,问题检查只针对 planned,因此,唯一以不可逆操作告终的状态转换,恰恰是唯一无人检查的转换;一份带有两个未解决问题的计划直接进入 landed,打印的内容只有状态行。DEFERRED 阻止删除仍写在退役流程的正文中,而不是门控规则里——参见其中的第 2 步。
不要使用纯 TODO/FIXME——它们会与代码注释冲突,导致在同时包含源代码的仓库中,rg TODO 变得毫无用处。
标记声明,而不是章节。 每条独立、可单独提取的事实使用一个标记。若标记的范围是“本标题下的所有内容”,就无法通过机械方式迁移,而这正是要避免的。
标记单独占一行,作为段落开头,或紧跟列表标记之后。搜索时也必须采用相同的锚定方式——直接运行 rg '\[DEFERRED:' 会匹配所有正文中对标签的_提及_,因此任何讨论此约定的文档都会产生虚假的待办项。plans.py tags 会应用这种锚定,并覆盖仓库路由读取的每个目录:
python3 <path> tags --tag DEFERRED # the whole backlog, no file opened
python3 <path> tags --file <file>.md # one plan, all five tags
在状态转换时标记,而不是起草时标记——这些时候总有人正在仔细阅读。一次性改造现有文档,而不是拖延着逐步处理;只标记一部分正是上述失效模式。
提升计划状态
在原文件中原地提升,不要为同一主题拆分出第二个文件。 先解决所有 NEEDS CLARIFICATION,然后运行 python3 <path> set-status <file> planned——该命令会运行检查,如果仍有未解决项就会拒绝执行;因此,拒绝就是最终结果,不要用 --force 绕过。将正文改写为 ## Context → ## Design(使用编号小节,每个受影响的文件或组件各设一个小节,并在其中说明理由)→ ## Files touched → ## Verification。
随着工作推进,使用同一命令再次更新 status(它也会写入 updated)——切勿编辑 frontmatter,因为如上文“status: and updated: are set-status' output”所述,这两项由 set-status 输出,记录也在此处。各状态的含义如下:
in-progress— 正在积极开发。blocked on <reason>— 因外部因素而停滞,原因须写在状态行本身,例如blocked on the upstream API adding a /search endpoint。landed— 已实现并验证。此状态是暂时的;参见“Retiring a plan”。abandoned— 在落地前终止。同样是暂时的。superseded by plans/<file>.md— 已落地的计划,其决策后来被另一项计划推翻。
可选的 depends_on: [<repo-name>, ...] frontmatter 用于列出该计划要完全落地所依赖的同级仓库。对于常规的单仓库情况,请省略此项。
来自另一个仓库的计划
new --for 会写入另外四个字段和一个 ## Evidence 小节——这是 depends_on 的入站镜像,适用于在一个仓库中阅读计划、而产生该计划的事情发生在另一个仓库中的情况:
source_repo: <the repo you were in> # filled in for you, from where the session was
source_session: 8f3c….jsonl # the harness's transcript for that session
source_moment: 2026-08-22T16:50:15Z # plus a distinctive quoted phrase in the body
source_plan: plans/2026-08-22-why-this.md # the filing repo's plan that owns this decision
关键在于,分诊会话可以重新阅读原始对话轮次,而不是相信摘要。记录时间戳和引用的短语——在数兆字节的转录中,单独记录其中任何一项都可能找不到对应内容——并注明,只有当运行框架保留转录时,被引用的转录才会留存(默认 30 天);因此,值得采取行动的记录也值得尽早处理。
source_plan 回答的问题与其他三个字段不同:其他字段指向证据,而它指向仍对决策负责的人。计划提出某项内容时填写;计划报告事实时留空——留空本身就是一种有效答案,表示没有人需要确认。已归档的计划只是一个快照;计划被吸收后,归档所在的仓库仍可能改变主意。已于 2026-09-04 确认:某个仓库根据自身判断归档了一项放置决策;当天用户就在该仓库中推翻了这项决策,而吸收该计划的会话忠实地实现了已被取代的版本,把被否决的论点写进了已发布的文档字符串和 contributing/ 页面,随后将计划退役并推送。没有人犯错——归档会话在正确的位置记录了修正,却无法写入另一个仓库;吸收会话则完全按照收到的内容实施。缺少的是一个明确的查询位置。
所以:吸收的计划如果是在提出方案,而不是报告事实,就先查看它的 source_plan,再开始实施。absorb 会在 decision owned elsewhere 下打印这些信息,让提示在需要时出现。已不存在的具名计划并非死路——归档计划所在的仓库会通过删除计划文件将其退役,而 plans.py archive --file <name> 可以从该仓库自己的退役提交中将其读出。
[DECISION:**有意只设一个字段和一条提示,不添加监视器,也不进行过期扫描。**于 2026-09-09 定案。已归档计划的数量不多,而这次问题需要用户在同一天推翻归档会话自己提出的决策——这种情况很少见,恢复也花了大约一小时,因此对每份归档计划都触发机制并不划算。问题从来不是查看成本高,而是没人知道有地方可查。无论哪种方向都能安全处理:字段缺失、字段未填写或目标已退役,都会被视为“没有更多信息”,这恰好就是该机制出现前的状态。]
**如果计划带有 source_repo,只有在 ## Verification 中写明原始复现案例,并且在问题发生的仓库中、修复安装后完成检查,才算完成。**只在编写修复的仓库中验证,并未针对产生该问题的案例进行测试。
[PITFALL:**模板会提出要求,因为判断本身不会。**填写这些字段正是全部价值所在,但恰恰也是最容易跳过的步骤。曾两次有记录:2026-08-23,一次会话在没有提示的情况下正确捕捉到了这类摩擦,随后却改述了事件,而没有引用原文——尽管该 Agent 有充分理由做得更好。2026-09-01,阅读描述这次失败的计划的会话提交了一份跨仓库计划,却也改述了事件。两次都如此,因此这些字段会以空白形式直接写入文件,而不是在某个可能没人打开的技能中加以说明。]
退役内容的去处
有三个去处,按作用而非路径命名,因为每个仓库的布局都不一样,而且仓库可能根本没有为中间两项单独设目录:
| 作用 | 适合放在这里的内容 |
|---|---|
| 代码本身 | 无需代码及其注释之外的解释的更改 |
| 使用文档 | 关于人员或 Agent 如何使用结果的任何内容 |
| 设计理由 | 已确定的决策、被否决的替代方案、已确认的陷阱 |
将它们映射到仓库现有的任何位置——docs/ 目录树、contributing/ 目录树、软件包自己的 references/、AGENTS.md 的一个章节,或代码注释。要求是_放在某个持久且易于查找的地方_,而不是必须放在某个特定目录中。如果仓库没有明显适合存放设计理由的地方,选定一个位置就是首次退役工作的一部分,而不是跳过它的理由。
退役计划
计划达到 landed、abandoned 状态,或旧计划被 superseded by ... 替代时:plans/ 是一个会逐渐清空的工作集——但绝不会悄悄丢弃任何真正需要付出大量工作才能得出的内容。删除文件只是将其从工作集中移除,并非从仓库中抹去:起草提交、最终状态和 ## Migrated to 提交仍可通过 git 访问,并可通过 python3 <path> archive 读取——见下文“找回已退役的计划”。正是这一点让删除变得轻松,也说明计划只会保留在版本控制中。
让计划落地的会话,应在同一会话中、推送更改后将其退役。 不能等一等,也不能留给后来的人:该会话已经读过代码和目标文档,而这正是退役过程中最费力的两个部分;三天后的会话则要从头重新推导两者。门槛是 pushed,而不是 landed——退役会删除文件,因此退役尚未推送的更改,就会抹去一个尚未进入产品的内容的依据(见下文步骤 3 的 PITFALL)。因此,absorb 提示词中的所有操作,都是为了处理漏过此规则的计划,而非常规流程。
根据已衡量的积压情况,于 2026-09-06 确立此规则:agent-skills 中积累了十六个已到终态的计划,其中十五个在同一天落地,清理这些计划用了一个会话中的四批操作。这轮工作得出的结论,也是值得将当天退役规则明确写下来的原因:
- 即使计划中的规则已经随产品发布,仍须记录被否决的替代方案。 几乎所有结论都已经写成了
SKILL.md中的句子,措辞往往比计划本身更好。始终没有归档的是未被采纳的选项,以及否决它的测量结果。因此,退役主要是在搜寻那些被否决的决策,而负责落地的会话仍记得这些内容。 - 如果确实形成了积压,按目标位置批量处理,而不是按时间先后:最费力的部分是确认目标文件已经涵盖了什么,而读一次就能处理四到六个计划。应通过阅读确认目标位置,而不是根据计划讨论的内容来判断——计划讨论的是它在其中发现问题的工具,但得出的结论涉及必须更改的工具。
- 最有价值的理由章节,通常是任何一个计划里都没有单独写出的内容。 一起退役的四个计划最终被发现是同一项发现的四种表述,只有将它们作为一组来阅读才能看出来。
将计划保存在自身仓库中的项目,也应在自身历史记录中将其退役——先吸收,再退役。
set-status不允许将仍留在仓库路由项目的仓库镜像中的计划设为终态,并会指出absorb。原因在于,退役会删除文件,而archive会从删除提交中将文件读回:从 store 退役会使草拟、落地和删除都发生在 store 的历史记录中,而仓库的历史记录中却什么都没有,因此在该仓库中运行archive时会发现计划缺失。一个计划,一份历史记录。非终态状态不受影响——文件尚未删除,因此历史记录不会分裂。
保存在 store 中的计划,其退役方式与保存在仓库中的计划完全相同,删除方式也一样。 这里说的是计划永久保存在 store 中的仓库,而不是计划仅处于流转过程中的仓库。使用文档和设计依据仍然要放入计划所针对的仓库——更新仓库自身的文档是普通贡献,即使无法添加 plans/ 目录也能进行;即便规划是在跟踪工具中完成的,也同样需要作出这项贡献。迁移后剩下的内容,是只属于你自己的推理;它会留在 store 的 git 历史记录中,这也是 store 本身采用 git 仓库形式的原因。
先按生命周期整理文件内容。 按每段内容_是什么_来拆分,而不是按文件有多长来拆分:内容全属于同一生命周期的长文件应保持为一个文件;混合了多个生命周期内容的短文件则应拆分。
| 类型 | 示例 | 去向 |
|---|---|---|
| 已确定的决策 | 为什么工具 X 胜过工具 Y,以及相关证据 | 设计依据 |
| 陷阱 | 亲自踩过后确认的陷阱 | 设计依据 |
| 代码契约 | 签名、标志、行为 | 已在代码/测试/README 中——删除 |
| 验证日志 | “运行过了,能用”、试运行记录 | 删除,未验证的遗留问题除外 |
| 尚未完成的工作 | 任何仍想做但尚未完成的事项 | 一份待处理计划——见步骤 2 |
代码契约和验证日志通常占可删除内容的大部分。
-
默认:保留。 除非调试、调查和被否决方案的推理已经在其他地方记录,否则应假设它们对未来有价值。它们往往已经有所记录——在认定需要新增设计依据前,先检查现有文档。
- 在同一仓库系列中,检查负责该事项的关联仓库,而不只是当前仓库。如果某项计划设计的内容后来被提取到其他仓库,那么它描述的是该仓库现在负责并持续维护的决策;将它迁移到这里会留下第二份逐渐分歧、看起来又像权威版本的副本。应改为指向负责该事项的仓库,只迁移其中确实未涵盖的内容。
-
包含仍在进行的未完成工作的计划不可删除。 运行删除检查,并将其找到的所有内容移入一份保留的计划中,然后再继续:
python3 <path> tags --file <file>.md --tag DEFERRED python3 <path> tags --file <file>.md --tag UNVERIFIED与其新建文件,不如优先追加到已有的、负责相关事项的待处理计划中。对于没有标签的旧计划,可改为搜索正文 (
deferred|not yet|follow-up|TODO|known limitation),并阅读搜索结果。 -
开始前而非开始后,查找所有入站引用——运行
python3 <path> refs <file>.md;它还会在仓库有未推送的提交时发出警告,并列出计划持有的附件。数量决定这是一次提交还是多次提交。已提交的附件应随计划一起迁移;本地附件是这里唯一无法通过删除撤销的内容,因此由用户决定如何处理,不属于操作流程中的步骤。 [陷阱:landed不代表已发布,退役会删除文件。set-status只会在存在未关闭标签时允许状态设为landed,完全不会检查工作是否已到达远程端——因此,计划可能已经标记为 landed 并退役,而它所解释的更改却仍停留在本地提交中。这样一来,更改没有进入产品,说明更改原因的文件也已被删除。请先推送,或者在推送之前保留计划。故意在退役时报告此问题,而不是在landed阶段设置门槛:对于没有远程端的仓库,门槛无法通过——敏感存储刻意保持这种状态,且会永久如此——因此只会让人学会强行绕过它;所以对于没有上游的仓库,完全不会发出警告。] 它会在整个仓库(代码注释和文档字符串也会引用计划路径)以及存储中搜索裸文件名,而不是完整的plans/路径,因为短格式引用最容易漏掉;并且包括尚未提交的文件——退役期间编写的后继计划在运行refs时尚未被跟踪,这是退役的常见情形,而非边缘情况。2026-09-02 确认:在此之前,refs报告没有任何结果,而同一小时写入的两个文件都提到了该计划。接着搜索按章节引用的内容,因为
refs无法发现这些内容——在plans/以及仓库中存在的文档目录上运行rg -n '§'。一句引用“该计划的 §9 决策”的话没有提到文件名,因此删除门槛不会检查到它,链接检查器也不会:单独的§11并不是链接。2026-08-30 退役两个计划时确认:refs在七个文件中发现了 9 处真实引用,却漏掉了另外两处;这两处都由之前打开过该计划的会话写下,并像读者结合上下文阅读那样引用了其中的章节。删除时两处引用都变成了悬空引用,而且只有因为该会话凭直觉运行了搜索才得以发现。按编号章节组织的计划,恰恰最容易出现按章节引用的情况。 请预期大部分输出都是噪声,并且随着仓库增长,噪声占比还会越来越高。 两次测量:一个仓库有 17 行,其中 2 行是真实结果,日期为 2026-08-30;另一个仓库有 45 行,其中 0 行是真实结果,日期为 2026-09-02,两次运行共退役了六份计划。差异并非运气所致——在第二个仓库中,45 行里有 24 行是某份参考文档自身的内部§N编号,这是一个会随语料增长、且永远不会包含发现结果的永久噪声底线。因此要阅读输出,但要做好什么都没有的预期;这条 grep 命令值得保留,是因为真实命中一旦漏掉便悄无声息且无法挽回,而不是因为它经常判断正确。这也是为什么它仍应是一条有文档说明的 grep 命令,而不是refs标志——45 次命中中真实结果为 0,标志只会报告自身的噪声;而refs回答的是“谁链接到这个文件”,定义明确,也正是删除门禁所需的内容。 -
添加一个
## Migrated to章节,列出每个迁移目标,并说明你有意没有迁移哪些内容及其原因。先单独提交这项新增内容,再删除文件——如果在同一次提交中新增并删除,该章节就根本不会留在历史记录中,写它也就失去了意义。- 按读者带着什么问题前来来组织说明,而不是每份退役计划各用一个文件——那只不过是换个名字,重复每份计划混杂生命周期信息的做法。最有价值的文件很可能是从未出现在任何一份计划中的文件。
- 要把内容标为“代码中已有”之前,先检查它是否确实存在。对迁移的内容也要核实:几个月前写的模块说明可能已经过时,计划也不能证明当前行为。
-
修复第 3 步中的引用,然后删除文件——只有在第 4 步确实完成后才能这样做。 如果不确定是否已记录值得保留的内容,请在删除前先询问;提交落地后,这就是一扇单向门。
commit也会处理删除操作,而且仍然是正确的处理方式:无论是新增还是移除,存储的索引都是共享的。
传入路径,而不是单独的文件名——对于已不存在的文件,无法进行搜索。无论删除操作是已暂存(python3 <path> commit <the plan that is now gone> -m "retire <topic>"git rm),还是文件只是被移除,都能正常工作;git rm还会清理刚被清空的目录,这对于只剩最后一份计划的存储镜像来说很常见。一次退役多份计划时,一次调用并使用一条消息即可,与吸收操作相同。- 不要在每个命中处都盲目地把旧路径替换为新路径。指向特定引述章节标题的引用,需要更新该标题,使其与内容实际落入的位置一致——有效路径若指向已重命名的标题,仍然会形成悬空引用。有些被引用的内容已在第三处重复,应该指向那份副本,而不是再迁移一份。有些引用则最好改写,而不是重新指向:“X landed in
<plan>” 只需改为 “X landed.”。 - 最后的 grep 应当返回没有仍然有效的指针,但这不等于零命中。来源记录可以合理地保留(“extracted from the now-retired
plans/X.md”),而且应该保留——但必须写明 retired,以便读者知道不必再去查找。
-
**在提交引用修复之前,再运行一次仓库的门禁。**一次性编辑大量注释和文档字符串,正是悄悄触发行长度规则的常见原因。
找回已归档的计划
删除计划之所以成本低,只是因为文件仍在仓库中;而这在实际操作中只有在找回计划只需一条命令、而不是一场考古之旅时才成立:
python3 <path> archive # every plan deleted from this repo's plans/, newest first
python3 <path> archive --search "<phrase>" # only the ones whose content ever contained it
python3 <path> archive --show <file> # print one back, as it stood the moment before deletion
python3 <path> archive --file <file> # one plan's whole lifecycle: drafting, landing, retirement
python3 <path> archive --all # every repo on the machine, plus both stores' histories
每一行都包含计划最终的 status 和 ## Migrated to 中列出的目标位置,这些通常才是真正的答案——内容在某个文档中,而只有无处安放的推理才留在已删除的文件里。此操作不会写入任何内容,也不会还原任何文件:已归档的计划会输出到标准输出;如果相关工作重新启动,就应新建一个计划文件,而不是复活旧文件。
信任结果前,有三点值得了解:
--search会跨行匹配,因此即使格式化程序后来调整了短语的换行,仍然可以找到它。它还会搜索每个已归档计划的所有版本,而不仅是最终版本——因此,即使某段内容在删除前两个提交时就已被删掉,仍然会匹配,但--show不会包含它。使用--file查找包含该内容的提交,然后用git show <sha>:<path>阅读该版本。- 在仓库与存储区之间迁移的计划不算已归档,尽管其旧位置的历史记录显示它已被删除。这些行会标记为
still live并指出文件所在位置;请直接阅读该文件。 - 没有 git 仓库的存储区不会归档任何内容。 如果
archive找到这样的存储区,会在对应层级的标题中说明。请先运行python3 <path> install修复,然后再归档其中的任何内容。两个层级都会保留完整历史,正是为了确保这一点对客户端计划也成立,因为客户端计划没有其他副本。
迁移旧式单一计划文件
早于此约定的仓库通常会有一个大型 PLAN.md/DESIGN.md/NOTES.md,其中混杂着处于不同生命周期阶段、彼此无关的事项。不要把它作为一个整体归档——先按事项拆分,再将上述生命周期流程应用于每一部分:
- 将各个章节归类为不同事项:已实现并验证(→
landed)、确实仍未定或受阻(→ 各自新建计划文件,使用status: idea或blocked on <reason>),以及如今已不准确的内容(后续已被取代的设计——删除即可,无需迁移)。一个旧文件通常会拆成零个、一个或多个计划,而不是一个。 - 在同一轮中,立即对已落地的事项执行“归档计划”流程——无需先将它们复制到
plans/,再马上归档。 - 为每个仍未完成的事项创建一个正式计划文件,填写正确的 frontmatter,并按照标准章节重新整理,而不是保留旧文件中残缺的文字片段。
- 只有在每个事项都有归属后,才为旧文件添加自己的
## Migrated to部分(列出所有目标位置),并按常规的先提交后删除流程处理。
不要在说明文档中塞入未来工作
适用于所有叙述性文档,包括 README.md 和文档页面,而不只是 AGENTS.md。每份文档都应描述仓库当前的实际情况。已知缺陷、未完成的功能或尚未解决的风险,应放在单独的计划文件中;如果值得提及,可在文档中附上链接,但绝不要直接写在文档里:关于未来工作的叙述会逐渐变成听起来永远正确的句子;更糟的是,已经修复的问题仍自称“尚未修复”。
AGENTS.md(或任何同类指令文件)的要求最严格:只包含开发和部署仓库的指令,绝不包含计划、构思或状态报告。值得审查的三种偏离:
- 带日期的状态叙述。“状态:已于 2026-08-14 实现并在线验证”是变更日志条目,不是指令——今天有效,明天就会过时。应精简为不带日期、描述当前架构事实的陈述,并彻底删去“截至
<date>,已确认可用,测试通过”这类表述;这些内容应放在提交说明中。 - 过时的实现说明。“这些函数只是存根”“尚未确认”之类的说法,会在工作完成后悄无声息地过时。不要只审阅这类文字:在判断相关内容是否准确、已被取代或可以安全删除之前,应先搜索代码中所描述的内容(例如,不要轻信声称某项功能已实现的文档字符串,先用
rg NotImplementedError搜索代码)。 - 推测性的旁枝。“一旦理解 X,也许换一种方式会更便宜”是随想,不是指令。它要么是真正值得单独编写计划文件的待解问题,要么是已在存放理由的地方记录的背景信息,直接删掉即可。