task-estimator

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Task estimator

任务估算器

Estimates from your measured history, not from the model's intuition about how hard something looks. The model's intuition is the thing that is broken: developers using AI tools believed they were 20% faster while measuring 19% slower (METR 2025). An estimate that is not anchored to recorded actuals just reproduces that gap in writing.
So this skill does three things: it keeps a ledger of what tasks actually cost, it quotes new tasks against the empirical distribution of similar past tasks, and it scores its own past quotes so the bias is visible.
Depth on the evidence is in
references/
:
estimation-science.md
(the classical results — EBS, reference classes, anchoring, flow metrics),
ai-era-evidence.md
(what changed once an agent was in the loop),
measurement-model.md
(the phase taxonomy, the measured constants, and the calibration math the scripts implement). Read them when a rule here looks arbitrary — every rule traces to a result there. Do not re-derive the theory during an estimate; execute the steps.
估算依据 你实测的历史记录,而不是模型对一件事看起来有多难的直觉。模型的直觉恰恰是失效之处:使用 AI 工具的开发者认为自己快了 20%,实测却慢了 19%(METR 2025)。没有锚定到已记录实际耗时的估算,只会把这种差距写成文字而已。
因此,这个 skill 做三件事:维护一份记录任务实际成本的账本;依据相似历史任务的经验分布为新任务给出估算;并对过去的估算进行评分,让偏差变得可见。
有关证据深度的内容见
references/
:
estimation-science.md
(经典研究——EBS、reference class、anchoring、flow metrics)、
ai-era-evidence.md
(Agent 参与流程后发生了哪些变化)、
measurement-model.md
(阶段分类、实测常数,以及脚本所实现的校准数学)。当这里的某条规则看起来武断时,阅读这些文档——每条规则都可追溯至其中的某个研究结果。估算时不要重新推导理论;直接执行步骤。

Pick a mode

选择模式

The user is doing thisMode
Asking how long something will take, ETA, sizing, "is this a day?"estimate (default)
First run, no
$LEDGER
(see Ledger layout), "set this up", "calibrate me", "backfill"
setup
"record this task", "log this", finished a task, the CLAUDE.md hook firedrecord —
record.mjs
"re-run calibration", "how good are your estimates", "were you right", "recalibrate"recalibrate —
calibrate.mjs
+
score.mjs
The four modes are one loop, and it only closes if every leg runs: estimate logs a quote → record measures the actual and scores it against that quote → recalibrate reports whether the quotes are any good → the next estimate corrects for it. Skipping the log in estimate mode, or never running record mode, leaves the skill quoting from history forever without ever learning whether its own quotes were right.
If
$LEDGER/calibration.json
does not exist and the user asks for an estimate, say so and offer setup — then still give a prior-only estimate if they want one now. Never silently estimate off an empty ledger.
用户正在做什么模式
询问某件事需要多久、ETA、工作量评估、“这需要一天吗?”estimate(默认)
首次运行,没有
$LEDGER
(见 账本布局)、“设置一下”、“校准我”、“回填”
setup
“记录这个任务”、“记录下来”、任务已完成、CLAUDE.md hook 触发record —
record.mjs
“重新运行校准”、“你的估算准不准”、“你之前对了吗”、“重新校准”recalibrate —
calibrate.mjs
+
score.mjs
这四种模式构成一个闭环,只有每一环都运行过,闭环才算完成:estimate 记录一次估算 → record 测量实际耗时,并将其与先前估算进行评分 → recalibrate 报告这些估算是否准确 → 下一次 estimate 据此进行修正。如果在 estimate 模式下跳过记录,或从不运行 record 模式,这个 skill 就会一直依据历史给出估算,却永远不知道自己的估算是否正确。
如果
$LEDGER/calibration.json
不存在,而用户要求估算,应说明这一点并主动提供 setup 选项——如果用户希望现在就能得到结果,仍可给出仅基于先验的估算。绝不能在空账本的情况下静默估算。

Ledger layout

账本布局

$LEDGER
resolves in this order, first hit wins:
  1. $CLAUDE_ESTIMATION_DIR
    — explicit override.
  2. ~/.claude/estimation
    — the portable default.
The ledger lives outside the skill directory so it survives reinstalls, and outside any per-account config dir so it is one history, not one per account.
$LEDGER/
  ledger/
    chats.ndjson       # one row per Claude Code conversation (mined)
    tasks.ndjson       # one row per unit of work; the reference class lives here
    estimates.ndjson   # one row per quote this skill emitted, for scoring
  calibration.json     # population priors + per-class empirical quantiles
Scripts (
$SKILL/scripts/
). The four primitives are pure and stdout-oriented:
extract.mjs
(transcript JSONL → one chat row),
stitch.mjs
(chats → tasks),
digest.mjs
(one chat → compact text for an LLM pass),
calibrate.mjs
(ledger → calibration.json).
slice.mjs
sits between the last two: it cuts each epic's chats into labelled sub-deliverables (
task.sub_tasks[]
) without ever splitting the epic row.
Two more close the loop, and they are the ones record and recalibrate run:
record.mjs
(one finished session → extract → stitch → rework-merge → outcome → score, appended to the ledger) and
score.mjs
(estimates × tasks → how good the quotes were). Note the division of labour, because the two calibrations answer different questions and get confused constantly:
QuestionReadsWrites
calibrate.mjs
how long do tasks like this take?tasks.ndjsoncalibration.json
score.mjs
were my quotes any good?estimates.ndjson + tasks.ndjsonnothing
calibrate.mjs
is what an estimate quotes from.
score.mjs
is what tells you the quotes are systematically low. A ledger with no
estimates.ndjson
rows can do the first forever and never the second — which is the failure this loop exists to stop.
$LEDGER
按以下顺序解析,首个命中项生效:
  1. $CLAUDE_ESTIMATION_DIR
    — 显式覆盖项。
  2. ~/.claude/estimation
    — 可移植的默认路径。
账本位于 skill 目录之外,因此重新安装后仍可继续使用;也位于任何按账户隔离的配置目录之外,因此它是一份统一历史,而不是每个账户各有一份。
$LEDGER/
  ledger/
    chats.ndjson       # one row per Claude Code conversation (mined)
    tasks.ndjson       # one row per unit of work; the reference class lives here
    estimates.ndjson   # one row per quote this skill emitted, for scoring
  calibration.json     # population priors + per-class empirical quantiles
脚本位于
$SKILL/scripts/
。四个基础操作都是纯函数且以 stdout 为输出:
extract.mjs
(transcript JSONL → 一行 chat)、
stitch.mjs
(chats → tasks)、
digest.mjs
(一个 chat → 用于 LLM pass 的紧凑文本)、
calibrate.mjs
(ledger → calibration.json)。
slice.mjs
位于最后两个操作之间:它将每个 epic 的 chats 切分为带标签的子交付项(
task.sub_tasks[]
),但绝不拆分 epic 本身所在行。
另外两个操作负责闭合循环,也正是 record 和 recalibrate 会运行的操作:
record.mjs
(一个已结束的 session → extract → stitch → rework-merge → outcome → score,并追加到账本)和
score.mjs
(estimates × tasks → 估算质量)。请注意二者的分工,因为两种 calibration 回答的是不同问题,却经常被混为一谈:
问题读取写入
calibrate.mjs
类似任务需要多长时间?tasks.ndjsoncalibration.json
score.mjs
我的估算准确吗?estimates.ndjson + tasks.ndjson无
calibrate.mjs
是 estimate 用来引用数据的来源。
score.mjs
才能告诉你这些估算是否系统性偏低。如果账本中没有任何
estimates.ndjson
行,它可以永远完成前一件事,却永远无法完成后一件事——这正是该闭环要阻止的失败。

estimates.ndjson — the row schema

estimates.ndjson — 行结构

One row per quote, written by estimate step 8, read by
record.mjs
and
score.mjs
.
schema: "estimate.v1"
:
json
{"schema":"estimate.v1",
 "estimate_id":"2026-08-31-add-oauth-provider",
 "date":"2026-08-31",
 "task":"add-oauth-provider",
 "ticket":null,
 "task_desc_hash":null,
 "features":{"type":"feature-epic","novelty":"adaptation","precedent":"saml-provider"},
 "class":{"neighbors":["example-api/TICKET-3880"],"n":1,"gate":"prior-only"},
 "quoted":{"agent_hours":{"p50":76,"lo":50,"hi":150},
           "human_rounds":{"p50":null,"lo":600,"hi":1000},
           "calendar_days":{"p50":null,"lo":28,"hi":49}},
 "source":"ledger-neighbor+bounded-adjustment"}
Field rules, each of which exists because breaking it makes the row unscorable:
  • estimate_id
    is the join key
    record.mjs --estimate-id
    takes. Default it to
    <date>-<task>
    . Once a task is recorded against it, the id is written back onto the task row as
    estimate_id
    , so the link is never re-guessed.
  • task
    is a slug, and it must be one
    record.mjs
    can find later: the ticket key, the branch name, or the phrase the classifier will summarise the work as. Matching runs
    estimate_id → ticket → task_desc_hash → task_id slug → summary → epic-slice slug → sub-deliverable slug
    , first hit wins. A quote that matched an epic slice is scored against that slice's hours, not the epic's — scoring "the webhook part" against a 76-hour integration would report a 60× miss on a quote that was never about the integration.
    score.mjs
    names the slice in
    sub_task
    when it does this.
  • quoted
    carries the three axes the ledger measures —
    agent_hours
    ,
    human_rounds
    ,
    calendar_days
    — and no others.
    A band quoted in weeks is stored in days. Quote something on every axis you can: an axis with no band is silently unscorable, which is how an estimator ends up with a coverage number built from one axis.
  • lo
    /
    hi
    are the band you actually committed to
    (the P50→P85 span, or the regime-split range).
    score.mjs
    scores coverage at 80% nominal against them.
  • p50
    may be null
    , and then
    score.mjs
    uses the band's log midpoint (
    sqrt(lo*hi)
    ) and flags every ratio derived from it — so a derived centre is never mistaken for something you said.
  • task_desc_hash
    — sha256 of the anchor-stripped description. Optional but preferred when there is no ticket: it keeps prose out of the ledger while still matching later.
Legacy rows (the pre-schema hand-written shape:
agent_h_p50
,
agent_h_band
,
rounds_band
,
calendar_weeks_band
) are normalised on read and still score. Write new rows in the canonical shape.
Not every real string is a real join signal.
stitch.mjs
denies generic branches (
main
is on every chat that ever pushed) and, for the same reason, unattested ticket keys: a dated report filename pasted into a review round yielded
REPORT-2026
and welded 46 chats and 79 agent-hours into one "task". A key only becomes a stitch edge if it is below the load-bearing fan-out, or its prefix appears in a feature branch, or its namespace is live — several sibling keys that also turn up apart from it, since co-pasted fragments always travel together and real tickets do not. The rules and their evidence are in the comments on
isPlausibleTicketKey
(extract.mjs) and
eligibleTicketKeys
(stitch.mjs).
Three drivers run a whole corpus and are resumable — re-running one continues, it never restarts:
backfill.mjs
(walk a transcripts root → chats.ndjson + tasks.ndjson),
classify.mjs
(haiku → classifications.ndjson, merged into tasks.ndjson),
audit.mjs
(stratified sonnet re-judgement → audit.ndjson + per-field agreement).
每次估算占一行,由 estimate 的步骤 8 写入,并由
record.mjs
和
score.mjs
读取。
schema: "estimate.v1"
:
json
{"schema":"estimate.v1",
 "estimate_id":"2026-08-31-add-oauth-provider",
 "date":"2026-08-31",
 "task":"add-oauth-provider",
 "ticket":null,
 "task_desc_hash":null,
 "features":{"type":"feature-epic","novelty":"adaptation","precedent":"saml-provider"},
 "class":{"neighbors":["example-api/TICKET-3880"],"n":1,"gate":"prior-only"},
 "quoted":{"agent_hours":{"p50":76,"lo":50,"hi":150},
           "human_rounds":{"p50":null,"lo":600,"hi":1000},
           "calendar_days":{"p50":null,"lo":28,"hi":49}},
 "source":"ledger-neighbor+bounded-adjustment"}
