🗓️ この記事の前提(2026-09-12 時点)
  • Python 3.12 / FastAPI 0.141.1 / SQLite(Python 同梱の sqlite3)/ htmx 2.0.10 / Windows 11 で書いています。macOS/Linux では動かしていません
  • 記事は 2 本立てです。今回は Web アプリが動くまで次回、ここに MCP サーバーを生やして Claude Code から書き込みます
  • 画面に写っている項目・ワークスペース名・AI の名前 ai:demo-assistant は、すべてこの記事のために用意した架空のデータです。同じ画面はリポジトリのダミーデータで再現できます
  • ソースは GitHub の ai-taskboard(MIT)に全文があります。本文では要点だけを引用し、行番号は v1.0.0 のものです

🧭 はじめに:残項目がチャットの末尾に散らばる

Claude Code に仕事を頼むと、セッションの終わりに「残項目」が並びます。次の日、別のセッションで続きを頼むと、その残項目はもう見えません。仕方なく人間がメモ帳に書き写す。担当を分けた AI が複数いれば、それぞれの完了報告の末尾にも残項目が生えている。AI 側のメモリに「続報を追う」と書いてある案件もあって、それは人間からは一覧で見えない。

つまり、こうなります。

今の置き場 何が入っているか 困りごと
チャットの会話 セッション末尾の「残項目」まとめ セッションが切り替わると探せない。人間が別の場所へ書き写している
タスクごとの完了報告 各担当の AI が書く「残課題」欄 タスク単位に分かれていて横断できない。片付いたかどうかを誰も更新しない
AI 側のメモリ 続報ウォッチ・企画のタネ AI の記憶であって、人間が一覧で見る画面が無い

欲しかったのは、人と AI が同じ台帳に書ける場所です。人はブラウザで見て書き、AI は自分の道具(ツール)で同じ項目を読み書きする。そして、後から「これは誰がいつ書いたのか」が全部たどれること。

作ったものの全体像はこうです。今回作るのは実線の部分で、点線の入口は次回です。

