やってみた 2026年8月25日

claude-obsidianのガードレールを測ったら、手で書いたページがコミットを止めるのにコミットには入らなかった

XECIN PythonAIエージェントナレッジ管理CLI

AIエージェントに社内ドキュメントを書かせる、という話が最近よく出ます。私が毎回引っかかるのは品質でも速度でもなく、エージェントが書き込んだ結果を誰がどう受け入れるのかという一点です。生成物をそのまま本番のリポジトリに入れる仕組みには、必ず「ここから先は人間が見る」という線が要ります。

GitHubのトレンドで AgriciDaniel/claude-obsidian を見かけたとき、READMEにその線らしきものが書いてありました。「Workers return drafts. One orchestrator inspects and applies one recoverable transaction.」——並列エージェントが直接書くのではなく、下書きを返し、ひとつのトランザクションとして適用する。

運用を考えると、こういう主張は文章で読んでも意味がありません。実際にどこで止まるのかを測らないと、設計に取り込めない。というわけで使い捨てのコンテナで動かして、ゲートを1つずつ壊しながら挙動を確かめました。

試した環境

ホストを汚したくないので、検証はすべて使い捨ての Docker コンテナ内で完結させています。以下の数値はその中での実測です。

  • 対象: AgriciDaniel/claude-obsidian(HEAD 1c1bc49 / 2026-08-01、リリースは v2.1.0 / 2026-07-31、MIT)
  • ベースイメージ: python:3.12-bookworm(Debian GNU/Linux 12、Python 3.12.13、git 2.39.5)
  • ホストは Windows + Docker Desktop。コンテナ以外には何も入れていません

まず所感として良かったのは、依存が本当にゼロだったことです。pip install を一度も打っていません。

# すべて使い捨てコンテナ内
git clone --depth 1 https://github.com/AgriciDaniel/claude-obsidian.git   # 1,202ms
cd claude-obsidian && python3 scripts/claude-obsidian.py --version
# -> 2.1.0

# claude_obsidian パッケージが import している名前(重複除去)
# __future__ argparse builtins contextlib contextvars csv dataclasses datetime
# errno fnmatch hashlib hmac html io ipaddress json math mimetypes os pathlib
# posixpath re shutil socket stat struct subprocess sys tempfile time typing
# unicodedata urllib uuid zipfile
#   -> すべて標準ライブラリ。サードパーティは0

チェックアウトは .git を除いて 3,313,623 バイト、うち本体の claude_obsidian/ が 20 モジュール・18,762 行・686,990 バイト。最大のモジュールは transaction.py の 4,680 行でした。skills/ に 15 個のスキル定義が入っていますが、そちらは Claude Code などのエージェントホスト向けで、今回はほとんど触っていません。

ここが設計判断として面白いところです。トークンを消費する部分(読解・要約・リンク付け)はエージェント側に、消費しない部分(初期化・検査・トランザクション・Gitコミット)は決定的なPython側に、はっきり分かれている。前者は従量課金ですが、後者は何度回しても無料で、しかも出力が毎回同じです。運用コストの見積もりが立てやすい構造だと思います。

初期化は「計画を出して、そのハッシュを承認して、適用する」の2段階

init は一発では書きません。まず計画(JSON)を出し、その計画の SHA-256 を人間が読んで、同じコマンドにハッシュを付け直して再実行する、という形です。

# --- 1回目: dry-run。ファイルは1つも作られない ---
$ python3 scripts/claude-obsidian.py init /root/MyKnowledgeVault \
    --generated-at 2026-08-25T12:00:00Z --operation-id init-reviewed
# rc=0 / 241ms / stdout 7,516 バイト
#   status                = dry-run
#   approved_plan_sha256  = e061befb5ef6c33a7f00b59096231e48a8e10c7fabfdf512994aaec93cdc4e6e
#   changed_paths         = 14件
#   approval_hint         = review the operation, then repeat the exact pinned command with
#                           --approved-plan-sha256 e061befb... --apply
$ ls /root/ | grep MyKnowledgeVault   # -> 何もない

# --- ハッシュを間違えて --apply した場合 ---
$ ... --approved-plan-sha256 0000...0000 --apply
# rc=2
# ERR PLAN_CHANGED: the regenerated transaction differs from the reviewed plan;
#                   run a new dry-run and pin its generated_at and operation_id

# --- 正しいハッシュで --apply ---
$ ... --approved-plan-sha256 e061befb... --apply
# rc=0 / 464ms / status=complete / changed_paths=14
#   .claude-obsidian.json  .gitignore  .obsidian/*(4)  .raw/.manifest.json
#   inbox/.gitkeep  wiki/{hot,index,log,overview}.md  wiki/meta/ledgers/*(2)
# 出来上がったvault: 17ファイル / 65,649バイト

