14
15

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【備忘録】AIで編集しやすいWBSの作り方 - 機能軸・工程軸で工数を集計するMarkdown表

14
Posted at

はじめに

WBS(作業分解構成図)をスプレッドシートで管理していると、こんな場面に出くわします。

  • セル結合と色分けが増えて、作った人以外が触れなくなる
  • 差分が追えないので、「先週から何が増えたのか」が分からない
  • AIに渡そうとすると、まずCSVに書き出すところから始まる
  • 機能ごとの工数は出せるが、工程ごとの工数がすぐに出てこない(またはその逆)

この記事では、WBSを 1行1タスクのフラットなMarkdown表 として書き、機能軸と工程軸の両方で工数を集計する方法を整理します。AIに編集を任せることを前提にした列設計と、AIの集計を鵜呑みにしないための検算スクリプトまでをセットにしています。

※本記事は個人の運用メモです。2026-08-05時点で確認した内容をもとにしています。Markdownの表の挙動はGitHub Flavored Markdown(GFM)の仕様に基づいて記述しており、記事内の挙動は参照実装 cmark-gfm で実際に確認しています。ただし、Qiitaを含む各サービスのMarkdownレンダラは挙動が完全に一致するとは限りません。

この記事で扱う範囲

intro.png

この記事では、次の3点に絞ります。

  1. AIが壊しにくいWBS表の書き方(列設計のルール)
  2. 機能軸・工程軸・クロス集計の出し方
  3. AIの集計を検算する仕組み(スクリプトとGit運用)

一方で、以下は扱いません。

  • 特定のプロジェクト管理ツール(Jira、Backlog、MS Projectなど)の使い方
  • 見積り技法そのもの(三点見積り、プランニングポーカーなど)
  • 依存関係の解析やクリティカルパスの算出
  • 実案件の実データ

結論:1行1タスクのフラットな表にして、集計は別で作る

fig_02_conclusion.png

先に結論です。AIで編集しやすいWBSは、次の4点を守った形になりました。

  1. 1行1タスクのフラットな表にする — 階層はインデントやセル結合ではなく、機能 工程 という列で表現する
  2. 軸は列として持つ — 機能軸の表と工程軸の表を別々に作らない。1枚の表から2つのビューを生成する
  3. 集計行を表の中に混ぜない — 合計行が混ざると、AIが行を追加したときに合計と明細がずれる
  4. 集計は毎回スクリプトで作り直す — AIに足し算を任せてもよいが、そのまま信じない

要は、WBSの表を「人間が読む完成品」ではなく 「集計の入力データ」 として扱う、という割り切りです。

なぜスプレッドシートではなくMarkdownなのか

fig_03_why_markdown.png

スプレッドシートが悪いわけではありません。ただ、AIとGitを前提にすると、テキストであること自体が効いてきます。

1. 差分が行単位で読める

.xlsx はZIP圧縮されたXMLの集まり(Office Open XML)なので、Gitからはバイナリファイルとして扱われます。実際に、中身を1か所だけ変えたxlsxをコミットして差分を取ると、次のようになります。

$ git diff --stat
 wbs.xlsx | Bin 299 -> 299 bytes
 1 file changed, 0 insertions(+), 0 deletions(-)

$ git diff
diff --git a/wbs.xlsx b/wbs.xlsx
index 433c46f..6bf598d 100644
Binary files a/wbs.xlsx and b/wbs.xlsx differ

「どのタスクの見積が何日増えたのか」は分かりません。Markdownの表なら、変わった行だけが差分に出ます。

-| T-007 | 承認フロー | 実装 | 承認・差戻しAPIの実装 | 鈴木 | 5 | 5 | doing |
+| T-007 | 承認フロー | 実装 | 承認・差戻しAPIの実装 | 鈴木 | 8 | 5 | doing |

2. AIにそのまま渡せる

Markdownの表はプレーンテキストなので、ファイルをそのまま読ませられます。「T-007の見積を8人日に変えて、理由を備考に書いて」のような部分編集も、行を特定できるぶん指示しやすくなります。