flowchart TD H["人間
ブラウザ"] -->|"フォーム POST + htmx"| UI["Web UI
/w/blog"] AI["Claude Code
(次回)"] -.->|"stdio"| MCP["MCP サーバー
(次回)"] OT["他の AI・スクリプト"] -.->|"HTTP"| REST["REST /api/v1"] UI --> SVC["service.py
唯一の書き込み経路
author と event を必ず残す"] MCP -.-> SVC REST -.-> SVC SVC --> DB[("SQLite 1 ファイル(WAL)
workspace / item / note / event")]

入口が 3 つあっても、書き込みは全部 service.py を通ります。誰がどこから書いても、同じ形の履歴が残る。この 1 点を守るために、あとの設計はほぼ決まりました。

前作の MCP サーバーの作り方と同じく、ハードウェアは使いません。uv が入った PC が 1 台あれば、最後まで手元で動きます。


🤔 なぜ既製のタスク管理ではなく作るのか

タスク管理ツールは世の中に山ほどあります。それでも作ったのは、譲れない条件が 3 つあったからです。

  1. AI が書ける口がある。しかも「誰が書いたか」が人と AI で区別されて残る
  2. ページを分けられて、ページごとに AI からの見え方を変えられる。ブログの候補は AI に整理させたいが、仕事の項目は AI に渡したくない
  3. ローカルで完結する。仕事の項目をクラウドに置かない。バックアップはファイルのコピー 1 回

この 3 つで既製ツールを見ると、こうなります。

AI が書ける口 ページ分割と AI 権限 ローカル完結 見送った理由
Notion(API) 仕事の項目をクラウドに置くことになる
Trello(API) 同上。author を ai:* で残す仕組みは自前で被せる必要がある
GitHub Projects ○(GraphQL) 開発項目には向くが、ブログ候補や仕事の項目を GitHub に置く理由が無い
Obsidian/Markdown+git 人間には快適だが、AI が状態と履歴を構造化して書く口が弱い
自作 この記事

Markdown ファイル+git は最後まで迷いました。決め手は「状態を変えた」という操作を構造化された履歴として残したかったことです。テキストの差分からは「誰が候補を着手に動かしたのか」は読み取れません。

ページ=ワークスペース、と AI 権限の 3 段階

💡 ワード解説:ワークスペースと ai_policy

このアプリでは、ページ(URL)がそのままワークスペースです。/w/blog はブログ、/w/work は仕事、というように、項目にタグを付けて分類するのではなく、ボードそのものを分けます

分ける理由は AI からの見え方を変えるためで、ワークスペースごとに ai_policy を 3 段階から選びます。

AI(MCP/REST)から 使いどころ
read_write 読める・書ける ブログの候補・工作室の残項目
read_only 読めるが書けない AI に要約はさせたいが、勝手に書かせたくないページ
hidden 存在しないものとして扱う。一覧に出ず、ID を指定しても「見つからない」 仕事の項目

人間のブラウザからは、この値に関係なく常に全部見えます。初回起動で作られる 3 つのうち「仕事」は既定で hidden です。

hidden が「アクセス拒否」ではなく「存在しない」なのは、拒否の応答そのものが「そこに何かある」という情報になるからです。AI が一覧を取っても出てこないし、ID を総当たりしても「見つからない」しか返りません。

ただし、アプリが守れるのはAI が自分で読みに行けないことだけです。人間が仕事の項目をチャットに貼れば、当然 AI に渡ります。境界はアプリではなく、人間の手元にあります。

この 3 値は DB のレベルで固定しています。テーブル定義の CHECK 制約なので、アプリのバグでも 4 つ目の値は入りません(src/taskboard/migrations/0001_init.sql L7-8)。

ai_policy   TEXT NOT NULL DEFAULT 'read_write'
            CHECK (ai_policy IN ('read_write','read_only','hidden')),
ワークスペース一覧画面。ブログ・工作室・読書の 3 枚のカードに状態別の件数と、AI からの見え方を示すバッジが並ぶ

ワークスペース一覧。右上のバッジが ai_policy。「読書」は AI から読み取りのみ(ダミーデータ)


🧱 技術選定:ビルド工程ゼロで動く組み合わせ

1 人で使う CRUD アプリに、フロントエンドのビルド工程を持ち込みたくありませんでした。テンプレートを直して再読み込みしたら反映される、その速さを優先しています。

部品 採る理由
FastAPI 0.141.1 型で入力検証・OpenAPI が自動で出る。次回の MCP と同じ Pydantic モデルを流用できる
SQLite Python 同梱の sqlite3 1 ファイル=バックアップはコピー 1 回。Web と MCP の 2 プロセスからの同時アクセスは WAL で足りる
htmx 2.0.10 ビルド工程なしで部分更新ができる。1 ファイルを static/ に同梱してオフラインでも動く
Jinja2 3.1.6 FastAPI 公式が案内するテンプレート
markdown-it-py 4.2.0 本文とノートの Markdown 描画。生 HTML を無効にできる
uv uv run で仮想環境の有効化なしに起動。Python 3.12 も uv が用意する

見送った案も並べておきます。「もっと速く作れる」ものは、それぞれ別の理由で外れました。

見送った理由
Streamlit 画面は速いが、URL で項目を直接開く・フォーム POST・部分更新に向かない。AI が「ここを見て」と返す URL を人間が踏む運用と相性が悪い
SPA(React/Vue+API) ビルド工程と依存が増える。1 人用の CRUD に見合わない
Django 管理画面は魅力だが、MCP と Pydantic モデルを共有する点で FastAPI のほうが素直
Flask FastAPI との差は OpenAPI 自動生成と型検証。REST を他の AI に渡す前提なので FastAPI
Node(Express/Hono) 前作の MCP チュートリアルが Python なので、続編としての連続性を優先
PostgreSQL 等のサーバー DB 1 ユーザー・ローカルで運用コストに見合わない
htmx 4.0 2026-08-28 にリリース済みだが、htmx.org は 2.x 系の利用者が誤って上げないよう npm の latest にしていない、と案内している(2026-09-12 時点)。今回は 2.0.10
💡 ワード解説:htmx とは

htmx は、HTML の属性だけでサーバーとやり取りを書く JavaScript ライブラリです。

hx-post="/items/12/move" を付けた要素が操作されると、その URL に POST し、返ってきた HTML の断片で hx-target に指定した要素を差し替えます。JSON を返して JS 側で DOM を組み立てる SPA と違い、サーバーは最初から HTML を返します。

うれしいのは、ページ全体を返すテンプレートと、断片を返すテンプレートを同じ部品にできることです。かんばん全体を描く _board.html を、初回表示では board.html から include し、htmx からの POST にはそれ単体で返す。JS を書かずに「そこだけ更新」ができます。

動かすまで

手順はこれだけです。python が PATH に無くても構いません。uv.python-version を見て 3.12 を用意します。

git clone https://github.com/ramtuc/ai-taskboard.git
cd ai-taskboard
uv sync                                 # 初回のみ。uv.lock どおりの版が入る
uv run taskboard seed --demo            # ダミーデータを data/demo.sqlite3 に投入
$env:TASKBOARD_DB = "data/demo.sqlite3"; uv run taskboard serve

http://127.0.0.1:8765/ を開けば、上のスクリーンショットと同じ画面が出ます。serve は 127.0.0.1 にしかバインドしません。--host オプションは、あえて作っていません。


🖥️ 画面:ワークスペース → ボード → 詳細

画面は 3 段です。一覧からボードへ、ボードから項目の詳細へ。書き込みはすべてフォーム POST で、htmx が乗っていれば部分更新、乗っていなければ 303 で元の画面に戻ります。

flowchart TD W["/ ワークスペース一覧
件数と AI からの見え方"] --> B["/w/blog ボード
かんばん 6 列/リスト
クイック追加は部分更新"] W --> N["/workspaces/new
ワークスペース追加"] B --> D["/w/blog/items/1 項目の詳細
本文・ノート・履歴
編集・移動・ノートも部分更新"] B --> S["/w/blog/settings 設定
名前・説明・ai_policy"]

かんばんの列は、そのまま項目の状態です。6 つに固定しています。

status 意味
候補 candidate やるかもしれないこと。AI が追加した項目もまずここに入る
着手 doing 動いている
待ち(人) waiting_human 人間の判断や作業を待っている。AI がここへ動かして人間に渡す
待ち(AI) waiting_ai AI の作業を待っている。人間がここへ戻す
完了 done completed_at が打たれる。列には直近 20 件だけ表示
保留 hold 削除の代わり

「待ち(人)」と「待ち(AI)」を分けたのは、このボードの用途そのものです。人と AI が交互に動く仕事では、今どちらのターンかがいちばん知りたい情報になります。

ブログのかんばん画面。候補・着手・待ち(人)・待ち(AI)・完了・保留の 6 列にカードが並び、各カードに AI か H のバッジと優先度、状態を変えるセレクトが付いている

かんばん表示。カード左上のバッジが作成者(AI は青・human は灰)。列を動かすのはカード下のセレクト(ダミーデータ)

同じワークスペースのリスト表示。状態・タグ・担当・全文の絞り込みと、更新順の並び替えができる表

?view=list でリスト表示。状態の絞り込みはリストだけに付けている(ダミーデータ)

カードの状態を変えられるのは、「状態を変更」のセレクトただ 1 か所です。編集フォームには状態の欄がありません。理由は次の節の履歴にあって、状態変更を必ず item.moved として記録したいからです。フォーム編集で状態を変えられると、その経路だけ「前後の値と理由」が残らなくなります。

削除機能もありません。項目は「保留」列へ、ワークスペースはアーカイブで隠します。履歴を消さない、という方針の帰結です。


⚡ htmx で JS を書かずに列が動く

いちばん気持ちよかったのがここです。カードを別の列へ動かすコードは、セレクトに属性を 4 つ付けただけです。

<form hx-post="/items/{{ item.id }}/move" hx-target="#board" hx-swap="outerHTML"
      method="post" action="/items/{{ item.id }}/move">
  <select name="status" hx-trigger="change" hx-post="/items/{{ item.id }}/move"
          hx-target="#board" hx-swap="outerHTML" hx-include="closest form">
    {% for s in STATUSES %}<option value="{{ s }}" {% if s == item.status %}selected{% endif %}>{{ STATUS_LABELS[s] }}</option>{% endfor %}
  </select>
  <noscript><button type="submit">移動</button></noscript>
</form>

src/taskboard/templates/_macros.html L18-25 の move_select マクロから、絞り込みの引き継ぎを省いたもの。省いた部分は後述の罠に関係します)

  • hx-trigger="change" — セレクトを変えた瞬間に送る。ボタンは要らない
  • hx-post — 送り先。普通のフォームの action と同じ URL
  • hx-target="#board"hx-swap="outerHTML" — 返ってきた HTML で、かんばん全体(<section id="board">)を丸ごと差し替える
  • <noscript> の送信ボタン — JS が無い環境では普通のフォームとして動く。methodaction はそのために付けてある

流れを図にするとこうです。

flowchart TD A["カードのセレクトを『着手』に変える"] -->|"hx-trigger=change"| B["POST /items/12/move
ヘッダに HX-Request: true
HX-Target: board"] B --> C["service.move_item()
UPDATE item + INSERT event"] C --> D{"HX-Target は?"} D -->|"board か detail"| E["対応する部分テンプレートだけ描画
_board.html か _detail.html"] D -->|"無し(JS オフのフォーム)"| G["303 で元の画面へ"] E -->|"hx-swap=outerHTML"| S["ブラウザは id=board を差し替える
ページは再読み込みしない"]

サーバー側は「どこに差し込むか」を見て応答を選ぶ

htmx は POST に HX-Request: true と、差し替え先の id を HX-Target ヘッダで付けてきます。サーバーはこれを見て、部分テンプレートを返すか、303 で戻すかを選びます(src/taskboard/routes/items.py L32-54 の _after_write)。

def _after_write(request: Request, conn: sqlite3.Connection, item: m.Item, form: dict):
    """書き込み後の応答: htmx なら差し替え先に合わせた部分、それ以外は 303。"""
    target = hx_target(request) if is_htmx(request) else ""
    ws = service.get_workspace_by_id(conn, item.workspace_id)
    if target == "board":
        ctx = board_context(conn, ws, view=form.get("view") or "kanban", ...)
        return render(request, "_board.html", ctx)
    if target == "detail":
        return render(request, "_detail.html", _detail_context(conn, item))
    back = form.get("back") or f"/w/{ws.slug}"
    return RedirectResponse(back, status_code=303)

同じ URL /items/{id}/move が、かんばんからの操作にはかんばんの断片を、詳細画面からの操作には詳細の断片を、JS 無しのフォームには 303 を返します。エンドポイントは 1 本、テンプレートは 2 枚、JS は 0 行。クイック追加もノート追加も完了ボタンも、全部この型です。

差し替え先を #board 全体にしているのは手抜きではなく、列の件数表示・「残り n 件をリストで見る」リンク・列内の並び順まで、ひとつのカード移動で全部変わるからです。カード 1 枚だけ差し替えて整合を取るより、かんばん全体を描き直すほうが単純で、規模的にも速度は問題になりません。

クイック追加の直後。候補列の件数が (2) に増え、新しいカード #20 が先頭に差し込まれている

クイック追加の直後。候補列に #20 が増えているが、ページは再読み込みされていない(ダミーデータ)

セレクトで #20 を着手に変えた直後。候補列が (1) に戻り、着手列が (3) になって #20 が先頭に来ている

セレクトを『着手』に変えた直後。候補 (2)→(1)、着手 (2)→(3)。かんばん全体が差し替わっている(ダミーデータ)

「再読み込みされていない」ことは目で見ても分からないので、headless Chrome で window にマーカーを置いてから操作し、操作後もマーカーが残っていれば部分更新、消えていればフルリロード、という判定にしています(docs/screenshots/RESULTS.json)。

自前の JS は、これだけ

htmx は 4xx/5xx の応答を差し込みません。サーバーが同じ状態への移動を 409 で弾いても、画面には何も起きない。そこで、エラー本文を画面上部に 6 秒だけ出す小さなハンドラを 1 つ書きました(src/taskboard/templates/base.html L20-27)。

<script>
  // htmx は 4xx/5xx を差し込まないので、サーバーの短いエラー文を #flash に出す(唯一の自前 JS)
  document.body.addEventListener('htmx:responseError', function (e) {
    var f = document.getElementById('flash');
    f.textContent = (e.detail.xhr.status + ': ' + e.detail.xhr.responseText).slice(0, 300);
    f.hidden = false;
    setTimeout(function () { f.hidden = true; }, 6000);
  });
</script>

アプリ全体で、自分で書いた JavaScript はこの 1 ブロックです。サーバー側も、htmx からの要求でエラーになったときは HTML ページではなく短い本文だけを返すようにしています(app.py_error_response)。

⚠️ 踏んだ罠:status の同名衝突

リスト表示には「状態: すべて/候補/着手…」の絞り込みがあり、そのセレクトの名前は status です。一方、カードを動かす POST が送る新しい状態も status

カード移動のあとも絞り込みを保ちたいので、素直に書くと移動フォームに hx-include="#filters" を付けて絞り込みフォームの値を同送することになります。すると 1 つの POST に status が 2 つ乗る。「候補で絞り込んだリストで、カードを着手へ動かす」と、サーバーが受け取る status はどちらか分からなくなります。

回避は 2 段です(_board.html L3-4・_macros.html L19, L21)。

  1. hx-include の選択子で絞り込み側の status を除外する: #filters [name]:not([name=status])
  2. 絞り込みの状態は、かんばんと一緒に再描画される隠し要素 #status-filtername="status_filter")で別名にして運ぶ

hx-include は CSS 選択子なので、:not() がそのまま効きます。属性だけで書けるのは楽ですが、同名のフォーム値が別の意味で混ざるのは、複数フォームを 1 リクエストにまとめる htmx ならではの事故でした。


🧾 誰が書いたかが全部残る

このアプリの中心は、かんばんではなく event テーブルです。項目の作成・更新・状態変更・ノート追加・ワークスペースの作成と設定変更、全部がここに 1 行ずつ追記されます

CREATE TABLE event (                                -- 追記のみ。UPDATE/DELETE しない
  id           INTEGER PRIMARY KEY,
  workspace_id INTEGER NOT NULL REFERENCES workspace(id),
  item_id      INTEGER REFERENCES item(id),
  kind         TEXT NOT NULL,
  author       TEXT NOT NULL,
  payload      TEXT NOT NULL DEFAULT '{}',          -- JSON。moved なら {"from":..,"to":..,"reason":..}
  source       TEXT NOT NULL CHECK (source IN ('ui','mcp','rest','import')),
  created_at   TEXT NOT NULL
);

0001_init.sql L58-67。コメントごと載せたのは、この 1 行目が設計の全部だからです)

列の意味を並べます。

何が入るか
kind item.created / item.updated / item.moved / item.completed / note.added / workspace.created / workspace.updated
author humanai:<名前>。形式は正規表現で固定(後述)
payload moved なら {"from": "candidate", "to": "doing", "reason": "…"}updated なら変更したフィールド名だけ
source どの入口から来たか。ui(ブラウザ)/ mcp / rest / import(取り込み)

payload に本文を入れないのは意図的です。item.updated は「bodypriority を変えた」とだけ残し、変更後の本文は残しません。仕事の項目に機密が書かれていても、履歴テーブルにそれが溜まっていかない。テストでも、本文に入れた文字列が payload に含まれないことを固定しています(tests/test_service.py L103)。

author の形は正規表現 1 本で決める

AUTHOR_RE = re.compile(r"^(human|ai:[a-z0-9][a-z0-9._-]{0,39})$")

src/taskboard/models.py L36)