このハッシュが何に依存しているかも確かめました。同じ引数なら何度でも同じ値、--generated-at を変えれば別の値、--operation-id を変えても別の値、対象パスを変えても別の値。

計画の生成条件approved_plan_sha256(先頭8桁)
基準e061befb
まったく同じ引数でもう一度e061befb
—generated-at だけ変更382fbce4
—operation-id だけ変更997a4a7d
対象パスだけ変更a35ddc0b

承認したものと1バイトでも違えば適用されない、という契約がきちんと成立しています。エージェントが「計画を出す」ところまでやって、人間が読んでハッシュを渡す——レビューの単位としては、私はかなり好みです。

ちなみに doctor をvault指定なしで叩くと rc=2 ERR PLUGIN_ROOT_IS_NOT_VAULT: implicit discovery selected the plugin installation; pass a user vault with --vault で止まります。製品のチェックアウトを誤ってvault扱いする事故は、ここで潰れます。

Gitチェックポイントは、初期化直後には成功しない

この製品では Git へのコミットも自動ではなく、checkpoint <operation-id> という明示コマンドです。インストールガイドにも「Git history is optional and never automatic.」と書かれています。

で、初期化したてのvaultでこれを叩くと、素直には通りませんでした。まずvaultは init の時点ではGitリポジトリになっていません。.gitignore は作られるのに git init はされないので、そのまま叩くと rc=4 で ERR NOT_A_GIT_REPOSITORY: vault is not a Git repository: /root/MyKnowledgeVault が返ります。ここは想定内です。

では git init すればいいのかというと、そこから先が少し込み入っています。3通り試しました。

init 直後のGitの状態checkpoint の結果エラー
git init のみ(コミット0件)rc=4GIT_HEAD_MISSING(チェックポイントの親コミットが無い)
git add -A && git commit で全部入れるrc=4NOTHING_TO_COMMIT(トランザクションのパスに差分が無い)
git commit —allow-empty で空の基準コミットだけ作るrc=014 files changed, 162 insertions(+)

つまり init で作ったvaultを最初にチェックポイントしたいなら、空コミットを1つ置いてからでないと成立しません。真ん中の行が地味に効くところで、「とりあえず全部コミットしておくか」と手が動くと、その瞬間に init の成果物がベースラインに吸われて、チェックポイントする対象が消えます。

正直なところ、ここは自分の使い方次第だとも思っています。既存のObsidian vault(=すでにGit履歴がある)を adopt で取り込む経路なら、この鶏と卵は起きません。真っさらから init して即座に履歴を刻もうとした私の入り方が、想定の主経路ではないのでしょう。ただ、docs/install-guide.md にも README にも空コミットの話は見当たらなかったので、同じ順序で入る人はいるはずです。

ゲートは「全部の指摘」で止まるわけではない

ここからが本題です。checkpoint は内部で vault 全体の lint を回し、指摘があると止まります。ではどの指摘で止まるのか。

vaultを毎回作り直し、1ケースにつき1プロセスで、wiki/case-page.md を1枚だけ置き方を変えて置き、checkpoint の終了コードを測りました。対照群として「余計なページを置かない」ケースも取っています。

置いたページlint の指摘checkpointコミット内容
A: 置かない(対照)なしrc=014ファイル
B: 正しいページorphans=1rc=014ファイル(そのページは入らない)
C: 存在しない先へのリンクを含むdead_links=1, orphans=1rc=4コミットなし
D: frontmatter が無いmissing_frontmatter=1, orphans=1rc=4コミットなし
E: 見出し「Sources」が空empty_sections=1, orphans=1rc=014ファイル
F: 同名ファイルを別階層に2枚duplicate_basenames=1, orphans=2rc=014ファイル

止まったのは C と D だけで、エラーは ERR LINT_FAILED: vault lint has blocking findings: dead_links=1 のようにどのカテゴリで止めたかまで書いてくれます。この線引きは実装にそのまま書いてありました。

# claude_obsidian/checkpoint.py:41 — 止める側のカテゴリ(10カテゴリ中7つ)
BLOCKING_LINT_CATEGORIES = (
    "dead_links", "ambiguous_targets", "missing_frontmatter",
    "stale_index_entries", "read_errors", "configuration_errors",
    "provenance_errors",
)
# 止めない側: orphans / duplicate_basenames / empty_sections

# lint 単体の終了コード(同じvault、dead_links=1)
$ ... lint            ; echo $?   # -> 0   (既定は報告のみ)
$ ... lint --strict   ; echo $?   # -> 1   (指摘があれば1)