3. レビューの土俵に乗る

WBSの更新をプルリクエストにできます。工数の増減が差分として残るので、「なぜこの見積が増えたのか」をコメントで残せます。

割り切る点

一方で、失うものもあります。

  • 数式による自動集計(Markdownの表に計算式はありません)
  • 並べ替え・フィルタのUI
  • 同時編集とセル単位のコメント

このうち 自動集計は、後述するスクリプトで代替します。並べ替えやフィルタが本当に必要な規模になったら、素直に専用ツールへ移す判断も必要です。

この記事での「機能軸」「工程軸」

fig_04_wbs_basics.png

この記事では、WBSを集計するときの軸を次の意味で使います。

呼び方 意味 この記事での列
機能軸 何を作るか(機能・成果物単位) 機能
工程軸 どの段階の作業か(要件定義・設計・実装…) 工程

この2つを別々の表として管理するのではなく、1つのタスク表に列として持たせます。これにより、同じマスタから機能別・工程別のどちらの集計も作れるようにします。

なお、スクリプトで確認できるのは 登録されているタスクの集計が整合しているか までです。そもそも必要なタスクが抜けていないかは、人間のレビューで確認します。

機能軸と工程軸を1枚の表で持つ

fig_05_two_axes.png

よくある失敗が、機能軸のWBSと工程軸のWBSを別々のシートで持ってしまう ことです。片方だけ更新されて、合計が合わなくなります。

そこで、持ち方をこう変えます。

  • 実体(マスタ)は 1行1タスクのフラットな表 ただ1つ
  • 機能軸・工程軸・クロス集計は、そこから 生成されるビュー として扱う

スプレッドシートで言えばピボットテーブルと同じ考え方です。マスタが1つなら、更新箇所も1か所で済みます。

wbs.md(マスタ:1行1タスク)
   │
   ├─▶ 機能軸の集計(機能ごとの見積・実績)
   ├─▶ 工程軸の集計(工程ごとの見積・実績)
   └─▶ 機能 × 工程のクロス集計

AIが壊しにくい列設計とテンプレート

fig_06_columns.png

AIに編集させることを前提にすると、列の決め方に効くルールがあります。

  1. IDは固定・不変にするT-001 のような通し番号を振り、並べ替えても値を変えない。AIへの指示が「T-007の見積を8人日に」と書けるようになります
  2. 軸は列にする — インデントやセル結合で階層を表さない。機能 工程 の値が揃っていれば集計できます
  3. 数値列は単位を見出しに書き、セルには数値だけ置く見積(人日) という見出しにして、セルは 33人日 と書くと集計時に数値化できません
  4. 合計行を表の中に混ぜない — 合計は別表として生成します
  5. 空欄にも - を置く — 見た目で列のズレに気づけます
  6. セル内で改行しない — GFMの仕様上、表のセルにはブロック要素を入れられません。長い説明は別ファイルに逃がすか、備考 を短く保ちます
  7. セルに | を書くときは \| とエスケープする — そのまま書くと列が1つ増えます
  8. 状態は決め打ちの語彙にするtodo / doing / done のように限定すると、AIも人間も表記が揺れません

このルールで書いたテンプレートが次のものです(wbs.md として保存します)。

