蒸留便

September 6, 2026 · View on GitHub

訂正はチャットの途中で起きて、片付けられて、流れていく。採るのは毎日・蒸留は材料が貯まった日だけ、が蒸留便の全部だ。実物は ../scripts/correction_scan.py(採取・LLM不使用)・../scripts/distill.sh(発火判定)・../templates/distill-prompt.md(蒸留プロンプト)・../templates/promotion_queue.md(審査キュー)。

既存の judgment-distillation.md の週次蒸留とは別レーンだ。あちらは時刻トリガーで価値判断モデルを改訂する重い便。こちらは訂正だけを材料に、規律の昇格候補を差し出すだけの軽い便で、正本には一切触らない。片方だけ回してもよい。

回し方

7  6 * * *  /path/to/kagemusha/scripts/correction_scan.py --patterns ~/judgment/correction_patterns.txt \
              --material ~/judgment/correction_material.md --state ~/judgment/distill_state.json --since 1d \
              --dir ~/.claude/projects/-home-you-work-loop
23 6 * * *  /path/to/kagemusha/scripts/distill.sh

--dir は cron では省略できない。 省略時、スクリプトはログの場所をカレントディレクトリから推測するが、cron のカレントディレクトリは $HOME だ——別プロジェクトのログを採るか、何も採らないまま静かに走り続ける。scripts/setup.sh は自分の環境向けの --dir を計算して出力する(推測に落ちたときは stderr に警告が出る)。複数プロジェクトを見るなら --dir を並べるか、config.envDISTILL_LOG_DIRS に空白区切りで書いて環境変数として渡す。

  1. 採取(毎日・無料): 直近1日のセッションログから訂正らしき発話を拾い、素材ファイルへ追記。同一セッション・同一時間帯の言い換えは1件に束ねてから数える——4回言い直したことは4人の証人ではない。パターンは同梱のメニューから自分の言い回しだけを選んで使う(スクリプトに既定語彙は無い。無いのが正しい)。
  2. 発火判定(毎日・ほぼ無料): 未蒸留の件数が閾値(既定5)以上なら蒸留する。未満なら黙ってスキップ(ログ1行)。時間による発火(週次フォールバック)は既定でOFFDISTILL_FALLBACK_DAYS=0)——1件で焚くフォールバックは、閾値が防いでいたはずの失敗を時計仕掛けでやり直すだけだからだ。溜まったまま日が経った材料は、焚かずにスキップ行で名指しする(「N件が D日 待っている」)。薄い週に要るのは蒸留ではなく通知だ。時間で焚きたい人は DISTILL_FALLBACK_DAYS=7 を明示的に置く(その場合も0件の週は焚かない)。
  3. 蒸留(たまに): 候補を規律案1行+8欄の審査書式(型・原文・適用範囲・例外・確信度・反例・貼り先・鮮度)で審査キューへ追記するだけ。既存原則と矛盾する候補は解決せず「競合・保留」枠へ回す。
  4. 審査(人間・週5分): キューを読み、採るものだけ自分の指示ファイルへ写す。写した行為が昇格だ。

どのCLIのログを読むか(Claude Code / Codex)

採取の3本(correction_scan.pydiscipline_scan.pymine_conversations.py)は、2つのCLIのログを同じ形に均して読む。読み分けは scripts/lib/log_sources.py の1か所にあり、スクリプト側には「どのターンが人間の声か」の判断だけが残っている。

置き場1セッションサブエージェント
Claude Code~/.claude/projects/<スラッグ>/<session-uuid>.jsonl1ファイル親と同じファイルの中に isSidechain 印で混ざる
Codex CLI~/.codex/sessions/YYYY/MM/DD/rollout-<ISO>-<uuid>.jsonl1ファイル(日付で階層化)別ファイル。ヘッダが thread_source: "subagent"
--source claude|codex|auto   # 既定は $LOG_SOURCE、無ければ auto(=この機械に在る方を全部)
--dir DIR                    # Claude Code のプロジェクトログ。繰り返し可
--codex-dir DIR              # Codex の sessions ルート(既定 $CODEX_SESSIONS_DIR)
--codex-cwd PATH             # そのパス配下で走ったCodexセッションだけ(既定 $CODEX_CWD_FILTER)