人間は human の 1 種類、AI は ai: の接頭辞付きです。ブラウザからの書き込みは無条件に human になり、AI 側は名前を自分で名乗れません。この「名乗れない」を実装する場所が次回の主題なので、ここでは形だけ。名前を付けられるので、複数の AI を繋いだときにどの AI が書いたかまで履歴で区別できます。

書き込みは service 層だけが行う

UI・REST・MCP のどこから来ても、書き込みは src/taskboard/service.py の関数を呼びます。そして、書き込む関数は例外なく _log_event を呼びます。状態変更を見てみます(L513-536 の move_item。一部省略)。

def move_item(conn, item_id, status, *, reason="", author, source, _kind="item.moved"):
    item = get_item(conn, item_id)
    status = m.validate_status(status)
    m.validate_author(author)
    if status == item.status:
        raise Conflict(f"item #{item_id} is already '{status}'")
    now = now_utc()
    with conn:
        conn.execute(
            "UPDATE item SET status = ?, completed_at = ?, sort_order = ?, updated_at = ? WHERE id = ?",
            (status, now if status == "done" else None, _top_sort_order(conn, item.workspace_id, status), now, item_id),
        )
        _log_event(conn, item.workspace_id, item_id, _kind, author,
                   {"from": item.status, "to": status, "reason": reason or ""}, source)
    return get_item(conn, item_id)
  • authorsourceキーワード専用引数で必須。呼び出し側が「誰が・どこから」を渡さないと、そもそも呼べません
  • UPDATEINSERT INTO event同じ with conn: ブロック、つまり同じトランザクションです。片方だけ成功して履歴が欠ける、という状態が起きません
  • 同じ状態への移動は Conflict で弾きます。ブラウザなら 409、次回の MCP ではそのまま AI に届く文言になります