| ID | 機能 | 工程 | タスク | 担当 | 見積(人日) | 実績(人日) | 状態 |
| --- | --- | --- | --- | --- | ---: | ---: | --- |
| T-001 | 申請フォーム | 要件定義 | 入力項目とバリデーション条件の洗い出し | 佐藤 | 2 | 2 | done |
| T-002 | 申請フォーム | 設計 | 画面遷移図とAPIリクエスト仕様の作成 | 佐藤 | 3 | 3 | done |
| T-003 | 申請フォーム | 実装 | フォーム画面の実装 | 田中 | 5 | 6 | doing |
| T-004 | 申請フォーム | テスト | 入力値の境界値テスト | 田中 | 2 | 0 | todo |
| T-005 | 承認フロー | 要件定義 | 承認ルートの条件整理 | 佐藤 | 3 | 3 | done |
| T-006 | 承認フロー | 設計 | 状態遷移とテーブル設計 | 鈴木 | 4 | 4 | done |
| T-007 | 承認フロー | 実装 | 承認・差戻しAPIの実装 | 鈴木 | 8 | 5 | doing |
| T-008 | 承認フロー | テスト | 承認ルートの網羅テスト | 鈴木 | 4 | 0 | todo |
| T-009 | 一覧・検索 | 設計 | 検索条件とソート仕様の整理 | 田中 | 2 | 2 | done |
| T-010 | 一覧・検索 | 実装 | 一覧画面とページングの実装 | 田中 | 5 | 0 | todo |
| T-011 | 一覧・検索 | テスト | 大量データでの表示確認 | 田中 | 2 | 0 | todo |
| T-012 | 通知 | 実装 | 承認依頼メールの送信処理 | 鈴木 | 3 | 0 | todo |
| T-013 | 共通基盤 | 設計 | 認証・権限モデルの設計 | 佐藤 | 3 | 3 | done |
| T-014 | 共通基盤 | 実装 | CI設定とテスト自動実行 | 田中 | 2 | 2 | done |
| T-015 | 共通基盤 | リリース | 本番デプロイ手順書の作成 | 佐藤 | 2 | 0 | todo |

数値列の区切り行を ---: にしておくと右寄せになり、桁が揃って読みやすくなります。

機能軸で工数を集計する

fig_07_rollup_feature.png

機能 列でグループ化して、見積と実績を合計します。後述のスクリプトを実行すると、次の表が出力されます。

| 機能 | 見積(人日) | 実績(人日) | タスク数 |
| --- | ---: | ---: | ---: |
| 申請フォーム | 12 | 11 | 4 |
| 承認フロー | 19 | 12 | 4 |
| 一覧・検索 | 9 | 2 | 3 |
| 通知 | 3 | 0 | 1 |
| 共通基盤 | 7 | 5 | 3 |
| **合計** | **50** | **30** | 15 |

機能軸で見ると、次のような会話ができます。

  • 承認フローが19人日で全体の約4割。ここが遅れると全体が遅れる
  • 通知が3人日しかない。要件定義・設計・テストのタスクが立っていないが、本当に不要か
  • スコープを削るなら、どの機能を丸ごと落とせるか

「機能を1つ落とす」という判断は、機能軸で工数がまとまっていないとできません。

工程軸で工数を集計する

fig_08_rollup_phase.png

同じ表を 工程 列でグループ化すると、こうなります。

| 工程 | 見積(人日) | 実績(人日) | タスク数 |
| --- | ---: | ---: | ---: |
| 要件定義 | 5 | 5 | 2 |
| 設計 | 12 | 12 | 4 |
| 実装 | 23 | 13 | 5 |
| テスト | 8 | 0 | 3 |
| リリース | 2 | 0 | 1 |
| **合計** | **50** | **30** | 15 |

工程軸は、スケジュールと人の配置を考えるときに使います。

  • 実装23人日に対してテスト8人日。この比率で妥当かをレビューする
  • 設計が終わっている工程と、これからの工程で、必要な人が変わる
  • 要件定義・設計の実績が見積どおりでも、実装が遅れていれば残りの見通しは変わる

さらに、2軸を掛け合わせたクロス集計も出せます。空欄(-)は「そのタスクが立っていない」ことを意味するので、抜けの発見に効きます

| 機能 | 要件定義 | 設計 | 実装 | テスト | リリース | 合計 |
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
| 申請フォーム | 2 | 3 | 5 | 2 | - | 12 |
| 承認フロー | 3 | 4 | 8 | 4 | - | 19 |
| 一覧・検索 | - | 2 | 5 | 2 | - | 9 |
| 通知 | - | - | 3 | - | - | 3 |
| 共通基盤 | - | 3 | 2 | - | 2 | 7 |
| **合計** | 5 | 12 | 23 | 8 | 2 | **50** |

この例では、通知に設計とテストのタスクがなく、一覧・検索に要件定義がありません。意図的に省いたのか、単なる漏れなのかを確認する材料になります。