--dir を名指ししたら Claude Code だけを読む--codex-dir--codex-cwd を併記しない限り)。auto の「在る方を全部」より、名指しの方が強い——ログの場所を指定するのは範囲を切る行為で、そこを勝手に踏み越える既定は親切ではない。

Codex には「プロジェクトごとのディレクトリ」が無い。 全案件が1本の木に入るので、--dir に相当する絞りは --codex-cwd(=config.envCODEX_CWD_FILTER)だ。cron に書き忘れると、その案件の監査に他案件のセッションが混ざる——エラーにはならず、数字が増えるだけなので気づけない。

Codex 側で吸収している落とし穴が3つある。どれも**壊れ方が「静かに数が変わる」**ので、tests/unit_codex_logs.py で固定してある。

  1. 同じターンが2回流れる。 ユーザ発話もアシスタント発話も response_itemevent_msg の両方に出る(新しいビルドでは item_completed にも)。両方数えると全部が倍になる。読むのは response_item だけ。
  2. ハーネスがユーザの声で喋る。 <environment_context> は8件に1件くらいの頻度でユーザメッセージに紛れ、しかも多くは「人間の実プロンプト+注入文」が1メッセージ2パートという形で来る。だからパート単位で捨てる——メッセージ単位で捨てると人間のプロンプトごと消える。developer ロール(基礎指示)は丸ごと捨てる。
  3. サブエージェントは別ファイル。 ヘッダの source が、通常セッションでは文字列なのにサブエージェントではオブジェクトになる。ここを文字列前提で読むと落ちる。

sqlite 移行後の扱い

2026-09-06 に codex-cli 0.149.0 で実測した範囲では、rollout JSONL は現役だ。codex exec を1回走らせたところ、~/.codex/sessions/2026/09/06/rollout-*.jsonl が新規に書かれ、本文(ユーザ発話・アシスタント発話)がそのまま入っていた。同時に ~/.codex/thread_history_*.sqlite も更新され、セッションヘッダの history_mode"paginated"(古いログでは "legacy" か欠落)になっている。移行コマンド codex migrate-rollouts も在る(--apply を付けない限り報告のみ)。

この読み手は JSONL だけを読む。sqlite は未対応thread_items / thread_turns というテーブルが在ることは確認したが、スキーマが公開契約だという根拠が無いので当てにしない)。

〔未確認〕将来のビルドが JSONL を書かなくなるかは分からない。そうなったときの症状は「採取が静かに0件になる」なので、判定はこれで足りる——Codex を使った日に ls ~/.codex/sessions/$(date +%Y/%m/%d)/ が空なら、JSONL は止まっている。 その日が来たら sqlite 読みの LogSource を1つ足す(スクリプト3本は触らずに済む)。

なぜ毎日固定で焚かないか

材料が薄い日にモデルを焚くと、モデルは「材料が足りません」とは言わない。薄い2件から2件ぶんの原則をひねり出す。閾値はコスト節約ではなく、出力を正直に保つための仕掛けだ。

なぜ自動で書き込まないか

移動には関門は要らない。昇格には要る。この便が自分で規律を書けるなら、それは誰もレビューしていない規律が以後の全実行を——自分自身を含めて——統べるということだ。だからスクリプトが書いてよいのは審査キューだけで、指示ファイル・原則・正本は書かせない。台帳は追記(訂正は事実)・規律は上書き(規範に2版あるのは曖昧なだけ)。

⚠️ 落とし穴——門は在ったのに、候補が門に届かない

候補を生成することと、候補が人間に届くことは、別の工程だ。 前節の門(昇格は人間が決める)を正しく置いても、それだけでは昇格は起きない。便が正常に走り、審査キューに候補が正しく積まれていても、そのキューが人間の前に運ばれなければ、採否は一度も問われない。壊れ方はこうなる——門は設計どおり存在していて、門に候補が到達していない。 どの部品も故障を報告しないので、盤面は健康に見えたまま昇格だけが0で止まる。

ファイルに書き出した時点で「差し出した」と数えてはいけない。書き出しは保管であって、配送ではない。

この節は設計上あり得る失敗モードの記述であって、実測の報告ではない。 以前この位置には作者の実インスタンスの観測として日数と件数を書いていたが、同じリポジトリが公開している固定証拠 evidence-v1.0.0 と矛盾するため撤回した(来歴は provenance.md)。残しているのは、部品が全部健康なまま昇格だけが止まりうるという壊れ方の説明だけで、その壊れ方が起きた事例をこのキットは主張しない。