字段规则如下;每条规则都存在,因为一旦违反,对应行就无法评分:
  • estimate_id
    是连接键,
    record.mjs --estimate-id
    会使用它。默认采用
    <date>-<task>
    。任务针对该 ID 完成记录后,ID 会被写回 task 行的
    estimate_id
    字段,因此绝不需要重新猜测关联关系。
  • task
    是 slug,且必须是
    record.mjs
    之后能够找到的内容:ticket key、branch name,或分类器会用来概括该工作的短语。匹配顺序为
    estimate_id → ticket → task_desc_hash → task_id slug → summary → epic-slice slug → sub-deliverable slug
    ,首个命中项生效。如果估算匹配到的是 epic slice,评分时使用的是该 slice 的工时,而不是整个 epic 的工时——把“webhook 部分”的估算拿去对比一项 76 小时的 integration,会把一次原本并非针对该 integration 的估算误报为偏差 60 倍。
    score.mjs
    在发生这种情况时会在
    sub_task
    中标出对应 slice。
  • quoted
    只承载账本测量的 三个维度——
    agent_hours
    、
    human_rounds
    、
    calendar_days
    ——不得包含其他维度
    。以周为单位的区间按天存储。每个能够估算的维度都要给出结果:某个维度没有区间就会在暗中失去可评分性,最终导致估算器用一个维度构建出整体覆盖率。
  • lo
    /
    hi
    是实际承诺的区间
    (P50→P85 跨度,或按不同分布形态划分的范围)。
    score.mjs
    会以名义 80% 覆盖率对这些区间评分。
  • p50
    可以为 null
    。此时
    score.mjs
    使用区间的 log 中点(
    sqrt(lo*hi)
    ),并标记所有由该中点推导出的比率,确保推导出的中心值不会被误认为是你实际给出的结论。
  • task_desc_hash
    — 去除锚点后描述的 sha256。没有 ticket 时可选但推荐:它既能让账本不保存原始描述文字,又能在之后完成匹配。
旧版行,即预定义 schema 之前的手写结构(
agent_h_p50
、
agent_h_band
、
rounds_band
、
calendar_weeks_band
),在读取时会被标准化,并且仍可评分。新行必须采用标准结构。
并非每个看起来像真的字符串都是真正的连接信号。
stitch.mjs
会拒绝通用 branch(
main
出现在每一个曾经 push 过的 chat 中),出于同样的原因,它也会拒绝缺乏佐证的 ticket key:一次 review 轮次中粘贴的带日期报告文件名生成了
REPORT-2026
,结果将 46 个 chats 和 79 个 Agent 工时焊接成了一个“任务”。只有满足以下条件之一,key 才能成为 stitch edge:其 fan-out 低于承载关键合并的阈值;其前缀出现在 feature branch 中;或者其 namespace 处于活跃状态——即除该 key 之外,还能独立发现多个 sibling key,因为共同粘贴的片段总是一起出现,而真实 ticket 并非如此。相关规则及证据见
isPlausibleTicketKey
(extract.mjs)和
eligibleTicketKeys
(stitch.mjs)中的注释。
三个 driver 可以处理整个语料库,并且是 可恢复的——重新运行会继续已有进度,而不会从头开始:
backfill.mjs
(遍历 transcripts 根目录 → chats.ndjson + tasks.ndjson)、
classify.mjs
(haiku → classifications.ndjson,再合并到 tasks.ndjson)、
audit.mjs
(分层使用 sonnet 重新判断 → audit.ndjson + 各字段一致率)。

Mode: estimate

模式:estimate

The order below is mandatory. Each step exists to stop a specific failure. Do not reorder, do not skip ahead to "well, this looks like about a day".
以下顺序是强制性的。 每个步骤都是为了阻止一种特定失败。不要重新排序,也不要直接跳到“看起来大约需要一天”。

1. Strip the anchors — before any reasoning

1. 去除锚点——先于任何推理

Read the task description. Before you think about duration at all, remove from consideration: stated deadlines, target dates, story points, "should be quick / small / huge / trivial", the user's own guess, a client's promised date, a ticket estimate field.
An irrelevant number in the prompt shifts estimates by 4–5×, and the effect survives being told to ignore it and survives coming from an obviously unqualified source. You cannot un-see an anchor, so handle it explicitly: if the ask contained one, say "noting you said ~X — the number below is history-derived and was computed before reading it" and never let it move the quantile.
读取任务描述。在开始考虑时长之前,先从考量中移除:既定截止日期、目标日期、story points、“应该很快/很小/很大/很琐碎”、用户自己的猜测、客户承诺的日期,以及 ticket 中的估算字段。
提示中的一个无关数字会让估算偏移 4–5 倍,而且这种影响既不会因为被明确告知忽略它而消失,也不会因为它来自明显不合格的信息源而消失。你无法撤销已经看到的锚点,因此必须显式处理:如果请求中存在锚点,应说明 “注意到你提到约 X——下面的数字来自历史数据,并且在阅读它之前就已计算完成”,并且绝不让它影响分位数。

2. Readiness gate — refuse to estimate unready work

2. Readiness gate——拒绝估算尚未准备好的工作

Do not produce an estimate if either is true:
  • No testable acceptance criteria. Nobody can say what "done" is, in a way a test or a concrete observation could settle.
  • No converged implementation strategy. Two or more materially different approaches are still live, and the choice changes the work by more than one bucket.
These are exactly the tasks that explode: the distribution is bimodal — ~78% of "easy" tasks land near their class median, and the other ~22% overrun by more than 180%, and the overrunners are overwhelmingly the ones that failed this gate.
Output instead:
Not estimable yet — recommend a timeboxed spike. Missing:
<the specific criterion or the unresolved fork>
. Timebox:
<1 bucket on the ladder>
, and the deliverable of the spike is the missing thing, not the feature. Re-run the estimate after the spike; it will be a different (and much tighter) class.
Recommending a spike is a real answer. Producing a number for an unspecified task is not.
如果以下任一条件成立,就不要给出估算:
  • 没有可测试的 acceptance criteria。 没有人能以测试或具体观察为依据说明什么才叫“完成”。
  • 没有收敛的 implementation strategy。 仍有两个或更多实质不同的方案,并且方案选择会让工作量相差超过一个档位。
这些恰恰是最容易失控的任务:其分布是双峰的——约 78% 的“简单”任务会落在所属类别中位数附近,另外 22% 则会超出 180% 以上,而且超支者绝大多数都是未通过这道 gate 的任务。
应改为输出:
尚不可估算——建议进行有时限的 spike。 缺少:
<具体标准或尚未解决的分支>
。 时限:
<阶梯中的一个档位>
,spike 的交付物应是所缺少的内容,而不是整个功能。 Spike 完成后重新估算;届时它会属于一个不同且紧得多的类别。
建议 spike 是一个真实有效的答案。为未明确的任务给出数字则不是。

3. Extract features

3. 提取特征

Follow
prompts/estimate-features.md
. It is a checklist with the actual commands to run — do the searches, do not guess the answers. It yields:
repos[]
,
task_type
(bugfix|feature|refactor|migration|infra|investigation|script),
greenfield|brownfield
,
precedent_in_repo
(did we ship this shape here before — find it or state none),
self_verifiable
(tests|typecheck-only|human-eyes|prod-only),
blast_radius
(grep-counted files likely touched),
external_deps[]
(third-party approvals, other humans, deploys),
parallelizable
(across files/repos).
Precedent and self-verifiability do the most work of any features in the set. Spend the searches on them.
Then place the task in the speedup quadrant, because the agent discount is not a scalar:
SimpleComplex
Greenfield+30–40%+10–15%
Brownfield+15–20%0–10%, sometimes negative
Most work in a mature repo is bottom-right, where the discount is near zero and can invert. Record the quadrant; step 6 uses it.
按照
prompts/estimate-features.md
执行。它是一份包含实际运行命令的 checklist——要真正执行搜索,不要猜测答案。它会产出:
repos[]
、
task_type
(bugfix|feature|refactor|migration|infra|investigation|script)、
greenfield|brownfield
、
precedent_in_repo
(我们是否在这里交付过类似形态——找到先例,否则明确说明没有)、
self_verifiable
(tests|typecheck-only|human-eyes|prod-only)、
blast_radius
(通过 grep 统计的可能受影响文件数)、
external_deps[]
(第三方审批、其他人员、部署)、
parallelizable
(跨文件或跨仓库)。
在全部特征中,先例和自我可验证性发挥的作用最大。应把搜索时间重点花在这两项上。
然后将任务放入 speedup quadrant,因为 Agent 带来的加速并不是一个固定标量:
简单复杂
Greenfield+30–40%+10–15%
Brownfield+15–20%0–10%,有时为负
成熟仓库中的大多数工作都位于右下象限,其折扣接近于零,甚至可能反转。记录所在象限;步骤 6 会使用它。

4. Retrieve the reference class

4. 检索 reference class

From
$LEDGER/ledger/tasks.ndjson
, in this order:
  1. k = 1–3 nearest neighbours on the feature vector. Small k on purpose — larger k regresses to the mean and destroys what made analogy work.
  2. If the tight class has n < 5, widen to the whole
    task_type
    class and say you widened.
  3. If still thin, fall back to the population priors in
    calibration.json
    and say the estimate is prior-based.
Check
field_reliability
before you lean on a field, and say what you find.
Every field in it is either
reliable: true
or
reliable: false
with a measured agreement rate — there is no unmeasured field. As of the 2026-09-01 audit
task_type
is the one that fails (0.38 raw / 0.43 admissible, n=58 stride), so widening to the
task_type
class is the weakest move in this list and must be stated as such
: prefer the k=1–3 neighbours, and when you do widen, say the class label itself agreed with a stronger judge only ~4 times in 10. It agrees 0.51 on well-stitched rows, so the fault is largely the 26% of rows that are over-merged rather than the label.
Always record which class you landed in and its
n
. Report the variance across the neighbours, not just the centre: 1h/1h/9h and 3h/3h/4h centre in the same place and mean completely different things.
When the ask is a slice of a bigger piece of work, use the
epic-slice
class instead.
calibration.json
carries
reference_classes["epic-slice"]
(with its own
by_task_type
), built by
slice.mjs
from the sub-deliverables of every task with ≥6 chats. Reach for it when the question is "how long is the webhook part of the payments integration" rather than "how long is the payments integration" — a slice inherits an epic's already-loaded context, its branch and its conventions, so it is not the same animal as a standalone task of the same
task_type
. Say which of the two classes you quoted; they answer different questions and the epic row remains the unit of record for both.
With no usable class at all, size the human-rounds axis from the constant-hazard model rather than from feel: an agent's 80%-reliability horizon is ~30 minutes of human-equivalent work, so
human_rounds ≈ ceil(human_equivalent_minutes / 30)
. Sanity band: real sessions run 3.3–5.4 interventions. A prior-only quote landing outside that band needs a reason.
从
$LEDGER/ledger/tasks.ndjson
中按以下顺序检索:
  1. 在特征向量上寻找 k = 1–3 个最近邻。刻意使用较小的 k——更大的 k 会向均值回归,并破坏类比之所以有效的部分。
  2. 如果紧凑类别的 n < 5,则扩大到整个
    task_type
    类别,并明确说明已经扩大。
  3. 如果仍然样本不足,则回退到
    calibration.json
    中的总体先验,并说明该估算基于先验。