ルートハンドラ側(routes/items.py)は、フォームを受け取って service.move_item(..., author="human", source="ui") を呼び、応答を選ぶだけです。ロジックが service 層に集まっているので、次回 MCP を足すときも同じ関数を別の入口から呼ぶだけで済みます。

画面では青と灰のバッジになる

ai:* が書いたものは青、human は灰。カードでも、ノートでも、履歴の行でも、同じマクロで同じ色にしています(_macros.html L2-8)。

項目の詳細画面。作成者に AI · demo-assistant の青いバッジ、Markdown の表を含む本文、AI と human のノートが 3 件、履歴 6 行にそれぞれ作成者バッジと種別と経路が並ぶ

項目の詳細。本文は Markdown(表もそのまま)。下の履歴は新しい順で、右端の小さな文字が source(ダミーデータ)

履歴の欄を見ると、item.moved の行には「候補 → 着手『ドラフトを書き始める』」のように前後の状態と理由が、note.added には経路(uiimport か)が出ています。AI が「オシロの波形写真 3 枚が必要」という理由で「待ち(人)」に動かし、人間が「写真は週末に撮る」とノートを返す。このやり取りが、チャットではなく項目に残ります。

編集フォームも htmx で同じ場所に差し込まれます。「編集」ボタンが hx-get でフォームの断片を取り、#detail を丸ごと置き換える。保存すると _detail.html が返ってきて元の表示に戻ります。