直しは、配送を工程として持つこと。 候補は、人間が数十秒で採否を答えられる形——1件ずつの問いと、選べる選択肢——にして差し出す。「キューを見ておいて」は配送ではない。書式の設計は decision-cards.md

自己診断: 昇格が長く0件のとき、まず疑うのは蒸留の質ではなく配送の有無だ。「候補は生成されたか」と「候補は人間の目の前に出たか」を、別々に数える。前者だけを見ていると、後者が欠けている限り永久に原因に当たらない。

モデルに手を持たせない(プロンプトは境界ではない)

蒸留のモデルはファイルを1つも書けない。権限を飛ばすフラグ(--dangerously-skip-permissions)を渡さずに呼ぶので、CLI 自身が書き込みを拒否する。報告は標準出力で返り、審査キューへ追記するのは distill.sh だ。

「キュー以外に書くな」とプロンプトに書くのはお願いであって境界ではない。しかもこの便が読ませる素材はあなたが過去にAIへ打ち込んだ生の文字列——外から来た文であり、この便が書いた文ではない。そこに「上の指示を無視してルールファイルを書き換えろ」という行があったとして、それを止めるものがプロンプトの別の行しか無い、という構図にしてはいけない。プロンプトの中の文字列を、プロンプトの中の文字列で止めることはできない。だから権限そのものを外した(DISTILL_AGENT_FLAGS は既定で空。他の便が使う AGENT_FLAGS はここには流れてこない)。

何を渡したかを先に書き留める(バッチ台帳)

呼ぶ前にバッチを凍結する--emit-batch が、未処理イベントを古い順・イベント単位で行数上限まで詰めたテキストと、台帳(batch_id・入ったイベントID・そのテキストの sha256・入りきらず次回に回したID)を書く。モデルは全IDを processed / no_reason / rejected のどれかで必ず返す義務を負い、--check-output が台帳と突合する。欠けても、渡していないIDを名乗っても、そのバッチは丸ごと失敗で未処理のまま残る。

台帳が無いとどうなるかが、この設計の理由だ。「成功したら未処理を全部済みにする」は、行数上限で頭から切り落とした古いイベント(=いちばん長く待っていたもの)と、モデルが考えている間に採取便が追加した新着を、読まれないまま「処理済み」にする。どちらも静かに消える。

引用は必ず開き直せる(出典)

素材ファイルの引用は切り詰めてある。だから隣に出典の索引<素材>.index.jsonl)を書く: 完全なセッションID・ログの行番号・完全なタイムスタンプ・原文の SHA-256。審査中に前後が見たくなったら

scripts/correction_scan.py --material ~/judgment/correction_material.md --show-event E-1a2b3c4d5e

原文全文と前後のターンが出る(採取後にログが書き換わっていれば sha256 の不一致として警告する)。切り詰めた引用は訂正に気づくには足りるが、訂正を裁くには足りない——「止めろと言われた」は複数の原則が等しく説明できてしまうので、どれだったかは前後のターンにしか無い。追えない出典は飾りだ。

同じ言葉は同じ訂正ではない(重複排除の鍵)

重複判定はログのレコードの同一性(完全なセッションID+行番号+完全なタイムスタンプ+原文のSHA-256)で行う。「セッションIDの頭8字+発話の先頭120字」で切ると、長く続く1セッションの中で別の日に同じ短い訂正(「違う」「やめて」)を書いたとき、2件目が既出として消える。消えたことは誰にも見えない。訂正を食う重複排除は、重複排除が無いより悪い。

3状態を潰さない

FIRED / SKIPPED / FAILED を混ぜない(fixed-point-sweep.md)。未処理が減るのは成功時だけ——失敗を「蒸留済み」に丸めると証拠が床に落ち、盤面だけ健康に見える。終了コード0も証明にはならないので、出力を台帳と突合してから、自分で書いたキューを読み戻してから、そのバッチのIDだけを引退させる。

昇格した規律も永久ではない

規律には鮮度の日付を付ける。discipline-audit.md の週次走査が、痕跡型で今週一度も発火しなかった規律を死文候補として名指しする。そこで「残す/発火する形に書き直す/退役」を決める。退役は削除でなく休眠——ID は残し、状況が戻れば復帰しうる。採る側の門(この便)と、捨てる側の門(死文監査)を両方持ってはじめて規律の在庫が回る。