AIに集計させるときの落とし穴

fig_09_pitfalls.png

ここが本題です。AIに「この表を集計して」と頼むと、それらしい表がすぐ返ってきます。ただ、そのまま使うと踏む落とし穴があります。

1. 列が増えても、静かに消える

GFMの仕様では、行のセル数が見出し行より多い場合、超過分は無視されます。少ない場合は空セルが補われます。参照実装の cmark-gfm で確認すると、次のとおりです。

入力:

| ID | 機能 | 見積 |
| --- | --- | --- |
| T-001 | 申請フォーム | 2 | 追記されたメモ |
| T-002 | 承認フロー |

出力(抜粋):

<tr><td>T-001</td><td>申請フォーム</td><td>2</td></tr>
<tr><td>T-002</td><td>承認フロー</td><td></td></tr>

追記されたメモ はエラーにならず、そのまま消えます。AIが1行だけ列を増やしても、プレビューでは気づけません。

2. 見出し行と区切り行の列数が違うと、表ですらなくなる

見出し行と区切り行のセル数が一致しない場合、GFMはそれを表として認識しません。AIが列を1つ追加したのに区切り行を直し忘れると、表全体がただの段落になります。

| ID | 機能 | 見積 |
| --- | --- |
| T-001 | 申請フォーム | 2 |

これはHTMLに変換すると <p>| ID | 機能 | 見積 | ...</p> になります。「なぜか表が崩れた」の典型例です。

3. セル内の | が列を割る

タスク名に A|B のようなパイプを含めると、そこで列が分かれます。\| とエスケープすれば、セルの中身として | が入ります。

4. AIの足し算を信用しない

言語モデルは桁数の多い加算や、行数の多い集計で間違えることがあります。しかも、間違えても自信のある書き方で返してきます。集計結果は、必ず機械的に再計算して突き合わせます

5. 「表を整形して」は行を消すことがある

整形やソートを依頼したときに、行が落ちたり順序が変わったりすることがあります。マスタに手を入れさせたら、git diff で行数と差分を必ず確認します。

AIにレビューを頼むときのプロンプト

fig_10_review_prompt.png

落とし穴を踏まえると、AIに頼むことは 工数の算出ではなく 表の構造レビューに寄せるのが安全です。数字はこちらで持ち、粒度や表記ゆれを見てもらいます。

貼り付けて使えるプロンプトの例です。

以下のWBS表をレビューしてください。

目的:
- 機能軸・工程軸で工数を集計しやすくしたい
- 作業粒度のばらつきを減らしたい
- あとでCSVにしてExcelでピボット集計したい

確認してほしい観点:
1. 機能名の粒度が揃っているか
2. 工程名に表記ゆれがないか(例: 単体テスト / 単体試験 / UT)
3. タスクが1行1作業になっているか
4. 見積が極端に大きい行がないか(分割すべき行はどれか)
5. 状態が todo / doing / done 以外になっている行がないか
6. 数値列に単位や記号が混ざっていないか
7. セル内に未エスケープの `|` がないか

出力してほしいもの:
- 指摘事項(行IDを添えて)
- 修正版のMarkdown表(行を減らさず、IDは変えない)
- 変更した行の一覧

工数の値そのものは変更しないでください。

最後の2行が重要です。ID を変えない・行を減らさないと明示しておくと、差分レビューが成立します。工数の値を変えさせないのは、AIの見積もりを信用しないというより、AIが直したのか自分が直したのかを git diff で切り分けられるようにするためです。

集計まで一緒に頼んでも構いませんが、その数字は次節のスクリプトで必ず突き合わせます。

集計を検算するスクリプト

fig_11_verify_script.png

落とし穴の1〜4は、標準ライブラリだけのPythonスクリプトで機械的に潰せます。wbs_rollup.py として保存し、python3 wbs_rollup.py wbs.md で実行します。

#!/usr/bin/env python3
"""Markdown表で書いたWBSを読み込み、機能軸・工程軸で工数を集計する。"""

