issue #63: wiki_sync プロジェクト一覧への自動追加機能
この文書は Brain が run_implement で Gemini に実装を依頼するための手順書。
ステップ順に実行すること。各ステップ完了後に work_record で記録する。
設計詳細は docs/検討用/63_プロジェクト一覧への自動追加機能_実装案.md 参照。
butler/wiki_project_registry.py を新規作成する。
project_root: /home/akira/develop/butler2
goal: wiki_sync 実行時に同期済みプロジェクト情報を記録するレジストリモジュールを新規作成する
target_paths:
- butler/wiki_project_registry.py
scope:
- butler/wiki_project_registry.py (新規作成のみ)
success_criteria:
- butler/wiki_project_registry.py が存在する
- record_project() 関数が実装されている
- get_all_projects() 関数が実装されている
- python -c "from butler.wiki_project_registry import record_project, get_all_projects" がエラーなく通る
以下の仕様で butler/wiki_project_registry.py を新規作成する。
保存先ファイル: {butler_server_root}/session_logs/wiki_project_registry.json
butler_server_root は Path(__file__).resolve().parents[1] で解決する(= butler2 リポジトリのルート)。
プロジェクトが変わっても常に同じ JSON ファイルに集約される。
JSON スキーマ:
{
"projects": {
"butler2": {
"repo_slug": "butler2",
"wiki_root_prefix": "butler2",
"title": "Butler 2",
"description": "作業記録・Wiki同期システム",
"last_synced": "2026-06-08T13:00:00Z"
}
}
}
実装すべき関数:
def _registry_path() -> Path:
"""レジストリ JSON ファイルの絶対パスを返す。session_logs/ が未作成なら作る。"""
def _load() -> dict:
"""レジストリを読み込む。ファイルが存在しない or 破損していれば {"projects": {}} を返す。"""
def _save(data: dict) -> None:
"""レジストリを JSON に書き出す(ensure_ascii=False, indent=2)。"""
def record_project(
repo_slug: str,
wiki_root_prefix: str,
title: str,
description: str = "",
) -> None:
"""
プロジェクト情報を記録する(upsert)。
last_synced は UTC の ISO 8601 形式で設定する。
"""
def get_all_projects() -> list[dict]:
"""
全プロジェクト情報を title(なければ repo_slug)でソートして返す。
戻り値は list[dict]。
"""
インポート: from __future__ import annotations, json, datetime, timezone, Path のみ使用。
サードパーティライブラリは使わない。
butler/maids/wiki_worker.py の WikiWorker クラス定義の直前に、ホームページ更新用のヘルパー関数を追加する。
project_root: /home/akira/develop/butler2
goal: wiki_worker.py に Wiki.js /home ページ自動更新用のヘルパー関数を追加する
target_paths:
- butler/maids/wiki_worker.py
context_files:
- butler/maids/wiki_worker.py
- butler/wiki_project_registry.py
scope:
- butler/maids/wiki_worker.py への追記のみ(既存コードの変更・削除禁止)
success_criteria:
- _HOME_PAGE_PATH 定数が追加されている
- _generate_project_list_markdown() 関数が追加されている
- _inject_project_list() 関数が追加されている
- _update_wiki_home_page() 関数が追加されている
- _extract_project_description() 関数が追加されている
- python -m pytest tests/test_wiki_non_llm_maid.py が通る(既存テストの破壊がない)
butler/maids/wiki_worker.py の class WikiWorker: の直前(現在 442 行目付近)に以下の定数・関数を追加する。
既存コードは一切変更しない。追記のみ。
WikiWorker クラス直前)_HOME_PAGE_PATH = "home"
_HOME_PAGE_TITLE = "ホーム"
_PROJECT_LIST_START = "<!-- AUTO:PROJECT_LIST:START -->"
_PROJECT_LIST_END = "<!-- AUTO:PROJECT_LIST:END -->"
_HOME_PAGE_DEFAULT_HEADER = "# ホーム\n\nKeinafarm 各プロジェクトのドキュメント一覧です。\n\n"
WikiWorker クラス直前)_extract_project_description(content: str) -> str
docs/README.md の内容を受け取り、最初の H1 見出しの直後にある最初の非空行(段落の1文目)を description として返す。
見つからない場合は空文字列を返す。
def _extract_project_description(content: str) -> str:
lines = content.splitlines()
found_h1 = False
for line in lines:
if not found_h1:
if re.match(r'^#\s+', line):
found_h1 = True
continue
stripped = line.strip()
if stripped and not stripped.startswith('#'):
# 最初の文だけ(句点またはピリオドまで)
sentence = re.split(r'[。.\.]', stripped)[0]
return sentence.strip()
return ""
_generate_project_list_markdown(projects: list[dict]) -> str
プロジェクト一覧の Markdown テーブルを生成する。projects が空の場合は _(まだ登録されたプロジェクトはありません)_\n を返す。
テーブル形式:
## プロジェクト一覧
| プロジェクト | 説明 |
|---|---|
| [Title](/prefix) | description |
各行の値:
[{title}](/{wiki_root_prefix}) (title がなければ repo_slug を使う)description(空なら空欄)_inject_project_list(content: str, project_list_md: str) -> str
既存ページ内容 content の _PROJECT_LIST_START ~ _PROJECT_LIST_END 区間を project_list_md で置換する。
マーカーが存在しない場合はページ末尾に \n\n{_PROJECT_LIST_START}\n{project_list_md}{_PROJECT_LIST_END}\n を追記する。
マーカーありの場合の置換後: {START}\n{project_list_md}{END}(マーカー行を含む区間全体を入れ替える)
_update_wiki_home_page(wikijs_url: str, token: str, projects: list[dict], timeout: int = 30) -> tuple[bool, str]
Wiki.js の /home ページ(path="home")を更新する。
singleByPath(path="home", locale="en") で既存ページを fetch する。content フィールドを取得し、_inject_project_list() で project list 区間を差し替えて update mutation で保存する。成功時は (True, "home page updated") を返す。_HOME_PAGE_DEFAULT_HEADER + _PROJECT_LIST_START\n{project_list_md}{_PROJECT_LIST_END}\n を content として create mutation で作成する。成功時は (True, "home page created") を返す。(False, エラーメッセージ) を返す(例外は raise しない)。使用する GraphQL クエリ/ミューテーション:
singleByPath(path, locale) → id, content, titleupdate_mutation と同じ形式(wiki_worker.py 内の update_mutation 文字列を参考にする)create_mutation と同じ形式update/create の共通変数:
{
"content": new_content,
"description": "",
"editor": "markdown",
"isPublished": True,
"isPrivate": False,
"locale": "en",
"path": _HOME_PAGE_PATH,
"tags": [],
"title": _HOME_PAGE_TITLE,
}
既存の _graphql_call() を使うこと。
WikiWorker.execute() の末尾(return ok(...) の直前)に、プロジェクト記録とホームページ更新の呼び出しを追加する。
project_root: /home/akira/develop/butler2
goal: WikiWorker.execute() の末尾に、wiki_sync 成功時のプロジェクト登録とホームページ自動更新を追加する
target_paths:
- butler/maids/wiki_worker.py
context_files:
- butler/maids/wiki_worker.py
- butler/wiki_project_registry.py
scope:
- butler/maids/wiki_worker.py の WikiWorker.execute() メソッド末尾への追記のみ
success_criteria:
- WikiWorker.execute() が dry_run=False かつ created_pages or updated_pages があるとき record_project() を呼ぶ
- WikiWorker.execute() が dry_run=False かつ created_pages or updated_pages があるとき _update_wiki_home_page() を呼ぶ
- ホームページ更新の失敗は evidence["home_page_update"] に記録され、sync 全体の結果は変わらない
- python -m pytest tests/test_wiki_non_llm_maid.py が通る
butler/maids/wiki_worker.py の WikiWorker.execute() を修正する。
変更箇所: if failed_orphans: ブロックの後、return ok(task.task_id, ...) の直前に以下を挿入する。
# プロジェクト登録とホームページ自動更新
if not dry_run and (created_pages or updated_pages):
from butler.wiki_project_registry import record_project, get_all_projects
readme_path = repo_root / docs_root / "README.md"
project_title = repo_slug
project_description = ""
if readme_path.exists():
try:
readme_content = readme_path.read_text(encoding="utf-8")
project_title = _extract_title(readme_content, str(readme_path))
project_description = _extract_project_description(readme_content)
except Exception:
pass
try:
record_project(repo_slug, wiki_root_prefix, project_title, project_description)
all_projects = get_all_projects()
home_ok, home_msg = _update_wiki_home_page(wikijs_url, wikijs_token, all_projects, timeout)
evidence["home_page_update"] = {"ok": home_ok, "message": home_msg}
except Exception as exc:
evidence["home_page_update"] = {"ok": False, "error": str(exc)}
注意:
import は関数内ローカルインポートで行う(モジュールレベルに追加しない)evidence["home_page_update"] に記録するだけで、return failed(...) は呼ばないrepo_root, repo_slug, wiki_root_prefix, docs_root, dry_run, created_pages, updated_pages, wikijs_url, wikijs_token, timeout は execute() のローカル変数として既に存在しているtests/test_wiki_home_update.py を新規作成する。
project_root: /home/akira/develop/butler2
goal: ホームページ自動更新機能のユニットテストを作成する
target_paths:
- tests/test_wiki_home_update.py
context_files:
- butler/maids/wiki_worker.py
- butler/wiki_project_registry.py
- tests/test_wiki_non_llm_maid.py
scope:
- tests/test_wiki_home_update.py (新規作成のみ)
success_criteria:
- python -m pytest tests/test_wiki_home_update.py -v が全テスト PASS する
- 以下のテストケースが含まれる: test_generate_empty, test_generate_entries, test_inject_with_markers, test_inject_no_markers, test_update_home_page_update, test_update_home_page_create, test_record_project
tests/test_wiki_home_update.py を新規作成する。既存の tests/test_wiki_non_llm_maid.py の構造を参考にする。
test_generate_empty
_generate_project_list_markdown([]) が "_(まだ登録されたプロジェクトはありません)_" を含む文字列を返すことを確認。
test_generate_entries
projects = [
{"repo_slug": "butler2", "wiki_root_prefix": "butler2", "title": "Butler 2", "description": "作業記録"},
{"repo_slug": "vps", "wiki_root_prefix": "vps", "title": "VPS", "description": ""},
]
を渡して、結果が "## プロジェクト一覧" を含み、"[Butler 2](/butler2)" を含み、"[VPS](/vps)" を含むことを確認。
test_inject_with_markers
マーカーありのページ内容に対して _inject_project_list() を呼ぶと、マーカー区間のみ置換されて前後の手書き部分が保持されることを確認。
content = "# ホーム\n\n手書き部分\n\n<!-- AUTO:PROJECT_LIST:START -->\n古い内容\n<!-- AUTO:PROJECT_LIST:END -->\n\n後書き\n"
result = _inject_project_list(content, "新しい内容\n")
assert "手書き部分" in result
assert "後書き" in result
assert "古い内容" not in result
assert "新しい内容" in result
test_inject_no_markers
マーカーなしのページ内容に対して _inject_project_list() を呼ぶと、末尾に追記されることを確認。
content = "# ホーム\n\n既存内容\n"
result = _inject_project_list(content, "プロジェクト一覧\n")
assert "既存内容" in result
assert "<!-- AUTO:PROJECT_LIST:START -->" in result
assert "プロジェクト一覧" in result
test_update_home_page_update
_graphql_call をモックして、既存ページが存在する場合に update mutation が呼ばれることを確認。
# _graphql_call の戻り値:
# 1回目 (fetch): (True, {"data": {"pages": {"singleByPath": {"id": 5, "content": "# ホーム\n", "title": "ホーム"}}}})
# 2回目 (update): (True, {"data": {"pages": {"update": {"responseResult": {"succeeded": True, "message": ""}}}}})
# _update_wiki_home_page() が (True, "home page updated") を返すことを確認
test_update_home_page_create
_graphql_call をモックして、ページが存在しない場合に create mutation が呼ばれることを確認。
# 1回目 (fetch): (True, {"data": {"pages": {"singleByPath": None}}})
# 2回目 (create): (True, {"data": {"pages": {"create": {"responseResult": {"succeeded": True, "message": ""}, "page": {"id": 1, "path": "home", "title": "ホーム"}}}}})
# _update_wiki_home_page() が (True, "home page created") を返すことを確認
test_record_project
record_project() を呼んだ後 get_all_projects() で取得できることを確認。
tmp_path fixture を使ってレジストリファイルをテンポラリディレクトリに向ける(monkeypatch で _registry_path をオーバーライドする)。
def test_record_project(tmp_path, monkeypatch):
reg_file = tmp_path / "wiki_project_registry.json"
monkeypatch.setattr("butler.wiki_project_registry._registry_path", lambda: reg_file)
record_project("myrepo", "myrepo", "My Repo", "説明")
projects = get_all_projects()
assert len(projects) == 1
assert projects[0]["title"] == "My Repo"
work_record(issue #63 に紐付け)work_record(issue #63 に紐付け)work_record(issue #63 に紐付け)work_record(issue #63 に紐付け)uv run pytest tests/ で既存テストが壊れていないことを確認してから work_publish_extract_project_description, _update_wiki_home_page が存在していないと動かない)_graphql_call のモックは unittest.mock.patch で butler.maids.wiki_worker._graphql_call をターゲットにする設計詳細は docs/検討用/63_プロジェクト一覧への自動追加機能_実装案.md のフェーズ2セクション参照。
butler/wiki_doc_summarizer.py を新規作成する。
project_root: /home/akira/develop/butler2
goal: ドキュメントの本文冒頭から説明文を生成する要約専用モジュールを新規作成する
target_paths:
- butler/wiki_doc_summarizer.py
scope:
- butler/wiki_doc_summarizer.py (新規作成のみ)
success_criteria:
- butler/wiki_doc_summarizer.py が存在する
- summarize_doc() 関数が実装されている
- uv run python -c "from butler.wiki_doc_summarizer import summarize_doc" がエラーなく通る
butler/wiki_doc_summarizer.py を新規作成する。
公開 API:
def summarize_doc(title: str, content_head: str) -> str:
"""
ドキュメントのタイトルと本文冒頭を受け取り、1〜2文の日本語説明文を返す。
Gemini CLI を使って生成する。失敗時は空文字列を返す(例外を raise しない)。
"""
仕様:
以下のドキュメントの内容を1〜2文で要約してください。日本語で答えてください。説明文のみ出力してください。
タイトル: {title}
本文:
{content_head}
butler/maids/gemini_maid.py(または同等の既存モジュール)を参考にするbutler/maids/wiki_worker.py の WikiWorker クラス定義の直前に、インデックスページ生成・更新用の関数を追加する。
project_root: /home/akira/develop/butler2
goal: wiki_worker.py にプロジェクトインデックスページ自動生成・差分更新用のヘルパー関数を追加する
target_paths:
- butler/maids/wiki_worker.py
context_files:
- butler/maids/wiki_worker.py
- butler/wiki_doc_summarizer.py
scope:
- butler/maids/wiki_worker.py への追記のみ(既存コードの変更・削除禁止)
success_criteria:
- _generate_index_page() 関数が追加されている
- _update_index_page() 関数が追加されている
- uv run pytest tests/test_wiki_non_llm_maid.py が通る(既存テストの破壊がない)
# {プロジェクト名}
{プロジェクト概要文}
## ドキュメント一覧
### {サブディレクトリ名}
- [{タイトル}](/{wiki_path}) — {説明文}
### {別のサブディレクトリ}
- [{タイトル}](/{wiki_path}) — {説明文}
### セクションなし)—(全角ダッシュ)で区切る_generate_index_page(prefix: str, project_title: str, project_overview: str, entries: list[dict]) -> strインデックスページの Markdown 文字列を生成して返す。
entries の各要素:
{
"wiki_path": "keinasystem/00_設計ポリシー", # /{prefix}/ を除いたパス
"title": "設計ポリシー:生産履歴中心設計",
"description": "Gemini が生成した説明文",
"subdir": "圃場管理", # サブディレクトリ名。ルート直下なら ""
}
サブディレクトリごとにグループ化して ### {subdir} セクションを作る。subdir が空のものは先頭に配置する。
_update_index_page(wikijs_url: str, token: str, prefix: str, updated_entries: list[dict], all_entries: list[dict], project_title: str, timeout: int = 30) -> tuple[bool, str]インデックスページを差分更新する。
singleByPath(path=prefix, locale="en") で既存ページを fetch する- [{title}](/{wiki_path}) — {description} 形式の行を正規表現でパース)updated_entries に含まれるエントリの説明文を新しい値で上書きするall_entries のうち既存ページにない新規エントリを追加するall_entries にないエントリ(削除されたドキュメント)を取り除く_generate_index_page() でページ全体を再構築して update mutation で保存するall_entries の説明文をそのまま使って _generate_index_page() でページを生成し create mutation で作成する(True, "index page updated/created")、失敗時 (False, エラーメッセージ) を返す既存のプロジェクト登録・ホームページ更新フック(フェーズ1で追加済み)の直後に、インデックスページ更新を追加する。
project_root: /home/akira/develop/butler2
goal: WikiWorker.execute() のプロジェクト登録フックにインデックスページ自動更新を追加する
target_paths:
- butler/maids/wiki_worker.py
context_files:
- butler/maids/wiki_worker.py
- butler/wiki_doc_summarizer.py
scope:
- butler/maids/wiki_worker.py の既存フックブロックへの追記のみ
success_criteria:
- WikiWorker.execute() が created_pages + updated_pages のドキュメントについて summarize_doc() を呼ぶ
- WikiWorker.execute() が _update_index_page() を呼ぶ
- インデックスページ更新の結果が evidence["index_page_update"] に記録される
- uv run pytest tests/test_wiki_non_llm_maid.py が通る
フェーズ1で追加した以下のブロック:
if not dry_run and (created_pages or updated_pages):
...
try:
record_project(...)
...
home_ok, home_msg = _update_wiki_home_page(...)
evidence["home_page_update"] = {"ok": home_ok, "message": home_msg}
except Exception as exc:
evidence["home_page_update"] = {"ok": False, "error": str(exc)}
の try ブロック内、_update_wiki_home_page() の呼び出しの後に以下を追加する:
# インデックスページ更新
from butler.wiki_doc_summarizer import summarize_doc
# created + updated のエントリの説明文を Gemini で生成
changed_paths = {p["wiki_path"] for p in created_pages + updated_pages}
all_mapped = mapped_pages # WikiWorker.execute() 内のローカル変数
updated_entries = []
all_entries = []
for page in all_mapped:
wiki_path = page["wiki_path"]
title = page.get("title", wiki_path.split("/")[-1])
subdir_parts = wiki_path[len(wiki_root_prefix)+1:].split("/")
subdir = subdir_parts[0] if len(subdir_parts) > 1 else ""
entry = {"wiki_path": wiki_path, "title": title, "subdir": subdir, "description": ""}
if wiki_path in changed_paths:
repo_file = page.get("repo_file", "")
content_head = ""
if repo_file:
try:
content_head = Path(repo_file).read_text(encoding="utf-8")[:1000]
except Exception:
pass
entry["description"] = summarize_doc(title, content_head)
updated_entries.append(entry)
all_entries.append(entry)
index_ok, index_msg = _update_index_page(
wikijs_url, wikijs_token, wiki_root_prefix,
updated_entries, all_entries, project_title, timeout
)
evidence["index_page_update"] = {"ok": index_ok, "message": index_msg}
_generate_project_list_markdown() のリンク生成を修正する。
インデックスページが常に存在するようになったため、リンク切れが起きなくなる。
project_root: /home/akira/develop/butler2
goal: _generate_project_list_markdown() のリンクが /{wiki_root_prefix} を指すよう修正する(現状と同じだが、インデックスページが常に存在するようになったことを前提として明示する)
target_paths:
- butler/maids/wiki_worker.py
scope:
- _generate_project_list_markdown() 関数のみ
success_criteria:
- _generate_project_list_markdown() のリンクが /{wiki_root_prefix} を指している
- uv run pytest tests/test_wiki_home_update.py が通る
_generate_project_list_markdown() 内のリンク生成行:
md += f"| [{title}](/{prefix}) | {description} |\n"
はそのままで良い。インデックスページ(/{prefix})が常に存在するようになるため、リンク切れが解消される。
ただし、title と description が空のプロジェクト(registry に登録済みだがインデックスページ未生成)については、title を repo_slug にフォールバックする処理が既に実装されていることを確認する。
work_record(issue #63 に紐付け)work_record(issue #63 に紐付け)work_record(issue #63 に紐付け)work_record(issue #63 に紐付け)uv run pytest tests/ で確認してから work_publishsummarize_doc が存在していないと動かない)page["repo_file"] の形式は mapped_pages の実際の構造を確認してから実装するgemini_maid.py 等)に合わせる