依赖某个字段之前,要检查
field_reliability
,并说明发现的结果。
其中每个字段都标有
reliable: true
或
reliable: false
,并附带实测一致率,不存在未经测量的字段。截至 2026-09-01 的 audit,
task_type
是唯一未通过的字段(原始一致率 0.38/可采信一致率 0.43,n=58 stride),因此 扩大到
task_type
类别是本列表中最弱的操作,必须明确说明这一点
:优先使用 k=1–3 的近邻;确需扩大时,应说明该类别标签与更强判断者的一致率仅约为十分之四。它在拼接正确的行上一致率为 0.51,因此问题主要来自那 26% 被过度合并的行,而不是标签本身。
始终记录最终采用的类别及其
n
。报告 近邻之间的方差,而不只是中心值:1h/1h/9h 与 3h/3h/4h 的中心相同,但含义完全不同。
当问题针对的是更大工作项的一部分时,应改用
epic-slice
类别。
calibration.json
包含
reference_classes["epic-slice"]
(拥有自己的
by_task_type
),由
slice.mjs
根据每个包含至少 6 个 chats 的任务中的子交付项构建。当问题是“payments integration 中的 webhook 部分需要多久”,而不是“整个 payments integration 需要多久”时,应使用该类别。Slice 继承了一个 epic 已经加载的上下文、branch 和约定,因此它 不是 同
task_type
的独立任务。说明本次引用的是哪一类;二者回答的是不同问题,而且无论引用哪一类,epic 行仍然是记录单位。
如果完全没有可用的类别,应依据 constant-hazard model 而非直觉来估算 human-rounds 维度:Agent 的 80% 可靠性视野约为 30 分钟的人工等效工作量,因此
human_rounds ≈ ceil(human_equivalent_minutes / 30)
。合理区间:真实 session 通常包含 3.3–5.4 次人工干预。仅基于先验的估算如果落在该区间之外,必须给出理由。

5. Compute empirical quantiles — never a fit, never a mean

5. 计算经验分位数——绝不使用拟合,绝不使用均值

Sort the class's actuals and take the quantiles directly off the observed values, in log space. Do not fit a lognormal (or anything else) and read quantiles off the curve: the fit systematically understates the tail, which is the only part of the distribution anyone is actually asking about. Do not quote a mean; on a fat-tailed class the mean is neither the typical case nor the risk.
Check your own bias before quoting. Run
node $SKILL/scripts/score.mjs
and read the per-axis line: it is the only evidence about this estimator rather than about the tasks.
  • If it prints TOO EARLY TO CALL (fewer than 5 scored pairs), quote the class straight and say the estimator itself is unvalidated. Do not apply a correction derived from one or two points — a single ratio is an observation, not a bias.
  • Once it reports a geometric mean ratio over n ≥ 5, prefer EBS ratio resampling: draw whole historical estimate/actual ratios with replacement (100+ draws) and divide the class centre by each draw. Same principle — whole observed values, resampled, never averaged. Note this in
    source
    on the logged row so the provenance survives.
Sample gates. State which one you are at, every time:
n in classYou may quote
< 5Nothing from the class. Prior-only, and say so explicitly.
5–9P50 only, with a warning that there is no usable upper bound yet.
10–19P85.
≥ 20P95 allowed.
Below n ≈ 20 the class quantile is also shrunk toward the population prior by
w = n/(n+κ)
— a personal P85 at n=11 spans 0.31–2.26× of the truth, so it is worth something as an adjustment and nothing on its own.
calibrate.mjs
emits the shrunk value; use it, do not recompute the raw quantile and quote that instead.
15 fresh points beat 150 stale ones.
calibrate.mjs
already applies a 90-day half-life — do not re-weight by hand.
对类别中的实际值排序,并 直接在观测值上、以对数空间 取得分位数。不要拟合 lognormal 或其他分布后再从曲线上读取分位数:这种拟合会系统性低估尾部,而尾部恰恰是人们真正想问的部分。不要引用均值;在肥尾类别中,均值既不代表典型情况,也不代表风险。
给出估算前先检查自己的偏差。 运行
node $SKILL/scripts/score.mjs
,读取每个维度对应的一行:这是有关 当前估算器本身、而非有关任务的唯一证据。
  • 如果输出 TOO EARLY TO CALL(已评分样本少于 5 对),直接引用该类别,并说明估算器本身尚未得到验证。不要根据一两个数据点进行修正——单个比率只是一次观察,不是偏差。
  • 一旦报告了 n ≥ 5 的几何平均比率,优先采用 EBS ratio resampling:有放回地抽取完整的历史估算/实际比率(至少 100 次),并用每个抽样值除以类别中心值。原则相同——重采样完整的观测值,绝不取平均。在记录行的
    source
    中注明这一点,以保留完整的来源链路。
样本门槛。每次都必须说明当前处于哪一档:
类别中的 n可以引用什么
< 5不能引用类别中的任何数据。仅使用先验,并明确说明。
5–9仅 P50,并警告目前还没有可用的上界。
10–19P85。
≥ 20可以使用 P95。
当 n ≈ 20 以下时,类别分位数还会按照
w = n/(n+κ)
向总体先验收缩——n=11 时的个人 P85 可能横跨真实值的 0.31–2.26 倍,因此它只能作为修正,本身不足以作为最终结论。
calibrate.mjs
会输出收缩后的值;应直接使用,不要重新计算原始分位数后取而代之。
15 个新数据点胜过 150 个陈旧数据点。
calibrate.mjs
已应用 90 天半衰期,不要再手工重新加权。

6. Inside-view adjustment — at most one bucket

6. Inside-view 调整——最多移动一个档位

The log ladder: ≈15m · 1h · 3h · 1d · 3d · 1w+.
You may move the class result by at most one bucket, and only while naming the specific evidence: "the identical Prisma migration shipped twice this month, both under 2h". Unnamed intuition ("this feels simpler") buys zero adjustment. Two buckets is not an adjustment, it is a different class — go back to step 4.
Then apply the quadrant discount from step 3 — and widen the interval when you do, never narrow it. Speedup and variance move together: the quadrants with the biggest expected gains also have the widest spread. "The agent will handle it" is a reason for a wider band, not a tighter one.
Budget the failure branch longer than the success branch. Failed trajectories run 18–82% longer than successful ones; failure is expensive and late, not cheap and early.
Default to the class P85. Dropping to P50 requires naming the mitigation that removed the tail: spec frozen, harness exists, no external dependency, precedent in this repo.
对数阶梯为:≈15m · 1h · 3h · 1d · 3d · 1w+。
最多只能将类别结果移动 一个档位,并且必须同时指出具体证据:“同一个 Prisma migration 本月已经交付两次,两次都低于 2h”。没有名称支撑的直觉(“感觉会更简单”)带来的调整幅度 为零。移动两个档位不是调整,而是换了一个类别——应返回步骤 4。
随后应用步骤 3 中的 象限折扣——应用折扣时 只能扩大区间,绝不能收窄区间。加速幅度和方差会同步变化:预期收益最大的象限,分布也最宽。“Agent 会处理好”意味着区间更宽,而不是更窄。
为 失败分支分配比成功分支更长的时间。失败轨迹比成功轨迹长 18–82%;失败既昂贵又发生得晚,并不廉价且来得早。
默认使用类别的 P85。若要降至 P50,必须指出消除了尾部风险的缓解措施,例如 spec 已冻结、测试 harness 已存在、没有外部依赖、仓库中有先例。

7. Output — the SLE, three axes, never a single number

7. 输出——SLE、三个维度,绝不只给一个数字

**85% of your similar tasks finished in ≤ X agent-hours, ≤ Y human rounds, Z calendar days**
(class: <description>, n=<N>, neighbors: <task ids>)
Then, in this order:
  • P50 line — "half finished in ≤ …", so the typical case is visible next to the limit.
  • Regime call — the distribution is bimodal, not a spread around a centre. Say which mode you think this is in and why: agent-nails-it (precedent exists, tests verify, narrow blast radius) or integration-thrash (verification is human-eyes or prod-only, no precedent, external surface). Where both are live, give both with rough weights. A single blended number sits in the trough between the modes and describes almost nothing.
  • Blow-up risk flags, each named, only the ones that apply: vague spec · human-eyes-only verification · wide blast radius · external waits · no precedent in repo. Name the flag and the specific thing that triggered it.
  • External calendar waits, listed separately. App review, a platform approval, someone else's deploy window, a human decision. This is dead time, not work — never fold it into agent-hours; it is why calendar days and agent-hours are separate axes.
  • The review tail is part of calendar days. If done means merged, the estimate does not stop at "code written" — that is the part that got faster. Review time is up 441% and agent-authored PRs wait 5.3× longer for pickup. Include the wait for a reviewer explicitly whenever the task ends in a PR someone else must merge.
  • "Split this" whenever the estimate exceeds the class P85. Right-sizing beats budgeting more. Propose the actual split into ≤1-bucket pieces, do not just advise it.
Never emit a single number. A point estimate on a fat-tailed class is a category error, and it is the format that makes people commit to dates they then miss.
**85% of your similar tasks finished in ≤ X agent-hours, ≤ Y human rounds, Z calendar days**
(class: <description>, n=<N>, neighbors: <task ids>)
然后按以下顺序输出:
  • P50 行——“一半在不超过……时完成”,让典型情况与上限同时可见。
  • 分布形态判断——该分布是双峰的,而不是围绕某个中心展开的离散区间。说明你认为任务处于哪一种模式以及原因:agent-nails-it(存在先例、测试能够验证、影响范围狭窄)或 integration-thrash(只能依靠人工目视或生产环境验证、没有先例、涉及外部接口)。当两种模式都有可能时,分别给出,并附上粗略权重。单一混合数字会落在两种模式之间的低谷,几乎无法描述任何事情。
  • 失控风险标记,逐一命名,并且只输出适用的项:vague spec · human-eyes-only verification · wide blast radius · external waits · no precedent in repo。说明标记名称以及触发它的具体因素。
  • 外部日历等待时间,单独列出。 App review、平台审批、他人的部署窗口、人工决策。这些都是 无效工作时间,而非实际工作——绝不能将其并入 Agent 工时;这正是 calendar days 和 agent-hours 分开的原因。
  • Review 尾部属于 calendar days。 如果“完成”意味着已经合并,估算就不能止于“代码写完”——那只是变快的部分。Review 时间增加了 441%,Agent 编写的 PR 被接手等待的时间长 5.3 倍。只要任务最终会形成一个需要他人合并的 PR,就必须明确将等待 reviewer 的时间计入。
  • 当估算超过类别 P85 时,必须 拆分任务。正确确定规模优于单纯增加预算。提出实际的拆分方案,将其拆成每项不超过一个档位的子任务,不要只是泛泛建议拆分。
绝不能只输出一个数字。对于肥尾类别,点估计属于范畴错误,也会诱导人们承诺最终无法完成的日期。

8. Log the quote — not optional, and not later

8. 记录估算——不是可选项,也不能推迟