orphans(どこからもリンクされていないページ)が止まらない側にいるのは、頭に入れておく価値があります。READMEは「one connected knowledge graph」を掲げていますが、孤立ページの混入だけは、チェックポイントを素通りします。放置すると「入っているのに誰も辿り着けないページ」が静かに溜まる形なので、私が実運用に載せるなら lint --strict を別ゲートとして必ず併走させると思います。

手で書いたページは、コミットを止めるのに、コミットには入らない

上の表でいちばん気になったのは B の行です。指摘の有無にかかわらず、チェックポイントされるのは常に14ファイル——つまり init トランザクションが宣言したパスだけでした。私が置いた wiki/case-page.md は、成功したケースでも一度も入っていません。

# B(正しいページを1枚置いてチェックポイントが成功したvault)
$ git log --oneline
ed901cf case B_clean_page
50dd037 baseline

$ git status --short
?? wiki/case-page.md          # <- 置いたページは未追跡のまま

$ git ls-tree -r --name-only HEAD
.claude-obsidian.json  .gitignore  .obsidian/app.json  .obsidian/appearance.json
.obsidian/graph.json   .obsidian/snippets/vault-colors.css  .raw/.manifest.json
inbox/.gitkeep  wiki/hot.md  wiki/index.md  wiki/log.md
wiki/meta/ledgers/claim-ledger.json  wiki/meta/ledgers/source-ledger.json  wiki/overview.md

整理するとこうです。トランザクション外のファイルは、lint の対象には入る(=チェックポイントを落とす力はある)のに、コミットの対象には入らない。C と D では、私が置いた1枚のせいで、そのページとは何の関係もない14ファイルのコミットが丸ごと止まっていました。

これ自体は筋が通っていて、「コミットするのは承認済みトランザクションが宣言したパスだけ」という原則を守った結果です。ただ運用を考えると、非対称であることは知っておかないと事故ります。エージェントが下書きを wiki/ に置いたまま人間がチェックポイントを回すと、別作業の履歴が、無関係な下書きの体裁で止まるわけです。落ちたときのエラーは LINT_FAILED としか言わないので、「今回のトランザクションは正しいのに落ちた」理由にたどり着くまで、私は少し遠回りしました。

vaultは自分のファイルだが、ハッシュは覚えられている

もうひとつ。README は「Your vault remains a normal directory of Markdown」と書いていて、実際そのとおり普通のMarkdownです。ただ、トランザクションが書いたファイルを手で編集すると、次のチェックポイントが止まります。

$ python3 ... checkpoint init-reviewed        # 何も触っていない状態
rc=0

$ echo '- [[my-note]]' >> wiki/index.md       # Obsidian上で1行足したのと同じこと
$ python3 ... checkpoint init-reviewed
rc=4
ERR TRANSACTION_DRIFT: wiki/index.md changed after transaction completion

$ cp /tmp/index.orig wiki/index.md            # 元のバイト列に戻す
$ python3 ... checkpoint init-reviewed
rc=0

$ echo '- [[my-note]]' >> wiki/index.md
$ python3 - # 追記した1行だけを削って、元と同じバイト列に戻す
$ python3 ... checkpoint init-reviewed
rc=0                                          # 内容が同じなら通る(mtimeは見ていない)

タイムスタンプではなく内容ハッシュで見ているので、「触ったこと」ではなく「変えたこと」を検出します。ここは丁寧です。とはいえ、wiki/index.md は目次ページで、Obsidianで開いていたらいちばん手が伸びるファイルでもあります。手編集とチェックポイントは同じサイクルに混ぜられない、というのが実務上の結論でした。索引の更新はエージェント側のトランザクションに任せる、という設計思想がここに現れています。

試してみての所感

半日ほど触った範囲での評価です。

止め方の設計は、私が見てきたAI連携ツールの中ではかなり真面目な部類でした。計画のハッシュ承認、内容ハッシュでのドリフト検出、コミット対象をトランザクション宣言パスに限定する原則、そしてカテゴリ単位で明示された lint ゲート。どれも「エージェントが暴れたときに何が守られるか」を先に決めてから作られています。しかも LLM を一切呼ばない標準ライブラリだけの実装なので、この部分は何度回しても課金されず、出力も毎回同じ。課金される層と決定的な層の境界がコードの構造として引かれているのは、素直に良い設計だと思います。

一方で、私がそのまま社内に入れるかというと、あと2つ手当てが要ります。ひとつは orphans が止まらない件で、これは lint --strict を別ゲートに立てれば済みます。もうひとつは、トランザクション外のファイルがゲートには効くのにコミットには入らない非対称で、こちらは「エージェントの下書き置き場をvaultの外に分ける」といった運用ルール側の対処になりそうです。

真っさらから始めるなら、init --apply の直後に git init && git commit --allow-empty -m baseline を1回打っておく。この順序だけは、次に触るときの自分に向けて残しておきます。