古くなったドキュメントに症状はありません。ファイルはちゃんと開き、マークアップは妥当で、ヘッダーの日付ももっともらしく、リンクも以前と同じ場所へ飛びます。そこに欠けているのは、いまのコードについての真実だけです。誰かが一か月前に三つの関数を別モジュールへ切り出し、エントリポイントの名前を変え、引数の順序を入れ替えました。AGENTS.md がそれに気づくことはありませんでした。気づくための手段が、そもそもなかったからです。
人間にとっては、これは苛立ちで済みます。読んで、つまずいて、結局コードを読みに行くだけです。エージェントにとっては話が違います。モデルは「どうも噛み合わない」という感覚を持ちません。コンテキストファイルを事実として受け取り、最新の指示も半年前のアーキテクチャを説明した記述も、まったく同じ確信度で実行します。そのままエージェントは古い説明どおりにコードを書き、コミットして去っていきます。ClewWiki — 人間とコーディングエージェントが一緒にページを書く self-hosted のナレッジベースで、AGPL-3.0、いまのところリリースのない pre-alpha です — の code anchors という仕組みは、この問題から生まれました。
なぜ「ファイル名と行番号」はアンカーにならないのか#
分かりやすい解法はこうです。ページの隣に src/anchors/resolve.ts:127-184 と書いておき、その行が変わったかどうかを見る。これは機能しませんし、想像よりずっと早く壊れます。
行範囲は、自分より 上 の編集を生き延びられません。誰かがファイルの先頭に import を足せば、コードは丸ごと一行下へずれ、範囲は隣の宣言を指すようになります。フォーマッタを一度走らせれば、リポジトリ内のほぼすべての範囲が同時にひっくり返ります。振る舞いを何ひとつ変えない関数のリネームですら、変更としてカウントされます。結果はフラグの洪水で、そのうち本物はごくわずか。二週間もすればチームは誰も読まなくなります。
この数字は目分量ではなく、実測したものです。実際のリファクタリング履歴 — 26 コミット、479 アンカー — に対して、素朴な line-range アンカーは約 50% の誤検知 を出しました。同じ履歴で、シンボルアンカーの誤検知は ゼロ でした。
ここから最初の設計判断が出てきます。アンカーの同一性は位置ではなく {kind, qualified_name} のペアである、ということです。languageForPath という関数は、別のファイルへ引っ越しても languageForPath のままです。アンカーには file_hint フィールドもありますが、それは探索を正しい場所から始めるためのものであって、何を探すかを決めるためのものではありません。
とはいえ line range も残してあります。解決可能な宣言を持たないブロック — 設定ファイル、定数テーブル、README の散文の一部 — のための、正直なフォールバックとしてです。これらについてはインデントと空行を正規化し、check のレスポンスには毎回 fallback_share、つまり space 内でそうしたアンカーが占める割合が載ります。この割合が増えているということは、壊れ方が分かっている経路へ空間がじわじわ寄っていることを意味します。レスポンスの飾りではなく、ノイズの早期指標です。
テキストのハッシュではなく、トークンのハッシュ#
二つめの設計判断は、何をハッシュするかです。宣言のテキストをハッシュするのは、行範囲が役に立たないのと同じ理由で役に立ちません — あらゆる変更に反応してしまうからです。そこでアンカーが保持するのは、パーサが吐き出す トークン列 のハッシュで、コメントは取り除いてあります。
実際の差はこうなります。関数のインデントし直し、引数を複数行に折り返す、doc コメントの書き直し、シングルクォートからダブルクォートへの変更 — このどれもハッシュを変えません。一方、コードが何をするか が変われば、構成上ハッシュは必ず変わります。「このドキュメントは古くなった」というシグナルに期待するのは、まさにこの振る舞いです。
パーサには tree-sitter を WebAssembly へコンパイルしたものを使っています。これは格好つけではありません。ネイティブバインディングを使えば、読むだけのパーサのために node-gyp をランタイムイメージへ持ち込み、Node を更新するたびにリビルドを強いられます。WASM ビルドならデータとしてイメージに置けるので、ランタイムのアップグレードを生き延びます。宣言テーブルが現在カバーしているのは Swift、TypeScript、TSX(.swift、.ts、.mts、.cts、.tsx)で、次は Kotlin の予定です。
5 段階のラダーと 4 つの状態#
アンカーの検査は順序の決まったラダーで、その順序こそがリファクタリングとドキュメント側の問題を切り分けます。
| ステージ | 何を探すか | 結果 |
|---|---|---|
| 1 | 同じファイル内の、同じ同一性 | fresh または stale |
| 2 | 別のファイル内の、同じ同一性 | moved |
| 3 | 同じコンテナ内で、別名になった同じ本体 | renamed |
| 4 | 場所を問わず、同じ本体 | moved and renamed |
| 5 | 何も見つからない | lost |
ステージ 3 と 4 でマッチングに使うのは、宣言全体ではなく 本体 のハッシュです。理由は機械的なものです。完全なハッシュは名前も含むため、リネームすれば定義上ハッシュは変わります。つまり完全なハッシュでリネームを捕まえることは原理的に不可能です。
本体ハッシュには本体ハッシュなりの罠があります。短い本体は偶然一致してしまうのです。{ return nil } は一つのファイルに二十回現れることもあり、そのどれもが「関数はこれにリネームされた」と“証明”してしまいます。そのため MIN_BODY_TOKENS = 8 トークン未満の本体は、そもそもマッチング対象にしません。自信満々の誤答は、正直な lost より質が悪いからです。
アンカーの状態は 4 つです。fresh は宣言がその場にあり、変更されていない状態。stale は同じ宣言で、本体が変わった状態。moved-renamed は見つかった状態で、レスポンスがいまどこにあり、いま何という名前かを教えてくれます。lost は何も解決できない状態です。これは「壊れた」ではなく、人間に判断を求めるシグナルです — アンカーを消すのか、貼り直すのか、それともページが存在しないコードを説明していると認めるのか。
三つのアンカー、一つのコミット#
仕組みはローカルインスタンスで手を動かして確かめました。パッケージ内の実在の関数に対する symbol アンカーを三つ — resolveAnchor、languageForPath、lineRangeAnchor。最初の検査は fresh、fresh、fresh。次に languageForPath の本体へ 一行だけ 追加するコミットを入れ、もう一度検査します。
{
"complete": true,
"budget": { "files_read": 3, "bytes_read": 10524, "elapsed_ms": 35, "limit": null },
"anchors": [
{ "qualified_name": "resolveAnchor", "state": "fresh",
"detail": { "reason": "identity_matched" } },
{ "qualified_name": "languageForPath", "state": "stale",
"detail": { "reason": "body_changed" } },
{ "qualified_name": "lineRangeAnchor", "state": "fresh",
"detail": { "reason": "identity_matched" } }
],
"fallback_share": { "total": 3, "fallback": 0, "share": 0 }
}状態が変わったのはちょうど一つだけです。この編集で行がずれた、同じファイル内の隣接する二つの宣言は fresh のまま — 行は同一性と無関係だからです。UI ではこれはカードとして現れます。「function languageForPath — Stale — The declaration is still there, but its body changed」と、「Confirm reviewed」ボタン。
このボタンは本質的な部分です。フラグを解除できるのは 明示的な confirm(POST /api/v1/anchors/{id}/confirm)だけ — 人間またはエージェントによる、意識的で監査可能な行為です。ひとりでに消えるバッジは、他のすべてのバッジの沈黙の価値を損ないます。緑がときどき「誰も見ていないだけで、こっそり古くなっている」を意味するなら、緑のステータスはもう信用できません。
アンカーは作成した時点で即座に解決されます。シンボル名のタイプミスはその場で弾かれ、一週間後に謎の lost として浮上することはありません。
予算と、このシステムがやらないこと#
一回の検査には厳しい上限があります。2000 ファイル、ソース 32 MB、30 秒。1 MB を超えるファイルはスキップし、repo-wide のステージは最大 4000 ファイルまで読みます。予算を使い切った場合、レスポンスはそれを正直に伝え — "complete": false、budget.limit、unchecked_anchor_ids のリスト — 未検査のアンカーは 以前の状態を保持 します。それらを lost と記すことは、読んでもいないコードについて嘘をつくことだからです。
このときリポジトリは読まれますが、決して実行されません。space には REPOS_DIR 配下に bare mirror を置き、blob は git show で取り出し、ワーキングツリーは作りません。つまりビルドも install スクリプトもフックも、物理的に実行されようがありません。プライベートリポジトリの認証情報は、値ではなく環境変数の 名前 で指定します。
CLEWWIKI_GIT_TOKEN_INTERNAL=ghp_... # 値は環境の中だけに存在するリポジトリ設定に保存されるのは CLEWWIKI_GIT_TOKEN_INTERNAL という文字列 — シークレットではなく名前です。許可されるのは CLEWWIKI_GIT_TOKEN と CLEWWIKI_GIT_TOKEN_<NAME> だけです。
トークンが渡されるのは自分のリポジトリの https オリジンに対してのみで、データベースにもバックアップにも API レスポンスにも入りません。
同じ staleness の仕組みは、コードがなくても機能します。ページはペアになったドキュメント(technical ↔ human)を持てて、その場合アンカーの対象はペアの content hash になります — テクニカル版が、ヒューマン版が先に進んだことを知るわけです。
プロジェクトは pre-alpha です。タグはなく、GHCR のイメージも npm パッケージも未公開で、動かす方法はソースからビルドするしかありません。それでもこの部分だけは、書いた目的だった問いにすでに答えています。ページはもう、昨日書き換えられた関数について、黙ったまま嘘を主張することができません。