An estimate that was never logged can never be scored. This is the step that makes the skill improve instead of repeating itself, and it is the one that gets skipped because the user already has their answer. Do it in the same turn you quote.
Append one
estimate.v1
row
to
$LEDGER/ledger/estimates.ndjson
— schema and field rules are in Ledger layout; the three axes and a
task
slug
record.mjs
can find again are the load-bearing parts:
bash
LEDGER=${CLAUDE_ESTIMATION_DIR:-~/.claude/estimation}
cat >> $LEDGER/ledger/estimates.ndjson <<'EOF'
{"schema":"estimate.v1","estimate_id":"2026-09-01-csv-export","date":"2026-09-01","task":"TICKET-1234","ticket":"TICKET-1234","features":{"task_type":"feature","greenfield":false,"precedent_in_repo":"example-api/TICKET-1180","self_verifiable":"tests","blast_radius":9},"class":{"neighbors":["example-api/TICKET-1180","example-api/TICKET-1204"],"n":7,"gate":"p50-only"},"quoted":{"agent_hours":{"p50":3,"lo":1.5,"hi":9},"human_rounds":{"p50":12,"lo":6,"hi":30},"calendar_days":{"p50":1,"lo":1,"hi":4}},"source":"class-p50+one-bucket-precedent-adjustment"}
EOF
Then close the estimate by telling the user the loop is armed, in one line: "logged as
2026-09-01-csv-export
— run record mode when it lands and it scores itself."
Two rules about the numbers in that row:
  • Log what you told the user, not a hedged version. A band quietly widened in the ledger manufactures coverage the estimator never earned.
  • Never edit a logged row once work has started. A quote revised with knowledge of the outcome is not a quote. Log a second row and let both score.
Store the hash, not the text — the ledger stays free of task prose, and
record
mode matches on it later to score ratio (actual/quoted) and interval coverage.
从未被记录的估算永远无法评分。 这一步让 skill 得以改进,而不是不断重复过去的错误;它之所以经常被跳过,是因为用户已经拿到了答案。必须在给出估算的同一轮中完成。
向
$LEDGER/ledger/estimates.ndjson
追加 一行
estimate.v1
——结构及字段规则见 账本布局;三个维度和
record.mjs
之后能够再次找到的
task
slug 是关键部分:
bash
LEDGER=${CLAUDE_ESTIMATION_DIR:-~/.claude/estimation}
cat >> $LEDGER/ledger/estimates.ndjson <<'EOF'
{"schema":"estimate.v1","estimate_id":"2026-09-01-csv-export","date":"2026-09-01","task":"TICKET-1234","ticket":"TICKET-1234","features":{"task_type":"feature","greenfield":false,"precedent_in_repo":"example-api/TICKET-1180","self_verifiable":"tests","blast_radius":9},"class":{"neighbors":["example-api/TICKET-1180","example-api/TICKET-1204"],"n":7,"gate":"p50-only"},"quoted":{"agent_hours":{"p50":3,"lo":1.5,"hi":9},"human_rounds":{"p50":12,"lo":6,"hi":30},"calendar_days":{"p50":1,"lo":1,"hi":4}},"source":"class-p50+one-bucket-precedent-adjustment"}
EOF
随后用一行说明闭环已经就绪:“已记录为
2026-09-01-csv-export
——任务落地后运行 record 模式,它会自行评分。”
关于该行中的数字,有两条规则:
  • 记录你实际告诉用户的数字,而不是一个经过模糊处理的版本。 在账本中悄悄扩大区间,会制造出估算器从未真正获得的覆盖率。
  • 工作开始后,绝不能修改已经记录的行。 利用已知结果修订的估算不再是估算。应记录第二行,并让两行分别参与评分。
存储 hash,而不是文本——账本中不保留任务描述文字,而 record 模式之后仍可依靠它匹配,以计算实际值/估算值比率和区间覆盖率。

Doctrine — state this when the shape of the estimate is questioned

Doctrine——当有人质疑估算形态时,说明这些原则

duration ≈ rounds × (per-round agent time + per-round human wait)
Human round count is the dominant predictor (r ≈ 0.83 on measured data). First-turn duration predicts nothing (r ≈ 0.0).
So a task that one-shots in 40 minutes and a task that takes eight rounds of "not quite, try again" are not comparable, however similar the diff looks. Estimate the rounds; the minutes follow. And the per-round human wait is the user's own latency — it is in the recorded actuals already, do not model it separately.
Two corollaries worth saying out loud when they apply:
  • A long first turn is a good sign, not a bad one. It predicts fewer rounds — front-loaded framing pays for itself. Never quote a task as slow because it started slow.
  • Waiting for permission prompts is the largest recoverable loss. Approval gaps are 52% of long intra-task pauses, median 32 minutes each. If the user's setup asks for approval per tool call, that — not model speed — is what is costing them the afternoon.
Estimate rounds and steps, never lines. Assistant steps correlate with duration at r ≈ 0.86 and human rounds at r ≈ 0.82; LOC manages only 0.53. A diff-size estimate is a worse predictor than the thing it feels more concrete than.
Never accept self-reported speedups as calibration input. "That took me 20 minutes" is not data; it is the perception gap talking. Only mined actuals go in the ledger.
时长 ≈ 交互轮数 ×(每轮 Agent 执行时间 + 每轮人工等待时间)
人工交互轮数是主导预测因子(实测数据中 r ≈ 0.83)。首轮时长没有任何预测力(r ≈ 0.0)。
因此,一个在 40 分钟内一次完成的 task,与经历八轮“还差一点,再试一次”的 task 不可比较,无论二者的 diff 看起来多么相似。应估算轮数;分钟数会随之而来。每轮人工等待时间就是用户自身的响应延迟——它已经包含在记录的实际数据中,不要再单独建模。
适用时,以下两个推论值得明确说出:
  • 首轮耗时很长是好现象,不是坏现象。 它预测的是 更少的轮数——把上下文前置能够收回成本。绝不能因为一个任务开始得慢,就把它估算为慢任务。
  • 等待权限确认是最大的可回收损失。 审批间隔占任务内长暂停的 52%,每次中位数为 32 分钟。如果用户的环境要求每次工具调用都等待审批,那么拖慢其整个下午的是审批,而不是模型速度。
应估算 轮数和步骤,绝不估算代码行数。Assistant steps 与时长的相关性约为 r ≈ 0.86,与人工轮数的相关性约为 r ≈ 0.82;LOC 只有 0.53。diff 大小的估算效果,比它感觉更具体的那项指标更差。
绝不能把用户自行报告的提速幅度作为 calibration 输入。 “那个任务花了我 20 分钟”不是数据,而是在反映感知偏差。只有从记录中挖掘出的实际值才能进入账本。

Mode: setup

模式:setup

1. Structure

1. 结构

Check
$LEDGER
(resolution order in Ledger layout). Create
ledger/
, empty
chats.ndjson
/
tasks.ndjson
/
estimates.ndjson
, and a placeholder
calibration.json
. Do not overwrite anything that exists — if the ledger already has rows, you are in recalibrate, not setup.
检查
$LEDGER
(解析顺序见 账本布局)。创建
ledger/
,以及空的
chats.ndjson
/
tasks.ndjson
/
estimates.ndjson
,再创建一个
calibration.json
占位文件。不要覆盖任何已有内容——如果账本中已经有记录,你应当进入 recalibrate,而不是 setup。

2. Consent — before touching any transcript

2. 获得同意——接触任何 transcript 之前

Stop and ask with AskUserQuestion. Do not read a single transcript before an answer. Explain exactly this:
  • It reads Claude Code conversation JSONL under
    ~/.claude*/projects/
    (both accounts) from this machine only.
  • Nothing leaves the machine except the classification calls, which go to the Anthropic API as
    claude -p
    — the same place the user's normal sessions already go.
  • What gets stored: task shape and durations. Not the conversation text.
  • It is reversible:
    rm -rf ~/.claude/estimation
    .
Offer to scope it: all projects, one project, or the last N months.
使用 AskUserQuestion 停止并询问。 在得到回答之前,不要读取任何一份 transcript。准确说明以下事项:
  • 它只读取本机
    ~/.claude*/projects/
    下的 Claude Code conversation JSONL(涵盖两个账户)。
  • 除分类调用之外,任何内容都不会离开本机;分类调用通过
    claude -p
    发送到 Anthropic API,与用户日常 session 使用的是同一位置。
  • 存储内容:任务形态和持续时间。不存储 conversation 文本。
  • 可以撤销:执行
    rm -rf ~/.claude/estimation
    即可。
提供范围选项:全部项目、单个项目,或最近 N 个月。

3. Backfill — deterministic pass

3. Backfill——确定性处理

One command over the whole transcripts root — it walks every project dir, extracts each
.jsonl
sequentially (never parallel: these files run to 140 MB and this is a live box), and stitches globally at the end:
bash
nice -n 19 ionice -c3 node --max-old-space-size=2048 $SKILL/scripts/backfill.mjs \
  --root ~/.claude/projects
It skips transcripts under 5 KB as noise (consent probes, sessions that never got past the banner) and counts them; it skips any chat whose file and sidecar directory are both unchanged since the last run. Interrupt it and re-run — chats are appended as they are extracted.
Scratch and its progress log go in
/tmp/claude/task-estimator-backfill/
, never in a repo and never in the ledger.
Subagent sidecars are mined too, and they are where the delegated work is. Next to
<chat_id>.jsonl
the harness writes
<chat_id>/subagents/agent-*.jsonl
plus a
.meta.json
naming each agent's type.
extract.mjs
runs the same extraction core over each one and folds the result into the parent chat's
subagents
object —
loc_added
/
loc_removed
,
files_edited
,
repos
,
active_min
,
sidecar_count
and a
per_type
breakdown. This is not an optional refinement: a
Task
/
Agent
tool_result in this Claude Code era carries no
toolStats
, so a chat that delegated everything reads as zero LOC without it, and the parent does not even persist every spawn (one 104-spawn sprint kept two). Two rules hold absolutely:
  • Nothing folds into the parent's own fields. A subagent's minutes run inside its parent's wall clock, so adding them would count time that was spent once, twice. The chat's
    loc_added
    ,
    files_edited
    and
    agent_active_min
    never move.
  • Nothing is counted twice inside
    subagents
    either.
    Where the parent did report
    toolStats
    for a spawn and a sidecar for that same
    toolUseId
    exists, the sidecar measurement replaces the reported one — it is the primary record. Spawns with no sidecar keep their reported numbers. Depth-2 agents (spawned by another subagent, so no
    tool_use
    block exists in the parent at all) are added, which is why
    subagents.count
    can exceed the number of spawn blocks.
Sidecars are never emitted as chats of their own.
Every chat also gets a
repo_share
—
{repo: fraction}
, summing to exactly 1, splitting its minutes across repos in proportion to the files edited there (parent edits plus subagent edits). Chats that edited nothing attribute wholly to their cwd repo, or to the workspace when the cwd is a multi-repo root.
stitch.mjs
rolls this into
agent_hours_by_repo
/
subagent_hours_by_repo
on the task row. Use those for any per-repo or per-area question. Summing
agent_hours
over
repos
instead counts every multi-repo chat once per repo — on this corpus that inflated an area roll-up ~25x.
Probe sessions get a
noise
flag
—
"permtest"
(a permission probe:
echo PERMTEST_OK
, "testing perms here") or
"qa-probe"
(a session run out of a
/tmp
scratch tree). Detection is ratio-based, not keyword-based, because real chats discuss permission probes at length; a chat is a probe only when probing is essentially all it did. The rows stay in the ledger and
calibrate.mjs
drops them from the quantiles.
对整个 transcripts 根目录只需运行一条命令——它会遍历每个项目目录,顺序提取每份
.jsonl
(绝不并行处理:这些文件可能达到 140 MB,而且这是一台正在使用的机器),并在最后统一进行 stitch:
bash
nice -n 19 ionice -c3 node --max-old-space-size=2048 $SKILL/scripts/backfill.mjs \
  --root ~/.claude/projects