編集フォームが詳細の位置に差し込まれた画面。タイトル・本文(Markdown)・優先度・担当・期限・タグ・リンクの欄があり、状態の欄は無い

編集フォーム。状態の欄が無い。「状態はここでは変えられません(履歴に残すため)」の一文が入っている(ダミーデータ)


🗃️ SQLite を「ちゃんと」使う

SQLite は import sqlite3 で動くので雑に使いがちですが、2 プロセスから同じファイルを触る前提だと、押さえる点が 3 つありました。

接続ごとに PRAGMA を 3 つ

conn = sqlite3.connect(path, timeout=5.0, check_same_thread=False)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode = WAL")
conn.execute("PRAGMA foreign_keys = ON")
conn.execute("PRAGMA busy_timeout = 5000")

src/taskboard/db.py L50-54)

💡 ワード解説:WAL(Write-Ahead Logging)

SQLite の既定のジャーナル方式(ロールバックジャーナル)では、書き込み中は読み取りが待たされ、読み取り中は書き込みが待たされます

WAL モードでは、変更をまず別ファイル(taskboard.sqlite3-wal)に追記し、読み取り側は元の DB ファイルを読み続けます。読み取りが書き込みをブロックせず、書き込みも読み取りをブロックしない。あとで「チェックポイント」として本体に書き戻されます。