import re
import sys
from collections import defaultdict

KEY_FEATURE = "機能"
KEY_PHASE = "工程"
KEY_ESTIMATE = "見積(人日)"
KEY_ACTUAL = "実績(人日)"


def split_row(line):
    """1行のMarkdown表をセルのリストに分解する(\\| は区切りにしない)。"""
    line = line.strip()
    if line.startswith("|"):
        line = line[1:]
    if line.endswith("|"):
        line = line[:-1]
    cells = re.split(r"(?<!\\)\|", line)
    return [c.strip().replace("\\|", "|") for c in cells]


def is_delimiter_row(cells):
    return bool(cells) and all(re.fullmatch(r":?-{1,}:?", c) for c in cells)


def parse_first_table(text):
    """本文中の最初のMarkdown表を (見出し, 行データ, 警告) として返す。"""
    lines = text.splitlines()
    header, rows, warnings = None, [], []

    for i, line in enumerate(lines):
        if not line.lstrip().startswith("|"):
            if header is not None:
                break
            continue

        cells = split_row(line)

        if header is None:
            header = cells
            continue
        if is_delimiter_row(cells):
            if len(cells) != len(header):
                raise SystemExit(
                    f"{i + 1}行目: 見出し行({len(header)}列)と区切り行({len(cells)}列)の"
                    "列数が違います。この表はMarkdownの表として描画されません"
                )
            continue

        if len(cells) != len(header):
            warnings.append(
                f"{i + 1}行目: 列数が{len(cells)}(見出しは{len(header)}): "
                f"{cells[0] if cells else ''}"
            )
        cells = (cells + [""] * len(header))[: len(header)]
        rows.append(dict(zip(header, cells)))

    if header is None:
        raise SystemExit("Markdownの表が見つかりませんでした")
    return header, rows, warnings


def to_number(value, row_id, column, warnings):
    value = value.strip()
    if value in ("", "-", ""):
        return 0.0
    try:
        return float(value)
    except ValueError:
        warnings.append(f"{row_id}: {column} が数値ではありません: {value!r}")
        return 0.0


def render_table(headers, rows, align_right=()):
    delim = ["---:" if h in align_right else "---" for h in headers]
    out = ["| " + " | ".join(headers) + " |", "| " + " | ".join(delim) + " |"]
    for row in rows:
        out.append("| " + " | ".join(str(c) for c in row) + " |")
    return "\n".join(out)


def fmt(value):
    return str(int(value)) if float(value).is_integer() else f"{value:g}"


def main(path):
    with open(path, encoding="utf-8") as f:
        header, rows, warnings = parse_first_table(f.read())

    for key in (KEY_FEATURE, KEY_PHASE, KEY_ESTIMATE, KEY_ACTUAL):
        if key not in header:
            raise SystemExit(f"{key!r} が見つかりません。見出し: {header}")

    by_feature = defaultdict(lambda: [0.0, 0.0, 0])
    by_phase = defaultdict(lambda: [0.0, 0.0, 0])
    cross = defaultdict(float)
    features, phases = [], []
    total_estimate = total_actual = 0.0

    for row in rows:
        row_id = row.get("ID", "?")
        feature, phase = row[KEY_FEATURE], row[KEY_PHASE]
        estimate = to_number(row[KEY_ESTIMATE], row_id, KEY_ESTIMATE, warnings)
        actual = to_number(row[KEY_ACTUAL], row_id, KEY_ACTUAL, warnings)

        if feature not in features:
            features.append(feature)
        if phase not in phases:
            phases.append(phase)

        for bucket, key in ((by_feature, feature), (by_phase, phase)):
            bucket[key][0] += estimate
            bucket[key][1] += actual
            bucket[key][2] += 1
        cross[(feature, phase)] += estimate
        total_estimate += estimate
        total_actual += actual

    print(f"## 集計結果({len(rows)}タスク)\n")

    print("### 機能軸\n")
    body = [[f, fmt(by_feature[f][0]), fmt(by_feature[f][1]), by_feature[f][2]]
            for f in features]
    body.append(["**合計**", f"**{fmt(total_estimate)}**",
                 f"**{fmt(total_actual)}**", len(rows)])
    print(render_table(["機能", "見積(人日)", "実績(人日)", "タスク数"], body,
                       align_right=("見積(人日)", "実績(人日)", "タスク数")))

    print("\n### 工程軸\n")
    body = [[p, fmt(by_phase[p][0]), fmt(by_phase[p][1]), by_phase[p][2]]
            for p in phases]
    body.append(["**合計**", f"**{fmt(total_estimate)}**",
                 f"**{fmt(total_actual)}**", len(rows)])
    print(render_table(["工程", "見積(人日)", "実績(人日)", "タスク数"], body,
                       align_right=("見積(人日)", "実績(人日)", "タスク数")))

    print("\n### 機能 × 工程(見積人日)\n")
    body = []
    for f in features:
        row = [f] + [fmt(cross[(f, p)]) if cross[(f, p)] else "-" for p in phases]
        row.append(fmt(by_feature[f][0]))
        body.append(row)
    body.append(["**合計**"] + [fmt(by_phase[p][0]) for p in phases]
                + [f"**{fmt(total_estimate)}**"])
    print(render_table(["機能"] + phases + ["合計"], body,
                       align_right=tuple(phases) + ("合計",)))

    if warnings:
        print("\n### 警告\n")
        for w in warnings:
            print(f"- {w}")
        sys.exit(1)