它会跳过小于 5 KB 的 transcript,将其视为噪声(权限探测、尚未越过启动页面的 session),并统计跳过数量;如果某个 chat 的文件及其 sidecar 目录自上次运行后都没有变化,也会跳过。中断后可以重新运行——chats 会随着提取过程持续追加。
临时文件和进度日志写入
/tmp/claude/task-estimator-backfill/
,绝不能放入仓库,也绝不能放入账本。
Subagent sidecars 也会被挖掘,而且委派工作恰恰位于其中。 在
<chat_id>.jsonl
旁边,harness 会写入
<chat_id>/subagents/agent-*.jsonl
,以及一个用于标明每个 Agent 类型的
.meta.json
。
extract.mjs
会对每份 sidecar 运行 相同的提取核心,并将结果折叠到父 chat 的
subagents
对象中——
loc_added
/
loc_removed
、
files_edited
、
repos
、
active_min
、
sidecar_count
以及
per_type
breakdown。这不是可选的增强:在当前 Claude Code 时代,
Task
/
Agent
的 tool_result 不包含
toolStats
,因此一个将所有工作都委派出去的 chat 会被读取成零 LOC,而且父 chat 甚至不会持久化每一次 spawn——有一次包含 104 次 spawn 的冲刺只保留了其中两个。这里有两条绝对规则:
  • 绝不能把任何数据折叠进父级自己的字段。 Subagent 的耗时运行在父级的 wall clock 内部,因此相加会把只计算过一次的时间计算两次。父 chat 的
    loc_added
    、
    files_edited
    和
    agent_active_min
    都不能变化。
  • subagents
    内部也绝不能重复计数。
    如果父级确实针对某个 spawn 报告了
    toolStats
    ,同时又存在同一
    toolUseId
    对应的 sidecar,则 sidecar 测量值 替换 报告值,因为 sidecar 是主要记录。没有 sidecar 的 spawn 保留报告值。由另一个 subagent 派生的二级 Agent(因此父级中完全不存在
    tool_use
    block)会被加入,这也是
    subagents.count
    可能超过 spawn block 数量的原因。
Sidecars 永远不会被输出为独立 chats。
每个 chat 还会获得一个
repo_share
——即
{repo: fraction}
,总和恰好为 1,并按各仓库中实际编辑文件所占比例拆分耗时(父级编辑与 subagent 编辑都包含在内)。没有编辑任何文件的 chat,会将其耗时全部归属于 cwd 对应的仓库;如果 cwd 是多仓库根目录,则归属于 workspace。
stitch.mjs
会将其汇总到 task 行的
agent_hours_by_repo
/
subagent_hours_by_repo
中。任何按仓库或按区域进行的问题,都应使用这些字段。 如果改为对
repos
中的
agent_hours
求和,每个多仓库 chat 都会被每个仓库各计算一次——在该语料库上,这曾让某个区域的汇总膨胀约 25 倍。
探测 session 会获得
noise
标记
——
"permtest"
(权限探测,例如执行
echo PERMTEST_OK
或“testing perms here”)或
"qa-probe"
(从
/tmp
临时目录运行的 session)。检测采用基于比例的方法,而不是关键词方法,因为真实 chats 也会长篇讨论权限探测;只有当探测行为几乎构成全部工作时,才将其视为 probe。相关行仍保留在账本中,但
calibrate.mjs
会将它们从分位数中剔除。

4. Backfill — semantic pass

4. Backfill——语义处理