このアプリでは Web サーバーと(次回の)MCP サーバーが別プロセスで同じファイルを開くので、この性質が要ります。journal_mode = WAL は DB ファイル自体に記録される永続設定ですが、初回作成時に確実に効くよう、接続のたびに発行しています。

  • foreign_keys = ON — SQLite は既定で外部キー制約を検査しません。DDL に REFERENCES を書いても、この PRAGMA を接続ごとに出さないと飾りです。しかも接続単位の設定なので、毎回出す必要があります
  • busy_timeout = 5000 — もう片方のプロセスが書いている最中なら、即エラーではなく 5 秒待つ
  • check_same_thread=False — FastAPI は同期の依存関係(ここでは接続を開く get_conn)をスレッドプールで動かし、async def のルートはイベントループで動かします。接続を開いたスレッドと使うスレッドが違うので、これが無いと sqlite3 が例外を投げます。1 つの接続は 1 リクエストの中でしか使わないので、複数スレッドで同時に触ることはありません

バックアップは backup()

「1 ファイルだからコピーすればいい」は、WAL だと半分正しくありません。書き込み途中の変更が -wal ファイル側にあるので、本体だけコピーすると整合が取れない可能性があります。

Python の sqlite3 にはオンラインバックアップ API があり、動作中の DB から整合した 1 ファイルを作れます(db.py L118-126)。