if __name__ == "__main__":
    if len(sys.argv) != 2:
        raise SystemExit("使い方: python3 wbs_rollup.py <WBSのMarkdownファイル>")
    main(sys.argv[1])

このスクリプトのポイントは、集計そのものより 異常を検知して終了コード1で落ちる ことです。たとえば T-012 の行を「見積を 3人日 と書き、列を1つ増やしてしまった」状態にして実行すると、こう出ます。

$ python3 wbs_rollup.py wbs.md
(…集計結果…)

### 警告

- 14行目: 列数が9(見出しは8): T-012
- T-012: 見積(人日) が数値ではありません: '3人日'
$ echo $?
1

終了コードで落ちるので、CIに載せられます。AIがWBSを編集したプルリクエストで、列崩れと数値でないセルを自動で弾けます。

# .github/workflows/wbs.yml の一部
- name: Validate WBS
  run: python3 wbs_rollup.py wbs.md

Gitでの運用フロー

fig_12_git_flow.png

ここまでを運用に落とすと、次の流れになります。

  1. wbs.md(マスタ)をリポジトリに置く
  2. タスクの追加・見積の変更は、AIか人間が wbs.md を直接編集する
  3. python3 wbs_rollup.py wbs.md を実行し、集計結果を wbs_summary.md に書き出す
  4. git diff で変更行を確認する(行数が想定どおりか、意図しない行が消えていないか)
  5. プルリクエストにして、工数の増減をレビューする
  6. CIで wbs_rollup.py を実行し、表の崩れを検知する

集計結果をファイルに残しておくと、集計そのものの差分も追えます。

$ python3 wbs_rollup.py wbs.md > wbs_summary.md
$ git diff --stat
 wbs.md         |  2 +-
 wbs_summary.md | 12 ++++++------
 2 files changed, 7 insertions(+), 7 deletions(-)

「T-007の見積を5→8に変えたら、承認フローの合計が16→19、全体が47→50になった」という因果が、そのまま差分に残ります。

CSVに変換してExcelへ渡す

fig_13_csv_export.png

最終的な報告はExcelで、という現場は多いです。Markdown表をそのまま貼り付けても入ることはありますが、列数が増えると崩れるので、CSVを経由するほうが安定します。

wbs_rollup.py と同じく、標準ライブラリだけで書けます。

#!/usr/bin/env python3
"""wbs.md の表を CSV に変換する。使い方: python3 wbs_to_csv.py wbs.md wbs.csv"""
import csv
import re
import sys

# エスケープされた \| を一時的に退避するための、表に出てこない文字
SENTINEL = "\x00"