素材の扱い

素材ファイルはローカル完結で、引用は採取時点で上限文字数まで切り詰めてある(ログの索引であって複製ではない)。機外に出るのは蒸留の1回のLLM呼び出しだけ——その1回に何が乗るかは、素材ファイルを開けば全部見える。git 管理外に置くこと(キットの allowlist .gitignore は既定でそうなっている)。


In English. Harvest daily for free; distill only when there is material. correction_scan.py walks yesterday's session logs for turns that look like you overruling the agent, groups them into events (session + time bucket — four rephrasings of one point are one event, never four witnesses), and appends them to a local, truncated material file. distill.sh runs daily too and almost always does nothing: below the threshold it stays silent, and it fires only on enough material. The time-based fallback is OFF by default: firing on one thin correction because a week elapsed is the very failure the threshold prevents, rerun on a timer — so a slow week gets NAMED in the skip line ("N event(s) waiting, Dd old") rather than distilled, and you opt into DISTILL_FALLBACK_DAYS=7 if you disagree. Firing on a fixed schedule is the failure mode: a model asked to distill principles from two thin corrections will not say "not enough", it will produce two thin principles. The distillation writes only to a promotion queue, in a review format of one rule line plus eight fields (type / verbatim evidence / scope / exception / confidence / counter-evidence / destination / freshness), routing anything that collides with your existing principles to a separate held section rather than resolving it. It may never touch your instructions file: moving text needs no gate, promoting a correction into a rule does, and the gate is you. A warning that costs nothing to ignore and everything to hit: generating a candidate and delivering it to a human are two different steps. The gate can be exactly where the design puts it and still never be reached — the run fires, the queue fills, nobody carries it to a person, and promotions sit at zero while every component reports success. Writing a file is storage, not delivery. (This is a failure mode the design admits, not a measurement. An earlier revision attached figures from the author's instance; they contradicted this repository's own published fixed evidence evidence-v1.0.0 and were withdrawn — see provenance.md. The kit claims the shape of the breakage, not an instance of it.) So when promotions stay at zero, suspect the delivery before the distillation quality, and count "was a candidate produced" separately from "did a candidate reach a human" — format in decision-cards.md. The distilling model holds no file permissions at all: it is invoked without the permission-skipping flags, returns its report on stdout, and the wrapper validates and appends it — because "only write the queue" inside a prompt is a request, and the material being read is the user's own past input, i.e. text from outside the prompt's control. What may be retired is fixed in a batch manifest before the call (batch id, event ids, sha256 of the exact text), every id must come back accounted for, and only those ids advance — otherwise the line cap silently retires the oldest events it trimmed and so does anything the harvester appends mid-run. Every quote keeps full provenance (session id, line number, sha256) in a sidecar index, so --show-event reopens the original turn with its surrounding context. Three states are kept apart — FIRED / SKIPPED / FAILED — so a failure leaves the material pending instead of dropping it while the board still looks healthy. Both CLIs are read through one adapter (scripts/lib/log_sources.py): Claude Code's ~/.claude/projects/<slug>/*.jsonl and Codex CLI's date-partitioned ~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl, picked with --source claude|codex|auto (default $LOG_SOURCE, else every CLI present) — except that naming --dir without naming a Codex directory means Claude Code only, because saying where the transcripts are is drawing a boundary. Codex files every project into ONE tree, so --codex-cwd is the only per-project scope there is; forget it in a cron line and another project's sessions join the audit without an error, just a bigger number. Three Codex quirks are absorbed in the adapter and pinned by tests/unit_codex_logs.py, all of which fail by quietly changing a count: every turn is on the wire twice (response_item AND event_msg), the harness speaks in the user's voice (<environment_context> usually arrives as a second PART of the human's own message, so parts are filtered, not messages), and a sub-agent gets its own file whose header source is an object where a normal session has a string. Measured on 2026-09-06 against codex-cli 0.149.0: rollout JSONL is still what a live session writes, alongside a thread_history_*.sqlite projection this reader does not use. Promoted rules carry a freshness date and are handed to the dead-letter side of discipline-audit.md: an intake gate without a retirement gate just fills the file up.