src = sqlite3.connect(src_path)
dst = sqlite3.connect(dest)
src.backup(dst)

uv run taskboard backup でこれを呼び、data/backups/ に 30 世代まで残します。serve は起動時と 1 時間ごとに「直近のバックアップが 24 時間より古ければ」1 回書きます。外部の cron に頼らず、サーバーの中で済ませています。

マイグレーションは前にだけ進む

schema_version テーブルの値を見て、migrations/000N_*.sql を番号順に、まだ当てていないものだけ適用します(db.py L75-89)。後方には戻しません。Alembic のような道具は入れず、20 行ほどの自前実装です。data/ を消して起動しても、この仕組みでゼロから初期化されます。


🔧 小さいけれど効いたこと

Markdown は生 HTML を通さない

本文とノートは Markdown で、サーバー側で描画します。AI が書いた文字列をそのまま描画する前提なので、markdown-it-py を html=False で使い、生 HTML は必ずエスケープ、javascript: などの URL は markdown-it 既定の validateLink に落とさせています(src/taskboard/render.py L16)。ノートに <script>alert(1)</script> を書いても文字列として表示されることは、テストで固定しています(tests/test_web.py L80-89)。

Windows の Python にはタイムゾーンのデータが無い

DB の時刻は UTC の ISO 8601 文字列で、表示時にローカル時刻へ変換します。ここで zoneinfo.ZoneInfo("Asia/Tokyo") を素直に使うと、Windows では止まります。Windows の Python には IANA のタイムゾーンデータベースが同梱されておらずtzdata パッケージを別途入れないと ZoneInfoNotFoundError になります。

依存を増やす代わりに、OS のタイムゾーン設定に任せる datetime.astimezone() にしました(render.py L36-43)。サーバーとブラウザが同じ PC なら、これでずれません。別 PC のブラウザから見ると、サーバー側のタイムゾーンで表示されます。

ダークモードは CSS だけ

CSS はフレームワーク無しの 1 ファイル、約 100 行です。ダークモードは prefers-color-scheme で変数を差し替えるだけで、切り替えボタンも JS もありません(static/style.css L8)。

工作室ワークスペースのかんばんをダークモードで表示した画面。列ごとに濃い色の見出しが付き、カードの配色も暗い背景に合わせて変わっている

OS をダークモードにするとこうなる。CSS 変数の差し替えだけ(ダミーデータ)