def split_row(line: str) -> list[str]:
    line = line.strip().replace(r"\|", SENTINEL)
    if line.startswith("|"):
        line = line[1:]
    if line.endswith("|"):
        line = line[:-1]
    return [c.strip().replace(SENTINEL, "|") for c in line.split("|")]


def parse(text: str) -> tuple[list[str], list[list[str]]]:
    lines = [ln for ln in text.splitlines() if ln.strip().startswith("|")]
    if len(lines) < 2:
        sys.exit("表が見つかりません")
    header = split_row(lines[0])
    if not all(re.fullmatch(r":?-{1,}:?", c) for c in split_row(lines[1])):
        sys.exit("2行目が区切り行ではありません")

    rows = []
    for lineno, ln in enumerate(lines[2:], start=3):
        cells = split_row(ln)
        if len(cells) != len(header):
            # ここで落とすのが肝。列数の不一致を先に検知する
            sys.exit(f"{lineno}行目: 列数が{len(header)}ではなく{len(cells)}です")
        rows.append(cells)
    return header, rows


def main() -> None:
    src, dest = sys.argv[1], sys.argv[2]
    with open(src, encoding="utf-8") as f:
        header, rows = parse(f.read())
    # Excel向けなので utf-8-sig(BOM付き)。newline="" は csv モジュールの作法
    with open(dest, "w", encoding="utf-8-sig", newline="") as f:
        writer = csv.writer(f)
        writer.writerow(header)
        writer.writerows(rows)
    print(f"{len(rows)}行を {dest} に書き出しました")


if __name__ == "__main__":
    main()

このスクリプトで意図的に効かせているのは、次の3点です。

  1. 列数が合わない行で落とす: zip(header, cells) のような素朴な実装では短い側で打ち切られるため、列の不足・超過を事前に確認しないとデータの欠落を見逃しやすくなります。そこで、zip などで組み立てる前に列数を検証し、1行でも合わなければ終了させます。
  2. \| エスケープを復元する: GFMではセル内のパイプを \| と書けます。単純に split("|") すると、そこで列がずれます。
  3. utf-8-sig で書き出す: BOMなしのUTF-8をExcelで開くと、日本語環境ではShift_JISと誤認されて文字化けすることがあります。utf-8-sig はファイル先頭に EF BB BF の3バイトを付けるため、Excelが素直にUTF-8として読みます。逆に、このCSVをプログラムで再処理する場合はBOMが邪魔になるので、その用途なら utf-8 のままにします。

newline="" は、csv モジュールが改行を自前で \r\n として出力するためのものです。付けないと、環境によって空行が1行おきに入ります。

Excelでピボット集計する

fig_14_excel_pivot.png

CSVを取り込んだら、ピボットテーブルで軸を切り替えます。wbs_rollup.py と同じ集計をGUIでもできる、という位置づけです。

見たいもの
機能別工数 機能 (なし) 見積(人日) の合計
工程別工数 工程 (なし) 見積(人日) の合計
機能 × 工程 機能 工程 見積(人日) の合計
担当別工数 担当 (なし) 見積(人日) の合計
見積と実績の対比 機能 (なし) 見積(人日)実績(人日) の合計

とくに 機能 を行・工程 を列にしたクロス集計は、「この機能だけテスト工数が薄い」「設計に寄りすぎている」といった偏りが一目で分かります。

ここでも、Excelの合計と wbs_rollup.py の合計が一致するかを1回は確認しておくのがおすすめです。一致しない場合、たいていは数値列に単位や空白が混ざっていて、Excel側で文字列として扱われています。

流れ全体としては、こうなります。

1. wbs.md をMarkdownで書く(マスタ)
2. AIに構造レビューを頼む(工数の値は変えさせない)
3. python3 wbs_rollup.py wbs.md で検算する
4. python3 wbs_to_csv.py wbs.md wbs.csv でCSV化する
5. Excelに取り込み、ピボットテーブルで集計・グラフ化する

正はあくまで wbs.md です。Excel側で直してしまうと差分が追えなくなるので、Excelは見せるためのビューと割り切ります。