One classification per chat:
digest.mjs
→ haiku. Before running, count the chats, estimate the token spend, print it, and ask to proceed. A backfill over a year of transcripts is not free and the user gets to see the number first —
--estimate-only
prints exactly that and does nothing else.
bash
node $SKILL/scripts/classify.mjs --estimate-only      # count + token/cost/wall estimate
node $SKILL/scripts/classify.mjs --concurrency 3      # then, once they agree
classify.mjs
drives this call per chat, with
MAX_THINKING_TOKENS=0
(measured: 6.5 s and $0.007 per call against 44 s and $0.025 with thinking, no loss against the prompt's rules — the audit stage is what keeps that honest):
bash
node $SKILL/scripts/digest.mjs --chat <chat-id> \
  | claude -p --model claude-haiku-4-5 \
      --output-format json \
      --system-prompt "$(cat $SKILL/prompts/classify-chat.md)" \
      --tools "" --safe-mode --no-session-persistence \
  | jq -r '.result'
Every flag is load-bearing:
  • --tools ""
    — no tool loop. It is a classifier, it reads text and returns JSON.
  • --safe-mode
    — no CLAUDE.md, skills, hooks, MCP servers. Keeps the classifier uncontaminated by project instructions and keeps the prompt prefix stable, so the batch gets prompt-cache hits. Auth still works normally (unlike
    --bare
    ).
  • --no-session-persistence
    — critical. Without it every classification writes a new JSONL into
    ~/.claude*/projects/
    , which the next backfill would mine as a chat. The ledger would start eating its own tail.
  • --output-format json
    returns an envelope; the model's text is
    .result
    , and it may still arrive fenced. Strip ``` fences before parsing. Optionally harden with
    --json-schema
    using the Schema block from the prompt file.
Merge the returned fields into the task row, plus
classified_at
and
classifier_model
. A task has several chats, so merging has rules:
  • Labels come from the main chat — the classification that called itself
    main
    , tie-broken by agent-active minutes. Labels do not vote: averaging a bugfix and a refactor produces neither.
  • outcome
    is decided by the artifact, not by the classifier.
    A task with
    commits > 0
    or a non-empty
    prs
    is
    shipped
    , full stop — those fields come from git and
    gh
    output, not from a model reading a truncated digest, and they are exactly the standard done means merged sets. The classifier keeps only the two verdicts an artifact cannot express:
    shipped-then-reverted
    and
    abandoned
    (in both, the commit exists and something else happened).
    outcome_source
    records
    deterministic
    or
    classifier
    and
    classifier_outcome
    keeps the original, so an audit can still score the model. This exists because the classifier ran 2.3:1 too pessimistic — a sonnet audit moved 62 rows
    unknown → shipped
    against 27 the other way, all of it the "prefer unknown over a guess" rule firing on a digest that simply did not show the commit line.
  • external_waits
    and
    user_touchpoint_kinds
    are unioned over every chat.
    They are a quantity and a set, not labels — a wait that happened in chat 3 is still dead time the task paid for.
  • Epics stay merged, and keep their detail. A multi-chat task is one row (splitting it is exactly the under-merging that makes a ledger flatter itself), with every chat's own labels preserved in a
    sub_deliverables[]
    array. A single chat that reports several units of work contributes several entries too.
Batch and resume. Process in batches, write after each, and skip any task that already has
classified_at
. An interrupted backfill must be resumable by re-running the same command, not by starting over.
每个 chat 只做一次分类:
digest.mjs
→ haiku。运行前,应统计 chats 数量、估算 token 消耗、打印结果,并询问用户是否继续。 对一年的 transcripts 进行 backfill 并非免费,用户应先看到具体数字——
--estimate-only
会准确打印这些信息,且不执行任何其他操作。
bash
node $SKILL/scripts/classify.mjs --estimate-only      # count + token/cost/wall estimate
node $SKILL/scripts/classify.mjs --concurrency 3      # then, once they agree
classify.mjs
会为每个 chat 发起如下调用,并设置
MAX_THINKING_TOKENS=0
(实测:开启 thinking 时为 44 秒、每次 0.025 美元;关闭后为 6.5 秒、每次 0.007 美元,且不会损失 prompt 规则所要求的输出——审计阶段负责持续验证这一点):
bash
node $SKILL/scripts/digest.mjs --chat <chat-id> \
  | claude -p --model claude-haiku-4-5 \
      --output-format json \
      --system-prompt "$(cat $SKILL/prompts/classify-chat.md)" \
      --tools "" --safe-mode --no-session-persistence \
  | jq -r '.result'
每个参数都承担关键作用:
  • --tools ""
    — 不启用工具循环。它只是一个分类器,只读取文本并返回 JSON。
  • --safe-mode
    — 不加载 CLAUDE.md、skills、hooks 或 MCP servers。这既可防止项目指令污染分类器,也能保持 prompt 前缀稳定,从而让批处理命中 prompt cache。身份验证仍会像平常一样工作,这一点不同于
    --bare
    。
  • --no-session-persistence
    — 至关重要。 如果不启用,每次分类都会在
    ~/.claude*/projects/
    中写入一份新的 JSONL,而下一次 backfill 会把这些内容当作 chats 挖出来,导致账本开始吞掉自己的尾部数据。
  • --output-format json
    返回一个外层封装;模型文本位于
    .result
    中,并且仍可能带有 Markdown 围栏。解析前应去除 ``` 围栏。还可以使用
    --json-schema
    ,并传入 prompt 文件中的 Schema 区块。
将返回字段合并到 task 行,并补充
classified_at
和
classifier_model
。由于一个 task 可能包含多个 chats,合并时遵循以下规则:
  • 标签来自主 chat——即分类结果自称
    main
    的 chat;如果存在多个,则按 Agent 活跃分钟数决胜。标签不投票:把 bugfix 和 refactor 混在一起平均,哪个也不是。
  • outcome
    由工件决定,而不是由分类器决定。
    如果 task 行的
    commits > 0
    或
    prs
    非空,就直接判定为
    shipped
    ——这些字段来自 git 和
    gh
    输出,而不是模型阅读截断摘要后的判断,并且它们精确对应“完成意味着已经合并”的标准集合。分类器只保留工件无法表达的两个结论:
    shipped-then-reverted
    和
    abandoned
    (两者都意味着 commit 存在,同时又发生了其他情况)。
    outcome_source
    记录
    deterministic
    或
    classifier
    ,
    classifier_outcome
    保留原始结论,以便 audit 仍能对模型评分。这样设计是因为分类器曾 悲观 2.3 倍——一次 sonnet audit 将 62 行从
    unknown → shipped
    ,反向转换只有 27 行;差异全部来自“宁可标记 unknown,也不要猜测”的规则,因为摘要没有展示 commit 行。
  • external_waits
    和
    user_touchpoint_kinds
    在所有 chats 上取并集。
    它们分别表示数量和集合,而不是标签——chat 3 中发生过的等待,仍是整个 task 付出的成本。
  • Epics 保持合并,并保留细节。 多 chat task 只占一行(拆分它恰恰会造成 under-merging,让账本把自己变平),每个 chat 自身的标签保存在
    sub_deliverables[]
    数组中。单个 chat 如果报告了多个工作单元,也会贡献多个条目。
批处理与恢复。 按批次处理,每批完成后写入,并跳过已经具有
classified_at
的 task。被中断的 backfill 必须能够通过重新运行同一命令恢复,而不是从头开始。

5. Audit — is the classifier trustworthy?

5. Audit——分类器是否可信?

Take a stratified 1-in-5 sample, oversampling: the biggest tasks, rows with low
stitch_confidence
, and rows where haiku contradicts the deterministic signals (says
shipped
with no commit; says
abandoned
with a merged PR).
Re-judge each with sonnet and
prompts/audit-task.md
.
audit.mjs
does the stratification (the three strata in full, the remainder on a deterministic 1-in-
rate
stride, so a re-run audits the same rows), feeds the judge the task record plus its chats' digests, and prints the per-field agreement:
bash
node $SKILL/scripts/audit.mjs --plan-only     # what would be sampled, and why
node $SKILL/scripts/audit.mjs --rate 5
Report the per-field agreement rate. If any field agrees < 80%, flag that field: say the prompt needs revision, and mark the field unreliable in
calibration.json
rather than quietly using it in the feature vector. A classifier nobody checked is not evidence.
Two things to read carefully in that table:
  • The rate is reported on the stride stratum only. The other three strata are deliberately the hardest rows, so their agreement is not a population rate.
  • outcome
    is scored on the value the ledger actually holds, which for a row with an artifact is the deterministic one. That is the right number for "can I trust this field", but it is not a measurement of the classifier —
    classifier_outcome
    is what to score the model against.
An auditor correction only counts if the auditor was entitled to make it. Three kinds are inadmissible and
calibrate.mjs
discounts all three from the adjusted rate — which is the rate
reliable
is decided on, since an inadmissible correction must not be able to condemn a field. When they are both the majority of the disagreements and at least two in number, the field is reported
unmeasured
rather than unreliable: that is a defect in
prompts/audit-task.md
, not in the data, so fix the prompt and re-audit before believing either number.
  • off_spec_corrections
    — a value outside the field's own enum. A "correction" in a different vocabulary is a different question answered.
  • corrections_contradicting_deterministic_fields
    —
    outcome → unknown
    on a row whose
    commits
    /
    prs
    came out of git. The judge sees at most six truncated digests; the record is the census and the digest is a sample, so "I did not see the commit" is not a finding.
  • self_contradicting_corrections
    — a
    false
    whose correction repeats the value already in the record. It asserts a disagreement and then fails to name one.
Read the stitch verdict beside the label rates, because it explains most of what is left of them.
field_reliability.stitch
reports the verdict distribution and
over_merged_share
, and every field also carries
agreement_on_well_stitched_rows
— the same rate over the rows the auditor called
correct
. Measured here:
task_type
agrees 0.51 on stitch-
correct
rows and 0.00 on the 26% called
over-merged
. Zero across fifteen rows is not a coin flip; it is the signature of a question with no answer, because an over-merged row holds two or more units of work and therefore has no single true
task_type
. That conditioning is a diagnostic and deliberately does not move
reliable
— the ledger really does hold the bad label on those rows. It tells you what to fix: the stitch, not the prompt.
采用 五取一的分层样本,并进行过采样:最大的 tasks、
stitch_confidence
较低的行,以及 haiku 结论与确定性信号冲突的行(声称
shipped
却没有 commit;声称
abandoned
却有已合并 PR)。
使用 sonnet 和
prompts/audit-task.md
重新判断每一行。
audit.mjs
负责分层(完整抽取前三个层,其余使用确定性的 1/
rate
stride,因此重新运行会审核同一批行)、向 judge 提供 task 记录及 chats 摘要,并打印各字段一致率:
bash
node $SKILL/scripts/audit.mjs --plan-only     # what would be sampled, and why
node $SKILL/scripts/audit.mjs --rate 5
报告 各字段的一致率。如果任何字段的一致率 低于 80%,应标记该字段:说明 prompt 需要修改,并在
calibration.json
中将该字段标为不可靠,而不是悄悄继续在特征向量中使用它。未经检查的分类器不能算作证据。
这张表中有两点需要仔细理解:
  • 该比率只在 stride 层中报告。其他三层特意选择了最困难的行,因此它们的一致率不是总体比率。
  • outcome
    是按照账本实际保存的值进行评分的;对于有工件的行,该值是确定性结论。这正是判断“这个字段能否信任”所需要的数字,但它 不是 对分类器的测量;
    classifier_outcome
    才是用于评价模型的值。
只有审计者 有资格 做出的修正才算数。以下三类修正不可采信,
calibrate.mjs
会将三者全部从调整后比率中扣除——而是否标为
reliable
正是依据该调整后比率决定的,因为不可采信的修正不应导致某个字段被判定为不可靠。当不可采信的修正既占分歧多数,又有至少两项时,字段应报告为
unmeasured
,而不是
unreliable
:这是
prompts/audit-task.md
的缺陷,而不是数据的问题,应先修复 prompt 并重新 audit,再相信其中任何一个数字。
  • off_spec_corrections
    — 修正值超出了字段自身的 enum。一个使用不同词汇给出的“修正”,回答的是另一个问题。
  • corrections_contradicting_deterministic_fields
    — 在
    commits
    /
    prs
    来自 git 的行上,将
    outcome
    修正为
    unknown
    。Judge 最多只能看到六份截断摘要;记录才是完整普查,摘要只是样本,因此“没有看到 commit”并不构成发现。
  • self_contradicting_corrections
    — 将
    false
    修正为记录中已经存在的值。它声称存在分歧,却没有指出分歧所在。
请结合 stitch 结论阅读标签比率,因为前者解释了剩余差异中的大部分。
field_reliability.stitch
报告结论分布和
over_merged_share
,每个字段还带有
agreement_on_well_stitched_rows
——即审计师判定为
correct
的行上的同一比率。实测结果:stitch 判定为
correct
的行上,
task_type
一致率为 0.51;在被判定为
over-merged
的 26% 行上则为 0.00。十五行中一致率为零并不是抛硬币的结果,而是“这个任务没有单一正确答案”的特征,因为一个过度合并的行包含两个或更多工作单元,因此不存在唯一的真实
task_type
。这种条件化分析是一项诊断,并不会改变
reliable
标志——账本中确实仍保存着错误标签。它告诉你应该修复的是 stitch,而不是 prompt。

6. Slice the epics

6. 切分 epics

bash
node $SKILL/scripts/slice.mjs --min-chats 6        # add --dry-run --json to look first
A task with dozens of chats is the right unit of record and the wrong unit to quote: nobody is asked "how long is a 76-hour integration", they are asked "how long is the webhook part". Splitting the epic into separate task rows is exactly the under-merging that makes a ledger flatter itself, so the row never moves. Instead
slice.mjs
writes a view onto it —
task.sub_tasks[]
— grouping its chats by shared PR first, then by classifier label overlap AND time adjacency (both halves required: the same slug three weeks later is a regression, not the same slice).
A slice whose member chats all reported no unit of work is flagged
no_work
and dropped from the
epic-slice
quantiles, exactly as a
no_work
task row is dropped from the task quantiles and for the same reason — it sits at the bottom of every class and answers a different question. It stays in
sub_tasks
; only the statistics ignore it, and
excluded_no_work_slices
reports the count.
Every chat lands in exactly one slice, so the slices' hours and rounds sum back to the epic's own. That invariant is what makes a slice quotable rather than an impression. Run this after
classify.mjs
(it reads
sub_deliverables
) and before
calibrate.mjs
, which turns the slices into the
epic-slice
reference class.
bash
node $SKILL/scripts/slice.mjs --min-chats 6        # add --dry-run --json to look first
包含几十个 chats 的 task 是正确的 记录单位,却是错误的 引用单位:没有人会问“一次 76 小时的 integration 需要多久”,他们会问“webhook 部分需要多久”。把 epic 拆成独立 task 行,恰恰会造成 under-merging,让账本把自己变平,因此原有行不会移动。取而代之,
slice.mjs
会写入一个指向该行的视图——
task.sub_tasks[]
——优先按 共享 PR 对 chats 分组,其次按 分类标签重叠且时间相邻 分组(两个条件缺一不可:同一个 slug 在三周后再次出现属于 regression,并不是同一个 slice)。
如果某个 slice 的所有成员 chats 都报告 没有工作单元,则将其标记为
no_work
并从
epic-slice
分位数中剔除——这与
no_work
task 行从 task 分位数中剔除完全相同,原因也相同:它位于每个类别的最底部,描述的是另一种问题。它仍保留在
sub_tasks
中,只是统计时忽略,并由
excluded_no_work_slices
报告数量。
每个 chat 恰好属于一个 slice,因此 slices 的工时和轮数能够加总回 epic 本身。这项不变量使 slice 成为一个可引用的对象,而不是一种主观印象。应在
classify.mjs
之后、
calibrate.mjs
之前运行该脚本,因为后者会将 slices 转换为
epic-slice
reference class。

7. Calibrate

7. Calibrate

bash
node $SKILL/scripts/calibrate.mjs --ledger $LEDGER/ledger --out $LEDGER/calibration.json
Two kinds of row are excluded from every quantile:
noise
(permission probes, scratch QA sessions) and
no_work: true
(the classifier found no unit of work — pure Q&A, a status check, a session that only read). Both stay in
tasks.ndjson
; only the statistics ignore them, and
calibration.json
reports the counts in
excluded
so the exclusion is never invisible.
The
no_work
exclusion reverses an earlier call, on evidence. The old reasoning — "a question that took two hours of reading cost two hours" — is true about the hours and wrong about the class. These rows are 127 of 426 in this ledger, they cluster at the bottom of every class, and a reference class only forecasts if its members are the same KIND of thing. Leaving them in answered "how long does a bugfix take" with a distribution that is 30% "how long does answering a question take". Ask about a question and you get a question's number; ask about a bugfix and you should not.
Consequence for anything reading
calibration.json
:
n_tasks
is the quotable count, not the ledger size.
n_tasks_in_ledger
is the ledger size.
Then print: per-class
n
, the population priors, which classes are already past the n≥10 gate, and how many rows were excluded. That tells the user which questions the ledger can actually answer yet.
bash
node $SKILL/scripts/calibrate.mjs --ledger $LEDGER/ledger --out $LEDGER/calibration.json
有两类行会从所有分位数中排除:
noise
(权限探测、临时 QA session)和
no_work: true
(分类器没有发现工作单元——纯问答、状态检查,或只进行了读取的 session)。这些行仍保留在
tasks.ndjson
中,只是统计时忽略;
calibration.json
会在
excluded
中报告数量,确保排除情况始终可见。
对
no_work
的排除推翻了早先基于另一种理由作出的判断。旧 reasoning 是“一个纯粹阅读了两小时的问题,确实花了两小时”——就工时而言这是正确的,就类别而言却是错误的。这些行在该账本的 426 行中占 127 行,集中分布在每个类别的底部,而 reference class 只有在成员属于同一种工作时才能用于预测。如果保留它们,“bugfix 需要多久”会得到一个其中 30% 实际是“回答一个问题需要多久”的分布。询问问题会得到问题的数字;询问 bugfix,则应该得到 bugfix 的数字。
对于读取
calibration.json
的一切逻辑:
n_tasks
是可引用样本数,而不是账本大小。
n_tasks_in_ledger
才是账本大小。
然后打印:每个类别的
n
、总体先验、哪些类别已经越过 n≥10 的门槛,以及排除了多少行。这能让用户了解账本目前究竟能够回答哪些问题。

8. Offer the CLAUDE.md hook

8. 提供 CLAUDE.md hook

Show
templates/claude-md-snippet.md
, show the exact diff, ask, and only then append it to the user's global CLAUDE.md between its
<!-- task-estimator:start -->
markers. If the markers are already present, replace the block instead of appending a second copy. Never edit a CLAUDE.md without showing the diff first.
展示
templates/claude-md-snippet.md
和准确的 diff,先询问用户,只有得到同意后,才能将其追加到用户的全局 CLAUDE.md 中
<!-- task-estimator:start --
与结束标记之间。如果标记已经存在,应替换原有区块,而不是追加第二份。绝不能在未先展示 diff 的情况下编辑 CLAUDE.md。

Mode: record

模式:record

Runs at the end of a substantive task, either because the user said "record this task" or because the CLAUDE.md snippet fired.
Run
record.mjs
. Do not hand-assemble a task row.
Every rule below is already in it, and the two that are easy to get wrong by hand — rework merging and outcome sourcing — are exactly the two that make a ledger flatter itself:
bash
SKILL=.claude/skills/task-estimator
该模式在实质性任务结束时运行,原因可能是用户说了“记录这个任务”,也可能是 CLAUDE.md snippet 触发了它。
运行
record.mjs
。不要手工组装 task 行。
下面的所有规则都已经写在脚本中,而最容易手工出错的两个规则——rework 合并和 outcome 来源——恰恰是最容易让账本把自己变平的两个规则:
bash
SKILL=.claude/skills/task-estimator

the session you are in right now:

the session you are in right now:

nice -n 19 ionice -c3 node --max-old-space-size=2048 $SKILL/scripts/record.mjs
--session <session-uuid> --project <transcript-dir-name>
nice -n 19 ionice -c3 node --max-old-space-size=2048 $SKILL/scripts/record.mjs
--session <session-uuid> --project <transcript-dir-name>

or, when the transcript is simply the newest one in that project:

or, when the transcript is simply the newest one in that project:

node $SKILL/scripts/record.mjs --latest --project -path-to-your-project

Useful flags: `--dry-run` (print the row, write nothing — **always do this first**),
`--outcome <shipped|shipped-then-reverted|abandoned|superseded|unknown>`, `--in-progress`
(the work is still open; outcome stays `unknown`, sourced honestly), `--estimate-id <id>`
to force the quote it scores against, `--ticket <KEY>` to supply a rework join key the
transcript never named, `--json`, `--no-calibrate`.

What it does, and why each step is not yours to improvise:

1. **Extract + stitch, structurally.** A **resumed** session is the same task continuing,
   not a new one — stitch follows parent edges (first `sessionId` inside a file ≠ its
   filename) and never groups on elapsed time. Do not hand-split a long task by clock. The
   chat is upserted, so re-recording the same session is idempotent: the existing task row
   is *replaced*, never duplicated.
2. **Rework is charged to the original row.** If a task row exists with the same PR or
   ticket and this work lands **within 14 days**, the rows merge and the merged row keeps
   the **original `task_id`**, with the merge recorded in `rework_merges[]`. Costs are
   re-derived from the chats, so files stay a set union and a sha both rows recorded counts
   once. Filing follow-up work as a fresh task is the single most common way a history
   silently reports itself as accurate.
3. **`done` means accepted or merged.** An agent saying "done" overstates completion by
   roughly **7×**. A commit or PR on the row sets `outcome: shipped` with
   `outcome_source: "deterministic"`, and `--outcome` cannot override it — only
   `shipped-then-reverted` and `abandoned` survive an artifact, because those are the two
   verdicts an artifact cannot express. No artifact and no answer is `unknown`, never an
   optimistic default.
4. **Ask only for what cannot be mined**, and ask at most a couple of things:
   - outcome, if there is no merged PR to read it from (`--outcome`);
   - external waits — what was dead time, and roughly how long.
   Everything else (rounds, spans, files touched, repos) comes from the transcript. Do not
   ask the user how long it took; that is exactly the self-report the ledger exists to
   replace.
5. **Scoring is automatic when a quote matches** (`estimate_id` → ticket →
   `task_desc_hash` → slug → summary → sub-deliverable). It writes the ratio and the
   inside-band verdict per axis onto the estimate row, and stamps `estimate_id` onto the
   task row so the link is never re-guessed. **If it reports "no quote matches", say so to
   the user** — that is the loop being open, not a detail. Either the estimate was never
   logged (fix step 8 of estimate mode) or the slug does not match; `--estimate-id` links
   it by hand.
6. **Epics are re-sliced and `calibrate.mjs` re-runs automatically.** One completed task is
   one more point in the class, and the next estimate should already reflect it. Recording a
   chat onto an existing epic also changes that epic's `sub_tasks`, so the slicing is redone
   in the same write — a stale slice list would feed the `epic-slice` class hours that no
   longer match the ledger.

Then read the one-paragraph summary back to the user, including what was **not** measured
(no repo detected, no commits, outcome sourced from them rather than an artifact). Finish
with `score.mjs` whenever a quote was scored, so the quote and its verdict land together.
node $SKILL/scripts/record.mjs --latest --project -path-to-your-project

常用参数:`--dry-run`(打印该行,不写入任何内容——**始终先运行一次**)、`--outcome <shipped|shipped-then-reverted|abandoned|superseded|unknown>`、`--in-progress`(工作仍在进行中;outcome 保持 `unknown`,并如实标注来源)、`--estimate-id <id>`(强制指定用于评分的估算)、`--ticket <KEY>`(提供 transcript 中未出现的 rework 连接键)、`--json`、`--no-calibrate`。

它会执行以下工作,以及为什么每一步都不应由你自行即兴处理:

1. **以结构化方式 Extract + stitch。** 一个 **恢复的 session** 是同一任务的延续,而不是一个新任务——stitch 会沿 parent edge 连接(文件内第一个 `sessionId` 不等于其文件名),绝不会仅根据经过时间来分组。Chat 采用 upsert,因此重复记录同一个 session 是幂等的:已有 task 行会被 **替换**,绝不会重复。
2. **Rework 计入原始行。** 如果已有 task 行拥有相同 PR 或 ticket,并且本次工作发生在其 **14 天内**,两行会合并;合并后的行保留 **原始 `task_id`**,并在 `rework_merges[]` 中记录此次合并。成本会从 chats 重新推导,因此文件保持集合并集,两个行都记录过的同一 sha 只计算一次。将后续工作作为新 task 提交,是历史记录悄无声息地把自己描述得准确无误的最常见方式。
3. **`done` 意味着已接受或已合并。** Agent 宣称“done”会让完成度被高估约 **7 倍**。task 行中存在 commit 或 PR 时,会设置 `outcome: shipped` 和 `outcome_source: "deterministic"`;`--outcome` 无法覆盖它,只有 `shipped-then-reverted` 和 `abandoned` 能与工件共存,因为这两个结论无法由工件表达。没有工件也没有答案时,结果为 `unknown`,绝不采用乐观默认值。
4. **只询问无法挖掘的信息,而且最多询问一两项:**
   - 没有可读取的已合并 PR 时,询问 outcome(`--outcome`);
   - 外部等待——哪些时间属于无效等待,以及大致持续多久。
   其他所有信息(轮数、时间跨度、涉及文件、仓库)都来自 transcript。不要询问用户任务花了多久;那正是账本要取代的自我报告。
5. **匹配到估算后会自动评分**(`estimate_id` → ticket → `task_desc_hash` → slug → summary → sub-deliverable)。它会将每个维度的实际值/估算值比率和区间内判定写入 estimate 行,并将 `estimate_id` 标记到 task 行上,因此绝不需要重新猜测关联关系。**如果它报告“no quote matches”,应告知用户**——这意味着闭环尚未闭合,而不是一个可以忽略的细节。要么是估算从未被记录(应修复 estimate 模式的步骤 8),要么是 slug 不匹配;使用 `--estimate-id` 可以手动建立联系。
6. **Epics 会自动重新切分,并自动重新运行 `calibrate.mjs`。** 每完成一个 task,就相当于给相应类别增加一个数据点,下一次估算应立即反映它。将一个 chat 记录到已有 epic 上也会改变该 epic 的 `sub_tasks`,因此切分会在同一次写入中重新完成;使用过期的 slice 列表,会让 `epic-slice` 类别的工时与账本不再匹配。

随后向用户读回一段总结,其中包括 **没有被测量的内容**(未检测到仓库、没有 commits、outcome 来源于它们而非工件)。如果评分了任何估算,最后还要运行 `score.mjs`,让估算及其判定一同落地。

Mode: recalibrate

模式:recalibrate

Re-run
stitch.mjs
, then
slice.mjs
, then
calibrate.mjs
over the whole ledger, then run
score.mjs
— it computes every number below and refuses to compute the ones the ledger has not earned:
bash
node $SKILL/scripts/slice.mjs --min-chats 6      # refresh the epic sub_deliverables first
node $SKILL/scripts/calibrate.mjs --ledger $LEDGER/ledger --out $LEDGER/calibration.json
node $SKILL/scripts/score.mjs                    # add --json for the machine-readable form
Report from its output:
  • Interval coverage — of the quotes that have actuals, what fraction landed inside the quoted P80 band? It should be ~80%. Below that the quotes are too tight; well above, too loose.
    score.mjs
    prints the binomial acceptance band at the current n beside it; quote that band and never call a miss without it. At n=1 the band is 0–100%.
  • Signed bias — the median of log(actual / quoted P50), so over- and under-estimation do not cancel, reported alongside the geometric mean ratio. Never average ratios linearly: 4× and 0.25× are the same error in opposite directions, and their arithmetic mean (2.1×) invents a bias that is not there.
  • WIS-lite decomposition at n ≥ 5 — the 80% interval score split into sharpness + over-prediction + under-prediction. Read the shares: dominated by sharpness means the bands are too wide, dominated by under-prediction means the quotes are too low. Below n = 5 it is withheld, and "withheld" is the honest report — do not substitute a number.
  • Quotes still awaiting actuals — every one is a task that finished without record mode running. Name them; that is the loop leaking.
  • Per-class n, and which classes cleared the 5 / 10 / 20 gates since last time.
  • Anything that moved: a class that just became quotable, a class whose coverage collapsed.
Recency (90-day half-life) is already inside
calibrate.mjs
. Do not apply a second decay on top of it.
在整个账本上重新依次运行
stitch.mjs
、
slice.mjs
、
calibrate.mjs
,然后 运行
score.mjs
——它会计算下列所有数字,并拒绝计算账本尚未提供足够证据的数据:
bash
node $SKILL/scripts/slice.mjs --min-chats 6      # refresh the epic sub_deliverables first
node $SKILL/scripts/calibrate.mjs --ledger $LEDGER/ledger --out $LEDGER/calibration.json
node $SKILL/scripts/score.mjs                    # add --json for the machine-readable form
根据其输出报告:
  • 区间覆盖率——在已有实际值的估算中,有多少落在所引用的 P80 区间内?理想值约为 80%。低于该值说明区间过窄;明显高于则说明过宽。
    score.mjs
    会在当前 n 下打印 二项式接受带,引用该带,并且在没有它时绝不能把某次偏差称为 miss。当 n=1 时,该区间为 0–100%。
  • 有符号偏差——
    log(actual / quoted P50)
    的中位数,同时报告 几何 平均比率。绝不能直接线性平均各项比率:4 倍与 0.25 倍是方向相反但程度相同的误差,而它们的算术平均值 2.1 倍会制造出一种实际上不存在的偏差。
  • n ≥ 5 时的 WIS-lite decomposition——将 80% interval score 拆分为 sharpness + over-prediction + under-prediction。重点读取各部分占比:由 sharpness 主导表示区间太宽,由 under-prediction 主导表示估算偏低。当 n < 5 时不会给出该指标,“withheld”才是诚实报告,不要用其他数字替代。
  • 仍在等待实际值的估算——每一个都代表一个已经完成、但没有运行 record 模式的 task。应逐项列出;这就是闭环正在泄漏的部分。
  • 每个类别的 n,以及哪些类别自上次以来越过了 5/10/20 的门槛。
  • 任何发生变化的内容:刚变得可引用的类别、覆盖率突然崩落的类别。
Recency(90 天半衰期)已经包含在
calibrate.mjs
中。不要再额外应用第二次衰减。

Process hygiene — how to run these scripts on a live box

流程卫生——如何在正在使用的机器上运行这些脚本

This corpus is hundreds of megabytes of transcripts on a machine that is also serving the user's own sessions. Every rule here was paid for.
  • NEVER wait on a process with
    pgrep
    ,
    pkill -0
    ,
    ps | grep
    or any loop built on them.
    pgrep -f node
    ,
    pgrep -f backfill
    and friends match the checking process itself — the shell running the check, the subshell, the grep — so the loop observes a match forever and never exits. Two agents deadlocked on exactly this against this corpus. There is no safe flag combination; do not reach for
    pgrep -v $$
    either.
    • To wait for something you started: run it in the foreground, or capture its PID and wait on that PID specifically (
      wait <pid>
      for a child;
      kill -0 <pid>
      for a one-shot liveness check that is not in a loop).
    • To coordinate between separate runs: use a PID file.
      record.mjs
      writes
      $LEDGER/ledger/.record.lock
      containing its own pid, refuses immediately if a live holder exists (it never polls), and takes the lock over when the recorded pid is gone.
      --force-lock
      overrides. A killed run therefore never wedges the ledger, and nothing ever scans the process table.
    • A single, non-looping
      ps
      snapshot to answer "is a backfill running right now" is fine. It is the loop that kills you, not the tool.
  • nice -n 19 ionice -c3
    on anything that walks the corpus
    (
    backfill.mjs
    ,
    classify.mjs
    ,
    audit.mjs
    ,
    record.mjs
    on a large transcript). The user is working on this box.
  • Bound the heap:
    node --max-old-space-size=2048
    .
    Individual transcripts reach 140 MB; an unbounded run will happily take the machine with it.
  • Never parallelise the extract pass. Files are processed sequentially, one big transcript at a time, on purpose.
  • Scratch goes in
    /tmp/claude/task-estimator*/
    , never in a repo and never in the ledger. Back the ledger up there before any run that rewrites it.
  • Ledger writes are atomic (temp file + rename) so a concurrent reader never sees a half-written NDJSON. Keep it that way — use
    writeNdjsonAtomic
    from
    lib.mjs
    .
该语料库包含数百 MB 的 transcripts,而这台机器同时还在服务用户自己的 sessions。下面的每条规则都是通过实际代价换来的。
  • 绝不要使用
    pgrep
    、
    pkill -0
    、
    ps | grep
    或任何基于这些命令构建的循环等待进程。
    pgrep -f node
    、
    pgrep -f backfill
    等命令会 匹配检查进程自身——运行检查的 shell、子 shell、grep——因此循环会永远观察到一个匹配项,永远无法退出。曾有两个 Agent 正好因为这一点与该语料库发生死锁。 没有任何安全的参数组合;也不要使用
    pgrep -v $$
    。
    • 要等待自己启动的进程:在前台运行,或者捕获其 PID 并只等待该 PID(对子进程使用
      wait <pid>
      ;
      kill -0 <pid>
      仅可用于一次性的存活检查,不能放入循环)。
    • 要协调不同运行:使用 PID file。
      record.mjs
      会在
      $LEDGER/ledger/.record.lock
      中写入自己的 pid;如果发现存活持有者,它会 立即 拒绝,绝不轮询;如果记录的 pid 已不存在,则接管锁。
      --force-lock
      可以强制覆盖。被杀死的运行因此不会卡死账本,而且完全不需要扫描进程表。
    • 为回答“现在是否有 backfill 正在运行”而执行 一次非循环的
      ps
      快照
      是可以的。致命的是循环,而不是工具本身。
  • 任何遍历整个语料库的操作都要使用
    nice -n 19 ionice -c3
    (
    backfill.mjs
    、
    classify.mjs
    、
    audit.mjs
    、在大型 transcript 上运行的
    record.mjs
    )。用户正在使用这台机器。
  • 限制堆大小:使用
    node --max-old-space-size=2048
    。
    单份 transcript 可达 140 MB;不限制堆大小的运行可能直接拖垮机器。
  • 绝不要并行执行 extract pass。 文件会按顺序逐个处理,每次处理一个大型 transcript,这是有意为之。
  • 临时文件放入
    /tmp/claude/task-estimator*/
    ,绝不能放入仓库,也绝不能放入账本。在任何会重写账本的运行之前,先把账本备份到该目录。
  • 账本写入必须是原子的(临时文件 + rename),这样并发读取者永远不会看到只写入一半的 NDJSON。必须保持这种方式——使用
    lib.mjs
    中的
    writeNdjsonAtomic
    。

Why these rules

这些规则背后的原因

Short version. Sources and full treatment in
references/
.
  • METR RCT 2025 (16 expert devs, 246 real tasks in repos they maintain) — 19% slower with AI while believing they were 20% faster; a 39-point perception gap that does not self-correct with experience, and it was largest for experts in large mature repos — exactly the conditions here. This is why self-reports are banned as calibration input and why the ledger is mined rather than asked for.
  • Done inflation ~7× — agent-declared completion overstates real completion on a maintainer-review basis, hiding ~26 minutes of remediation per "pass". Done means merged or accepted. Nothing else counts.
  • Rounds, not minutes — measured r ≈ 0.83 between human round count and total duration (0.82 in the 651-session corpus; assistant steps score higher still at 0.86), against r ≈ 0.0 for first-turn duration and only 0.53 for LOC. Per-round agent work is stable at ~9s/step and a median turn near 45s; all the variance lives in the round count.
  • 78 / 22 bimodality — 78% of high-complexity tasks finished under 25% of expected effort, while 22% of low-complexity tasks blew past 180%, driven by validation and integration rather than coding. The joint distribution is bimodal, so a single central number sits in the trough. The readiness gate is the cheapest filter on which mode a task is in: the blowups correlate with absent or untestable acceptance criteria.
  • Anchoring (Løhre & Jørgensen) — an irrelevant number moves estimates 4–5×, survives explicit instructions to ignore it, and survives an obviously unqualified source. Hence anchor-stripping happens before any reasoning, not as a caveat afterwards. Same-person re-estimates of an identical task deviate 71%, so no remembered estimate is ever data.
  • Reference-class forecasting (Kahneman/Lovallo/Flyvbjerg) — outside view first, then a bounded one-bucket inside-view shift. Software's class base rate is a 73% mean overrun with an 18% tail averaging +447%: fat-tailed, so P85 by default and P95 whenever an external dependency is in play.
  • Evidence-Based Scheduling (Spolsky) — resample whole estimate/actual ratios; never average velocity, because averaging deletes exactly the tail draws that produce the overruns. Rework is charged back to the original item — filing it separately is how a history silently reports itself as accurate.
  • Sample-size gates (Vacanti) — 5 for a median, ~11 before the observed range means anything, 10–20 before publishing an upper limit; 15 fresh points beat 150 stale ones. A personal P85 at n=11 spans 0.31–2.26× the truth, which is why everything below n≈20 is shrunk toward the population prior.
  • Empirical quantiles, not fits — a fitted lognormal lands at ×0.72 of the true median and ×1.72 at p95, while raw empirical quantiles scored leave-one-out coverage of 49.9 / 79.8 / 89.9 against nominal 50 / 80 / 90 — essentially exact. Sort the actuals and read the quantile off the data, in log space.
  • Only 35% of tasks land within 2× of the median. At that dispersion a point estimate is indefensible; the interval is the answer.
  • The constraint moved downstream — task throughput +33.7% alongside review time +441%, pickup +156%, churn +861%, and 38% of agentic PR failures are "never reviewed". An estimate that stops at "code written" measures the part that got faster. And with coding only ~30–40% of a developer's calendar time, any claimed end-to-end gain above ~40% is a measurement error, not a result.
简版说明。完整论述见
references/
。
  • METR RCT 2025(16 名专家开发者、246 个由其维护的真实任务)——使用 AI 时实际 慢 19%,却认为自己 快了 20%;这是一个 39 个百分点的感知差距,而且不会随经验自行修正;在大型成熟仓库中的专家身上差距最大——这里正是相同的环境。因此,自我报告被禁止作为 calibration 输入,账本必须通过挖掘获得,而不能靠询问获得。
  • 完成度膨胀约 7 倍——从 maintainer review 的角度看,Agent 宣布完成会严重高估实际完成情况,每次所谓“通过”背后平均隐藏约 26 分钟的补救工作。完成意味着已合并或已接受,其他情况均不算。
  • 轮数,而不是分钟数——实测人工交互轮数与总时长之间的相关性为 r ≈ 0.83(在 651 个 session 的语料库中为 0.82);Assistant steps 的得分更高,达到 0.86;首轮时长仅为 r ≈ 0.0,LOC 也只有 0.53。每轮 Agent 工作量稳定在约 9 秒/step,单轮中位数接近 45 秒;所有方差都来自轮数。
  • 78/22 双峰现象——78% 的 高复杂度 任务在低于预期工作量 25% 的情况下完成,而 22% 的 低复杂度 任务则超出 180%,主要驱动因素是验证与集成,而不是编码。联合分布是双峰的,因此单一中心数字会落在低谷。Readiness gate 是判断任务处于哪一种模式最便宜的手段:失控任务与缺少或无法测试的 acceptance criteria 高度相关。
  • Anchoring(Løhre & Jørgensen)——一个无关数字会让估算偏移 4–5 倍,显式要求忽略它也无法消除影响,来源明显不合格时同样如此。因此,去除锚点必须发生在任何推理之前,而不能只在事后附加一句免责声明。同一个人对同一任务重新估算时,结果会偏差 71%,所以任何凭记忆给出的估算都不是数据。
  • Reference-class forecasting(Kahneman/Lovallo/Flyvbjerg)——先采用 outside view,再进行有界的一个档位 inside-view 调整。软件任务的类别基准率是 平均超出 73%,且 18% 的尾部平均超出 +447%;分布呈肥尾,因此默认使用 P85,出现外部依赖时使用 P95。
  • Evidence-Based Scheduling(Spolsky)——重采样完整的估算/实际比率;绝不能对 velocity 取平均,因为平均恰好会删除那些造成超支的尾部抽样。将 rework 记回原始项——把后续工作单独提交,正是历史记录悄无声息地把自己描述得准确无误的方式。
  • 样本量门槛(Vacanti)——5 个样本可支持中位数,约 11 个样本后观测范围才开始具备意义,10–20 个样本后才适合公布上限;15 个新数据点胜过 150 个陈旧数据点。n=11 时的个人 P85 可能横跨 真实值的 0.31–2.26 倍,因此 n≈20 以下的一切结果都必须向总体先验收缩。
  • 使用经验分位数,而不是拟合分布——拟合 lognormal 会落在 真实中位数的 ×0.72,以及 p95 的 ×1.72;而直接使用原始经验分位数,其留一法覆盖率为 49.9/79.8/89.9,对应的名义水平是 50/80/90,几乎完全准确。应排序实际值并直接从数据中读取对数空间分位数。
  • 只有 35% 的任务落在中位数的 2 倍以内。 在这种离散程度下,点估计无法辩护;区间才是正确答案。
  • 约束已经向下游转移——任务吞吐量提高了 33.7%,但 review 时间增加了 441%,接手等待增加了 156%,churn 增加了 861%,并且 38% 的 Agent PR 最终“从未被 review”。止步于“代码写完”的估算,只测量了变快的部分。而编码仅占开发者日历时间的约 30–40%,因此任何超过约 40% 的端到端收益声称都是测量误差,而不是真实结果。