⚠️ できないこと・割り切ったこと

  • 削除機能が無い。項目は「保留」へ、ワークスペースはアーカイブへ。物理的に消したければ SQLite を直接触ることになります
  • ローカル専用。127.0.0.1 固定・ログイン無し・CSRF 対策無し。LAN や他の PC から使う設計になっていません
  • 単一ユーザー。人間は 1 人で、author は human 固定。人間同士を区別したい用途には向きません
  • ドラッグ&ドロップは無い。htmx だけでは作れず JS が要るので、セレクトで動かします
  • 時刻表示はサーバー PC のタイムゾーンになる(前述)
  • Windows でしか動かしていません。OS 依存のコードは無いので macOS/Linux でも動くはずですが、確認はしていません

✅ まとめ

  • 人と AI が同じボードに書くための条件は 3 つ。AI が書ける口・ページごとの AI 権限・ローカル完結。既製ツールはどれか 1 つが欠けた
  • ページ=ワークスペースで分け、ai_policy の 3 段階(read_write / read_only / hidden)で AI からの見え方を決める。hidden は拒否ではなく「存在しない」
  • htmx なら、セレクトに属性 4 つでかんばんの列が動く。サーバーは HX-Target を見て断片か 303 かを選ぶだけ。自前の JS はエラー表示の 1 ブロック
  • 同名のフォーム値が別の意味で混ざる status の衝突は、hx-include:not() と隠し要素で回避
  • 書き込みは service 層だけauthorsource を必須にし、UPDATEevent の追記を同じトランザクションで行う。payload に本文は入れない
  • SQLite は接続ごとに WAL・foreign_keysbusy_timeout の 3 つ。バックアップは backup()。Windows の Python に tz データは無い

🔜 次回:この Web アプリに MCP を生やす

作ったボードには、まだ AI の入口がありません。次回は service.py の関数を MCP ツールとして 9 本並べ、Claude Code から「候補を 3 つ足して」と頼むと、かんばんに青いバッジ付きで現れるところまで通します。author をツールの引数ではなくサーバー側で固定する理由、hidden のワークスペースが AI から本当に見えないことの確かめ方、前作で扱った ToolError を「AI が自分で言い直す」ために使う話が中心です。

続きはこちら → 自作 Web アプリに MCP を生やして Claude Code から書き込む|状態を持つ MCP サーバーの作り方


よくある質問(FAQ)

Q: htmx を使うのに Node.js やビルド工程は必要ですか?

A: 不要です。htmx は htmx.min.js 1 ファイルで、このアプリでは src/taskboard/static/ に同梱して <script src="/static/htmx.min.js"> で読んでいます。npm も bundler も使いません。オフラインでも動きます。

Q: 削除機能が無いのはなぜですか?

A: 「誰が何をしたかを全部残す」という方針と衝突するからです。項目は「保留」列へ動かし、ワークスペースは設定からアーカイブして一覧から隠します。event テーブルにはアプリから UPDATEDELETE も発行しません。どうしても消したい場合は SQLite を直接操作することになります。

Q: SQLite に 2 つのプロセスが同時に書いても壊れませんか?

A: WAL モードと busy_timeout で扱っています。WAL では読み取りと書き込みが互いをブロックせず、書き込み同士が重なった場合は busy_timeout の 5 秒間だけ待ちます。1 人と数個の AI が使う規模なら、この待ちが体感されることはまずありません。同時書き込みが常時発生する規模になったら、サーバー型の DB を検討する段階です。

Q: 他の PC やスマホ(LAN)から使えますか?

A: 使えません。サーバーは 127.0.0.1 にしかバインドせず、--host オプションもありません。ログインも CSRF 対策も無いので、外に出す設計になっていないためです。LAN で使いたい場合はトークン認証を足す必要があり、今回のスコープ外です。

Q: Windows 以外でも動きますか?

A: macOS/Linux では確認していません。OS 固有のコードは無く、依存ライブラリにも Windows 専用のものは無いので動くはずですが、記事の手順は Windows 11 と PowerShell で通したものです。

Q: かんばんのカードをドラッグ&ドロップで動かせますか?

A: 動かせません。ドラッグ&ドロップは htmx の属性だけでは作れず、JavaScript を書く必要があります。今回は「JS を書かない」を優先して <select> で状態を変える形にしました。結果は同じで、履歴にも同じ item.moved が残ります。


関連記事


参考