この書き方が向かないケース

fig_15_limits.png

万能ではありません。次のような場合は、素直に別の手段を検討します。

状況 起きること 代わりに考えること
タスクが数百行を超える 表が長すぎて人もAIも読み切れない。AIに渡すコンテキストも膨らむ 機能ごとにファイルを分ける、または専用のプロジェクト管理ツールへ移す
依存関係やクリティカルパスを管理したい 表では前後関係を表現しきれない ガントチャート(Mermaidの gantt など)や専用ツールを併用する
複数人が同時に更新する コンフリクトが増える 更新の担当と頻度を決める。行の並び順を固定してコンフリクトを減らす
実績工数を日次で打刻したい Markdownの手入力では続かない 工数管理システムから実績を取り込み、実績(人日) 列に反映する
進捗率を細かく管理したい 状態 列だけでは粒度が足りない タスクをさらに分割して1タスクを小さくする

分けすぎない、混ぜすぎない

向き不向き以前に、粒度で失敗することがあります。

「1行1タスク」は大事ですが、分解しすぎると表の更新自体が仕事になります。数十分単位まで割ると、見積もりを直すたびに何十行も触ることになり、結局メンテされなくなります。目安としては、次のどれかに当てはまるときだけ分けます。

  • 集計したい単位(機能工程)が変わる
  • 担当が変わる
  • 見積が大きすぎて、そのままだと精度が出ない

逆に、分けても意思決定に使わない粒度までは分けません。

もう1つは、見積もり用と進捗管理用を1枚に詰め込みすぎないことです。この表は見積もりが主目的なので、状態実績(人日) くらいまでに留めています。ここに開始日・終了日・課題・優先度・進捗率まで足していくと、列が増えてAIにも人にも読みにくくなり、git diff のノイズも増えます。進捗を細かく追いたくなったら、その時点で別の道具に移す判断をするほうが健全でした。

依存関係を補助的に見せたいだけなら、Mermaidの gantt を併記する手もあります。Mermaidでは、タスク名のあとにコロンで区切ってメタデータを書き、after <taskID> で先行タスクを参照できます。

```mermaid
gantt
    title 経費精算アプリ リニューアル
    dateFormat YYYY-MM-DD
    section 申請フォーム
    要件定義 :done, t001, 2026-08-03, 2d
    設計     :done, t002, after t001, 3d
    実装     :active, t003, after t002, 5d
    section 承認フロー
    要件定義 :done, t005, 2026-08-03, 3d
    設計     :done, t006, after t005, 4d
    実装     :active, t007, after t006, 8d
```

ただし、ガント側とWBS側の二重管理になりがちです。工数の集計はあくまで wbs.md を正とし、ガントは「見せるためのビュー」と割り切るのが無難でした。

まとめ

fig_16_summary.png

AIで編集しやすいWBSにするために、やったことは次のとおりです。

  • WBSを 1行1タスクのフラットなMarkdown表 にし、階層ではなく 機能 工程 の列で軸を持つ
  • 機能軸・工程軸・クロス集計は、マスタから 生成するビュー として扱う
  • IDを固定し、数値列には数値だけを入れ、合計行を表に混ぜない
  • GFMの仕様上、列が多い行の超過セルは黙って捨てられ、見出し行と区切り行の列数が違うと表として認識されない。AIの編集は必ず検知する仕組みを用意する
  • 集計はスクリプトで再計算し、異常時は終了コード1で落としてCIで弾く
  • AIには工数の算出ではなく 表の構造レビューを頼み、ID を変えない・行を減らさない・値を変えないと明示する
  • Excelへ渡すときはCSVを経由し、列数が合わない行はそこで落とす。文字化けを避けるため utf-8-sig で書き出す
  • git diff で工数の増減が行単位で追えるようにする

WBSをAIに任せるかどうかの分かれ目は、モデルの賢さよりも 「壊れたことに気づける形式かどうか」 でした。表の形を先に整えておくと、AIに編集を任せる範囲を広げやすくなります。

参考(公式情報)

14
15
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
14
15

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?