diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 69e9806d5..6cab51b84 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -9,7 +9,7 @@ { "name": "ndf", "source": "./plugins/ndf", - "description": "Claude Code plugin (v10.18.0): 8 specialized agents (plus 1 worker agent for the 3-layer workflow) and 48 focused NDF skills for PR/review workflows, cross-review, implementation planning, plan-to-spec, Docker container access, statusline, external AI delegation (Codex/agy), transcript retention guard, and optional Slack notifications.", + "description": "Claude Code plugin (v10.19.0): 8 specialized agents (plus 1 worker agent for the 3-layer workflow) and 48 focused NDF skills for PR/review workflows, cross-review, implementation planning, plan-to-spec, Docker container access, statusline, external AI delegation (Codex/agy), transcript retention guard, and optional Slack notifications.", "policy": { "installation": "AVAILABLE", "authentication": "ON_INSTALL" diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 74344e73c..fdd26324b 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -70,6 +70,11 @@ jobs: - uses: actions/setup-python@v7 with: python-version: "3.x" + # スライドの生成スクリプトのテストが node で pptxgenjs を動かす。依存の `npm ci` はテストが + # 自分で打つ(前提のコマンドの確認は根の conftest.py が行う。#2065) + - uses: actions/setup-node@v7 + with: + node-version: "22" - name: Install uv run: python3 -m pip install --upgrade uv # 起点はリポジトリの根に置く。環境は根の pyproject.toml と uv.lock が固定する(#1142 の決定 22)。 diff --git a/.gitignore b/.gitignore index 61359c2b1..7b1e25e12 100644 --- a/.gitignore +++ b/.gitignore @@ -68,3 +68,7 @@ reports/ # cross-review が起動したレビュワーの PID(実行時にだけ作られる) *-review-pr*.pid + +# スライドの生成スクリプトの依存と生成物(#2065) +node_modules/ +*.pptx diff --git a/AGENTS.md b/AGENTS.md index 923d2adff..1f92a717e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -137,7 +137,7 @@ ai-plugins/ | [docs/ndf-plugin-reference.md](docs/ndf-plugin-reference.md) | NDFプラグイン詳細リファレンス | | [docs/ndf-experiments.md](docs/ndf-experiments.md) | NDF の実験版の台帳(試している仕組み・使った結果・行き先) | | [docs/specifications/](docs/specifications/) | 完了済みplan/issue由来の確定仕様 | -| [docs/presentations/](docs/presentations/) | 勉強会などで使うスライド資料(Marp形式)とビルド手順。**発表日時点の記録で、以後の構成変更には追随しない** | +| [docs/presentations/](docs/presentations/) | 勉強会などで使うスライド資料。生成スクリプト(pptxgenjs)・台本・構成メモを資料ごとのディレクトリに置く(作り方は `release` の `form-slide.md`)。**発表日時点の記録で、以後の構成変更には追随しない** | | [docs/articles/](docs/articles/README.md) | 社外ブログへ載せる記事の正本。**書いた日時点の記録で、以後の構成変更には追随しない** | | [docs/claude-code-skills-survey/](docs/claude-code-skills-survey/01-findings.md) | Claude Code Skills調査レポート(3 本) | | [docs/development-history/](docs/development-history/) | 2026-09-02 までの開発履歴と知見。**以降の振り返りは、起点の issue か Pull Request のコメントに残す**(`/ndf:retrospective`) | diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d034fcaa..4f738c7ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,14 @@ **開発版(接尾辞の付いた版)は載せない。** `9.8.0` は `9.8.0-dev.1` までしか出ておらず、 その内容は `10.0.0` で届いている。 +## [ndf 10.19.0] - 2026-10-18 + +- ndf の release Skill に、スライドの汎用テンプレートが入りました。ページの枠組みに沿って生成スクリプトから pptx を作り、トーン&マナーを差し替えられます。台本はノートになり、台本から発表の尺を計算できます(#2069)(#2070) +- スライドの生成は生成スクリプトの 1 経路です。構成メモと台本を用意し、生成スクリプトで作ります(#2069)(#2070) +- Google Slides の全ページのサムネイルを取るスクリプトで、変換後の体裁を画像で確かめられます。pptx の変換と差し替えに使う gws のコマンドは対応表から引けます(#2069)(#2070) +- 既知の失敗に、pptx で差し替えた Google スライドの書体が Arial になる場合と、その確かめ方を載せています(#2069)(#2070) +- Release: ndf v10.19.0-dev.1(#2073) + ## [ndf 10.18.0] - 2026-10-16 - Update: Bump actions/setup-python from 5 to 7(#1387) diff --git a/README.md b/README.md index f438f55af..f60715d78 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ Claude Code では `/ndf:` と打って呼びます。**Skill** は | プラグイン名 | バージョン | できること | 詳細 | | --- | --- | --- | --- | -| **ndf** | 10.18.0 | 開発の作業手順(Skill)と専門のサブエージェント。4 つの AI ツールへ同じ名前で入る | [README](./plugins/ndf/README.md) | +| **ndf** | 10.19.0 | 開発の作業手順(Skill)と専門のサブエージェント。4 つの AI ツールへ同じ名前で入る | [README](./plugins/ndf/README.md) | | **playwright-kit** | 3.0.1 | Playwright による E2E テストの計画・スクリプト作成・証跡のレポート | [README](./plugins/playwright-kit/README.md) | 外部サービスへつなぐ MCP サーバーの設定のプラグインが 10 個あります(Claude Code / Codex / Kiro CLI 向け)。 @@ -88,7 +88,7 @@ Claude Code では `/ndf:` と打って呼びます。**Skill** は ### NDF プラグインの Skill の一覧 -**NDFプラグイン v10.18.0** に入る Skill の数は、AI ツールごとに違います。 +**NDFプラグイン v10.19.0** に入る Skill の数は、AI ツールごとに違います。 Claude Code向け core 48個、Kiro向け core 45個、Codex向け core 44個、agy向け core 44個です。 元Skills(48個)の内訳は次のとおりです。 - Pull Request とレビュー (7): pr, pr-tests, fix, pr-review, cherry-pick-pr, deploy, merged diff --git a/conftest.py b/conftest.py index c24cecf2f..196498c9c 100644 --- a/conftest.py +++ b/conftest.py @@ -60,6 +60,9 @@ "plugins/ndf/skills/cross-review/tests": ("bash", "jq", "git"), # `assert-characterization.sh` を bash で起動し、チェックの本体が `jq` と `python3` を呼ぶ。 "tests/runtime-smoke": ("bash", "jq", "python3"), + # スライドの生成スクリプト(pptxgenjs)を node で実行し、依存が無ければ `npm ci` で入れる(#2065) + "plugins/ndf/skills/release/tests/test_slide_template.py": ("node", "npm"), + "tests/test_presentation_decks.py": ("node", "npm"), } # 読み飛ばしを選ぶ指定。**既定は失敗**である。読み飛ばしたい実行環境のために残すが、 diff --git a/docs/glossary.md b/docs/glossary.md index 5608085b4..bac006a6b 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -700,3 +700,18 @@ AI エージェントが実施する前提で、作業の A・H・L・C を類 | 見積もりの完了条件 | — | 見積もる作業がどこまで終われば完了かを、supervise のフェーズの名前(設計・実装・検査・配布(開発版)・配布(本番))で表したもの | — | — | `docs/specifications/ndf-estimate.md` | | 尺度を超える | — | A の見込みと Size 1 の基準との比が、尺度の最大を超えたときの Size の値。最大へ潰さず、分割を検討する印にする | — | — | `docs/specifications/ndf-estimate.md` | | Size 1 の基準 | — | Size を A の見込みから丸めるときの単位の秒。宣言が無ければ、実績のある課題のうち A の小さい 4 分の 1 の中央値 | — | — | `docs/specifications/ndf-estimate.md` | + +## NDF のスライドの作成(`ndf-slide`) + +スライドを生成スクリプトで作り、Google Slides へ変換して確かめるときの語 + +| 語 | 識別子 | 意味 | 廃止した語 | 廃止した識別子 | 正本 | +| --- | --- | --- | --- | --- | --- | +| トーン&マナー | — | スライドの書体・色・文字サイズ・著作権表示・ロゴの値の組。プロジェクトごとに差し替える | — | — | — | +| ページの枠組み | — | 見出し・罫線・ロゴ・著作権表示・ページ番号の位置を固定した、ページを作る共通の関数(page / box / note / tbl / section) | — | — | — | +| 生成スクリプト | — | pptx を出力するスクリプト(pptxgenjs の build-deck.js)。スライドの正本になる | — | — | — | +| 汎用テンプレート | — | 会社色を持たないトーン&マナーとページの枠組みを持つ、生成スクリプトの出発点 | — | — | — | +| 構成メモ | — | スライドのページごとの番号・見出し・役割・根拠の表と、構成の考え方を書いた文書 | — | — | — | +| 台本 | — | ページごとの見出しと読み上げの原稿。配分秒数は見出しに付けてよい(任意)。スピーカーノートへ入れる | — | — | — | +| トーン&マナーのファイル | — | プロジェクトのトーン&マナーの値を持つ JSON(slides/theme.json)。書いた項目だけが汎用テンプレートの既定値を上書きする | — | — | — | +| 尺 | — | 台本の読み上げにかかる時間。読み上げの字数を 350 字/分で割って出す | — | — | — | diff --git a/docs/glossary/glossary.json b/docs/glossary/glossary.json index b4d1cd43b..6af6d6376 100644 --- a/docs/glossary/glossary.json +++ b/docs/glossary/glossary.json @@ -85,6 +85,11 @@ "id": "ndf-estimate", "name": "NDF の工数の見積もり", "meaning": "AI エージェントが実施する前提で、作業の A・H・L・C を類似の実績から見積もり、予測と実績を突き合わせるときの語" + }, + { + "id": "ndf-slide", + "name": "NDF のスライドの作成", + "meaning": "スライドを生成スクリプトで作り、Google Slides へ変換して確かめるときの語" } ], "terms": [ @@ -4078,6 +4083,62 @@ "context": "ndf-release", "meaning": "PR の本文の `## 利用者向けの変化` が「無し」(「なし」も同じ)だけを持つこと。承認資料の「含む PR」の行に印を付け、CHANGELOG の版の節と「配る中身」には載せない。節の無い本文(題名で代える)とは別に扱う", "source": "docs/specifications/ndf-release-approval-included-prs.md" + }, + { + "term": "トーン&マナー", + "context": "ndf-slide", + "meaning": "スライドの書体・色・文字サイズ・著作権表示・ロゴの値の組。プロジェクトごとに差し替える", + "source": "", + "pending_source": "issues/issue-2065-requirements.md" + }, + { + "term": "ページの枠組み", + "context": "ndf-slide", + "meaning": "見出し・罫線・ロゴ・著作権表示・ページ番号の位置を固定した、ページを作る共通の関数(page / box / note / tbl / section)", + "source": "", + "pending_source": "issues/issue-2065-requirements.md" + }, + { + "term": "生成スクリプト", + "context": "ndf-slide", + "meaning": "pptx を出力するスクリプト(pptxgenjs の build-deck.js)。スライドの正本になる", + "source": "", + "pending_source": "issues/issue-2065-requirements.md" + }, + { + "term": "汎用テンプレート", + "context": "ndf-slide", + "meaning": "会社色を持たないトーン&マナーとページの枠組みを持つ、生成スクリプトの出発点", + "source": "", + "pending_source": "issues/issue-2065-requirements.md" + }, + { + "term": "構成メモ", + "context": "ndf-slide", + "meaning": "スライドのページごとの番号・見出し・役割・根拠の表と、構成の考え方を書いた文書", + "source": "", + "pending_source": "issues/issue-2065-requirements.md" + }, + { + "term": "台本", + "context": "ndf-slide", + "meaning": "ページごとの見出しと読み上げの原稿。配分秒数は見出しに付けてよい(任意)。スピーカーノートへ入れる", + "source": "", + "pending_source": "issues/issue-2065-design.md" + }, + { + "term": "トーン&マナーのファイル", + "context": "ndf-slide", + "meaning": "プロジェクトのトーン&マナーの値を持つ JSON(slides/theme.json)。書いた項目だけが汎用テンプレートの既定値を上書きする", + "source": "", + "pending_source": "issues/issue-2065-design.md" + }, + { + "term": "尺", + "context": "ndf-slide", + "meaning": "台本の読み上げにかかる時間。読み上げの字数を 350 字/分で割って出す", + "source": "", + "pending_source": "issues/issue-2065-design.md" } ] } diff --git a/docs/metrics/ndf-token-usage/2026-10-19.json b/docs/metrics/ndf-token-usage/2026-10-19.json new file mode 100644 index 000000000..51f0cb041 --- /dev/null +++ b/docs/metrics/ndf-token-usage/2026-10-19.json @@ -0,0 +1,2242 @@ +{ + "per_pr": [ + { + "version": "10.17.75", + "mode": "light", + "model": "claude-opus-5-5", + "cc": "2.1.299", + "reviewers": "-", + "sessions": 1, + "sessions_with_pr": 0, + "prs": 0 + }, + { + "version": "10.17.75", + "mode": "light", + "model": "claude-opus-5-5", + "cc": "2.1.300", + "reviewers": "codex+kiro", + "sessions": 1, + "sessions_with_pr": 1, + "prs": 1, + "conductor_cost": 419275.45, + "supervisor_cost": 0.0, + "worker_cost": 62831.9, + "context": 3706319.0, + "out": 16979.0, + "minutes": 18.848599998156228, + "codex_input": 2679120.0, + "codex_out": 4801.0, + "kiro_credit": 6.262314016086235, + "claude_seat_cost": 0.0 + }, + { + "version": "10.17.75", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.300", + "reviewers": "-", + "sessions": 2, + "sessions_with_pr": 0, + "prs": 0 + }, + { + "version": "10.17.75", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.301", + "reviewers": "-", + "sessions": 6, + "sessions_with_pr": 0, + "prs": 0 + }, + { + "version": "10.17.75", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.301", + "reviewers": "codex+kiro", + "sessions": 1, + "sessions_with_pr": 1, + "prs": 2, + "conductor_cost": 1072235.1500000001, + "supervisor_cost": 976850.725, + "worker_cost": 3849449.0250000004, + "context": 47813741.5, + "out": 255608.5, + "minutes": 135.10970833500227, + "codex_input": 859019.5, + "codex_out": 5620.0, + "kiro_credit": 3.6813764014096186, + "claude_seat_cost": 473851.1 + }, + { + "version": "10.17.75", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.302", + "reviewers": "-", + "sessions": 8, + "sessions_with_pr": 0, + "prs": 0 + }, + { + "version": "10.17.75", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.299", + "reviewers": "-", + "sessions": 1, + "sessions_with_pr": 0, + "prs": 0 + }, + { + "version": "10.17.75", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.300", + "reviewers": "-", + "sessions": 3, + "sessions_with_pr": 0, + "prs": 0 + }, + { + "version": "10.17.75", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.301", + "reviewers": "-", + "sessions": 1, + "sessions_with_pr": 1, + "prs": 3, + "conductor_cost": 840218.6, + "supervisor_cost": 0.0, + "worker_cost": 0.0, + "context": 9093116.333333334, + "out": 23256.0, + "minutes": 99.8118111093839, + "codex_input": 0.0, + "codex_out": 0.0, + "kiro_credit": 0.0, + "claude_seat_cost": 0.0 + }, + { + "version": "10.17.75", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.301", + "reviewers": "claude+codex+kiro", + "sessions": 1, + "sessions_with_pr": 1, + "prs": 1, + "conductor_cost": 1419885.9500000002, + "supervisor_cost": 1415860.35, + "worker_cost": 3576273.1500000004, + "context": 48622994.0, + "out": 299040.0, + "minutes": 149.2945999979973, + "codex_input": 3426267.0, + "codex_out": 18671.0, + "kiro_credit": 26.083424069154226, + "claude_seat_cost": 1439006.5 + }, + { + "version": "10.17.75", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.302", + "reviewers": "-", + "sessions": 4, + "sessions_with_pr": 2, + "prs": 6, + "conductor_cost": 397023.98333333334, + "supervisor_cost": 216743.66666666666, + "worker_cost": 347150.0416666667, + "context": 8897369.166666666, + "out": 39794.833333333336, + "minutes": 22.980102778143355, + "codex_input": 0.0, + "codex_out": 0.0, + "kiro_credit": 0.0, + "claude_seat_cost": 0.0 + }, + { + "version": "10.17.75", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.302", + "reviewers": "claude", + "sessions": 1, + "sessions_with_pr": 0, + "prs": 0 + }, + { + "version": "10.17.75", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.302", + "reviewers": "claude+codex", + "sessions": 1, + "sessions_with_pr": 1, + "prs": 2, + "conductor_cost": 1342723.7750000004, + "supervisor_cost": 0.0, + "worker_cost": 0.0, + "context": 14085447.0, + "out": 49267.0, + "minutes": 210.65440000295638, + "codex_input": 17072802.0, + "codex_out": 25558.5, + "kiro_credit": 0.0, + "claude_seat_cost": 1433119.4 + }, + { + "version": "10.17.75", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.302", + "reviewers": "claude+codex+kiro", + "sessions": 1, + "sessions_with_pr": 0, + "prs": 0 + }, + { + "version": "10.18.0", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.302", + "reviewers": "-", + "sessions": 2, + "sessions_with_pr": 0, + "prs": 0 + }, + { + "version": "10.18.0", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.302", + "reviewers": "-", + "sessions": 1, + "sessions_with_pr": 0, + "prs": 0 + }, + { + "version": "10.18.0", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.302", + "reviewers": "claude+codex+kiro", + "sessions": 1, + "sessions_with_pr": 1, + "prs": 1, + "conductor_cost": 3793977.5, + "supervisor_cost": 2873522.75, + "worker_cost": 2706382.95, + "context": 70563941.0, + "out": 389220.0, + "minutes": 333.7103833317757, + "codex_input": 3880219.0, + "codex_out": 15394.0, + "kiro_credit": 20.331091179601987, + "claude_seat_cost": 684832.65 + }, + { + "version": "10.18.0", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.303", + "reviewers": "-", + "sessions": 1, + "sessions_with_pr": 0, + "prs": 0 + } + ], + "per_role": [ + { + "version": "10.17.75", + "mode": "light", + "model": "claude-opus-5-5", + "cc": "2.1.299", + "reviewers": "-", + "layer": "conductor", + "role": "-", + "agent_type": "-", + "count": 1, + "cost": 1384834.6500000001, + "context": 7042851.0, + "out": 41503.0, + "minutes": 83.37204999923706, + "p": 43382.0, + "k": 46.0, + "w5": 0.0, + "w1h": 423120.0, + "rewrites": 2, + "rewrites_after_5m": 2, + "rewrites_untimed": 0, + "rewrite_tokens": 253027, + "rewrite_tokens_after_5m": 253027, + "read_tokens_after_5m": 0, + "rewrite_gap_median": 162.95187499920527 + }, + { + "version": "10.17.75", + "mode": "light", + "model": "claude-opus-5-5", + "cc": "2.1.299", + "reviewers": "-", + "layer": "supervisor", + "role": "その他", + "agent_type": "ndf:worker", + "count": 3, + "cost": 427951.43333333335, + "context": 3025840.0, + "out": 21322.333333333332, + "minutes": 4.7646611107720265, + "p": 37525.0, + "k": 27.666666666666668, + "w5": 141662.66666666666, + "w1h": 0.0, + "rewrites": 0, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 0, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 0, + "rewrite_gap_median": null + }, + { + "version": "10.17.75", + "mode": "light", + "model": "claude-opus-5-5", + "cc": "2.1.300", + "reviewers": "codex+kiro", + "layer": "conductor", + "role": "-", + "agent_type": "-", + "count": 1, + "cost": 419275.45, + "context": 3490530.0, + "out": 14613.0, + "minutes": 18.848599998156228, + "p": 44079.0, + "k": 36.0, + "w5": 0.0, + "w1h": 88007.0, + "rewrites": 0, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 0, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 178727, + "rewrite_gap_median": null + }, + { + "version": "10.17.75", + "mode": "light", + "model": "claude-opus-5-5", + "cc": "2.1.300", + "reviewers": "codex+kiro", + "layer": "worker", + "role": "work", + "agent_type": "claude -p", + "count": 1, + "cost": 62831.9, + "context": 215789.0, + "out": 2366.0, + "minutes": 0.5133333333333333, + "k": 9.0, + "w5": 0.0, + "w1h": 20613.0 + }, + { + "version": "10.17.75", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.300", + "reviewers": "-", + "layer": "conductor", + "role": "-", + "agent_type": "-", + "count": 2, + "cost": 648684.375, + "context": 3982251.5, + "out": 37224.5, + "minutes": 10.763658332824708, + "p": 56614.5, + "k": 31.0, + "w5": 0.0, + "w1h": 135072.0, + "rewrites": 0, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 0, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 89384, + "rewrite_gap_median": null + }, + { + "version": "10.17.75", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.300", + "reviewers": "-", + "layer": "supervisor", + "role": "pr", + "agent_type": "claude -p", + "count": 1, + "cost": 11770.6, + "context": 4633.0, + "out": 701.0, + "minutes": 0.14666666666666667, + "k": 1.0, + "w5": 0.0, + "w1h": 4119.0 + }, + { + "version": "10.17.75", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.300", + "reviewers": "-", + "layer": "supervisor", + "role": "その他", + "agent_type": "ndf:worker", + "count": 3, + "cost": 398333.3499999999, + "context": 3672132.3333333335, + "out": 19467.0, + "minutes": 3.9687222229109866, + "p": 23232.333333333332, + "k": 41.333333333333336, + "w5": 97761.0, + "w1h": 0.0, + "rewrites": 0, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 0, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 0, + "rewrite_gap_median": null + }, + { + "version": "10.17.75", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.300", + "reviewers": "-", + "layer": "worker", + "role": "work", + "agent_type": "claude -p", + "count": 1, + "cost": 97563.15, + "context": 399337.0, + "out": 4326.0, + "minutes": 0.8200000000000001, + "k": 13.0, + "w5": 0.0, + "w1h": 28688.0 + }, + { + "version": "10.17.75", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.301", + "reviewers": "-", + "layer": "conductor", + "role": "-", + "agent_type": "-", + "count": 6, + "cost": 832386.6416666666, + "context": 5948205.5, + "out": 43396.166666666664, + "minutes": 8.272847223281861, + "p": 56935.833333333336, + "k": 40.333333333333336, + "w5": 0.0, + "w1h": 163035.33333333334, + "rewrites": 0, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 0, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 0, + "rewrite_gap_median": null + }, + { + "version": "10.17.75", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.301", + "reviewers": "-", + "layer": "supervisor", + "role": "judge", + "agent_type": "claude -p", + "count": 3, + "cost": 23825.0, + "context": 10942.666666666666, + "out": 388.3333333333333, + "minutes": 0.10666666666666669, + "k": 1.0, + "w5": 0.0, + "w1h": 10940.666666666666 + }, + { + "version": "10.17.75", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.301", + "reviewers": "-", + "layer": "supervisor", + "role": "pr", + "agent_type": "claude -p", + "count": 3, + "cost": 12533.733333333332, + "context": 4717.333333333333, + "out": 753.3333333333334, + "minutes": 0.15166666666666667, + "k": 1.0, + "w5": 0.0, + "w1h": 4374.0 + }, + { + "version": "10.17.75", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.301", + "reviewers": "-", + "layer": "worker", + "role": "work", + "agent_type": "claude -p", + "count": 7, + "cost": 115068.96428571429, + "context": 464410.85714285716, + "out": 6515.714285714285, + "minutes": 1.4290476190476191, + "k": 14.714285714285714, + "w5": 0.0, + "w1h": 30381.571428571428 + }, + { + "version": "10.17.75", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.301", + "reviewers": "codex+kiro", + "layer": "conductor", + "role": "-", + "agent_type": "-", + "count": 1, + "cost": 2144470.3000000003, + "context": 16298787.0, + "out": 63031.0, + "minutes": 270.21941667000453, + "p": 41505.0, + "k": 108.0, + "w5": 0.0, + "w1h": 520071.0, + "rewrites": 3, + "rewrites_after_5m": 3, + "rewrites_untimed": 0, + "rewrite_tokens": 330462, + "rewrite_tokens_after_5m": 330462, + "read_tokens_after_5m": 867017, + "rewrite_gap_median": 87.00481666723887 + }, + { + "version": "10.17.75", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.301", + "reviewers": "codex+kiro", + "layer": "supervisor", + "role": "pr", + "agent_type": "claude -p", + "count": 3, + "cost": 18229.399999999998, + "context": 5476.0, + "out": 1589.0, + "minutes": 0.2755555555555555, + "k": 1.0, + "w5": 0.0, + "w1h": 5132.666666666667 + }, + { + "version": "10.17.75", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.301", + "reviewers": "codex+kiro", + "layer": "supervisor", + "role": "仕上げ", + "agent_type": "ndf:supervisor", + "count": 1, + "cost": 853363.65, + "context": 7112291.0, + "out": 34494.0, + "minutes": 21.362016665935517, + "p": 44682.0, + "k": 61.0, + "w5": 270963.0, + "w1h": 0.0, + "rewrites": 1, + "rewrites_after_5m": 1, + "rewrites_untimed": 0, + "rewrite_tokens": 128409, + "rewrite_tokens_after_5m": 128409, + "read_tokens_after_5m": 0, + "rewrite_gap_median": 9.760450001557668 + }, + { + "version": "10.17.75", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.301", + "reviewers": "codex+kiro", + "layer": "supervisor", + "role": "実装", + "agent_type": "ndf:supervisor", + "count": 2, + "cost": 282228.375, + "context": 1817493.5, + "out": 11888.0, + "minutes": 8.45034999847412, + "p": 44731.5, + "k": 25.0, + "w5": 109888.5, + "w1h": 0.0, + "rewrites": 1, + "rewrites_after_5m": 1, + "rewrites_untimed": 0, + "rewrite_tokens": 63141, + "rewrite_tokens_after_5m": 63141, + "read_tokens_after_5m": 0, + "rewrite_gap_median": 9.111450000603993 + }, + { + "version": "10.17.75", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.301", + "reviewers": "codex+kiro", + "layer": "supervisor", + "role": "実装", + "agent_type": "ndf:supervisor-waits", + "count": 1, + "cost": 481192.85000000003, + "context": 3992848.0, + "out": 15432.0, + "minutes": 14.907116667429607, + "p": 44768.0, + "k": 49.0, + "w5": 0.0, + "w1h": 104763.0, + "rewrites": 0, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 0, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 0, + "rewrite_gap_median": null + }, + { + "version": "10.17.75", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.301", + "reviewers": "codex+kiro", + "layer": "worker", + "role": "work", + "agent_type": "claude -p", + "count": 10, + "cost": 627759.99, + "context": 5471925.1, + "out": 29492.9, + "minutes": 11.7745, + "k": 48.5, + "w5": 0.0, + "w1h": 105958.1 + }, + { + "version": "10.17.75", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.301", + "reviewers": "codex+kiro", + "layer": "worker", + "role": "その他", + "agent_type": "ndf:worker", + "count": 5, + "cost": 284259.63, + "context": 1970578.2, + "out": 14957.6, + "minutes": 2.863076666990916, + "p": 35304.6, + "k": 20.0, + "w5": 92420.6, + "w1h": 0.0, + "rewrites": 0, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 0, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 0, + "rewrite_gap_median": null + }, + { + "version": "10.17.75", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.302", + "reviewers": "-", + "layer": "conductor", + "role": "-", + "agent_type": "-", + "count": 8, + "cost": 563086.04375, + "context": 3737988.25, + "out": 29165.25, + "minutes": 5.447893749177456, + "p": 55657.5, + "k": 33.0, + "w5": 0.0, + "w1h": 118101.375, + "rewrites": 0, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 0, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 0, + "rewrite_gap_median": null + }, + { + "version": "10.17.75", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.302", + "reviewers": "-", + "layer": "supervisor", + "role": "judge", + "agent_type": "claude -p", + "count": 1, + "cost": 27449.0, + "context": 12838.0, + "out": 355.0, + "minutes": 0.095, + "k": 1.0, + "w5": 0.0, + "w1h": 12836.0 + }, + { + "version": "10.17.75", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.302", + "reviewers": "-", + "layer": "supervisor", + "role": "pr", + "agent_type": "claude -p", + "count": 4, + "cost": 12842.1, + "context": 4870.0, + "out": 820.5, + "minutes": 0.16666666666666666, + "k": 1.0, + "w5": 0.0, + "w1h": 4356.0 + }, + { + "version": "10.17.75", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.302", + "reviewers": "-", + "layer": "worker", + "role": "work", + "agent_type": "claude -p", + "count": 5, + "cost": 82565.53, + "context": 294533.8, + "out": 3515.0, + "minutes": 1.2386666666666668, + "k": 10.6, + "w5": 0.0, + "w1h": 25766.0 + }, + { + "version": "10.17.75", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.299", + "reviewers": "-", + "layer": "conductor", + "role": "-", + "agent_type": "-", + "count": 1, + "cost": 1795406.8499999996, + "context": 18321350.0, + "out": 63927.0, + "minutes": 108.90946666399638, + "p": 33037.0, + "k": 118.0, + "w5": 0.0, + "w1h": 286909.0, + "rewrites": 2, + "rewrites_after_5m": 1, + "rewrites_untimed": 0, + "rewrite_tokens": 142134, + "rewrite_tokens_after_5m": 107824, + "read_tokens_after_5m": 485671, + "rewrite_gap_median": 259.8919666687647 + }, + { + "version": "10.17.75", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.300", + "reviewers": "-", + "layer": "conductor", + "role": "-", + "agent_type": "-", + "count": 3, + "cost": 1179233.6666666667, + "context": 10534115.333333334, + "out": 33314.0, + "minutes": 136.305527777142, + "p": 40148.666666666664, + "k": 79.0, + "w5": 0.0, + "w1h": 249127.33333333334, + "rewrites": 4, + "rewrites_after_5m": 3, + "rewrites_untimed": 0, + "rewrite_tokens": 340073, + "rewrite_tokens_after_5m": 305369, + "read_tokens_after_5m": 2206402, + "rewrite_gap_median": 302.61025000015894 + }, + { + "version": "10.17.75", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.300", + "reviewers": "-", + "layer": "supervisor", + "role": "judge", + "agent_type": "claude -p", + "count": 1, + "cost": 27249.0, + "context": 12623.0, + "out": 401.0, + "minutes": 0.10666666666666667, + "k": 1.0, + "w5": 0.0, + "w1h": 12621.0 + }, + { + "version": "10.17.75", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.300", + "reviewers": "-", + "layer": "supervisor", + "role": "pr", + "agent_type": "claude -p", + "count": 6, + "cost": 13693.4, + "context": 4941.333333333333, + "out": 895.6666666666666, + "minutes": 0.16694444444444442, + "k": 1.0, + "w5": 0.0, + "w1h": 4598.0 + }, + { + "version": "10.17.75", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.300", + "reviewers": "-", + "layer": "supervisor", + "role": "slow", + "agent_type": "claude -p", + "count": 2, + "cost": 3896.5, + "context": 1238.0, + "out": 284.5, + "minutes": 0.09916666666666667, + "k": 1.0, + "w5": 0.0, + "w1h": 1236.0 + }, + { + "version": "10.17.75", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.300", + "reviewers": "-", + "layer": "supervisor", + "role": "その他", + "agent_type": "ndf:supervisor", + "count": 1, + "cost": 375337.15, + "context": 2606253.0, + "out": 18146.0, + "minutes": 3.8672166665395102, + "p": 44654.0, + "k": 27.0, + "w5": 128536.0, + "w1h": 0.0, + "rewrites": 0, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 0, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 0, + "rewrite_gap_median": null + }, + { + "version": "10.17.75", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.300", + "reviewers": "-", + "layer": "worker", + "role": "work", + "agent_type": "claude -p", + "count": 17, + "cost": 165396.9705882353, + "context": 956231.5294117647, + "out": 8339.70588235294, + "minutes": 5.604705882352942, + "k": 20.764705882352942, + "w5": 0.0, + "w1h": 38898.0 + }, + { + "version": "10.17.75", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.301", + "reviewers": "-", + "layer": "conductor", + "role": "-", + "agent_type": "-", + "count": 1, + "cost": 2520655.8, + "context": 27279349.0, + "out": 69768.0, + "minutes": 299.4354333281517, + "p": 33612.0, + "k": 166.0, + "w5": 0.0, + "w1h": 414099.0, + "rewrites": 2, + "rewrites_after_5m": 1, + "rewrites_untimed": 0, + "rewrite_tokens": 220280, + "rewrite_tokens_after_5m": 185942, + "read_tokens_after_5m": 1337996, + "rewrite_gap_median": 650.26915833354 + }, + { + "version": "10.17.75", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.301", + "reviewers": "claude+codex+kiro", + "layer": "conductor", + "role": "-", + "agent_type": "-", + "count": 1, + "cost": 1419885.9500000002, + "context": 14530110.0, + "out": 51768.0, + "minutes": 149.2945999979973, + "p": 41474.0, + "k": 89.0, + "w5": 0.0, + "w1h": 222737.0, + "rewrites": 0, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 0, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 1530998, + "rewrite_gap_median": null + }, + { + "version": "10.17.75", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.301", + "reviewers": "claude+codex+kiro", + "layer": "supervisor", + "role": "judge", + "agent_type": "claude -p", + "count": 2, + "cost": 24068.0, + "context": 11427.5, + "out": 243.0, + "minutes": 0.08083333333333333, + "k": 1.0, + "w5": 0.0, + "w1h": 11425.5 + }, + { + "version": "10.17.75", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.301", + "reviewers": "claude+codex+kiro", + "layer": "supervisor", + "role": "slow", + "agent_type": "claude -p", + "count": 1, + "cost": 4282.0, + "context": 1202.0, + "out": 376.0, + "minutes": 0.11, + "k": 1.0, + "w5": 0.0, + "w1h": 1200.0 + }, + { + "version": "10.17.75", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.301", + "reviewers": "claude+codex+kiro", + "layer": "supervisor", + "role": "仕上げ", + "agent_type": "ndf:supervisor", + "count": 1, + "cost": 711914.1000000001, + "context": 3862090.0, + "out": 26974.0, + "minutes": 13.745799998442331, + "p": 45299.0, + "k": 32.0, + "w5": 319899.0, + "w1h": 0.0, + "rewrites": 1, + "rewrites_after_5m": 1, + "rewrites_untimed": 0, + "rewrite_tokens": 158746, + "rewrite_tokens_after_5m": 158746, + "read_tokens_after_5m": 0, + "rewrite_gap_median": 8.513750000794728 + }, + { + "version": "10.17.75", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.301", + "reviewers": "claude+codex+kiro", + "layer": "supervisor", + "role": "仕上げ", + "agent_type": "ndf:supervisor-waits", + "count": 1, + "cost": 651528.25, + "context": 5637588.0, + "out": 23517.0, + "minutes": 12.73921666542689, + "p": 45104.0, + "k": 57.0, + "w5": 0.0, + "w1h": 129207.0, + "rewrites": 0, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 0, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 123598, + "rewrite_gap_median": null + }, + { + "version": "10.17.75", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.301", + "reviewers": "claude+codex+kiro", + "layer": "worker", + "role": "work", + "agent_type": "claude -p", + "count": 3, + "cost": 129816.21666666667, + "context": 547780.3333333334, + "out": 6952.333333333333, + "minutes": 3.7272222222222227, + "k": 15.666666666666666, + "w5": 0.0, + "w1h": 34685.333333333336 + }, + { + "version": "10.17.75", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.301", + "reviewers": "claude+codex+kiro", + "layer": "worker", + "role": "修正", + "agent_type": "general-purpose", + "count": 1, + "cost": 111855.95000000001, + "context": 589699.0, + "out": 4228.0, + "minutes": 0.9344833334287007, + "p": 44176.0, + "k": 10.0, + "w5": 51010.0, + "w1h": 0.0, + "rewrites": 0, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 0, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 0, + "rewrite_gap_median": null + }, + { + "version": "10.17.75", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.301", + "reviewers": "claude+codex+kiro", + "layer": "worker", + "role": "修正", + "agent_type": "ndf:worker", + "count": 1, + "cost": 743183.4500000001, + "context": 5045757.0, + "out": 28013.0, + "minutes": 17.94728333155314, + "p": 36473.0, + "k": 45.0, + "w5": 292286.0, + "w1h": 0.0, + "rewrites": 1, + "rewrites_after_5m": 1, + "rewrites_untimed": 0, + "rewrite_tokens": 153475, + "rewrite_tokens_after_5m": 153475, + "read_tokens_after_5m": 0, + "rewrite_gap_median": 10.122516667842865 + }, + { + "version": "10.17.75", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.301", + "reviewers": "claude+codex+kiro", + "layer": "worker", + "role": "調査", + "agent_type": "ndf:worker", + "count": 2, + "cost": 222944.85, + "context": 1341658.0, + "out": 13848.5, + "minutes": 3.380066665013631, + "p": 35425.0, + "k": 19.5, + "w5": 72152.0, + "w1h": 0.0, + "rewrites": 0, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 0, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 0, + "rewrite_gap_median": null + }, + { + "version": "10.17.75", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.301", + "reviewers": "claude+codex+kiro", + "layer": "worker", + "role": "集計", + "agent_type": "ndf:worker", + "count": 3, + "cost": 628631.7999999999, + "context": 4869012.0, + "out": 38374.666666666664, + "minutes": 8.824977776739333, + "p": 35817.0, + "k": 46.666666666666664, + "w5": 161016.0, + "w1h": 0.0, + "rewrites": 0, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 0, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 0, + "rewrite_gap_median": null + }, + { + "version": "10.17.75", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.302", + "reviewers": "-", + "layer": "conductor", + "role": "-", + "agent_type": "-", + "count": 4, + "cost": 1332933.0, + "context": 14696743.5, + "out": 51490.75, + "minutes": 75.63113333384196, + "p": 39673.0, + "k": 99.5, + "w5": 0.0, + "w1h": 174583.5, + "rewrites": 0, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 0, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 2601313, + "rewrite_gap_median": null + }, + { + "version": "10.17.75", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.302", + "reviewers": "-", + "layer": "supervisor", + "role": "judge", + "agent_type": "claude -p", + "count": 2, + "cost": 26834.5, + "context": 12867.0, + "out": 220.5, + "minutes": 0.0875, + "k": 1.0, + "w5": 0.0, + "w1h": 12865.0 + }, + { + "version": "10.17.75", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.302", + "reviewers": "-", + "layer": "supervisor", + "role": "仕上げ", + "agent_type": "ndf:supervisor", + "count": 1, + "cost": 1246793.0, + "context": 11839648.0, + "out": 48101.0, + "minutes": 26.7875, + "p": 47250.0, + "k": 86.0, + "w5": 345109.0, + "w1h": 0.0, + "rewrites": 1, + "rewrites_after_5m": 1, + "rewrites_untimed": 0, + "rewrite_tokens": 171273, + "rewrite_tokens_after_5m": 171273, + "read_tokens_after_5m": 0, + "rewrite_gap_median": 11.312966664632162 + }, + { + "version": "10.17.75", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.302", + "reviewers": "-", + "layer": "worker", + "role": "work", + "agent_type": "claude -p", + "count": 6, + "cost": 85671.84999999999, + "context": 287981.6666666667, + "out": 3860.5, + "minutes": 0.9038888888888887, + "k": 10.166666666666666, + "w5": 0.0, + "w1h": 26642.0 + }, + { + "version": "10.17.75", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.302", + "reviewers": "-", + "layer": "worker", + "role": "その他", + "agent_type": "ndf:worker", + "count": 3, + "cost": 547257.4666666667, + "context": 4548943.333333333, + "out": 29792.0, + "minutes": 5.874227775467767, + "p": 36524.0, + "k": 37.0, + "w5": 142316.66666666666, + "w1h": 0.0, + "rewrites": 0, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 0, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 0, + "rewrite_gap_median": null + }, + { + "version": "10.17.75", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.302", + "reviewers": "claude", + "layer": "conductor", + "role": "-", + "agent_type": "-", + "count": 1, + "cost": 1235893.1999999997, + "context": 13240428.0, + "out": 41444.0, + "minutes": 57.31324999729792, + "p": 37592.0, + "k": 84.0, + "w5": 0.0, + "w1h": 187934.0, + "rewrites": 0, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 0, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 694269, + "rewrite_gap_median": null + }, + { + "version": "10.17.75", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.302", + "reviewers": "claude+codex", + "layer": "conductor", + "role": "-", + "agent_type": "-", + "count": 1, + "cost": 2685447.5500000007, + "context": 28170894.0, + "out": 98534.0, + "minutes": 421.30880000591276, + "p": 37642.0, + "k": 189.0, + "w5": 0.0, + "w1h": 401969.0, + "rewrites": 2, + "rewrites_after_5m": 2, + "rewrites_untimed": 0, + "rewrite_tokens": 103470, + "rewrite_tokens_after_5m": 103470, + "read_tokens_after_5m": 2003009, + "rewrite_gap_median": 82.83105833331744 + }, + { + "version": "10.17.75", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.302", + "reviewers": "claude+codex+kiro", + "layer": "conductor", + "role": "-", + "agent_type": "-", + "count": 1, + "cost": 1812725.3499999996, + "context": 19225744.0, + "out": 37707.0, + "minutes": 261.75940000216167, + "p": 45692.0, + "k": 126.0, + "w5": 0.0, + "w1h": 339811.0, + "rewrites": 1, + "rewrites_after_5m": 1, + "rewrites_untimed": 0, + "rewrite_tokens": 158474, + "rewrite_tokens_after_5m": 158474, + "read_tokens_after_5m": 1989926, + "rewrite_gap_median": 76.18286666472753 + }, + { + "version": "10.17.75", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.302", + "reviewers": "claude+codex+kiro", + "layer": "supervisor", + "role": "pr", + "agent_type": "claude -p", + "count": 3, + "cost": 17943.2, + "context": 5524.0, + "out": 1446.0, + "minutes": 0.2361111111111111, + "k": 1.0, + "w5": 0.0, + "w1h": 5351.333333333333 + }, + { + "version": "10.17.75", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.302", + "reviewers": "claude+codex+kiro", + "layer": "supervisor", + "role": "slow", + "agent_type": "claude -p", + "count": 3, + "cost": 3381.6666666666665, + "context": 1277.6666666666667, + "out": 165.66666666666666, + "minutes": 0.11166666666666666, + "k": 1.0, + "w5": 0.0, + "w1h": 1275.6666666666667 + }, + { + "version": "10.17.75", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.302", + "reviewers": "claude+codex+kiro", + "layer": "worker", + "role": "work", + "agent_type": "claude -p", + "count": 14, + "cost": 233283.98928571425, + "context": 1615562.2857142857, + "out": 11891.785714285714, + "minutes": 9.799166666666668, + "k": 22.571428571428573, + "w5": 0.0, + "w1h": 47695.642857142855 + }, + { + "version": "10.18.0", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.302", + "reviewers": "-", + "layer": "conductor", + "role": "-", + "agent_type": "-", + "count": 2, + "cost": 543204.425, + "context": 2459308.0, + "out": 32026.5, + "minutes": 5.589449999729792, + "p": 58597.0, + "k": 21.0, + "w5": 0.0, + "w1h": 133367.5, + "rewrites": 0, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 0, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 0, + "rewrite_gap_median": null + }, + { + "version": "10.18.0", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.302", + "reviewers": "-", + "layer": "supervisor", + "role": "pr", + "agent_type": "claude -p", + "count": 1, + "cost": 14342.0, + "context": 4792.0, + "out": 952.0, + "minutes": 0.16333333333333336, + "k": 1.0, + "w5": 0.0, + "w1h": 4790.0 + }, + { + "version": "10.18.0", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.302", + "reviewers": "-", + "layer": "worker", + "role": "work", + "agent_type": "claude -p", + "count": 1, + "cost": 122652.15, + "context": 385210.0, + "out": 4800.0, + "minutes": 1.2233333333333334, + "k": 15.0, + "w5": 0.0, + "w1h": 40701.0 + }, + { + "version": "10.18.0", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.302", + "reviewers": "-", + "layer": "conductor", + "role": "-", + "agent_type": "-", + "count": 1, + "cost": 4301485.649999999, + "context": 49903625.0, + "out": 182684.0, + "minutes": 148.1581833322843, + "p": 72988.0, + "k": 166.0, + "w5": 0.0, + "w1h": 457716.0, + "rewrites": 0, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 0, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 2301352, + "rewrite_gap_median": null + }, + { + "version": "10.18.0", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.302", + "reviewers": "-", + "layer": "supervisor", + "role": "その他", + "agent_type": "ndf:worker", + "count": 16, + "cost": 360630.534375, + "context": 2848803.4375, + "out": 22107.5, + "minutes": 4.508250000327825, + "p": 35556.625, + "k": 36.375, + "w5": 89653.125, + "w1h": 0.0, + "rewrites": 0, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 0, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 0, + "rewrite_gap_median": null + }, + { + "version": "10.18.0", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.302", + "reviewers": "claude+codex+kiro", + "layer": "conductor", + "role": "-", + "agent_type": "-", + "count": 1, + "cost": 3793977.5, + "context": 27184053.0, + "out": 82823.0, + "minutes": 333.7103833317757, + "p": 45170.0, + "k": 147.0, + "w5": 0.0, + "w1h": 1036077.0, + "rewrites": 5, + "rewrites_after_5m": 5, + "rewrites_untimed": 0, + "rewrite_tokens": 783412, + "rewrite_tokens_after_5m": 783412, + "read_tokens_after_5m": 1188991, + "rewrite_gap_median": 160.4631333311399 + }, + { + "version": "10.18.0", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.302", + "reviewers": "claude+codex+kiro", + "layer": "supervisor", + "role": "judge", + "agent_type": "claude -p", + "count": 1, + "cost": 27715.0, + "context": 12766.0, + "out": 437.0, + "minutes": 0.09833333333333334, + "k": 1.0, + "w5": 0.0, + "w1h": 12764.0 + }, + { + "version": "10.18.0", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.302", + "reviewers": "claude+codex+kiro", + "layer": "supervisor", + "role": "pr", + "agent_type": "claude -p", + "count": 1, + "cost": 19667.0, + "context": 6132.0, + "out": 1481.0, + "minutes": 0.24166666666666667, + "k": 1.0, + "w5": 0.0, + "w1h": 6130.0 + }, + { + "version": "10.18.0", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.302", + "reviewers": "claude+codex+kiro", + "layer": "supervisor", + "role": "その他", + "agent_type": "ndf:worker", + "count": 5, + "cost": 565228.15, + "context": 4443324.6, + "out": 31249.6, + "minutes": 8.089913334051769, + "p": 37220.2, + "k": 34.4, + "w5": 155623.8, + "w1h": 0.0, + "rewrites": 0, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 0, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 0, + "rewrite_gap_median": null + }, + { + "version": "10.18.0", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.302", + "reviewers": "claude+codex+kiro", + "layer": "worker", + "role": "work", + "agent_type": "claude -p", + "count": 6, + "cost": 451063.825, + "context": 3524061.1666666665, + "out": 24705.166666666668, + "minutes": 8.450277777777776, + "k": 37.333333333333336, + "w5": 0.0, + "w1h": 77575.66666666667 + }, + { + "version": "10.18.0", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.303", + "reviewers": "-", + "layer": "conductor", + "role": "-", + "agent_type": "-", + "count": 1, + "cost": 445142.54999999993, + "context": 3987661.0, + "out": 11726.0, + "minutes": 52.75321666797002, + "p": 45331.0, + "k": 39.0, + "w5": 0.0, + "w1h": 95920.0, + "rewrites": 0, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 0, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 392484, + "rewrite_gap_median": null + }, + { + "version": "10.18.0", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.303", + "reviewers": "-", + "layer": "supervisor", + "role": "judge", + "agent_type": "claude -p", + "count": 4, + "cost": 16146.4, + "context": 9919.0, + "out": 248.0, + "minutes": 0.08625, + "k": 1.0, + "w5": 0.0, + "w1h": 7389.0 + }, + { + "version": "10.18.0", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.303", + "reviewers": "-", + "layer": "supervisor", + "role": "その他", + "agent_type": "ndf:worker", + "count": 1, + "cost": 544508.3, + "context": 4821940.0, + "out": 31561.0, + "minutes": 7.77058333158493, + "p": 36913.0, + "k": 48.0, + "w5": 121261.0, + "w1h": 0.0, + "rewrites": 0, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 0, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 0, + "rewrite_gap_median": null + }, + { + "version": "10.18.0", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.303", + "reviewers": "-", + "layer": "worker", + "role": "work", + "agent_type": "claude -p", + "count": 5, + "cost": 70273.7, + "context": 236170.4, + "out": 4054.2, + "minutes": 1.015666666666667, + "k": 10.0, + "w5": 0.0, + "w1h": 19577.6 + } + ], + "external": [ + { + "version": "10.17.75", + "mode": "light", + "model": "claude-opus-5-5", + "cc": "2.1.300", + "reviewers": "codex+kiro", + "runtime": "codex", + "skill": "cross-review", + "cli_model": "gpt-6.1-sol", + "count": 1, + "input": 2679120.0, + "cached": 2522752.0, + "out": 4801.0, + "minutes": 2.5175833344459533, + "p": 30324.0, + "k": 23.0, + "rewrites": 0, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 0, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 0, + "rewrite_gap_median": null, + "call_seats": 1 + }, + { + "version": "10.17.75", + "mode": "light", + "model": "claude-opus-5-5", + "cc": "2.1.300", + "reviewers": "codex+kiro", + "runtime": "kiro", + "skill": "cross-review", + "cli_model": "auto", + "count": 1, + "credit": 6.262314016086235, + "minutes": 4.133333333333334, + "turns": 1.0 + }, + { + "version": "10.17.75", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.301", + "reviewers": "codex+kiro", + "runtime": "claude", + "skill": "cross-refactoring", + "cli_model": "claude-opus-5-5", + "count": 4, + "context": 967546.5, + "out": 13136.5, + "cost": 236925.55, + "minutes": 2.842595831553141, + "p": 27306.25, + "k": 17.0, + "w5": 0.0, + "w1h": 62991.5, + "rewrites": 0, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 0, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 0, + "rewrite_gap_median": null, + "call_seats": 4 + }, + { + "version": "10.17.75", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.301", + "reviewers": "codex+kiro", + "runtime": "codex", + "skill": "cross-review", + "cli_model": "gpt-6.1-sol", + "count": 3, + "input": 246364.66666666666, + "cached": 199125.33333333334, + "out": 1695.0, + "minutes": 0.5243944433000353, + "p": 31848.666666666668, + "k": 5.333333333333333, + "rewrites": 0, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 0, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 0, + "rewrite_gap_median": null, + "call_seats": 3 + }, + { + "version": "10.17.75", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.301", + "reviewers": "codex+kiro", + "runtime": "codex", + "skill": "cross-refactoring", + "cli_model": "gpt-6.1-sol", + "count": 1, + "input": 978945.0, + "cached": 871424.0, + "out": 6155.0, + "minutes": 2.2663999994595847, + "p": 31058.0, + "k": 12.0, + "rewrites": 0, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 0, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 0, + "rewrite_gap_median": null, + "call_seats": 1 + }, + { + "version": "10.17.75", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.301", + "reviewers": "codex+kiro", + "runtime": "kiro", + "skill": "cross-review", + "cli_model": "auto", + "count": 3, + "credit": 1.7078501904919845, + "minutes": 1.1888888888888889, + "turns": 1.0 + }, + { + "version": "10.17.75", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.301", + "reviewers": "codex+kiro", + "runtime": "kiro", + "skill": "cross-refactoring", + "cli_model": "auto", + "count": 1, + "credit": 2.2392022313432833, + "minutes": 1.8166666666666667, + "turns": 1.0 + }, + { + "version": "10.17.75", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.301", + "reviewers": "claude+codex+kiro", + "runtime": "claude", + "skill": "cross-review", + "cli_model": "claude-opus-5-5", + "count": 8, + "context": 640513.25, + "out": 8750.625, + "cost": 179875.8125, + "minutes": 1.8916062494119008, + "p": 26910.625, + "k": 11.125, + "w5": 0.0, + "w1h": 53372.25, + "rewrites": 0, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 0, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 0, + "rewrite_gap_median": null, + "call_seats": 8 + }, + { + "version": "10.17.75", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.301", + "reviewers": "claude+codex+kiro", + "runtime": "codex", + "skill": "cross-review", + "cli_model": "gpt-6.1-sol", + "count": 9, + "input": 380696.3333333333, + "cached": 324750.22222222225, + "out": 2074.5555555555557, + "minutes": 0.9578759250817476, + "p": 29265.0, + "k": 6.777777777777778, + "rewrites": 0, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 0, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 0, + "rewrite_gap_median": null, + "call_seats": 9 + }, + { + "version": "10.17.75", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.301", + "reviewers": "claude+codex+kiro", + "runtime": "kiro", + "skill": "cross-review", + "cli_model": "auto", + "count": 10, + "credit": 2.6083424069154226, + "minutes": 1.8183333333333331, + "turns": 1.0 + }, + { + "version": "10.17.75", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.302", + "reviewers": "claude", + "runtime": "claude", + "skill": "cross-review", + "cli_model": "claude-opus-5-5", + "count": 2, + "context": 282569.5, + "out": 6435.0, + "cost": 129655.825, + "minutes": 1.2717666665712992, + "p": 20973.5, + "k": 8.0, + "w5": 0.0, + "w1h": 42737.0, + "rewrites": 0, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 0, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 0, + "rewrite_gap_median": null, + "call_seats": 2 + }, + { + "version": "10.17.75", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.302", + "reviewers": "claude+codex", + "runtime": "claude", + "skill": "cross-review", + "cli_model": "claude-opus-5-5", + "count": 5, + "context": 3914365.6, + "out": 19790.4, + "cost": 573247.76, + "minutes": 4.63349666595459, + "p": 26695.8, + "k": 29.4, + "w5": 0.0, + "w1h": 142831.6, + "rewrites": 0, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 0, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 0, + "rewrite_gap_median": null, + "call_seats": 5 + }, + { + "version": "10.17.75", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.302", + "reviewers": "claude+codex", + "runtime": "codex", + "skill": "cross-review", + "cli_model": "gpt-6.1-sol", + "count": 6, + "input": 5690934.0, + "cached": 5464448.0, + "out": 8519.5, + "minutes": 3.8224861125151315, + "p": 30292.5, + "k": 26.333333333333332, + "rewrites": 0, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 0, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 0, + "rewrite_gap_median": null, + "call_seats": 6 + }, + { + "version": "10.17.75", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.302", + "reviewers": "claude+codex+kiro", + "runtime": "claude", + "skill": "cross-review", + "cli_model": "claude-opus-5-5", + "count": 1, + "context": 2214917.0, + "out": 16933.0, + "cost": 483631.55, + "minutes": 3.0656166672706604, + "p": 41656.0, + "k": 20.0, + "w5": 0.0, + "w1h": 147786.0, + "rewrites": 0, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 0, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 0, + "rewrite_gap_median": null, + "call_seats": 1 + }, + { + "version": "10.17.75", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.302", + "reviewers": "claude+codex+kiro", + "runtime": "claude", + "skill": "cross-refactoring", + "cli_model": "claude-opus-5-5", + "count": 7, + "context": 1453219.2857142857, + "out": 13316.714285714286, + "cost": 263368.8071428572, + "minutes": 3.2707976193655104, + "p": 28787.14285714286, + "k": 20.571428571428573, + "w5": 0.0, + "w1h": 63633.42857142857, + "rewrites": 0, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 0, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 0, + "rewrite_gap_median": null, + "call_seats": 7 + }, + { + "version": "10.17.75", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.302", + "reviewers": "claude+codex+kiro", + "runtime": "codex", + "skill": "cross-review", + "cli_model": "gpt-6.1-sol", + "count": 1, + "input": 2566908.0, + "cached": 2397312.0, + "out": 7872.0, + "minutes": 2.983199997742971, + "p": 38674.0, + "k": 22.0, + "rewrites": 0, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 0, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 0, + "rewrite_gap_median": null, + "call_seats": 1 + }, + { + "version": "10.17.75", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.302", + "reviewers": "claude+codex+kiro", + "runtime": "codex", + "skill": "cross-refactoring", + "cli_model": "gpt-6.1-sol", + "count": 1, + "input": 1792080.0, + "cached": 1648384.0, + "out": 9983.0, + "minutes": 3.711716667811076, + "p": 31517.0, + "k": 17.0, + "rewrites": 0, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 0, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 0, + "rewrite_gap_median": null, + "call_seats": 1 + }, + { + "version": "10.17.75", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.302", + "reviewers": "claude+codex+kiro", + "runtime": "kiro", + "skill": "cross-review", + "cli_model": "auto", + "count": 3, + "credit": 5.047864377998894, + "minutes": 2.6166666666666667, + "turns": 1.0 + }, + { + "version": "10.17.75", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.302", + "reviewers": "claude+codex+kiro", + "runtime": "kiro", + "skill": "cross-refactoring", + "cli_model": "auto", + "count": 1, + "credit": 3.8835785645107794, + "minutes": 2.4166666666666665, + "turns": 1.0 + }, + { + "version": "10.18.0", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.302", + "reviewers": "claude+codex+kiro", + "runtime": "claude", + "skill": "cross-review", + "cli_model": "claude-opus-5-5", + "count": 3, + "context": 850207.0, + "out": 7207.666666666667, + "cost": 228277.55000000002, + "minutes": 1.640577777226766, + "p": 34193.0, + "k": 11.666666666666666, + "w5": 0.0, + "w1h": 76772.66666666667, + "rewrites": 0, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 0, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 0, + "rewrite_gap_median": null, + "call_seats": 3 + }, + { + "version": "10.18.0", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.302", + "reviewers": "claude+codex+kiro", + "runtime": "codex", + "skill": "cross-review", + "cli_model": "gpt-6.1-sol", + "count": 4, + "input": 970054.75, + "cached": 879488.0, + "out": 3848.5, + "minutes": 1.3303749978542327, + "p": 31997.25, + "k": 11.25, + "rewrites": 0, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 0, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 0, + "rewrite_gap_median": null, + "call_seats": 4 + }, + { + "version": "10.18.0", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.302", + "reviewers": "claude+codex+kiro", + "runtime": "kiro", + "skill": "cross-review", + "cli_model": "auto", + "count": 8, + "credit": 2.5413863974502484, + "minutes": 1.6125, + "turns": 1.0 + } + ], + "meta": { + "by": [ + "version", + "mode", + "model", + "cc", + "reviewers" + ], + "min_version": "10.17.75", + "until": "2026-10-19T00:05:00Z", + "sessions": 37, + "sessions_with_pr": 8, + "unlinked_external": 3279, + "unlinked_ledger": 0, + "skipped": { + "ndf の Skill が無い": 444, + "版を判定できない": 17 + }, + "released": "10.19.0", + "previous": "2026-10-17.json" + } +} diff --git a/docs/metrics/ndf-token-usage/2026-10-19.md b/docs/metrics/ndf-token-usage/2026-10-19.md new file mode 100644 index 000000000..4d7898483 --- /dev/null +++ b/docs/metrics/ndf-token-usage/2026-10-19.md @@ -0,0 +1,143 @@ +# ndf の版ごとのトークン消費と所要時間(2026-10-19 集計・10.19.0 の配布) + +**10.19.0 を正式版として出したときの記録である。** 打ち切りの時刻は `2026-10-19T00:05:00Z`。表の最初の行は 10.17.75(前の記録: 2026-10-17.md)。既定では前の記録の最後の版で、記録どうしが 1 行ずつ重なる。生の記録・会話の本文・リポジトリ名・会話の ID は残していない。集計した日時点の記録で、以後の記録の増減には追随しない。 + +## 作り方 + +同じ打ち切りの時刻で走らせ直すと、記録が残っている間は同じ `.json` ができる。ただし kiro の記録はターンに時刻を持たないため、打ち切りをまたいで進行中だった席は credit が変わりうる。 + +```bash +python3 scripts/token-usage-snapshot.py --released 10.19.0 --until 2026-10-19T00:05:00Z --min-version 10.17.75 +python3 scripts/token-usage.py --min-version 10.17.75 --until 2026-10-19T00:05:00Z --by version # 末尾の「集計の出力」 +``` + +## 比べるときの注意 + +- **PR を作った会話が 2 件以下の版:** 10.18.0(1 件)。作業の中身の違いが版の違いより大きい +- **CHANGELOG.md にあって表に出ない版:** 10.19.0。その版で始めた PR つきの会話が無い(会話の版は最初に読んだ版で決まる) +- **打ち切りの 30 分前以降にも行がある会話を含む版:** 10.18.0。進行中だった会話は、値が打ち切りの時刻までの分である + +## 版ごとの差(PR 1 本あたり) + +換算の合計は conductor・supervisor・worker の換算の和。前の版との差は、表の 1 つ上の行との比である。±30% を超えた版を下の「読み取り」で扱う。 + +| ndf の版 | 会話 | PR | 換算の合計 | 前の版との差 | conductor | supervisor | worker | 入力 | 所要 | codex 入力 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 10.17.75 | 7 | 15 | 1.98M | - | 0.77M | 0.31M | 0.89M | 17.12M | 86.5 | 2.80M | +| 10.18.0 | 1 | 1 | 9.37M | +374% | 3.79M | 2.87M | 2.71M | 70.56M | 333.7 | 3.88M | + +## 版と層ごとの呼び出しとキャッシュ(1 起動あたり) + +P と k と書き込みは 1 起動あたり、書き直しとうち 5 分超は合計の回数である。 + +| ndf の版 | 層 | 起動 | P | k | 書き込み 5 分 | 書き込み 1 時間 | 書き直し | うち 5 分超 | 1 起動あたりの書き直し | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 10.17.75 | conductor | 32 | 48.0k | 67.7 | 0.00M | 0.20M | 16 | 13 | 0.50 | +| 10.17.75 | supervisor | 49 | 38.8k | 12.3 | 0.04M | 0.01M | 4 | 4 | 0.29 | +| 10.17.75 | worker | 79 | 36.3k | 23.9 | 0.02M | 0.04M | 1 | 1 | 0.07 | +| 10.18.0 | conductor | 5 | 56.1k | 78.8 | 0.00M | 0.37M | 5 | 5 | 1.00 | +| 10.18.0 | supervisor | 29 | 36.0k | 27.9 | 0.08M | 0.00M | 0 | 0 | 0.00 | +| 10.18.0 | worker | 12 | - | 24.1 | 0.00M | 0.05M | - | - | - | + +## 読み取り + +差の大きい版: 10.18.0(+374%)。手引き(README.md)に従い、版ごとに 1〜3 行をここへ書く。 + +## 集計の出力 + +対象の会話: 37 件 / PR を作った会話: 8 件 / 寄せ先の無い外部 CLI: 3279 件 / 寄せ先の無い帳簿の行: 0 件 + +換算は input を 1 とした費用(cache read はモデル別: claude-opus-5-5 0.05 / claude-fable-5-1 0.025 / 他 0.1。cache write 5 分 1.25・1 時間 2 / output 5)。入力は input + cache read + cache write。所要は分。 + +### PR 1 本あたり + +| 動いた版 | 会話 | PR | conductor 換算 | supervisor 換算 | worker 換算 | 入力 | 出力 | 所要 | codex 入力 | codex 出力 | kiro credit | claude 席 換算 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 10.17.75 | 7 | 15 | 0.77M | 0.31M | 0.89M | 17.12M | 82.3k | 86.5 | 2.80M | 5.7k | 2.65 | 0.35M | +| 10.18.0 | 1 | 1 | 3.79M | 2.87M | 2.71M | 70.56M | 389.2k | 333.7 | 3.88M | 15.4k | 20.33 | 0.68M | + +### フェーズごと(1 起動あたり) + +| 動いた版 | 層 | フェーズ | 定義 | 起動 | 換算 | 入力 | 出力 | 所要 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 10.17.75 | conductor | - | - | 32 | 1.10M | 9.74M | 42.4k | 78.0 | +| 10.17.75 | supervisor | judge | claude -p | 9 | 0.03M | 0.01M | 0.3k | 0.1 | +| 10.17.75 | supervisor | pr | claude -p | 20 | 0.01M | 0.01M | 1.0k | 0.2 | +| 10.17.75 | supervisor | slow | claude -p | 6 | 0.00M | 0.00M | 0.2k | 0.1 | +| 10.17.75 | supervisor | その他 | ndf:supervisor | 1 | 0.38M | 2.61M | 18.1k | 3.9 | +| 10.17.75 | supervisor | その他 | ndf:worker | 6 | 0.41M | 3.35M | 20.4k | 4.4 | +| 10.17.75 | supervisor | 仕上げ | ndf:supervisor | 3 | 0.94M | 7.60M | 36.5k | 20.6 | +| 10.17.75 | supervisor | 仕上げ | ndf:supervisor-waits | 1 | 0.65M | 5.64M | 23.5k | 12.7 | +| 10.17.75 | supervisor | 実装 | ndf:supervisor | 2 | 0.28M | 1.82M | 11.9k | 8.5 | +| 10.17.75 | supervisor | 実装 | ndf:supervisor-waits | 1 | 0.48M | 3.99M | 15.4k | 14.9 | +| 10.17.75 | worker | work | claude -p | 64 | 0.23M | 1.60M | 11.2k | 6.0 | +| 10.17.75 | worker | その他 | ndf:worker | 8 | 0.38M | 2.94M | 20.5k | 4.0 | +| 10.17.75 | worker | 修正 | general-purpose | 1 | 0.11M | 0.59M | 4.2k | 0.9 | +| 10.17.75 | worker | 修正 | ndf:worker | 1 | 0.74M | 5.05M | 28.0k | 17.9 | +| 10.17.75 | worker | 調査 | ndf:worker | 2 | 0.22M | 1.34M | 13.8k | 3.4 | +| 10.17.75 | worker | 集計 | ndf:worker | 3 | 0.63M | 4.87M | 38.4k | 8.8 | +| 10.18.0 | conductor | - | - | 5 | 1.93M | 17.20M | 68.3k | 109.2 | +| 10.18.0 | supervisor | judge | claude -p | 5 | 0.02M | 0.01M | 0.3k | 0.1 | +| 10.18.0 | supervisor | pr | claude -p | 2 | 0.02M | 0.01M | 1.2k | 0.2 | +| 10.18.0 | supervisor | その他 | ndf:worker | 22 | 0.42M | 3.30M | 24.6k | 5.5 | +| 10.18.0 | worker | work | claude -p | 12 | 0.27M | 1.89M | 14.4k | 4.8 | + +### フェーズごとの呼び出しとキャッシュ + +P は起動ごとの固定費(最初の呼び出しの文脈)、k は呼び出し回数(どちらも 1 起動あたり)。書き直しは 2 回目以降の呼び出しのうち、書き込みが文脈の 50% を超え、かつ 20k を超えたものの回数(合計)。5 分超はそのうち直前の呼び出しとの間隔が 5 分を超えた回数、間隔は直前の間隔の中央値(分)。5 分超の書き直し・読み込みは、直前の間隔が 5 分を超えた呼び出しの書き直しの量と、書き直しでない呼び出しの読み込みの量(どちらも合計)。定義はサブエージェントの定義の名前。 + +| 動いた版 | 層 | フェーズ | 定義 | 起動 | P | k | 書き込み 5 分 | 書き込み 1 時間 | 書き直し | 5 分超 | 5 分超の書き直し | 5 分超の読み込み | 間隔 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 10.17.75 | conductor | - | - | 32 | 48.0k | 67.7 | 0.00M | 0.20M | 16 | 13 | 1.44M | 13.98M | 117.0 | +| 10.17.75 | supervisor | judge | claude -p | 9 | - | 1.0 | 0.00M | 0.01M | - | - | - | - | - | +| 10.17.75 | supervisor | pr | claude -p | 20 | - | 1.0 | 0.00M | 0.00M | - | - | - | - | - | +| 10.17.75 | supervisor | slow | claude -p | 6 | - | 1.0 | 0.00M | 0.00M | - | - | - | - | - | +| 10.17.75 | supervisor | その他 | ndf:supervisor | 1 | 44.7k | 27.0 | 0.13M | 0.00M | 0 | 0 | 0.00M | 0.00M | - | +| 10.17.75 | supervisor | その他 | ndf:worker | 6 | 30.4k | 34.5 | 0.12M | 0.00M | 0 | 0 | 0.00M | 0.00M | - | +| 10.17.75 | supervisor | 仕上げ | ndf:supervisor | 3 | 45.7k | 59.7 | 0.31M | 0.00M | 3 | 3 | 0.46M | 0.00M | 9.8 | +| 10.17.75 | supervisor | 仕上げ | ndf:supervisor-waits | 1 | 45.1k | 57.0 | 0.00M | 0.13M | 0 | 0 | 0.00M | 0.12M | - | +| 10.17.75 | supervisor | 実装 | ndf:supervisor | 2 | 44.7k | 25.0 | 0.11M | 0.00M | 1 | 1 | 0.06M | 0.00M | 9.1 | +| 10.17.75 | supervisor | 実装 | ndf:supervisor-waits | 1 | 44.8k | 49.0 | 0.00M | 0.10M | 0 | 0 | 0.00M | 0.00M | - | +| 10.17.75 | worker | work | claude -p | 64 | - | 22.5 | 0.00M | 0.05M | - | - | - | - | - | +| 10.17.75 | worker | その他 | ndf:worker | 8 | 35.8k | 26.4 | 0.11M | 0.00M | 0 | 0 | 0.00M | 0.00M | - | +| 10.17.75 | worker | 修正 | general-purpose | 1 | 44.2k | 10.0 | 0.05M | 0.00M | 0 | 0 | 0.00M | 0.00M | - | +| 10.17.75 | worker | 修正 | ndf:worker | 1 | 36.5k | 45.0 | 0.29M | 0.00M | 1 | 1 | 0.15M | 0.00M | 10.1 | +| 10.17.75 | worker | 調査 | ndf:worker | 2 | 35.4k | 19.5 | 0.07M | 0.00M | 0 | 0 | 0.00M | 0.00M | - | +| 10.17.75 | worker | 集計 | ndf:worker | 3 | 35.8k | 46.7 | 0.16M | 0.00M | 0 | 0 | 0.00M | 0.00M | - | +| 10.18.0 | conductor | - | - | 5 | 56.1k | 78.8 | 0.00M | 0.37M | 5 | 5 | 0.78M | 3.88M | 160.5 | +| 10.18.0 | supervisor | judge | claude -p | 5 | - | 1.0 | 0.00M | 0.01M | - | - | - | - | - | +| 10.18.0 | supervisor | pr | claude -p | 2 | - | 1.0 | 0.00M | 0.01M | - | - | - | - | - | +| 10.18.0 | supervisor | その他 | ndf:worker | 22 | 36.0k | 36.5 | 0.11M | 0.00M | 0 | 0 | 0.00M | 0.00M | - | +| 10.18.0 | worker | work | claude -p | 12 | - | 24.1 | 0.00M | 0.05M | - | - | - | - | - | + +### 外部 CLI(1 起動あたり) + +kiro はトークン数を記録しない(値が 0)ため credit だけを載せる。agy は読まない。 + +| 動いた版 | ランタイム | Skill | モデル | 起動 | 入力 | cache | 出力 | credit | 所要 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 10.17.75 | claude | cross-review | claude-opus-5-5 | 16 | 1.72M | 0.00M | 12.4k | 0.00 | 2.7 | +| 10.17.75 | claude | cross-refactoring | claude-opus-5-5 | 11 | 1.28M | 0.00M | 13.3k | 0.00 | 3.1 | +| 10.17.75 | codex | cross-review | gpt-6.1-sol | 20 | 2.18M | 2.06M | 4.4k | 0.00 | 1.9 | +| 10.17.75 | codex | cross-refactoring | gpt-6.1-sol | 2 | 1.39M | 1.26M | 8.1k | 0.00 | 3.0 | +| 10.17.75 | kiro | cross-review | auto | 17 | 0.00M | 0.00M | 0.0k | 3.09 | 2.0 | +| 10.17.75 | kiro | cross-refactoring | auto | 2 | 0.00M | 0.00M | 0.0k | 3.06 | 2.1 | +| 10.18.0 | claude | cross-review | claude-opus-5-5 | 3 | 0.85M | 0.00M | 7.2k | 0.00 | 1.6 | +| 10.18.0 | codex | cross-review | gpt-6.1-sol | 4 | 0.97M | 0.88M | 3.8k | 0.00 | 1.3 | +| 10.18.0 | kiro | cross-review | auto | 8 | 0.00M | 0.00M | 0.0k | 2.54 | 1.6 | + +### 外部 CLI の呼び出しとキャッシュ(1 起動あたり) + +codex は input のうち cached に当たらなかった分を書き込みとみなし、呼び出しごとの値(last_token_usage)を持つ席だけで数える。kiro は呼び出し回数を記録しない(利用者のターン数は JSON の turns)。取れない値は -。 + +| 動いた版 | ランタイム | Skill | モデル | 起動 | P | k | 書き直し | 5 分超 | 5 分超の書き直し | 5 分超の読み込み | 間隔 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 10.17.75 | claude | cross-review | claude-opus-5-5 | 16 | 27.0k | 17.0 | 0 | 0 | 0.00M | 0.00M | - | +| 10.17.75 | claude | cross-refactoring | claude-opus-5-5 | 11 | 28.2k | 19.3 | 0 | 0 | 0.00M | 0.00M | - | +| 10.17.75 | codex | cross-review | gpt-6.1-sol | 20 | 30.5k | 14.0 | 0 | 0 | 0.00M | 0.00M | - | +| 10.17.75 | codex | cross-refactoring | gpt-6.1-sol | 2 | 31.3k | 14.5 | 0 | 0 | 0.00M | 0.00M | - | +| 10.17.75 | kiro | cross-review | auto | 17 | - | - | - | - | - | - | - | +| 10.17.75 | kiro | cross-refactoring | auto | 2 | - | - | - | - | - | - | - | +| 10.18.0 | claude | cross-review | claude-opus-5-5 | 3 | 34.2k | 11.7 | 0 | 0 | 0.00M | 0.00M | - | +| 10.18.0 | codex | cross-review | gpt-6.1-sol | 4 | 32.0k | 11.2 | 0 | 0 | 0.00M | 0.00M | - | +| 10.18.0 | kiro | cross-review | auto | 8 | - | - | - | - | - | - | - | diff --git a/docs/presentations/2026-10-17-skill-plugin-development/README.md b/docs/presentations/2026-10-17-skill-plugin-development/README.md new file mode 100644 index 000000000..9ee60cd3f --- /dev/null +++ b/docs/presentations/2026-10-17-skill-plugin-development/README.md @@ -0,0 +1,47 @@ +# 独自プラグイン開発の薦め + +社外の勉強会・登壇で話すスライド(台本の 21 ページと章扉 6 つの 27 ページ)。NDF の機能を例に、自分用のスキルを作り、プラグインにして配り、育てることを薦める。 +`release` の `form-slide.md` の生成スクリプトの経路で作った。 + +| ファイル | 中身 | +| --- | --- | +| [script.md](script.md) | 台本(第 7 稿の写し)。ページごとの見出しと読み上げ。スピーカーノートの正本 | +| [notes.md](notes.md) | 構成メモ。ページごとの見出し・役割・根拠の表と、章立て・尺 | +| [slides/build-deck.js](slides/build-deck.js) | 生成スクリプト。スライドの正本 | +| [slides/theme.json](slides/theme.json) | トーン&マナーのファイル | +| `slides/lib/` | NDF の汎用テンプレートの枠組み(`release` の `assets/slides/lib/`)の写し。書き換えない | + +## 成果物 + +Google Slides を成果物にする。pptx は生成スクリプトから作り直せるため、コミットしない。Google Slides の URL は +リポジトリに書かず、作った Pull Request の記録に残す。 + +## トーン&マナー + +汎用テンプレートの既定値(書体 Noto Sans JP、強調の色 `2B6CB0`)を使い、`theme.json` では著作権表示の文言だけを +足した。ロゴは置かない。 + +| 項目 | 値 | +| --- | --- | +| 版面 | 16:9(10 × 5.625 インチ) | +| 書体 | 既定値 | +| 色 | 既定値 | +| 著作権表示 | `NDF — github.com/devbasex/ai-plugins` | +| ロゴ | なし | +| 写した時点の `lib/` の内容のハッシュ | `e1fbac1642aef0fe49a57b00d7a2575eaf363bbfd5c55b324d53aa778f632800`(`cat slides/lib/*.js \| sha256sum`) | + +## 生成と確認の手順 + +```bash +cd docs/presentations/2026-10-17-skill-plugin-development +(cd slides && npm ci) +node slides/lib/script-time.js script.md --limit 35 # 尺(出力は notes.md の「尺」) +node slides/build-deck.js /tmp/skill-plugin-development.pptx +``` + +Google Slides への変換と、ページごとのサムネイルでの確認は `release` の `form-slide.md` の「変換と確認」の順に行う。 + +## 発表日時点の記録 + +発表日時点の記録で、以後の NDF の変更に追随しない([../README.md](../README.md) の「この資料の位置づけ」)。 +数値は 2026-10-17 時点の実測。 diff --git a/docs/presentations/2026-10-17-skill-plugin-development/notes.md b/docs/presentations/2026-10-17-skill-plugin-development/notes.md new file mode 100644 index 000000000..00daa3767 --- /dev/null +++ b/docs/presentations/2026-10-17-skill-plugin-development/notes.md @@ -0,0 +1,90 @@ +# 構成メモ: 独自プラグイン開発の薦め + +台本([script.md](script.md)。第 7 稿の写し)を、社外の勉強会・登壇で話すスライド(台本の 21 ページと章扉 6 つ)にした構成。 +各ページの読み上げはスピーカーノートに入り、生成スクリプト([slides/build-deck.js](slides/build-deck.js))が台本から読み込む。 + +## 構成 + +キーメッセージ「AI に自分のやり方を教えるのは 1 回でよい。プラグインにして、フォークして育てる」から逆算し、 +課題(1〜4)→ NDF の機能(5〜16)→ 事例(17〜18)→ 転換と実践(19〜20)→ まとめ(21)の順に並べた。 +台本はページに載せる要素を持たないため、各ページの図・表・箇条書きは読み上げから起こした。読み上げの文をそのまま +ページに写さず、縮めても載せない。ページには 1 つのメッセージと、それを支える短い句(体言止め)・表・流れ・数値だけを置く。 +聞き手は NDF を知らないため、内部の語(ゲート・フェーズ・conductor など)は聞き手に通じる語へ言い換えるか、ページの中で +一言で説明する。Skill 名(`/ndf:xxx`)はリードか箱の見出しに置き、何をするものかを必ず添える。事例のプロジェクトは種類で +呼び、名前は出さない。 + +表の番号は台本の番号で、章扉(「章立て」の節)は数えない。 + +| # | ページの見出し | 役割 | 根拠 | +| ---: | --- | --- | --- | +| 1 | 表紙 | — | — | +| 2 | 同じ指摘を、何度もしていませんか | 繰り返す 3 つの指摘を吹き出しで並べ、「セッションが切れると忘れる」を注意の地で示す | 台本 2 | +| 3 | CLAUDE.md への追記では足りない | 4 つの壁(コンテキスト・強制力・手順・ポータビリティ)を 2 × 2 の区画で示す | 台本 3 | +| 4 | スキルとプラグイン | スキル(作る単位・手順書)とプラグイン(配る単位・スキル・スクリプト・hook・サブエージェントをまとめる)の 2 区画、公式ドキュメント(Anthropic・OpenAI・Agent Skills の規格)へのリンク、4 つのランタイムの並び | 台本 4、Claude Code と Codex の公式ドキュメント | +| 5 | NDF: 自分のやり方をプラグインに | リリース 102 回・Skill 48 個・ランタイム 4 つ・導入 10 リポジトリの数値と、リポジトリの URL | 台本 5(2026-10-17 の実測) | +| 6 | Issue の束を渡せば、並列に進む | マイルストーン → グループ → スプリント(グループごとに並列)→ リリースの図と、2 回の承認(設計・本番リリース)。直前のリリースの例は注 | 台本 6 | +| 7 | 変更の大きさで、踏む工程を変える | `/ndf:development-workflow` が変更を分ける 5 つのモードの表と、light と standard の工程の違い | 台本 7、`development-workflow` の `references/workflow-modes.md` | +| 8 | 機能追加は、この流れで 1 周 | standard モードの工程を設計・実装・リリースの 3 行に並べ、承認 1・2 を注意の地で示す | 台本 8 | +| 9 | 実績のある開発技法を組み込む | DDD・テストファースト・アジャイルの 3 列。用語集 580 語と WSJF を載せる | 台本 9 | +| 10 | コードの前に、読める文書を | `/ndf:requirements-design`(完了の条件)と `/ndf:design`(作り方)の 2 区画、`/ndf:document-restructuring` の書いた順と読み手の順の対比(内容は足さない)、設計 PR と承認 1 の区画 | 台本 10 | +| 11 | 複数の AI で、見て直す | `/ndf:cross-review`(2 つの AI を入れ替えて指摘が尽きるまで)と `/ndf:cross-refactoring`(全員が提案し 1 つが既定 30 分で実施、壊れたら取り消す)の 2 列の流れと要点(数値は載せない) | 台本 11 | +| 12 | 3 層のエージェントで止めない | conductor(指揮役)・supervisor(監督役)・worker(作業役)を斜めに下げて並べた図と、再開とセッションの切り替えの 2 区画 | 台本 12 | +| 13 | 迷ったら MVV で判断する | Mission・Vision・Value の 3 区画と Value の例 2 つ、必ず人が承認する操作 | 台本 13、`.ndf/` のプロジェクト MVV | +| 14 | 「完了」も「無い」も証拠で | AI の言葉 2 つと、それぞれを受け止める `/ndf:quality-gates`・`/ndf:investigation-rules` の対応 | 台本 14 | +| 15 | やるべきことを整理し続ける | 導入したプロジェクトの Issue の一覧を現状に保つ 3 つの Skill の流れと、何の数かを添えた棚卸しの数値 2 つ(56%・49 件) | 台本 15(棚卸しの記録と 2026-10-17 の調査) | +| 16 | そのほかの特徴的な機能 | estimate・release・hook の、何をするかと要点の表 | 台本 16 | +| 17 | 事例: ほかのプロジェクトでも動く | 4 つのプロジェクトの種類と結果の表。インフラの注記(社内ツールの移行と重なる)を表の中に残す | 台本 17、事例の材料(インフラ・業務システム・機械学習と価格推定) | +| 18 | 事例から分かった 2 つのこと | 詰まりが次の版で直る例(17 件の Issue と 3 つの前提の違い)と、プロジェクトごとに足したルールの表 | 台本 18、事例の材料 | +| 19 | だから、フォークして自分用に | NDF のルールの例と、そのまま使う仕組みの対比、メリット 3 つ | 台本 19 | +| 20 | 始め方と育て方 | フォークする・削る・育てるの 3 列と、気をつける点 | 台本 20 | +| 21 | まとめ: 明日からの 3 ステップ | 3 つの手順とリポジトリの URL | 台本 21 | + +## 章立て + +章扉(生成スクリプトの `d.section`)で構成を見せる。章扉はスピーカーノートを持たず、台本の番号を進めない。 +資料は台本の 21 ページと章扉 6 つの 27 ページになる(ページ番号は章扉も数える)。 + +| 章 | 章扉の一言 | 台本のページ | +| --- | --- | --- | +| 1. なぜプラグインか | CLAUDE.md に書くだけでは届かない | 2〜4 | +| 2. NDF の全体像 | Issue の束を渡すと、リリースまで進む | 5〜8 | +| 3. 組み込んだ開発技法 | 実績のある技法を、AI が守れる手順に | 9〜11 | +| 4. AI に任せ切る仕組み | 止まらない・迷わない・ごまかさない | 12〜16 | +| 5. 事例 | ほかのプロジェクトでも動くか | 17〜18 | +| 6. 始め方 | フォークして、自分用に育てる | 19〜21 | + +## 尺 + +`node slides/lib/script-time.js script.md --limit 35` の出力(350 字/分)。合計 4,957 字・14 分 10 秒で、 +持ち時間(30〜35 分)に収まる。似たページを 2 組まとめた(まとめる前の台本の 10・11 ページと 12・13 ページ)。 + +| # | 見出し | 字数 | 尺(秒) | 配分(秒) | +| ---: | --- | ---: | ---: | ---: | +| 1 | タイトル | 111 | 19.0 | — | +| 2 | AI に同じことを何度も言っていませんか | 142 | 24.3 | — | +| 3 | CLAUDE.md への追記だけでは足りない理由 | 160 | 27.4 | — | +| 4 | スキルとプラグイン | 261 | 44.7 | — | +| 5 | NDF: 1 人の開発スタイルをプラグインにした | 130 | 22.3 | — | +| 6 | NDF の全体像: マイルストーンを渡せば、スプリントとして並列に進む | 316 | 54.2 | — | +| 7 | /ndf:development-workflow — 変更の種類でワークフローを切り替える | 283 | 48.5 | — | +| 8 | standard モードの全体の流れ | 277 | 47.5 | — | +| 9 | 実績のある開発技法を、AI が守れる形で組み込む — DDD・テストファースト・アジャイル | 381 | 65.3 | — | +| 10 | /ndf:requirements-design・/ndf:design・/ndf:document-restructuring — コードより先に文書を書き、読み手の順に並べる | 222 | 38.1 | — | +| 11 | /ndf:cross-review と /ndf:cross-refactoring — 複数の AI で、見落としを拾い、時間内に直す | 220 | 37.7 | — | +| 12 | 3 層のエージェント — 長いタスクを止めずに、止まっても再開できる | 249 | 42.7 | — | +| 13 | 判断基準: MVV と 2 つの承認ゲート | 193 | 33.1 | — | +| 14 | /ndf:quality-gates と /ndf:investigation-rules — 完了も「無い」も証拠で | 182 | 31.2 | — | +| 15 | 自分を直すループ — /ndf:out-of-scope・/ndf:retrospective・/ndf:backlog-refinement | 299 | 51.3 | — | +| 16 | その他の特徴的な機能 | 243 | 41.7 | — | +| 17 | 事例: 他のプロジェクトでも動く | 328 | 56.2 | — | +| 18 | 事例から分かったこと: 詰まったところが次のバージョンに、ルールはプロジェクトごとに違う | 244 | 41.8 | — | +| 19 | だから「フォークして自分用に」 | 225 | 38.6 | — | +| 20 | 始め方と育て方 | 314 | 53.8 | — | +| 21 | まとめ: 明日からの 3 ステップ | 177 | 30.3 | — | +| 合計 | 21 ページ(350 字/分) | 4957 | 849.7(14分10秒) | — | + +## ページに収まらない読み上げの扱い + +読み上げはスピーカーノートに全文が入る。ページには読み上げの要点だけを置き、例(6 ページの直前のリリース、 +15 ページの「放っておくと…」)は注へ回した。ページの見出しは台本の見出しと別に、そのページの結論を短く書いた +(目安 20 字以内)。Skill を紹介するページ(7・10・11・14・15)は、Skill 名と何をするものかをリードか箱の見出しに置いた。 diff --git a/docs/presentations/2026-10-17-skill-plugin-development/script.md b/docs/presentations/2026-10-17-skill-plugin-development/script.md new file mode 100644 index 000000000..ec945e3d5 --- /dev/null +++ b/docs/presentations/2026-10-17-skill-plugin-development/script.md @@ -0,0 +1,104 @@ +# 台本(第 7 稿): 独自プラグイン開発の薦め — NDF を例に + +- 想定: 社外の勉強会・登壇。30〜35 分・21 ページ +- 対象: Claude Code / Codex などの AI コーディングエージェントを使っている、または使い始めたエンジニア +- キーメッセージ: **AI に自分のやり方を教えるのは 1 回でよい。プラグインにして、フォークして育てる** +- 数値は 2026-10-17 時点の実測。事例のプロジェクトは種類で呼び、名前は出さない + +## 構成 + +| パート | ページ | 問い | +| --- | --- | --- | +| 1. 課題 | 1〜4 | AI に同じことを何度も言っていないか。なぜ CLAUDE.md への追記では足りないのか | +| 2. NDF の機能 | 5〜16 | 自分のやり方をプラグインにすると何ができるのか(ワークフロー全体・組み込んだ開発技法・主な Skill) | +| 3. 事例 | 17〜18 | 他のプロジェクトでも動くのか | +| 4. 転換と実践 | 19〜20 | なぜフォークして自分用にするのか。どう始めて、どう育てるか | +| 5. まとめ | 21 | 明日から何をするか | + +--- + +## 1. タイトル + +独自プラグイン開発の薦め — スキルを作り、プラグインで配る。NDF を例に、自分の開発スタイルを AI に持たせる + +> 今日は「自分用のスキルを作り、プラグインにして育てよう」という話をします。例として私が開発している NDF プラグインの機能を中心に紹介します。ゴールは NDF を使ってもらうことではなく、皆さんが自分のプラグインを持つことです。 + +## 2. AI に同じことを何度も言っていませんか + +> 「PR は develop 宛てで」「テストを先に書いて」「無いと言う前に検索結果を見せて」。AI エージェントを毎日使っていると、同じ指摘を何度もしていることに気づきます。セッションが切れるたびに AI は忘れ、指摘は消えていきます。指摘の回数は、自分のやり方がまだ AI に伝わっていない量です。 + +## 3. CLAUDE.md への追記だけでは足りない理由 + +> まず CLAUDE.md や AGENTS.md に追記しますが、4 つの壁に当たります。コンテキスト: 毎回すべてを読み込むので、書くほどコストが上がり、重要なルールが埋もれる。強制力: 書いても守られないことがある。手順: 「どの順に何を実行するか」は文章では伝わりにくい。ポータビリティ: リポジトリやランタイムを変えると失われる。 + +## 4. スキルとプラグイン + +> そこでスキルとプラグインです。スキルは作る単位で、「いつ・何を・どの順で」を書いた手順書です。必要なときだけ読み込まれます。プラグインは配る単位で、スキルに、決まった処理を確実に実行するスクリプト、ルールを強制する hook、役割を分けるサブエージェントを加えてまとめて配布します。Claude Code・Codex・Kiro・agy で同じものを使えます。呼び方は Anthropic と OpenAI の公式ドキュメントに合わせています。判断基準とワークフローをパッケージにして、どのリポジトリでも再利用できる。これがポイントです。 + +## 5. NDF: 1 人の開発スタイルをプラグインにした + +> NDF は、私の開発スタイルをプラグインにしたものです。GitHub で公開しています。2026 年 4 月に開発を始め、半年で 102 回リリース。Skill は 48 個、4 つのランタイムに対応し、自分以外の 10 のリポジトリにも導入されています。ほぼ 1 人と AI で開発しました。 + +## 6. NDF の全体像: マイルストーンを渡せば、スプリントとして並列に進む + +> 実際の使い方は、Issue を 1 つずつ渡すのではなく、マイルストーン単位でまとめて渡します。AI が触る場所と依存関係から Issue をグループに分け、スプリントとして並列に、要件定義・設計・実装・レビュー・テスト・リリースまで進めます。例えば直前のリリースでは、致命的な Issue 18 件を 2 つのスプリントに分け、1 つのスプリントの中では 3 つのグループの設計と実装を並列に進めました。人が判断するのは 2 か所だけ。ゲート 1 は設計の承認(スプリントの設計 PR をまとめて 1 回)、ゲート 2 は本番リリースの承認(開発版で確かめた後に 1 回)です。どちらの承認にも、決めたこと・未検証の点・判断の根拠がそろって届くので、問い直さずに 1 回で決められます。 + +## 7. /ndf:development-workflow — 変更の種類でワークフローを切り替える + +> 最初に動くのが development-workflow です。すべての変更にフルのワークフローを課すと遅くなるので、まず変更を 5 つのモードに分類します。light は文言や設定などの局所的な変更、operation はコードを変えずに外部の状態を変える作業、standard は機能追加やバグ修正、legacy-refactor はテストの薄いコードのリファクタリング、documentation は提案書や報告書などのビジネス文書です。モードごとに通る Skill が表で決まっていて、light なら実装してレビューするだけ、standard なら要件定義から振り返りまで通ります。 + +## 8. standard モードの全体の流れ + +> standard の流れを 1 ページで見ます。要件定義で受け入れ条件を決め、worktree を作り、設計を書きます。設計はドキュメントの再構成を通してからクロスレビューにかけ、ここでゲート 1 の承認。承認後は実装計画、テストファーストの実装、クロスリファクタリング、クロスレビュー、証跡による完了判定を経てマージし、設計を仕様書に変換します。開発版をリリースして自分で使い、ゲート 2 の承認で本番へ。最後にリリース後テスト、振り返り、Issue の棚卸しで 1 周です。この流れはスプリント単位で 1 回ずつ動き、中の設計と実装は Issue のグループごとに並列で進みます。 + +## 9. 実績のある開発技法を、AI が守れる形で組み込む — DDD・テストファースト・アジャイル + +> NDF は新しい方法論を発明したわけではありません。人間のチームで実績のある技法を、AI が毎回守れる手順にしています。DDD からはユビキタス言語。要件定義の前に用語集を作り、語・意味・コンテキストを決めます。同じ「注文」でも受注と配送では意味が違うので、コンテキストごとに 1 つの意味に決め、AI はその語で設計とコードを書きます。このリポジトリの用語集は 580 語あり、CI が用語集との食い違いを検査します。テストファーストからは、失敗するテストを書く → 通す最小の実装 → 整理、のサイクル。テストが薄いコードは、先に今の振る舞いを固定するテストを書いてからリファクタリングします。アジャイルからは、マイルストーンをバックログとして扱い、スプリントで進め、スプリントごとに振り返りとバックログリファインメントを行います。優先順位は WSJF(遅延コスト ÷ 大きさ)で付けます。 + +## 10. /ndf:requirements-design・/ndf:design・/ndf:document-restructuring — コードより先に文書を書き、読み手の順に並べる + +> 実装の前に 2 つの文書を書きます。requirements-design は「何を満たせば完了か」を、確かめられる受け入れ条件にします。design は「どう作るか」、つまりデータ構造・入出力の契約・処理の流れ・決定の理由を書きます。書いた文書は document-restructuring で、調べた順から、読み手が知りたい結論から読める順に並べ替えます。内容は足しません。設計は PR として出し、主題ごとに並列で開いて、ゲート 1 でまとめて承認します。 + +## 11. /ndf:cross-review と /ndf:cross-refactoring — 複数の AI で、見落としを拾い、時間内に直す + +> 同じ AI に書かせてレビューさせると、同じ見落としをします。cross-review は、claude・codex・kiro などから 2 つを選んでレビューさせ、組み合わせを入れ替えながら、新しい指摘が出なくなるまで繰り返します。cross-refactoring は、参加する AI 全員が直す場所を提案し、選ばれた 1 つの AI が、指定した時間(既定 30 分)に収まる分だけを実施します。テストが壊れた変更は取り消し、採用と取り消しの内訳を報告に残します。 + +## 12. 3 層のエージェント — 長いタスクを止めずに、止まっても再開できる + +> 長いタスクは 1 つのセッションでは終わりません。NDF は conductor・supervisor・worker の 3 層で進めます。conductor は人と話して全体を指揮し、supervisor は設計・実装・テストといった 1 つのフェーズを通し、worker は調査や修正といった 1 つの作業を行います。フェーズは JSON のプランとしてスクリプトが実行するので、途中で止まっても続きから再開できます。セッションのコンテキストが大きくなったら、次に実行するコマンドを残して自動で新しいセッションに切り替えます。 + +## 13. 判断基準: MVV と 2 つの承認ゲート + +> AI は判断のたびに迷います。指摘を直すか見送るか、人の承認を省いてよいか。NDF はプロジェクトの Mission・Vision・Value を明文化し、AI がそれを根拠に判断して、根拠の番号をログに残します。例えば「まず届ける」「処理の改善に寄与しない指摘は直さない」。本番デプロイのように必ず人の承認が必要な操作もここに定義します。どこまで AI に任せるかも、進め方の設定で事前に選べます。 + +## 14. /ndf:quality-gates と /ndf:investigation-rules — 完了も「無い」も証拠で + +> AI の「完了しました」「該当のコードはありません」は、そのまま信じると危険です。quality-gates は、実行したコマンド・終了コード・実行時刻・対象範囲がそろわない限り完了と認めません。investigation-rules は、「無い」という結論には必ず検索のコマンドと結果を添えさせます。AI はもっともらしいが間違った推論で「無い」と断定しがちだからです。 + +## 15. 自分を直すループ — /ndf:out-of-scope・/ndf:retrospective・/ndf:backlog-refinement + +> NDF でいちばん効いているのは、自分自身を直すループです。out-of-scope は、作業中に見つけた範囲外の問題をその場で Issue 化します。retrospective は、リリースのたびに進め方を見直します。backlog-refinement は、スプリントの終わりに Issue の一覧を現状に合わせ、優先順位を付け直します。ある時点の棚卸しでは、open の Issue 43 件のうち 24 件(56%)の本文が現状と食い違っていました。放っておくと、すでに直った Issue を直すことになります。今日も 71 件を 6 つの worker で並列に調べたところ、2 件はすでに修正済み、49 件は本文が古くなっていました。 + +## 16. その他の特徴的な機能 + +> 他にも特徴的な機能があります。estimate は、AI エージェントが実施する前提で工数を見積もります。人日では見積もらず、AI の実行時間・人の作業時間・完了までの時間・費用の 4 つを、似た過去の実績から別々に出します。release は、パッケージ・Web サービス・スライドや文書まで、届け方の違う成果物を同じ骨格でリリースします。hook は、メインディレクトリの直接編集の警告、sleep による待ちの禁止、同じファイルの繰り返し読み込みの防止など、AI がやりがちなことを強制的に止めます。 + +## 17. 事例: 他のプロジェクトでも動く + +> ここまでは NDF が自分自身を開発している話です。大事なのは、言語もチームも違うプロジェクトで動くかです。4 つ紹介します。AWS のインフラでは、導入前後の 37 日で 1 日あたりのマージ PR 数が 0.95 から 3.38 に増えました(社内ツールの移行と重なっており、すべてが NDF の効果ではありません)。20 人で開発する PHP の業務システムでは、設計 PR 1 本の承認から 51 時間で 31 本の実装 PR が入りました。機械学習のモデルでは、精度改善の調査で 4 か月分のデータ欠損を見つけ、修正からバックフィルまでを 1 つのスプリントで本番に出し、相関係数は 0.73 から 0.954 に改善しました。価格推定のプロジェクトでは PR 数は変わらず、レビューを必ず通す開発プロセスに変わりました。 + +## 18. 事例から分かったこと: 詰まったところが次のバージョンに、ルールはプロジェクトごとに違う + +> 事例から分かったことは 2 つです。1 つ目、どのプロジェクトでも最初は詰まり、それが NDF の Issue として上がって次のバージョンで直りました。PHP のプロジェクトでは worktree に vendor/ が無い、全テストに 64 分かかる、マージがそのまま本番リリースになる、といった前提の違いから 17 件の Issue が上がりました。2 つ目、どのプロジェクトも独自のルールを追加していました。インフラは本番デプロイの承認、業務システムは「マージ = 本番リリース」の承認、価格推定は全 PR へのレビューです。 + +## 19. だから「フォークして自分用に」 + +> NDF 自体も、承認は 2 回だけ、ドキュメントでは「道具」でなく「Tool」と書く、文言を照合するテストは書かない、といった私のルールのかたまりです。他人のルールは、自分にとってはノイズになります。だから NDF をそのまま導入するより、フォークしてください。ワークフロー・3 層のエージェント・クロスレビュー・判断基準といった仕組みはそのまま使い、ルールを自分のものに差し替える。メリットは 3 つ。指摘が資産になる、ランタイムに依存しない、計測して改善できる。 + +## 20. 始め方と育て方 + +> 始め方は 3 ステップです。まずフォークして、プラグイン名とマーケットプレイス名を自分のものに変え、自分のリポジトリからインストールします。次に削ります。配布する Skill はマニフェストで決まるので、使わない Skill は外すだけ。どれを残すかはセッションのログから呼び出し回数を計測して決めます。最後に育てます。同じ指摘を 2 回したら、手順なら Skill、決まった処理ならスクリプト、必ず守らせたいなら hook にします。気をつける点は、description を増やしすぎないこと(すべてのセッションで読み込まれる)、思いつきは experimental に置いて効いたものだけ本体に移すこと、開発版を自分で使ってから正式版にすることです。 + +## 21. まとめ: 明日からの 3 ステップ + +> AI に自分のやり方を教えるのは 1 回でよい。プラグインにして、フォークして育ててください。明日からの 3 ステップは、1. ai-plugins をフォークして自分の名前でインストールする、2. 使わない Skill を外す、3. 同じ指摘を 2 回したら Skill にする。リポジトリは github.com/devbasex/ai-plugins です。ありがとうございました。 diff --git a/docs/presentations/2026-10-17-skill-plugin-development/slides/build-deck.js b/docs/presentations/2026-10-17-skill-plugin-development/slides/build-deck.js new file mode 100644 index 000000000..cc6b9efd3 --- /dev/null +++ b/docs/presentations/2026-10-17-skill-plugin-development/slides/build-deck.js @@ -0,0 +1,396 @@ +// 発表資料「独自プラグイン開発の薦め」の生成スクリプト(台本の 21 ページと章扉 6 つの 27 ページ)。 +// +// node build-deck.js <出力>.pptx [] +// +// 枠組みは lib/(NDF の release の assets/slides/lib/ の写し。書き換えない)、 +// トーン&マナーは theme.json、スピーカーノートは ../script.md(台本)から入る。 +// ページの役割と根拠、章立ては ../notes.md にある。章扉(d.section)は台本の番号を進めない。 +// ページには要点だけを置く(読み上げはスピーカーノートが持つ)。1 ページ 1 メッセージ・短い句か体言止め。 +const path = require("path"); +const { run } = require("./lib/deck-frame"); + +const SCRIPT = path.join(__dirname, "..", "script.md"); +const REPO = "https://github.com/devbasex/ai-plugins"; + +// ---- このページ群で使う部品(座標は本文の領域の中だけ) ---- +const X0 = 0.26; +const W = 9.48; + +// 横に n 等分した区画の x と幅 +function cols(n, gap, x0, w) { + const g = gap == null ? 0.16 : gap; + const left = x0 == null ? X0 : x0; + const total = w == null ? W : w; + const cw = (total - g * (n - 1)) / n; + return Array.from({ length: n }, (_, i) => ({ x: left + i * (cw + g), w: cw })); +} + +// 見出しと本文を持つ区画 +function card(d, s, x, y, w, h, head, body, fill, opt) { + const o = opt || {}; + d.box(s, x, y, w, h, fill || "panel1", false); + if (o.band) d.box(s, x, y, w, 0.06, o.band, false); + d.text(s, head, { x: x + 0.14, y: y + 0.1, w: w - 0.28, h: o.headH || 0.32, fontSize: o.headSize || 12, bold: true, color: "text", valign: "middle" }); + if (body) d.text(s, body, { x: x + 0.14, y: y + 0.12 + (o.headH || 0.32), w: w - 0.28, h: h - 0.22 - (o.headH || 0.32), fontSize: o.bodySize || 10, valign: "top", paraSpaceAfter: 3 }); +} + +// 大きな数値の区画 +function stat(d, s, x, y, w, h, value, unit, label) { + d.box(s, x, y, w, h, "panel1", false); + d.box(s, x, y, 0.06, h, "accent", false); + d.text(s, [ + { text: value, options: { fontSize: 30, bold: true, color: "accent" } }, + { text: " " + unit, options: { fontSize: 13, bold: true, color: "text" } }, + ], { x: x + 0.2, y: y + 0.14, w: w - 0.3, h: 0.62, valign: "middle" }); + d.text(s, label, { x: x + 0.2, y: y + 0.8, w: w - 0.3, h: h - 0.9, fontSize: 10 }); +} + +// 箇条書き("- " で始まる項目は インデントを 1 つ下げる) +function bullets(items, size) { + return items.map((t, i) => { + const sub = t.startsWith("- "); + return { text: sub ? t.slice(2) : t, options: { bullet: { indent: 12 }, indentLevel: sub ? 1 : 0, fontSize: size || 10, breakLine: i < items.length - 1 } }; + }); +} + +// 横に並べた工程(chip と矢印) +function flow(d, s, x, y, w, h, labels, opt) { + const o = opt || {}; + const gap = o.gap == null ? 0.22 : o.gap; + const cw = (w - gap * (labels.length - 1)) / labels.length; + labels.forEach((l, i) => { + const cx = x + i * (cw + gap); + const gate = typeof l === "object"; + d.chip(s, cx, y, cw, h, gate ? l.text : l, gate ? "caution" : o.fill || "accentSoft", { fontSize: o.size || 9.5 }); + if (i < labels.length - 1) d.arrow(s, cx + cw + 0.02, y + h / 2, gap - 0.04, 0); + }); +} + +run(__dirname, { script: SCRIPT, title: "独自プラグイン開発の薦め", author: "NDF" }, (d) => { + let s; + + // 1. タイトル + d.cover("独自プラグイン開発の薦め", "スキルを作り、プラグインで配る\nNDF を例に、自分の開発スタイルを AI に持たせる", "github.com/devbasex/ai-plugins"); + + // ==== 章 1 ==== + d.section("1. なぜプラグインか", "CLAUDE.md に書くだけでは届かない"); + + // 2. AI に同じことを何度も言っていませんか + s = d.page("同じ指摘を、何度もしていませんか", "指摘の回数 = AI にまだ伝わっていない量"); + cols(3).forEach((c, i) => { + const q = ["「PR は develop 宛てで」", "「テストを先に書いて」", "「無いと言う前に\n検索結果を見せて」"][i]; + d.chip(s, c.x, 1.5, c.w, 1.0, q, "panel1", { fontSize: 15 }); + }); + d.arrow(s, 4.99, 2.62, 0, 0.42); + d.callout(s, 3.16, 0.5, "caution", "セッションが切れると、AI は忘れる", null); + + // 3. CLAUDE.md への追記だけでは足りない理由 + s = d.page("CLAUDE.md への追記では足りない", "追記はすぐに 4 つの壁に当たる"); + [ + ["コンテキスト", "毎回全部読む。書くほど埋もれる"], + ["強制力", "書いても守られない"], + ["手順", "順序は文章で伝わらない"], + ["ポータビリティ", "環境を変えると消える"], + ].forEach((r, i) => { + const c = cols(2)[i % 2]; + card(d, s, c.x, 1.5 + Math.floor(i / 2) * 1.5, c.w, 1.3, r[0], r[1], "panel1", { band: "accent", headSize: 18, bodySize: 16, headH: 0.46 }); + }); + + // 4. スキルとプラグイン + s = d.page("スキルとプラグイン", "スキルで作り、プラグインで配る"); + card(d, s, X0, 1.4, 5.5, 0.92, "スキル(Skill)= 作る単位", "手順書(いつ・何を・どの順で)\n必要なときだけ読む", "panel1", { band: "accent", headSize: 14, bodySize: 12 }); + card(d, s, X0, 2.44, 5.5, 1.62, "プラグイン = 配る単位", bullets([ + "スキル", + "スクリプト(決まった処理を確実に)", + "hook(ルールを強制)", + "サブエージェント(役割ごとの AI)", + ], 12), "panel1", { band: "accent", headSize: 14 }); + d.text(s, "同じものを 4 つのランタイムで", { x: X0, y: 4.14, w: 5.5, h: 0.28, fontSize: 11, bold: true, color: "text", valign: "middle" }); + cols(4, 0.1, X0, 5.5).forEach((c, i) => d.chip(s, c.x, 4.46, c.w, 0.5, ["Claude Code", "Codex", "Kiro", "agy"][i], "accentSoft", { fontSize: 11 })); + // 公式ドキュメントへのリンク(呼び方は Anthropic と OpenAI の公式に合わせる) + d.box(s, 5.92, 1.4, 3.82, 3.56, "panel1", false); + d.box(s, 5.92, 1.4, 3.82, 0.06, "accent", false); + d.text(s, "公式ドキュメント", { x: 6.06, y: 1.5, w: 3.54, h: 0.32, fontSize: 12, bold: true, color: "text", valign: "middle" }); + const OFFICIAL = [ + ["Anthropic(Claude Code)"], + ["Claude Code: プラグイン", "https://code.claude.com/docs/ja/plugins"], + ["Claude Code: Skills", "https://code.claude.com/docs/ja/skills"], + ["プラグインマーケットプレイス", "https://code.claude.com/docs/ja/plugin-marketplaces"], + ["Agent Skills の紹介(Engineering Blog)", "https://www.anthropic.com/engineering/equipping-agents-for-the-real-world-with-agent-skills"], + ["公式 Skill の例(anthropics/skills)", "https://github.com/anthropics/skills"], + ["OpenAI(Codex)"], + ["Codex: Skills", "https://developers.openai.com/codex/skills"], + ["Codex: Plugins", "https://developers.openai.com/codex/plugins"], + ["規格"], + ["Agent Skills(agentskills.io)", "https://agentskills.io"], + ]; + d.text(s, OFFICIAL.map(([label, url], i) => ({ + text: label, + options: Object.assign( + url ? { hyperlink: { url }, color: "accent", bullet: { indent: 12 } } : { bold: true, color: "text", paraSpaceBefore: i ? 6 : 0 }, + { breakLine: i < OFFICIAL.length - 1 }, + ), + })), { x: 6.06, y: 1.86, w: 3.54, h: 3.0, fontSize: 11.5, valign: "top", paraSpaceAfter: 3 }); + + // ==== 章 2 ==== + d.section("2. NDF の全体像", "Issue の束を渡すと、リリースまで進む"); + + // 5. NDF: 1 人の開発スタイルをプラグインにした + s = d.page("NDF: 自分のやり方をプラグインに", "GitHub で公開。ほぼ 1 人と AI で開発"); + [ + ["102", "回", "リリース(半年)"], + ["48", "個", "Skill"], + ["4", "つ", "ランタイム"], + ["10", "リポジトリ", "自分以外へ導入"], + ].forEach((r, i) => { + const c = cols(4)[i]; + stat(d, s, c.x, 1.55, c.w, 1.7, r[0], r[1], r[2]); + }); + d.text(s, [{ text: REPO, options: { hyperlink: { url: REPO }, color: "accent" } }], { x: X0, y: 3.6, w: W, h: 0.36, fontSize: 14, bold: true, align: "center", valign: "middle" }); + d.note(s, "数値は 2026-10-17 時点の実測。開発の開始は 2026 年 4 月"); + + // 6. NDF の全体像 + s = d.page("Issue の束を渡せば、並列に進む", "人が判断するのは 2 回の承認だけ"); + d.chip(s, X0, 1.62, 1.55, 0.9, "マイルストーン\n(Issue の束)", "panel2", { fontSize: 10 }); + d.arrow(s, 1.85, 2.07, 0.3, 0); + d.chip(s, 2.19, 1.62, 1.55, 0.9, "触る場所と依存で\nグループに分ける", "panel2", { fontSize: 10 }); + d.arrow(s, 3.78, 2.07, 0.3, 0); + d.box(s, 4.12, 1.36, 4.0, 1.98, "panel1", false); + d.text(s, "スプリント(グループごとに並列)", { x: 4.24, y: 1.4, w: 3.8, h: 0.28, fontSize: 10, bold: true, color: "text", valign: "middle" }); + ["グループ A", "グループ B", "グループ C"].forEach((g, i) => { + const y = 1.74 + i * 0.52; + d.text(s, g, { x: 4.24, y, w: 0.9, h: 0.4, fontSize: 9, valign: "middle" }); + flow(d, s, 5.12, y, 2.9, 0.4, ["設計", "実装", "レビュー"], { gap: 0.18, size: 9 }); + }); + d.arrow(s, 8.16, 2.35, 0.3, 0); + d.chip(s, 8.5, 1.9, 1.24, 0.9, "リリース", "panel2", { fontSize: 11 }); + cols(2).forEach((c, i) => { + const g = [ + ["承認 1: 設計", "設計をまとめて 1 回"], + ["承認 2: 本番リリース", "開発版で確かめて 1 回"], + ][i]; + card(d, s, c.x, 3.52, c.w, 0.86, g[0], g[1], "caution", { headSize: 12, bodySize: 11, headH: 0.3 }); + }); + d.text(s, "判断の材料はそろって届く", { x: X0, y: 4.46, w: W, h: 0.3, fontSize: 11, bold: true, color: "text", valign: "middle" }); + d.note(s, "例: 直前のリリース — 致命的な Issue 18 件を 2 スプリントに分け、スプリントの中は 3 グループを並列に"); + + // 7. /ndf:development-workflow + s = d.page("変更の大きさで、踏む工程を変える", "/ndf:development-workflow — 変更を 5 つのモードに分ける"); + d.tbl(s, [ + [d.H("モード"), d.H("対象")], + [d.B("light"), "文言・設定の小さな変更"], + [d.B("operation"), "コードを変えない外部の作業"], + [d.B("standard"), "機能追加・バグ修正"], + [d.B("legacy-refactor"), "テストの薄いコードの整理"], + [d.B("documentation"), "提案書・報告書などの文書"], + ], { x: X0, y: 1.4, w: W, colW: [2.6, 6.88], rowH: 0.38, fontSize: 13 }); + cols(2).forEach((c, i) => { + const r = [ + ["light", "実装 → レビューだけ"], + ["standard", "要件定義 → … → 振り返り"], + ][i]; + card(d, s, c.x, 3.9, c.w, 0.82, r[0], r[1], i ? "accentSoft" : "panel1", { headSize: 12, bodySize: 12, headH: 0.3 }); + }); + + // 8. standard モードの全体の流れ + s = d.page("機能追加は、この流れで 1 周", "standard モード: スプリントごとに 1 周、中はグループごとに並列"); + [ + ["設計", ["要件定義", "作業用の\nworktree", "設計", "構成の\n見直し", "クロス\nレビュー", { text: "承認 1\n設計" }]], + ["実装", ["実装計画", "テスト\nファースト", "クロス\nリファクタ", "クロス\nレビュー", "完了判定\n(証跡)", "マージ"]], + ["リリース", ["仕様書へ", "開発版\nリリース", { text: "承認 2\n本番" }, "本番\nリリース", "リリース後\nテスト", "振り返り", "Issue の\n棚卸し"]], + ].forEach((row, i) => { + const y = 1.48 + i * 1.08; + d.text(s, row[0], { x: X0, y, w: 0.8, h: 0.7, fontSize: 11, bold: true, color: "text", valign: "middle" }); + const n = row[1].length; + const g = 0.2; + flow(d, s, 1.06, y, (8.68 * n + g * (n - 7)) / 7, 0.7, row[1], { gap: g, size: 9.5 }); + // 行の続きは左の見出しの列で示す(工程の箱の間に置くと、箱どうしの流れに読める) + if (i < 2) d.arrow(s, X0 + 0.3, y + 0.76, 0, 0.26); + }); + d.note(s, "レビューの指摘は直して繰り返す。開発版は自分で使って確かめてから承認 2 へ"); + + // ==== 章 3 ==== + d.section("3. 組み込んだ開発技法", "実績のある技法を、AI が守れる手順に"); + + // 9. 実績のある開発技法を組み込む + s = d.page("実績のある開発技法を組み込む", "新しい発明ではない。AI が毎回守れる手順にした"); + [ + ["DDD: ユビキタス言語", ["要件定義の前に用語集", "1 語 1 意味", "580 語を CI が検査"]], + ["テストファースト", ["失敗するテストから", "- → 実装 → 整理", "薄いコードは先にテスト"]], + ["アジャイル", ["マイルストーン = バックログ", "スプリントごとに振り返り", "優先順位は WSJF"]], + ].forEach((r, i) => { + const c = cols(3)[i]; + card(d, s, c.x, 1.5, c.w, 2.1, r[0], bullets(r[1], 14), "panel1", { band: "accent", headSize: 14 }); + }); + + // 10. /ndf:requirements-design・/ndf:design・/ndf:document-restructuring + s = d.page("コードの前に、読める文書を", "要件と設計を書き、結論から読める順に並べ替える"); + [ + ["/ndf:requirements-design — 完了の条件", "確かめられる受け入れ条件"], + ["/ndf:design — 作り方", "構造・入出力・流れ・理由"], + ].forEach((r, i) => { + const c = cols(2)[i]; + card(d, s, c.x, 1.42, c.w, 0.96, r[0], r[1], "panel1", { band: "accent", headSize: 13, bodySize: 14 }); + }); + d.text(s, "/ndf:document-restructuring — 内容は足さず、順だけ直す", { x: X0, y: 2.56, w: W, h: 0.3, fontSize: 13, bold: true, color: "text", valign: "middle" }); + flow(d, s, X0, 2.96, 4.3, 0.58, ["問題に気づく", "測る", "結論"], { fill: "panel2", size: 11.5 }); + d.arrow(s, 4.66, 3.25, 0.5, 0, "accent"); + flow(d, s, 5.32, 2.96, 4.42, 0.58, ["結論", "根拠", "経緯"], { size: 11.5 }); + d.text(s, "AI が書いた順", { x: X0, y: 3.58, w: 4.3, h: 0.26, fontSize: 10, align: "center" }); + d.text(s, "読み手が知りたい順", { x: 5.32, y: 3.58, w: 4.42, h: 0.26, fontSize: 10, align: "center" }); + d.callout(s, 3.98, 0.72, "caution", "設計は PR で出す", "主題ごとに並列、承認 1 でまとめて"); + + // 11. /ndf:cross-review と /ndf:cross-refactoring + s = d.page("複数の AI で、見て直す", "見落としは別の AI が拾い、直すのは時間内に"); + [ + ["/ndf:cross-review — 指摘が尽きるまで", ["2 つの AI が\nレビュー", "指摘を修正", "組み替えて\n繰り返す"], "新しい指摘が出なくなったら終了", "観点は PR の中身で切り替え"], + ["/ndf:cross-refactoring — 時間内に直す", ["全員の AI が\n提案", "1 つが既定\n30 分で実施", "壊れたら\n取り消す"], "数値は持ち時間から逆算", "採用と取り消しを報告"], + ].forEach((r, i) => { + const c = cols(2, 0.3)[i]; + d.box(s, c.x, 1.42, c.w, 2.96, "panel1", false); + d.box(s, c.x, 1.42, c.w, 0.06, "accent", false); + d.text(s, r[0], { x: c.x + 0.14, y: 1.54, w: c.w - 0.28, h: 0.34, fontSize: 13, bold: true, color: "text", valign: "middle" }); + flow(d, s, c.x + 0.14, 2.02, c.w - 0.28, 0.96, r[1], { gap: 0.2, size: 12.5 }); + d.text(s, bullets([r[2], r[3]], 14), { x: c.x + 0.14, y: 3.2, w: c.w - 0.28, h: 1.0, valign: "middle", paraSpaceAfter: 8 }); + }); + + // ==== 章 4 ==== + d.section("4. AI に任せ切る仕組み", "止まらない・迷わない・ごまかさない"); + + // 12. 3 層のエージェント + s = d.page("3 層のエージェントで止めない", "長い作業も、止まったら続きから"); + [ + ["conductor", "指揮役。人と話し全体を進める"], + ["supervisor", "監督役。1 つの工程を通す"], + ["worker", "作業役。調査や修正を行う"], + ].forEach((r, i) => { + const y = 1.46 + i * 1.02; + d.chip(s, X0 + i * 0.3, y, 1.7, 0.74, r[0], i === 0 ? "accent" : "accentSoft", { fontSize: 13, color: i === 0 ? "onHeader" : "text" }); + d.text(s, r[1], { x: 2.12 + i * 0.3, y, w: 2.95, h: 0.74, fontSize: 11.5, valign: "middle" }); + if (i < 2) d.arrow(s, 1.1 + i * 0.3, y + 0.76, 0.2, 0.24); + }); + card(d, s, 5.7, 1.46, 4.04, 1.3, "止まっても続きから", "JSON の計画をスクリプトが実行", "panel1", { band: "accent", headSize: 13, bodySize: 12.5 }); + card(d, s, 5.7, 2.96, 4.04, 1.5, "セッションを自動で切り替え", "次のコマンドを残して新しいセッションへ", "panel1", { band: "accent", headSize: 13, bodySize: 12.5 }); + + // 13. 判断基準: MVV と 2 つの承認ゲート + s = d.page("迷ったら MVV で判断する", "判断の根拠の番号をログに残す"); + [ + ["Mission", "何のためにやるか"], + ["Vision", "目指す姿"], + ["Value", "判断の基準"], + ].forEach((r, i) => { + const c = cols(3, 0.16, X0, 5.9)[i]; + card(d, s, c.x, 1.5, c.w, 1.3, r[0], r[1], "accentSoft", { headSize: 15, bodySize: 13 }); + }); + d.text(s, "Value の例: 「まず届ける」「処理の改善に寄与しない指摘は直さない」", { x: X0, y: 2.96, w: W, h: 0.34, fontSize: 12.5, valign: "middle" }); + card(d, s, 6.4, 1.5, 3.34, 1.3, "必ず人が承認する操作", "本番デプロイなど\nAI は省かない", "caution", { headSize: 13, bodySize: 12 }); + d.callout(s, 3.5, 0.4, "panel1", "AI に任せる範囲は、事前に設定で選ぶ", null); + + // 14. /ndf:quality-gates と /ndf:investigation-rules + s = d.page("「完了」も「無い」も証拠で", "AI の言葉を、そのままは信じない"); + [ + ["「完了しました」", "/ndf:quality-gates — 完了の判定", "4 点がそろって完了\nコマンド・終了コード・時刻・範囲"], + ["「該当のコードはありません」", "/ndf:investigation-rules — 調査の規則", "検索のコマンドと結果を添える"], + ].forEach((r, i) => { + const c = cols(2)[i]; + d.chip(s, c.x, 1.48, c.w, 0.72, r[0], "panel2", { fontSize: 15 }); + d.arrow(s, c.x + c.w / 2, 2.26, 0, 0.3); + card(d, s, c.x, 2.62, c.w, 1.4, r[1], r[2], "panel1", { band: "accent", headSize: 12.5, bodySize: 13 }); + }); + d.text(s, "AI は「無い」と断定しがち", { x: X0, y: 4.3, w: W, h: 0.34, fontSize: 12, bold: true, color: "text", align: "center", valign: "middle" }); + + // 15. 自分を直すループ(導入先のプロジェクトの Issue の一覧を整理し続ける) + s = d.page("やるべきことを整理し続ける", "導入したプロジェクトの Issue の一覧を、いつも現状に"); + [ + ["/ndf:out-of-scope — 見つけたら起票", "範囲外の問題を即 Issue に"], + ["/ndf:retrospective — 振り返り", "リリースごとに見直す"], + ["/ndf:backlog-refinement — 一覧の手入れ", "スプリント末に一覧を現状へ"], + ].forEach((r, i) => { + const y = 1.42 + i * 1.08; + card(d, s, X0, y, 4.9, 0.92, r[0], r[1], "accentSoft", { headSize: 12, bodySize: 11, headH: 0.28 }); + if (i < 2) d.arrow(s, 2.7, y + 0.93, 0, 0.14); + }); + stat(d, s, 5.4, 1.42, 4.34, 1.44, "56", "%", "本文が現状とずれた open の Issue\n(43 件中 24 件)"); + stat(d, s, 5.4, 3.0, 4.34, 1.5, "49", "件", "本文が古かった Issue\n(71 件を調査。2 件は修正済み)"); + d.note(s, "56% はある時点の棚卸し、49 件は 2026-10-17 の調査。放っておくと、直った Issue を直すことになる"); + + // 16. その他の特徴的な機能 + s = d.page("そのほかの特徴的な機能", "見積もり・リリース・AI の操作の強制停止"); + d.tbl(s, [ + [d.H("機能"), d.H("何をするか"), d.H("要点")], + [d.B("/ndf:estimate"), "AI 前提の見積もり", "人日でなく、時間と費用の 4 指標"], + [d.B("/ndf:release"), "成果物を同じ骨格でリリース", "パッケージ〜スライド・文書"], + [d.B("hook"), "AI のやりがちな操作を止める", "直接編集・sleep の待ち・読み直し"], + ], { x: X0, y: 1.42, w: W, colW: [2.1, 3.5, 3.88], rowH: [0.42, 0.72, 0.72, 0.72], fontSize: 14 }); + + // ==== 章 5 ==== + d.section("5. 事例", "ほかのプロジェクトでも動くか"); + + // 17. 事例: 他のプロジェクトでも動く + s = d.page("事例: ほかのプロジェクトでも動く", "言語もチームも違う 4 つのプロジェクト"); + d.tbl(s, [ + [d.H("プロジェクトの種類"), d.H("結果")], + [d.B("AWS のインフラ"), "マージ PR 0.95 → 3.38 件/日\n※ 社内ツールの移行と重なる"], + [d.B("PHP の業務システム\n(20 人で開発)"), "設計の承認から 51 時間で実装 PR 31 本"], + [d.B("機械学習のモデル"), "データ欠損を発見し 1 スプリントで本番へ\n相関係数 0.73 → 0.954"], + [d.B("価格推定"), "PR 数は同じ。レビュー必須へ"], + ], { x: X0, y: 1.42, w: W, colW: [2.3, 7.18], rowH: [0.34, 0.74, 0.6, 0.74, 0.5], fontSize: 12 }); + d.note(s, "インフラは導入前後 37 日の比較。プロジェクトは種類で呼ぶ。数値は 2026-10-17 時点の実測"); + + // 18. 事例から分かったこと + s = d.page("事例から分かった 2 つのこと", "詰まりは次の版で直り、ルールはプロジェクトごと"); + card(d, s, X0, 1.42, 4.66, 2.48, "1. 最初は詰まり、次の版で直る", bullets([ + "詰まりが Issue になり、次の版で直る", + "PHP では 17 件", + "- 作業用の worktree に vendor/ が無い", + "- 全テストに 64 分", + "- マージ = 本番リリース", + ], 13), "panel1", { band: "accent", headSize: 13 }); + d.text(s, "2. どこも独自のルールを足した", { x: 5.08, y: 1.48, w: 4.66, h: 0.32, fontSize: 13, bold: true, color: "text", valign: "middle" }); + d.tbl(s, [ + [d.H("プロジェクト"), d.H("足したルール")], + [d.B("インフラ"), "本番デプロイの承認"], + [d.B("業務システム"), "「マージ = 本番リリース」の承認"], + [d.B("価格推定"), "全 PR へのレビュー"], + ], { x: 5.08, y: 1.9, w: 4.66, colW: [1.5, 3.16], rowH: 0.5, fontSize: 12 }); + + // ==== 章 6 ==== + d.section("6. 始め方", "フォークして、自分用に育てる"); + + // 19. だから「フォークして自分用に」 + s = d.page("だから、フォークして自分用に", "仕組みはそのまま、ルールだけ差し替える"); + card(d, s, X0, 1.42, 4.66, 1.86, "NDF も私のルールのかたまり", bullets(["承認は 2 回だけ", "文書では「道具」でなく「Tool」", "文言を照合するテストは書かない"], 12.5), "warn", { headSize: 13 }); + card(d, s, 5.08, 1.42, 4.66, 1.86, "そのまま使う仕組み", bullets(["ワークフロー", "3 層のエージェント", "クロスレビュー", "判断基準(MVV)"], 12.5), "accentSoft", { headSize: 13 }); + d.text(s, "他人のルールはノイズ → フォークする", { x: X0, y: 3.36, w: W, h: 0.34, fontSize: 12, bold: true, color: "text", align: "center", valign: "middle" }); + cols(3).forEach((c, i) => d.chip(s, c.x, 3.84, c.w, 0.72, ["指摘が資産になる", "ランタイムに依存しない", "計測して改善できる"][i], "panel1", { fontSize: 13 })); + + // 20. 始め方と育て方 + s = d.page("始め方と育て方", "フォークする → 削る → 育てる"); + [ + ["1. フォークする", ["名前を自分のものに", "自分のリポジトリから導入"]], + ["2. 削る", ["使わない Skill は外す", "残すかは呼び出し回数で"]], + ["3. 育てる", ["同じ指摘を 2 回したら形に", "手順 → Skill", "決まった処理 → スクリプト", "必ず守らせる → hook"]], + ].forEach((r, i) => { + const c = cols(3)[i]; + card(d, s, c.x, 1.42, c.w, 1.9, r[0], bullets(r[1], 13.5), "accentSoft", { headSize: 14 }); + }); + d.callout(s, 3.5, 1.04, "caution", "気をつける点", bullets([ + "description(Skill の説明文)は最小限", + "思いつきは experimental で試す", + "開発版を使ってから正式版に", + ], 10.5)); + + // 21. まとめ: 明日からの 3 ステップ + s = d.page("まとめ: 明日からの 3 ステップ", "教えるのは 1 回。プラグインにして、フォークして育てる"); + [ + ["1", "ai-plugins をフォークして\n自分の名前でインストール"], + ["2", "使わない Skill を外す"], + ["3", "同じ指摘を 2 回したら\nSkill にする"], + ].forEach((r, i) => { + const c = cols(3)[i]; + d.box(s, c.x, 1.5, c.w, 2.0, "panel1", false); + d.text(s, r[0], { x: c.x, y: 1.6, w: c.w, h: 0.7, fontSize: 36, bold: true, color: "accent", align: "center", valign: "middle" }); + d.text(s, r[1], { x: c.x + 0.14, y: 2.36, w: c.w - 0.28, h: 1.0, fontSize: 13, bold: true, color: "text", align: "center", valign: "middle" }); + }); + d.text(s, [{ text: REPO, options: { hyperlink: { url: REPO }, color: "accent" } }], { x: X0, y: 3.86, w: W, h: 0.5, fontSize: 18, bold: true, align: "center", valign: "middle" }); +}); diff --git a/docs/presentations/2026-10-17-skill-plugin-development/slides/lib/deck-frame.js b/docs/presentations/2026-10-17-skill-plugin-development/slides/lib/deck-frame.js new file mode 100644 index 000000000..629959582 --- /dev/null +++ b/docs/presentations/2026-10-17-skill-plugin-development/slides/lib/deck-frame.js @@ -0,0 +1,433 @@ +"use strict"; +// ページの枠組み(汎用テンプレート)。pptxgenjs で 16:9(10 × 5.625 インチ)の pptx を作る。 +// +// - 座標は FRAME の定数だけが持つ。トーン&マナー(theme.json)は座標を持たない +// - 書体と色の値は DEFAULT_THEME と theme.json だけが持つ。関数に渡す色は theme.colors の項目名で受ける +// - 台本(script.md)を渡すと、ページを作るたびに同じ番号の読み上げをスピーカーノートへ付け、 +// 保存の前に台本のページ数と作ったページ数を照合する +// - 章扉(section)は構成を見せるページで、スピーカーノートを持たず、台本の番号を進めない。 +// ページ数の照合からも外す(ページ番号は章扉も数える) +// +// 資料のディレクトリへ写して使う。写した後に書き換えない(プロジェクトの値は theme.json へ書く)。 + +const fs = require("fs"); +const path = require("path"); + +const EXIT_INPUT = 1; // 引数・theme.json・ページ数の照合の失敗 +const EXIT_SCRIPT = 2; // 台本の形の失敗 + +class DeckError extends Error { + constructor(message, code) { + super(message); + this.code = code; + } +} + +// ---- 座標(インチ)。トーン&マナーに依らない ---- +const FRAME = Object.freeze({ + slide: { w: 10, h: 5.625 }, + title: { x: 0.26, y: 0.15, w: 8.29, h: 0.63 }, + rule: { x: 0.24, y: 0.8, w: 9.52 }, + logo: { x: 8.78, y: 0.24, w: 0.96, h: 0.23 }, + lead: { x: 0.26, y: 0.9, w: 9.5, h: 0.34 }, + copyright: { x: 0.26, y: 5.18, w: 3.2, h: 0.3 }, + pageNo: { x: 9.27, y: 5.18, w: 0.5, h: 0.3 }, + note: { x: 0.26, y: 4.88, w: 9.5, h: 0.28 }, + body: { x: 0.26, y: 1.34, w: 9.48 }, + callout: { x: 0.26, w: 9.48, padX: 0.22, headH: 0.28 }, + section: { + band: { x: 0, y: 0, w: 0.36, h: 5.625 }, + title: { x: 0.9, y: 1.9, w: 8.4, h: 0.8 }, + accent: { x: 0.92, y: 2.82, w: 1.2, width: 4 }, + sub: { x: 0.9, y: 3.0, w: 8.4, h: 0.4 }, + copyright: { x: 0.56, y: 5.18, w: 3.2, h: 0.3 }, + }, + cover: { + band: { x: 0, y: 0, w: 10, h: 0.36 }, + logo: { x: 0.26, y: 0.6, w: 0.96, h: 0.23 }, + title: { x: 1.02, y: 1.3, w: 8.2, h: 0.72 }, + accent: { x: 1.04, y: 2.1, w: 1.2, width: 4 }, + sub: { x: 1.02, y: 2.26, w: 8.2, h: 0.62 }, + meta: { x: 1.02, y: 3.55, w: 8.2, h: 0.4 }, + }, +}); + +// ---- 既定のトーン&マナー(会社色を持たない)。正本はここ ---- +const DEFAULT_THEME = Object.freeze({ + font: "Noto Sans JP", + colors: Object.freeze({ + accent: "2B6CB0", + accentSoft: "E3EDF7", + text: "000000", + ink: "303030", + mute: "7F7F7F", + rule: "D9D9D9", + panel1: "F3F3F3", + panel2: "EEEEEE", + warn: "F4CCCC", + caution: "FFF2CC", + header: "303030", + onHeader: "FFFFFF", + background: "FFFFFF", + }), + sizes: Object.freeze({ + title: 20, + lead: 12, + body: 10, + table: 10, + note: 8, + footer: 9, + calloutHead: 11.5, + calloutBody: 9.5, + sectionTitle: 32, + sectionSub: 14, + coverTitle: 28, + coverSub: 16, + coverMeta: 12, + }), + copyright: "", + logo: null, +}); + +const THEME_KEYS = ["font", "colors", "sizes", "copyright", "logo"]; +const HEX = /^[0-9A-Fa-f]{6}$/; + +// theme.json の値を既定値へ重ねる。colors と sizes は項目ごと、ほかは値ごと置き換える。 +function mergeTheme(override, baseDir) { + const t = { + font: DEFAULT_THEME.font, + colors: { ...DEFAULT_THEME.colors }, + sizes: { ...DEFAULT_THEME.sizes }, + copyright: DEFAULT_THEME.copyright, + logo: DEFAULT_THEME.logo, + }; + if (override == null) return t; + if (typeof override !== "object" || Array.isArray(override)) { + throw new DeckError("theme.json はオブジェクトでなければならない", EXIT_INPUT); + } + const unknown = Object.keys(override).filter((k) => !THEME_KEYS.includes(k)); + if (unknown.length) throw new DeckError(`theme.json に知らない項目がある: ${unknown.join(", ")}`, EXIT_INPUT); + if ("font" in override) { + if (typeof override.font !== "string" || !override.font) throw new DeckError("theme.json の font は空でない文字列", EXIT_INPUT); + t.font = override.font; + } + for (const group of ["colors", "sizes"]) { + if (!(group in override)) continue; + const g = override[group]; + if (typeof g !== "object" || g == null || Array.isArray(g)) throw new DeckError(`theme.json の ${group} はオブジェクト`, EXIT_INPUT); + const bad = Object.keys(g).filter((k) => !(k in DEFAULT_THEME[group])); + if (bad.length) throw new DeckError(`theme.json の ${group} に知らない項目がある: ${bad.join(", ")}`, EXIT_INPUT); + for (const [k, v] of Object.entries(g)) { + if (group === "colors" && !(typeof v === "string" && HEX.test(v))) { + throw new DeckError(`theme.json の colors.${k} は 6 桁の 16 進(# なし): ${v}`, EXIT_INPUT); + } + if (group === "sizes" && !(typeof v === "number" && v > 0)) { + throw new DeckError(`theme.json の sizes.${k} は正の数(pt): ${v}`, EXIT_INPUT); + } + t[group][k] = group === "colors" ? v.toUpperCase() : v; + } + } + if ("copyright" in override) { + if (typeof override.copyright !== "string") throw new DeckError("theme.json の copyright は文字列", EXIT_INPUT); + t.copyright = override.copyright; + } + if ("logo" in override) { + if (override.logo === null) { + t.logo = null; + } else if (typeof override.logo === "string" && override.logo) { + const p = path.resolve(baseDir || process.cwd(), override.logo); + if (!fs.existsSync(p)) throw new DeckError(`theme.json の logo のファイルが無い: ${p}`, EXIT_INPUT); + t.logo = p; + } else { + throw new DeckError("theme.json の logo は PNG の相対パスか null", EXIT_INPUT); + } + } + return t; +} + +function loadTheme(theme) { + if (theme == null) return mergeTheme(null); + if (typeof theme === "object") return mergeTheme(theme, process.cwd()); + if (!fs.existsSync(theme)) return mergeTheme(null); + let data; + try { + data = JSON.parse(fs.readFileSync(theme, "utf8")); + } catch (e) { + throw new DeckError(`theme.json を JSON として読めない: ${theme}: ${e.message}`, EXIT_INPUT); + } + return mergeTheme(data, path.dirname(path.resolve(theme))); +} + +// ---- 台本(script.md)の読み込み ---- +// `## <番号>. <見出し>` がページの区切り。見出しの末尾の `(<秒>秒)` は配分秒数。 +// 区切りの中の `>` で始まる行が読み上げ。`>` だけの行は段落の区切り。ほかの行は読まない。 +const HEAD = /^##\s+(\d+)\.\s*(.*?)\s*$/; +const SECONDS = /[((]\s*(\d+)\s*秒\s*[))]\s*$/; + +function plain(s) { + return s.replace(/\*\*/g, "").replace(/`/g, ""); +} + +function parseScript(text, name) { + const pages = []; + let cur = null; + let para = []; + const flush = () => { + if (cur && para.length) cur.paragraphs.push(para.join("")); + para = []; + }; + for (const raw of text.split(/\r?\n/)) { + const m = raw.match(HEAD); + if (m) { + flush(); + let title = m[2]; + let seconds = null; + const sm = title.match(SECONDS); + if (sm) { + seconds = Number(sm[1]); + title = title.slice(0, sm.index).trim(); + } + cur = { no: Number(m[1]), title, seconds, paragraphs: [] }; + pages.push(cur); + continue; + } + if (/^#{1,2}\s/.test(raw)) { + flush(); + cur = null; // 番号の無い見出しの下は読まない + continue; + } + if (!cur) continue; + const q = raw.match(/^>\s?(.*)$/); + if (q) { + const body = q[1].trim(); + if (body) para.push(plain(body)); + else flush(); + } else { + flush(); + } + } + flush(); + if (!pages.length) throw new DeckError(`台本にページの区切り(## <番号>. <見出し>)が無い: ${name}`, EXIT_SCRIPT); + const seen = new Map(); + for (const p of pages) seen.set(p.no, (seen.get(p.no) || 0) + 1); + const dup = [...seen].filter(([, c]) => c > 1).map(([n]) => n); + if (dup.length) throw new DeckError(`台本のページの番号が重なる: ${dup.join(", ")}(${name})`, EXIT_SCRIPT); + const max = Math.max(...pages.map((p) => p.no)); + const missing = []; + for (let i = 1; i <= max; i++) if (!seen.has(i)) missing.push(i); + if (missing.length) throw new DeckError(`台本のページの番号が欠ける: ${missing.join(", ")}(${name})`, EXIT_SCRIPT); + pages.sort((a, b) => a.no - b.no); + for (const p of pages) p.reading = p.paragraphs.join("\n"); + return pages; +} + +function readScript(file) { + if (!fs.existsSync(file)) throw new DeckError(`台本が無い: ${file}`, EXIT_INPUT); + return parseScript(fs.readFileSync(file, "utf8"), file); +} + +// ---- 表のセル ---- +const H = (t, extra) => ({ text: t, options: Object.assign({ fill: { color: "header" }, color: "onHeader", bold: true, align: "center" }, extra || {}) }); +const C = (t, extra) => ({ text: t, options: Object.assign({ align: "center" }, extra || {}) }); +const B = (t, extra) => ({ text: t, options: Object.assign({ bold: true }, extra || {}) }); + +// ---- 資料 ---- +function createDeck(opts) { + const o = opts || {}; + const PptxGenJS = require(require.resolve("pptxgenjs", { paths: [o.baseDir || process.cwd(), __dirname] })); + const theme = loadTheme(o.theme); + const script = o.script ? readScript(o.script) : null; + const p = new PptxGenJS(); + p.layout = "LAYOUT_16x9"; + if (o.title) p.title = o.title; + if (o.author) p.author = o.author; + if (o.title) p.subject = o.title; + const F = theme.font; + const logo = theme.logo ? "image/png;base64," + fs.readFileSync(theme.logo).toString("base64") : null; + let pageNo = 0; // 作ったページ(章扉を含む)。ページ番号に使う + let scriptNo = 0; // 台本のページを消費したページ(章扉を除く) + let sections = 0; + + function color(name) { + if (!(name in theme.colors)) throw new DeckError(`色は theme.colors の項目名で指す: ${name}`, EXIT_INPUT); + return theme.colors[name]; + } + function size(v) { + if (typeof v === "number") return v; + if (!(v in theme.sizes)) throw new DeckError(`文字の大きさは pt か theme.sizes の項目名で指す: ${v}`, EXIT_INPUT); + return theme.sizes[v]; + } + // 色の項目名を値へ置き換える(color・fill.color・line.color・border.color) + function resolve(options) { + if (!options || typeof options !== "object") return options; + const r = Array.isArray(options) ? options.map(resolve) : { ...options }; + if (Array.isArray(r)) return r; + if (typeof r.color === "string") r.color = color(r.color); + for (const k of ["fill", "line", "border"]) { + if (r[k] && typeof r[k] === "object" && !Array.isArray(r[k]) && typeof r[k].color === "string") r[k] = { ...r[k], color: color(r[k].color) }; + if (Array.isArray(r[k])) r[k] = r[k].map((b) => (b && typeof b.color === "string" ? { ...b, color: color(b.color) } : b)); + } + if (r.fontSize !== undefined) r.fontSize = size(r.fontSize); + return r; + } + function runs(t) { + if (!Array.isArray(t)) return t; + return t.map((x) => (x && typeof x === "object" ? { ...x, options: resolve(x.options) } : x)); + } + + // withScript が false のページ(章扉)は台本の番号を進めず、ノートを持たない + function newSlide(withScript) { + const s = p.addSlide(); + pageNo++; + s.background = { color: color("background") }; + if (withScript === false) { + sections++; + return s; + } + scriptNo++; + if (script) { + const sp = script[scriptNo - 1]; + if (sp && sp.reading) s.addNotes(sp.reading); + } + return s; + } + function footer(s, cx, withNo, textColor) { + if (theme.copyright) { + s.addText(theme.copyright, { ...cx, fontFace: F, fontSize: theme.sizes.footer, color: color(textColor || "mute"), valign: "middle", margin: 0 }); + } + if (withNo) { + s.addText(String(pageNo), { ...FRAME.pageNo, fontFace: F, fontSize: theme.sizes.footer, color: color("mute"), align: "right", valign: "middle", margin: 0 }); + } + } + function putLogo(s, at) { + if (logo) s.addImage({ data: logo, ...at }); + } + + // 文字を置く。既定は本文の書体・大きさ・色(ink)。色は項目名で、大きさは pt か項目名で受ける + function text(s, t, opt) { + s.addText(runs(t), resolve(Object.assign({ fontFace: F, fontSize: theme.sizes.body, color: "ink", margin: 0, valign: "top" }, opt || {}))); + } + + function cover(title, sub, meta) { + const s = newSlide(); + const c = FRAME.cover; + s.addShape(p.ShapeType.rect, { ...c.band, fill: { color: color("accent") }, line: { type: "none" } }); + putLogo(s, c.logo); + s.addText(title, { ...c.title, fontFace: F, fontSize: theme.sizes.coverTitle, bold: true, color: color("text"), valign: "bottom", margin: 0, fit: "shrink" }); + s.addShape(p.ShapeType.line, { x: c.accent.x, y: c.accent.y, w: c.accent.w, h: 0, line: { color: color("accent"), width: c.accent.width } }); + if (sub) s.addText(sub, { ...c.sub, fontFace: F, fontSize: theme.sizes.coverSub, bold: true, color: color("ink"), valign: "top", margin: 0 }); + if (meta) s.addText(meta, { ...c.meta, fontFace: F, fontSize: theme.sizes.coverMeta, color: color("ink"), margin: 0 }); + footer(s, FRAME.copyright, false); + return s; + } + + function page(title, lead) { + const s = newSlide(); + s.addText(title, { ...FRAME.title, fontFace: F, fontSize: theme.sizes.title, bold: true, color: color("text"), valign: "middle", margin: 0, fit: "shrink" }); + putLogo(s, FRAME.logo); + s.addShape(p.ShapeType.line, { x: FRAME.rule.x, y: FRAME.rule.y, w: FRAME.rule.w, h: 0, line: { color: color("rule"), width: 1 } }); + if (lead) s.addText(lead, { ...FRAME.lead, fontFace: F, fontSize: theme.sizes.lead, bold: true, color: color("ink"), valign: "middle", margin: 0 }); + footer(s, FRAME.copyright, true); + return s; + } + + function section(title, sub) { + const s = newSlide(false); + const c = FRAME.section; + s.addShape(p.ShapeType.rect, { ...c.band, fill: { color: color("accent") }, line: { type: "none" } }); + putLogo(s, FRAME.logo); + s.addText(title, { ...c.title, fontFace: F, fontSize: theme.sizes.sectionTitle, bold: true, color: color("text"), margin: 0 }); + s.addShape(p.ShapeType.line, { x: c.accent.x, y: c.accent.y, w: c.accent.w, h: 0, line: { color: color("accent"), width: c.accent.width } }); + if (sub) s.addText(sub, { ...c.sub, fontFace: F, fontSize: theme.sizes.sectionSub, bold: true, color: color("ink"), margin: 0 }); + footer(s, c.copyright, true); + return s; + } + + function box(s, x, y, w, h, fill, line) { + s.addShape(p.ShapeType.rect, { x, y, w, h, fill: { color: color(fill) }, line: line === false ? { type: "none" } : { color: color(line || "rule"), width: 1 } }); + } + + // 箱に文字を中央寄せで載せる(図の部品) + function chip(s, x, y, w, h, t, fill, opt) { + box(s, x, y, w, h, fill, false); + text(s, t, Object.assign({ x, y, w, h, align: "center", valign: "middle", bold: true, color: "text" }, opt || {})); + } + + // 矢印(x, y から w, h だけ進む) + function arrow(s, x, y, w, h, lineColor) { + s.addShape(p.ShapeType.line, { x, y, w, h, line: { color: color(lineColor || "mute"), width: 1.5, endArrowType: "triangle" } }); + } + + function note(s, t) { + s.addText(runs(t), { ...FRAME.note, fontFace: F, fontSize: theme.sizes.note, color: color("mute"), valign: "middle", margin: 0 }); + } + + function tbl(s, rows, opt) { + const r = rows.map((row) => row.map((cell) => (cell && typeof cell === "object" ? { ...cell, options: resolve(cell.options) } : cell))); + s.addTable(r, resolve(Object.assign({ fontFace: F, fontSize: theme.sizes.table, color: "ink", valign: "middle", border: { type: "solid", color: "rule", pt: 1 }, autoPage: false, margin: [2, 4, 2, 4] }, opt || {}))); + } + + function callout(s, y, h, fill, head, body) { + const c = FRAME.callout; + box(s, c.x, y, c.w, h, fill, false); + s.addText(head, { x: c.x + c.padX, y: y + 0.05, w: c.w - 2 * c.padX, h: c.headH, fontFace: F, fontSize: theme.sizes.calloutHead, bold: true, color: color("text"), valign: "middle", margin: 0 }); + if (body) s.addText(runs(body), { x: c.x + c.padX, y: y + 0.05 + c.headH, w: c.w - 2 * c.padX, h: h - c.headH - 0.1, fontFace: F, fontSize: theme.sizes.calloutBody, color: color("ink"), valign: "top", margin: 0 }); + } + + async function save(out) { + if (!out || !/\.pptx$/i.test(out)) throw new DeckError("出力のパスは .pptx で終える", EXIT_INPUT); + if (script && script.length !== scriptNo) { + throw new DeckError(`ページ数が台本と合わない: 台本 ${script.length} ページ / 作ったページ ${scriptNo}(章扉 ${sections} を除く)`, EXIT_INPUT); + } + await p.writeFile({ fileName: out }); + return { out, pages: pageNo, sections }; + } + + return { + pptx: p, + theme, + frame: FRAME, + script, + get pageNo() { + return pageNo; + }, + get scriptNo() { + return scriptNo; + }, + color, + text, + cover, + page, + section, + box, + chip, + arrow, + note, + tbl, + callout, + H, + C, + B, + save, + }; +} + +// 生成スクリプトの入口。`node build-deck.js <出力>.pptx []` +// build(deck) でページを作り、保存まで行う。失敗は DeckError の code で終える +function run(dir, opts, build) { + const [out, themeArg] = process.argv.slice(2); + (async () => { + if (!out) throw new DeckError("usage: node build-deck.js <出力>.pptx []", EXIT_INPUT); + const theme = themeArg ? path.resolve(themeArg) : path.join(dir, "theme.json"); + if (themeArg && !fs.existsSync(theme)) throw new DeckError(`theme.json が無い: ${theme}`, EXIT_INPUT); + const deck = createDeck({ ...opts, theme, baseDir: dir }); + await build(deck); + const r = await deck.save(out); + console.log(`${r.out}: ${r.pages} ページ(章扉 ${r.sections})`); + })().catch((e) => { + console.error(e.message); + process.exit(e instanceof DeckError ? e.code : 1); + }); +} + +module.exports = { createDeck, run, parseScript, readScript, mergeTheme, DeckError, FRAME, DEFAULT_THEME, H, C, B, EXIT_INPUT, EXIT_SCRIPT }; diff --git a/docs/presentations/2026-10-17-skill-plugin-development/slides/lib/script-time.js b/docs/presentations/2026-10-17-skill-plugin-development/slides/lib/script-time.js new file mode 100644 index 000000000..d89484cbf --- /dev/null +++ b/docs/presentations/2026-10-17-skill-plugin-development/slides/lib/script-time.js @@ -0,0 +1,80 @@ +#!/usr/bin/env node +"use strict"; +// 台本の字数と尺を、ページごとと合計で出す。 +// +// node lib/script-time.js <台本> [--wpm 350] [--limit <分>] [--json] +// +// - 字数は読み上げ(`>` の行)の文字の数。空白と強調の記号(** と `)は数えない +// - 尺の秒は 字数 ÷ --wpm(1 分あたりの字数。既定 350)× 60。合計はページごとの値の和 +// - 終了コード: 0 / 1 合計が --limit(分)を超えた・引数の失敗 / 2 台本の形の失敗 +// 台本の読み取りは deck-frame.js と同じ関数を使う(スピーカーノートと同じ文を数える)。 + +const { readScript, DeckError, EXIT_INPUT } = require("./deck-frame"); + +function count(reading) { + return [...reading.replace(/\s/g, "")].length; +} + +function measure(pages, wpm) { + const rows = pages.map((p) => { + const chars = count(p.reading); + return { no: p.no, title: p.title, chars, seconds: Math.round((chars / wpm) * 600) / 10, planned: p.seconds }; + }); + const chars = rows.reduce((a, r) => a + r.chars, 0); + const seconds = Math.round(rows.reduce((a, r) => a + r.seconds, 0) * 10) / 10; + const planned = rows.every((r) => r.planned == null) ? null : rows.reduce((a, r) => a + (r.planned || 0), 0); + return { wpm, pages: rows, total: { pages: rows.length, chars, seconds, planned } }; +} + +function mmss(sec) { + const s = Math.round(sec); + return `${Math.floor(s / 60)}分${String(s % 60).padStart(2, "0")}秒`; +} + +function table(m) { + const lines = [ + `| # | 見出し | 字数 | 尺(秒) | 配分(秒) |`, + `| ---: | --- | ---: | ---: | ---: |`, + ]; + for (const r of m.pages) lines.push(`| ${r.no} | ${r.title.replace(/\|/g, "\\|")} | ${r.chars} | ${r.seconds.toFixed(1)} | ${r.planned == null ? "—" : r.planned} |`); + const t = m.total; + lines.push(`| 合計 | ${t.pages} ページ(${m.wpm} 字/分) | ${t.chars} | ${t.seconds.toFixed(1)}(${mmss(t.seconds)}) | ${t.planned == null ? "—" : t.planned} |`); + return lines.join("\n"); +} + +function parseArgs(argv) { + const a = { file: null, wpm: 350, limit: null, json: false }; + for (let i = 0; i < argv.length; i++) { + const v = argv[i]; + if (v === "--json") a.json = true; + else if (v === "--wpm" || v === "--limit") { + const n = Number(argv[++i]); + if (!(n > 0)) throw new DeckError(`${v} は正の数`, EXIT_INPUT); + a[v.slice(2)] = n; + } else if (v.startsWith("--")) throw new DeckError(`知らない引数: ${v}`, EXIT_INPUT); + else if (!a.file) a.file = v; + else throw new DeckError(`台本は 1 つだけ渡す: ${v}`, EXIT_INPUT); + } + if (!a.file) throw new DeckError("usage: node lib/script-time.js <台本> [--wpm 350] [--limit <分>] [--json]", EXIT_INPUT); + return a; +} + +function main() { + try { + const a = parseArgs(process.argv.slice(2)); + const m = measure(readScript(a.file), a.wpm); + if (a.limit != null) m.limit = { minutes: a.limit, over: m.total.seconds > a.limit * 60 }; + console.log(a.json ? JSON.stringify(m) : table(m)); + if (m.limit && m.limit.over) { + console.error(`尺が上限を超えた: ${mmss(m.total.seconds)} > ${a.limit} 分`); + process.exit(1); + } + } catch (e) { + console.error(e.message); + process.exit(e instanceof DeckError ? e.code : 1); + } +} + +if (require.main === module) main(); + +module.exports = { count, measure, table }; diff --git a/docs/presentations/2026-10-17-skill-plugin-development/slides/package-lock.json b/docs/presentations/2026-10-17-skill-plugin-development/slides/package-lock.json new file mode 100644 index 000000000..7d73ee931 --- /dev/null +++ b/docs/presentations/2026-10-17-skill-plugin-development/slides/package-lock.json @@ -0,0 +1,171 @@ +{ + "name": "slide-deck", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "slide-deck", + "version": "0.0.0", + "dependencies": { + "pptxgenjs": "4.0.1" + } + }, + "node_modules/@types/node": { + "version": "22.20.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.5.tgz", + "integrity": "sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/https": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/https/-/https-1.0.0.tgz", + "integrity": "sha512-4EC57ddXrkaF0x83Oj8sM6SLQHAWXw90Skqu2M4AEWENZ3F02dFJE/GARA8igO79tcgYqGrD7ae4f5L3um2lgg==", + "license": "ISC" + }, + "node_modules/image-size": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/image-size/-/image-size-1.2.1.tgz", + "integrity": "sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw==", + "license": "MIT", + "dependencies": { + "queue": "6.0.2" + }, + "bin": { + "image-size": "bin/image-size.js" + }, + "engines": { + "node": ">=16.x" + } + }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/jszip": { + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.2.tgz", + "integrity": "sha512-3l+rb15IOWtUhU0H5MFqES/T6Kh7abYwjosBey/vD6hDt8zoEffkSC5Ws5SGtgVw3gBx2NEbhTeSW1+kWkpyTQ==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, + "node_modules/pptxgenjs": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pptxgenjs/-/pptxgenjs-4.0.1.tgz", + "integrity": "sha512-TeJISr8wouAuXw4C1F/mC33xbZs/FuEG6nH9FG1Zj+nuPcGMP5YRHl6X+j3HSUnS1f3at6k75ZZXPMZlA5Lj9A==", + "license": "MIT", + "dependencies": { + "@types/node": "^22.8.1", + "https": "^1.0.0", + "image-size": "^1.2.1", + "jszip": "^3.10.1" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "node_modules/queue": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz", + "integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==", + "license": "MIT", + "dependencies": { + "inherits": "~2.0.3" + } + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + } + } +} diff --git a/docs/presentations/2026-10-17-skill-plugin-development/slides/package.json b/docs/presentations/2026-10-17-skill-plugin-development/slides/package.json new file mode 100644 index 000000000..24fc4a06d --- /dev/null +++ b/docs/presentations/2026-10-17-skill-plugin-development/slides/package.json @@ -0,0 +1,13 @@ +{ + "name": "slide-deck", + "version": "0.0.0", + "private": true, + "description": "pptxgenjs で pptx を作る生成スクリプト", + "scripts": { + "build": "node build-deck.js deck.pptx", + "time": "node lib/script-time.js ../script.md" + }, + "dependencies": { + "pptxgenjs": "4.0.1" + } +} diff --git a/docs/presentations/2026-10-17-skill-plugin-development/slides/theme.json b/docs/presentations/2026-10-17-skill-plugin-development/slides/theme.json new file mode 100644 index 000000000..040c1117d --- /dev/null +++ b/docs/presentations/2026-10-17-skill-plugin-development/slides/theme.json @@ -0,0 +1,3 @@ +{ + "copyright": "NDF — github.com/devbasex/ai-plugins" +} diff --git a/docs/presentations/README.md b/docs/presentations/README.md index b115f566f..33e1815d6 100644 --- a/docs/presentations/README.md +++ b/docs/presentations/README.md @@ -1,6 +1,7 @@ # プレゼンテーション資料 -発表用のスライド資料を Marp 形式で管理します。 +発表用のスライド資料を置きます。新しい資料は、NDF の `release` Skill の +[form-slide.md](../../plugins/ndf/skills/release/references/form-slide.md) の生成スクリプトの経路(pptxgenjs)で作ります。 公開先: **https://devbasex.github.io/ai-plugins/presentations/** @@ -25,27 +26,16 @@ ## 資料一覧 -| ファイル | 内容 | 想定時間 | +| 資料 | 内容 | 想定時間 | |---|---|---| +| [2026-10-17-skill-plugin-development/](2026-10-17-skill-plugin-development/README.md) | 独自プラグイン開発の薦め。NDF の機能を例に、自分用のスキルを作り、プラグインにして配り、育てることを薦める(社外の勉強会・登壇向け) | 30〜35分 / 21ページ | | [2026-08-06-ai-plugins-intro.md](2026-08-06-ai-plugins-intro.md) | ai-plugins / NDF v5.0.0 の個別機能紹介。各スキルに何が書いてあり、何を重視しているかを扱う | 15分 / 15枚 | -書き出し済みの成果物も同じディレクトリに置いています。**Markdown を編集したら `build.sh` で両方を再生成してコミットしてください。** +`2026-10-17-skill-plugin-development` の成果物は Google Slides で、pptx は生成スクリプトから作り直せるためコミットしません。 -| 成果物 | 用途 | 特徴 | -|---|---|---| -| [2026-08-06-ai-plugins-intro.pdf](2026-08-06-ai-plugins-intro.pdf) | 配布・共有ドライブへの掲載 | しおり付き。どこでも開ける。台本は出力されない | -| [2026-08-06-ai-plugins-intro.html](2026-08-06-ai-plugins-intro.html) | 発表本番 | 単一ファイル。**プレゼンタービューで台本が読める** | - -HTML は画像を data URI として埋め込み、絵文字も文字に戻してあるため、このファイル1つで完結します。外部への通信は発生しません。ブラウザで開いたときの操作は以下のとおりです。 - -| キー | 動作 | -|---|---| -| `→` / `←` / スペース | スライド送り・戻し | -| `f` | フルスクリーン | -| `p` | プレゼンタービュー(次スライド・台本・タイマー)を別ウィンドウで開く | -| `o` | 一覧表示 | - -各スライドの HTML コメントに発表台本と時間配分を書いており、プレゼンタービューに表示されます。 +`2026-08-06-ai-plugins-intro` は、当時の Marp(Markdown からスライドを作る変換器)で作った記録です。書き出した PDF と HTML +([pdf](2026-08-06-ai-plugins-intro.pdf) / [html](2026-08-06-ai-plugins-intro.html))を同じディレクトリに残しています。 +HTML は画像を data URI として埋め込んだ単一ファイルで、ブラウザで開いて `p` を押すと台本の読めるプレゼンタービューになります。 ## ディレクトリ構成 @@ -53,73 +43,24 @@ HTML は画像を data URI として埋め込み、絵文字も文字に戻し docs/presentations/ ├── README.md ├── index.html # 公開ページの入口(資料一覧) -├── 2026-08-06-ai-plugins-intro.md # スライド本体(Marp Markdown) -├── 2026-08-06-ai-plugins-intro.pdf # 配布用 PDF(build.sh が生成) -├── 2026-08-06-ai-plugins-intro.html # 発表用の単一ファイル HTML(build.sh が生成) -├── build.sh # PDF と HTML を書き出す -├── diagrams/ # 図版のソース(Mermaid) -│ ├── overview.mmd -│ ├── pr-flow.mmd -│ └── cross-review.mmd -└── images/ # diagrams/ から生成した PNG - ├── overview.png - ├── pr-flow.png - └── cross-review.png -``` - -図版は Mermaid で書き、PNG に変換したものをスライドから参照します。Marp は Mermaid を直接描画しないため、`.mmd` を編集したら PNG を再生成してください。 - -## ビルド - -### スライドを表示する - -VS Code の [Marp for VS Code](https://marketplace.visualstudio.com/items?itemName=marp-team.marp-vscode) 拡張を入れると、Markdown をプレビューするだけでスライドとして表示されます。 - -### PDF / HTML に書き出す - -```bash -bash docs/presentations/build.sh -``` - -PDF と、単一ファイル化した HTML の両方を書き出します。 - -`build.sh` が単一ファイル化まで行うのは、Marp の HTML 出力が `images/` を相対パスで参照し、絵文字を CDN 上の SVG に置き換えるためです。そのまま配ると画像が表示されず、オフラインでは絵文字も欠けます。`build.sh` は画像を data URI として埋め込み、絵文字を文字へ戻したうえで、外部アセット参照が残っていないことを検証します。 - -Chromium が必要です。見つからない場合は取得してからもう一度実行してください。 - -```bash -npx playwright@1.49 install chromium -``` - -台本も配る場合は、`--pdf-notes` を付けると各スライドの HTML コメントが PDF の注釈として埋め込まれます。 - -```bash -npx @marp-team/marp-cli@4 --pdf --pdf-notes --allow-local-files 2026-08-06-ai-plugins-intro.md +├── 2026-10-17-skill-plugin-development/ +│ ├── README.md # 成果物・トーン&マナー・生成と確認の手順 +│ ├── notes.md # 構成メモ(ページごとの見出し・役割・根拠と尺) +│ ├── script.md # 台本(スピーカーノートの正本) +│ └── slides/ # 生成スクリプト・theme.json・写した枠組み(lib/) +├── 2026-08-06-ai-plugins-intro.md # 2026-08-06 の資料(記録) +├── 2026-08-06-ai-plugins-intro.pdf +├── 2026-08-06-ai-plugins-intro.html +├── diagrams/ # 2026-08-06 の資料の図版のソース(Mermaid) +└── images/ # diagrams/ から作った PNG ``` -### 図版を再生成する - -```bash -cd docs/presentations - -cat > /tmp/puppeteer.json <<'EOF' -{"executablePath":"/home/user/.cache/ms-playwright/chromium-1148/chrome-linux/chrome", - "args":["--no-sandbox","--disable-dev-shm-usage"]} -EOF - -for d in overview pr-flow cross-review; do - npx @mermaid-js/mermaid-cli@11 \ - -i "diagrams/$d.mmd" -o "images/$d.png" \ - -p /tmp/puppeteer.json -b transparent -s 3 -done -``` - -`executablePath` は自分の環境の Chromium パスに置き換えてください。`-s 3` は 3 倍解像度でのレンダリング指定で、スライドに拡大表示しても文字が潰れないようにするためのものです。 - ## 新しい資料を追加するとき -- ファイル名は `YYYY-MM-DD-{topic}.md` とし、日本語は含めない -- 図版は Mermaid で書いて `diagrams/` にソースを残す。PNG だけをコミットしない -- 発表台本は各スライド末尾の HTML コメントに書く -- 書き出しは `build.sh` を使う(引数にスライドの Markdown を渡せる) -- 公開ページに載せる場合は `index.html` の資料一覧にも追加する +- `YYYY-MM-DD-{topic}/` のディレクトリを作り、日本語は含めない +- 中身は `form-slide.md` の「生成スクリプトの経路」の並び(README・構成メモ・台本・`slides/`)にする。汎用テンプレートは `plugins/ndf/skills/release/assets/slides/` から写す +- 生成は `node slides/build-deck.js <出力>.pptx`、尺は `node slides/lib/script-time.js script.md` で測る +- Google Slides への変換とページごとのサムネイルでの確認は `form-slide.md` の「変換と確認」の順に行う +- `slides/lib/` は書き換えない。トーン&マナーは `slides/theme.json` に書く +- 生成した pptx と `node_modules/` はコミットしない(`.gitignore`) +- 資料の一覧(この README の表)へ足す。公開ページに載せる場合は `index.html` の資料一覧にも追加する diff --git a/docs/presentations/build.sh b/docs/presentations/build.sh deleted file mode 100755 index bf9ea020f..000000000 --- a/docs/presentations/build.sh +++ /dev/null @@ -1,65 +0,0 @@ -#!/usr/bin/env bash -# スライドを PDF と単一ファイル HTML に書き出す。 -# Usage: bash docs/presentations/build.sh [SLIDE.md] -set -euo pipefail - -cd "$(dirname "$0")" -SRC="${1:-2026-08-06-ai-plugins-intro.md}" -BASE="${SRC%.md}" -MARP="npx -y @marp-team/marp-cli@4" - -command -v npx >/dev/null 2>&1 || { echo "ERROR: npx が必要です" >&2; exit 1; } - -if [ -z "${CHROME_PATH:-}" ]; then - for candidate in "$HOME"/.cache/ms-playwright/chromium-*/chrome-linux/chrome \ - /Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome; do - [ -x "$candidate" ] && { export CHROME_PATH="$candidate"; break; } - done -fi -if [ -z "${CHROME_PATH:-}" ]; then - echo "ERROR: Chromium が見つかりません。以下で取得して CHROME_PATH を設定してください:" >&2 - echo " npx playwright@1.49 install chromium" >&2 - exit 1 -fi - -echo "==> PDF: $BASE.pdf" -$MARP --pdf --pdf-outlines --allow-local-files -o "$BASE.pdf" "$SRC" - -echo "==> HTML: $BASE.html" -$MARP --html --allow-local-files -o "$BASE.html" "$SRC" - -# Marp の HTML 出力は画像を相対パスで参照し、絵文字を CDN の SVG に置き換える。 -# 単体で配布・閲覧できるように、画像を data URI へ埋め込み、絵文字は文字に戻す。 -echo "==> HTML を単一ファイル化" -python3 - "$BASE.html" <<'PY' -import base64, mimetypes, pathlib, re, sys - -path = pathlib.Path(sys.argv[1]) -html = path.read_text(encoding="utf-8") - -def embed(match): - src = match.group(1) - asset = path.parent / src - if not asset.is_file(): - sys.exit(f"ERROR: 画像が見つかりません: {asset}") - mime = mimetypes.guess_type(asset.name)[0] or "application/octet-stream" - data = base64.b64encode(asset.read_bytes()).decode("ascii") - return match.group(0).replace(src, f"data:{mime};base64,{data}") - -html, images = re.subn(r']*src="((?!data:|https?:)[^"]+)"[^>]*>', embed, html) -html, emojis = re.subn(r']*data-marp-twemoji[^>]*alt="([^"]*)"[^>]*/?>', - lambda m: m.group(1), html) -html, emojis2 = re.subn(r']*alt="([^"]*)"[^>]*data-marp-twemoji[^>]*/?>', - lambda m: m.group(1), html) - -# 本文中のリンク () は残ってよい。表示に必要な外部アセットだけを検出する。 -assets = re.findall(r'\bsrc="(https?://[^"]+)"', html) -assets += re.findall(r']*href="(https?://[^"]+)"', html) -if assets: - sys.exit("ERROR: 外部アセット参照が残っています: " + ", ".join(sorted(set(assets)))) - -path.write_text(html, encoding="utf-8") -print(f" 画像 {images} 件を埋め込み、絵文字 {emojis + emojis2} 件を文字に戻しました") -PY - -echo "完了: $BASE.pdf / $BASE.html" diff --git a/docs/specifications/ndf-documentation-mode.md b/docs/specifications/ndf-documentation-mode.md index 7e7d15187..9166b084a 100644 --- a/docs/specifications/ndf-documentation-mode.md +++ b/docs/specifications/ndf-documentation-mode.md @@ -36,7 +36,7 @@ | 企画承認 | 構成案と体裁設計を載せた設計 Pull Request のマージ | | 制作物承認 | `production` が真の提出先への操作 | | 下書き先 | `production` が偽の提出先。承認の前に書き込んでよい唯一の場所 | -| 決定的な経路 | 変換器を使う生成。同じ入力から同じ生成物が出る | +| 決定的な経路 | 変換器を使う生成と、生成スクリプト(スライドは pptxgenjs)を書く生成。同じ入力から同じ生成物が出る | | 非決定的な経路 | AI エージェントが組む生成。同じ入力から同じ生成物が出ない | | 実績 / 見込み / 概算 | 数値に付ける区別の語。実績にだけ出所を必須にする | @@ -102,6 +102,10 @@ 変換器と AI エージェントのどちらが良いかは、出力の形と求める見栄えで変わる。**選んだ経路が 「生成物を保存するか」の答えを決める**(非決定的な経路では、ソースから復元できないため保存する)。 +スライドだけは例外で、経路は「生成スクリプトを書く」の 1 つである。変換器(Marp)で作ったスライドは +変換の後に文字と図形を直せず、AI エージェントが組む経路は同じ台本から同じ資料を出さないためである。 +生成スクリプトは決定的なため、生成物は保存しない。 + ### 出所は本文の節へ置き、無い値を拒否しない frontmatter は生成の経路で落ち、別ファイルは生成物に含まれない。**読み手が確かめられなければ、 diff --git a/docs/specifications/ndf-google-workspace-gws.md b/docs/specifications/ndf-google-workspace-gws.md index 7e0350972..d199799b6 100644 --- a/docs/specifications/ndf-google-workspace-gws.md +++ b/docs/specifications/ndf-google-workspace-gws.md @@ -25,7 +25,8 @@ | --- | --- | | 3 段の流れ(確かめる → 同意のうえ入れる → 認証を案内する)と、終了コードごとの次の手 | [`google-workspace` の SKILL.md](../../plugins/ndf/skills/google-workspace/SKILL.md) の「流れ」 | | 同意の取り方(明示の依頼を同意とみなす・暗黙の依頼では待つ・人がいない起動で止まる) | 同上の「流れ」の 2. | -| Drive の操作の対応表(エクスポート・ダウンロード・アップロード・フォルダの共有範囲の確認・公開リンクの付与)と、エクスポートの MIME | 同上の「操作の対応表」 | +| Drive の操作の対応表(エクスポート・ダウンロード・アップロード・フォルダの共有範囲の確認・公開リンクの付与・pptx の Google Slides への変換と同じ URL のままの差し替え)と、エクスポートの MIME | 同上の「操作の対応表」 | +| Google Slides のページの ID とサムネイルの取得(サムネイルはスクリプト `slides-thumbnails.py`。契約は #2065 の設計の「入出力の契約」) | 同上の「操作の対応表」と [`issues/issue-2065-design.md`](../../issues/issue-2065-design.md) | | Drive の素材の取り込みと PDF の取得の経路 | `document-systems` の [`references/system-gdrive.md`](../../plugins/ndf/skills/document-systems/references/system-gdrive.md) | | playwright-kit の利用者向けの移行の案内 | [`plugins/playwright-kit/README.md`](../../plugins/playwright-kit/README.md) と根の `CHANGELOG.md` の playwright-kit 3.0.0 | @@ -41,7 +42,7 @@ | 扱う | 扱わない | | --- | --- | -| `google-workspace` の Skill と確認のスクリプト `gws-check.py` | gws の Drive 以外の操作の手順(Sheets の編集・Gmail・Calendar など。Skill は `gws <サービス> --help` を案内するだけ) | +| `google-workspace` の Skill と確認のスクリプト `gws-check.py` | gws の Drive と Google Slides のページの ID・サムネイル以外の操作の手順(Sheets の編集・Gmail・Calendar など。Skill は `gws <サービス> --help` を案内するだけ) | | `google-auth` と `google-drive` を配布から外したこと(manifests 4 つ・`plugin.json` 2 つ・Skill 間の参照の例外) | playwright-kit の Drive 連携を gws へ移すこと(#745 は not planned) | | `document-systems` と `document-sources` の Drive の経路を gws へ向けたこと | gws の版の固定と、gws の不具合の回避策 | | playwright-kit から Drive への保管を外したこと | 旧 Skill のトークンを gws へ移すこと(利用者は `gws auth login` で認証し直す) | diff --git a/docs/versioning-and-distribution.md b/docs/versioning-and-distribution.md index fd428ea41..2704ba6ee 100644 --- a/docs/versioning-and-distribution.md +++ b/docs/versioning-and-distribution.md @@ -57,15 +57,15 @@ semver の順序で除外されるのは、プラグイン間の依存解決(` | 版 | 形 | 意味 | | --- | --- | --- | -| 正式版 | `10.18.0` | 利用者が常用してよい | -| 開発版 | `10.19.0-dev.1` | 検証中。入れたくない利用者は取得を控えられる | -| 公開前の確認版 | `10.19.0-rc.1` | 正式版の候補。残るのは確認だけ | +| 正式版 | `10.19.0` | 利用者が常用してよい | +| 開発版 | `10.20.0-dev.1` | 検証中。入れたくない利用者は取得を控えられる | +| 公開前の確認版 | `10.20.0-rc.1` | 正式版の候補。残るのは確認だけ | -- 接尾辞は**次に出す正式版の版数へ付ける**。`10.18.0` の次を開発するなら `10.19.0-dev.1` +- 接尾辞は**次に出す正式版の版数へ付ける**。`10.19.0` の次を開発するなら `10.20.0-dev.1` - 連番は開発版を出すたびに増やす。**同じ版数で中身を差し替えない**。差し替えると、利用者の 手元にある版と `main` の版が同じ番号で別物になり、何を確かめたのかが分からなくなる -- **正式版を出すときは接尾辞を外す。** `10.19.0-dev.3` の次は `10.19.0` -- 順序は semver に従い `10.19.0-dev.1` < `10.19.0-rc.1` < `10.19.0` になる +- **正式版を出すときは接尾辞を外す。** `10.20.0-dev.3` の次は `10.20.0` +- 順序は semver に従い `10.20.0-dev.1` < `10.20.0-rc.1` < `10.20.0` になる ## ランタイムごとの取得と導入 diff --git a/issues/issue-2065-design-decisions.md b/issues/issue-2065-design-decisions.md new file mode 100644 index 000000000..b64096d71 --- /dev/null +++ b/issues/issue-2065-design-decisions.md @@ -0,0 +1,163 @@ +# #2065 の設計: 決定の記録 + +設計文書は [issue-2065-design.md](issue-2065-design.md) にある。 + +## 決定の記録 + +### 決定 1: Skill の数を増やさないため、既存の 4 つの規約と google-workspace へ足す + +利用者は新しい Skill の前に既存の規約への統合を優先すると決めた。体裁設計(`layout-slide.md`)・生成(`form-slide.md`)・ +変換と描画(`system-gdrive.md` と `google-workspace`)・体裁レビュー(`layout-review`)の分担は今のまま成り立ち、 +足すものはどれかの持ち場に収まる。gws のコマンドの正本は既に `google-workspace` の対応表にあるため、変換と +サムネイルの行もそこへ足す。 + +新しい Skill(例: スライドの作成)を作る案は、同じ手順の持ち場が 2 つになり、既存の 4 つの規約から辿る経路と +重なるため採らない。 + +根拠: Value 6・Value 7(MVV 版 2) + +### 決定 2: すべてのランタイムで同じテンプレートを使うため、汎用テンプレートを release Skill の assets に置く + +Skill のディレクトリは 4 ランタイムへディレクトリごと配られる(Kiro はリンク、agy はリンク、Codex と Claude Code は +プラグインのディレクトリ)。生成の手順を持つ `release` の配下に置けば、手順とテンプレートが同じ版で届く。 +配布先のリポジトリはそこから資料のディレクトリへ写す。 + +`docs/` に置く案は、配布物に含まれず配布先のリポジトリから辿れないため採らない。新しい Skill の配下に置く案は決定 1 で採らない。 + +根拠: Value 5(MVV 版 2) + +### 決定 3: 生成スクリプトの経路を規約で扱えるようにするため、スライドの生成の経路に「生成スクリプトを書く」を決定的な経路として足す + +生成スクリプトは AI が書くが、同じスクリプトと台本と `theme.json` からは同じ pptx が出る。決定的なため、生成物は +保存しなくてよく、正本は生成スクリプトと台本になる。今の「変換器を使う」は正本の Markdown を前提にし、「AI エージェントが +組む」は非決定的を前提にするため、どちらにも入らない。足す先はスライドだけにし、文書・表計算・ページの形の経路は +変えない。 + +「変換器を使う」に含める案は、起点(正本の Markdown の直前の版)と 1 ページの区切り(`##` の見出し)が成り立たない +ため採らない。 + +根拠: Value 6・Value 8(MVV 版 2) + +### 決定 4: 枠組みの更新でプロジェクトの値を失わないため、トーン&マナーは theme.json で上書きし、枠組みは写して書き換えない + +既定値は枠組みのファイルが持ち、プロジェクトは変えたい項目だけを `theme.json` に書く。枠組みの更新は `lib/` を写し直す +だけで、`theme.json` には触れない。座標は枠組みの定数に固定し、トーン&マナーに含めない(I1)。 + +手本のように生成スクリプトの先頭の定数を書き換える案は、枠組みの更新を取り込むたびに書き換えが上書きされるため採らない。 +環境変数か引数で値を渡す案は、資料ごとの値がリポジトリに残らず、同じ資料を作り直せないため採らない。 + +根拠: Value 5(MVV 版 2) + +### 決定 5: 会社色を持たず配布先で同じ見え方にするため、既定の書体を Noto Sans JP、強調の色を 2B6CB0 にし、著作権表示とロゴは既定で描かない + +Noto Sans JP は Google Slides の書体の一覧にあり、変換の後も代替が起きにくい。作業の端末の描画(Noto Sans CJK JP)とも +字形が揃う。強調の色は 1 色に絞る(`layout-slide.md`)。著作権表示とロゴは持ち主によって違うため、既定では描かず、 +`theme.json` で足す。 + +手本の書体と色を既定にする案は会社色になるため採らない。Windows の既定の書体(BIZ UDP ゴシックなど)は Google Slides と +macOS で揃わないため採らない。 + +根拠: Mission・Value 5(MVV 版 2) + +### 決定 6: スピーカーノートを台本と食い違わせないため、枠組みが台本を読んでページ番号でノートを付ける + +生成スクリプトに読み上げを写すと、台本を直したときに片方だけが古くなる。枠組みが台本を読み、ページを作るたびに同じ番号の +読み上げをノートへ付け、保存の前にページ数を照合する(I4・I5)。台本は Markdown の 1 ファイルにし、区切りは台本の +第 7 稿の形(`## <番号>. <見出し>` と `>` の読み上げ)に合わせる。 + +生成スクリプトの各ページで `addNotes` に文を書く案(手本の形)は、台本と 2 か所に同じ文を持つため採らない。 + +根拠: Value 7(MVV 版 2) + +### 決定 7: 台本の読み取りを 1 つにするため、尺の計算を枠組みと同じ JavaScript で持つ + +尺の計算は台本の読み上げを数えるため、ノートの付与と同じ読み取りを使う。Python で別に書くと、同じ形を読む関数が 2 つの言語に +分かれ、片方だけが直る。計算は字数を数えて割るだけの決まった手順なので、スクリプトにする。 + +手本の 1 行の Python を README に書く案は、手順を文書に置き、読み取りを 2 つにするため採らない。 + +根拠: Value 4・Value 6(MVV 版 2) + +### 決定 8: 全ページを決まった手順で見られるようにするため、サムネイルの取得をスクリプトにし、変換と差し替えは対応表のコマンドで打つ + +サムネイルはページの ID を取り、ページごとに API を呼び、返った一時的な URL から画像を取る。ページ数だけ同じ手順を繰り返す +ため、`google-workspace` のスクリプトにする。変換(上げて複製)と差し替えは 1〜2 回打つだけで、アップロード先の共有範囲を +確かめる判断を挟むため、対応表のコマンドのまま AI が打つ。 + +PDF に書き出してから画像にする今の経路は、Google Slides の描画をそのまま見られるサムネイルより手順が多いため、 +Google Slides では採らない(Google ドキュメントには残す)。 + +根拠: Value 4(MVV 版 2) + +### 決定 9: 発表資料を発表日時点の記録にするため、資料は枠組みを写して持ち、汎用テンプレートを直接読まない + +`docs/presentations/` の資料は発表日時点の記録で、以後の構成の変更に追随しない(`AGENTS.md` の「ドキュメント」の表)。 +資料が `plugins/` の枠組みを直接読むと、枠組みを変えたときに過去の資料の生成結果が変わる。写して持つと、配布先と同じ手順を +このリポジトリでも通すことになる。 + +リポジトリの中の相対パスで汎用テンプレートを読む案は、写しを持たずに済むが、記録が後から変わるため採らない。 + +根拠: Value 7・Value 9(MVV 版 2) + +### 決定 10: 成果物を 1 つにするため、pptx をコミットせず Google Slides を成果物にし、公開ページの入口に足さない + +生成は決定的なため pptx は作り直せる(決定 3)。発表に使うのは利用者の Drive の Google Slides で、共有の設定はこの課題の +範囲外である。Pages で公開できる書き出し(PDF・HTML)を持たないため、`index.html` には足さず、 +`docs/presentations/README.md` の資料一覧にだけ足す。 + +pptx か PDF をコミットして `index.html` から開ける案は、社外へ出す前の資料を公開の場所へ置くことになり、公開の判断 +(C5 に準じる)を先取りするため採らない。 + +根拠: Value 1・C5(MVV 版 2) + +### 決定 11: 生成のテストが CI で読み飛ばされないよう、pytest のジョブに Node.js 22 と npm ci を足す + +生成のテストは pptxgenjs を要る。前提のコマンドが無いとき既定で失敗する根の `conftest.py` の仕組みへ `node` を足し、 +CI の pytest のジョブで `actions/setup-node` の 22 と、汎用テンプレートと発表資料の `npm ci` を打つ。生成した pptx は +Python の標準ライブラリ(zip と XML)で検査する。 + +生成のテストを手元だけで回す案は、CI が版面と差し替えの壊れを見逃すため採らない。 + +根拠: Value 3(MVV 版 2) + +### 決定 12: 変換後のスライドを編集できるようにするため、Marp(変換器を使う経路)をスライドの規約とリポジトリから外す + +利用者の決定(2026-10-18)。Marp で変換したスライドは、Google Slides や PowerPoint で開いても文字と図形を直せない。 +発表の直前に 1 文字を直すにも変換の手順を通し直すことになり、資料として致命的である。スライドの規約(`form-slide.md`・ +`layout-slide.md`・`layout-common.md`・`distribution-forms.md`)から変換器の経路を外し、`docs/presentations/README.md`・ +`build.sh`・`AGENTS.md` の Marp の手順を生成スクリプトの方式へ書き換える。`build.sh` は Marp だけを打つため消す。 +2026-08-06 の資料の md・html・pdf は発表日時点の記録として残し、README にはそれが Marp で作った記録であることだけを書く。 + +Marp を残して経路の 1 つにする案は、編集できない成果物を既定の候補に残すため採らない。 + +根拠: Value 1・Value 6(MVV 版 2) + +### 決定 13: スライドの生成の経路を 1 つにするため、python-pptx と「AI エージェントが組む」をスライドから外し、生成スクリプト(pptxgenjs)だけにする + +利用者の決定(2026-10-18)。python-pptx は pptxgenjs に比べて利点が無く、「AI エージェントが組む」は同じ台本から同じ資料が +出ない。スライドの生成の経路は「生成スクリプトを書く(pptxgenjs)」の 1 つにし、決定的なため生成物は保存しなくてよい。 +文書・表計算・ページの形の経路は変えない。`official-skills-autoloader` の対応表は、スライドを新しく作る依頼を `release` の +`form-slide.md` へ回し、公式の `pptx` Skill は既存の .pptx を読む・直す依頼だけに使う。 + +**決定 3 を置き換える。** 決定 3 は今の 2 つの経路に「生成スクリプトを書く」を足して 3 つにしたが、決定 12・13 で残りの +2 つを外し、生成スクリプトの経路だけが残る。設計文書の「構成要素」の値の集合の記述もこの決定に従って読む。 + +根拠: Value 1・Value 6・Value 8(MVV 版 2) + +### 決定 14: 構成を聞き手に見せるため、章扉を台本のページを消費しないページにし、ページの文言を台本の文から起こさない + +利用者の差し戻し(2026-10-18)。発表資料を Google Slides で見て「読みづらい。スライドらしい言葉遣いに改め、章扉を入れる」 +「(内部用語の見出しが)意味が分からない」と指摘した。原因は 2 つある。各ページの文言が台本の文を縮めただけで長く、 +聞き手の知らない内部の語が説明なしに残っていた。章扉が無く、構成が見えなかった。 + +- **章扉はスピーカーノートを持たず、台本の番号を進めない。** 枠組みの `section` は台本のページを消費せず、ページ数の + 照合(I5)からも外れる。ページ番号は章扉も数える。台本は話す内容の正本で、章扉に読み上げは無いため、台本に章扉の + 区切りを置くと、読み上げの無いページを台本に持つことになる +- **ページの文言は台本の文を縮めて載せない。** 1 ページ 1 メッセージにし、見出しはそのページの結論を短く、箱と表は + 短い句か体言止めにする。聞き手が知らない内部の語は言い換えるか、ページの中で一言で説明する。規約は体裁設計 + (`layout-slide.md`)と生成の手順(`form-slide.md`)に置き、体裁レビュー(`layout-review`)が聞き手の目で見る + +章扉を台本の区切りとして持ち、ノートを空にする案は、台本に読み上げの無いページが混ざり、尺の計算と台本の番号が +章扉の数だけずれるため採らない。 + +根拠: Value 6・Value 8(MVV 版 2) diff --git a/issues/issue-2065-design.md b/issues/issue-2065-design.md new file mode 100644 index 000000000..ca6ee7683 --- /dev/null +++ b/issues/issue-2065-design.md @@ -0,0 +1,455 @@ +# スライド: 作り方の手順が NDF に無く、AI が毎回その場で作り方を組み立てて手本と違う資料になる → 生成スクリプトの汎用テンプレート・構成メモと台本・gws での変換とサムネイル確認を既存の規約から辿れ、その手順で発表資料を作れる(#2065) + +## 目的 + +- **何が壊れているか**: NDF のスライドの規約(`layout-slide.md`・`form-slide.md`・`layout-review`・`system-gdrive.md`)は原則だけを持ち、生成スクリプト・ページの枠組み・トーン&マナー・構成メモ・台本・Google Slides への変換と確認の手順を持たない +- **誰が困るか**: スライドを頼む利用者。AI が毎回その場で作り方を組み立て(2026-10-17 は python-pptx の自作)、手本と違う作りになって差し戻される +- **直すと何が成り立つか**: AI は既存の規約から汎用テンプレートと手順へ辿り、同じ作り方でスライドを作れる。その手順で発表資料「独自スキルプラグイン開発の薦め」(23 ページ)を作って、手順が通ることを示す + +## 適用範囲 + +- **働く範囲**: 配布先のリポジトリでも働く。汎用テンプレートは `release` Skill の配下に置き、4 ランタイムへ Skill のディレクトリごと配る。発表資料はこのリポジトリの `docs/presentations/` だけに置く +- **プロジェクトごとに違うもの**: トーン&マナー(書体・色・文字サイズ・著作権表示・ロゴ)はプロジェクトのファイル(`slides/theme.json`)で受ける。出力先・台本のパス・尺の上限は引数で受ける +- **当たるモード**: `documentation`(出力の形がスライドのとき)。ほかのモードでもスライドを作る作業はこの規約を読む + +## あるべき姿の根拠 + +| 根拠 | 種類 | 何を示すか | +| --- | --- | --- | +| 「slide の作り方が違う。…を参考に。Skill 化した方が良いかも」(2026-10-17) | 利用者の指示の原文 | 手本の作り方(生成スクリプト方式)に揃える。python-pptx の自作を採らない | +| 「新しい Skill を作る前に既存のスライドの規約…への統合を優先する。トーン&マナーは会社色の無い汎用テンプレートを既定にし、版面と枠組みの関数(page/box/note/tbl/section)は…踏襲、プロジェクトごとに差し替えられるようにする」(2026-10-17) | 利用者の指示の原文 | Skill を増やさず既存の規約へ足す。枠組みの座標は手本の値、トーン&マナーは差し替えられる形 | +| 手本の 3 件(要求の「何をしたいか」の表)。2 件の生成スクリプトが同じ値の定数と同じ枠組みの関数を写して持ち、README に同じ gws の変換手順を書いている | 実測(2026-10-17 に読んだ) | 枠組みとトーン&マナーと変換手順は資料ごとに繰り返される。共通の部品にする価値がある | +| `gws drive files copy`(`mimeType` 指定)・`gws slides presentations get`・`gws slides presentations pages getThumbnail` を `--dry-run` で打ち、要求の形(`POST …/files//copy`・`GET …/presentations/`・`GET …/pages/<ページ>/thumbnail`)を確かめた(2026-10-17) | 実測 | 変換と確認の経路が gws の今の版で組める | +| `npm view pptxgenjs version license` → `4.0.1`・`MIT`(2026-10-17) | 実測 | 依存として宣言できる版と使用許諾 | + +要求と受け入れ条件は #2065 の本文にある(コピーは [issue-2065-requirements.md](issue-2065-requirements.md) )。この文書は「どう作るか」だけを扱う。 + +## ドメインモデル + +### コンテキスト + +| コンテキスト | 何の語が 1 つの意味に決まるか | +| --- | --- | +| スライドの作成(`ndf-slide`) | 汎用テンプレート・ページの枠組み・トーン&マナー・生成スクリプト・構成メモ・台本・尺 | +| Google Workspace(`ndf-google-workspace`) | gws の操作・ファイルの ID・変換・サムネイル | + +**スライドの作成が顧客、Google Workspace が供給者の関係(顧客 / 供給者)。** スライドの作成は、変換と +サムネイルの取得を `google-workspace` の操作の対応表とスクリプトに頼み、gws のコマンドを自分の規約に +写さない。供給者は顧客の要求(pptx の変換・同じ URL のままの差し替え・ページごとのサムネイル)を対応表へ足す。 + +### 集約 + +| 集約 | 持ち主(書き換えてよいもの) | 根 | エンティティ | 値オブジェクト | +| --- | --- | --- | --- | --- | +| 汎用テンプレート | NDF の配布物(`release/assets/slides/`)。プロジェクトは写すだけで書き換えない | ページの枠組み(`lib/deck-frame.js`) | — | 枠組みの座標・既定のトーン&マナー | +| 資料 | プロジェクトの資料のディレクトリ(生成の作業をする AI) | 生成スクリプト(`slides/build-deck.js`) | ページ | トーン&マナー(プロジェクトの値)・読み上げ・尺 | +| 変換したスライド | 利用者の Drive(gws を通して操作する) | Google Slides のファイル | ページ(`objectId`) | サムネイル | + +資料は汎用テンプレートを ID(写した時点の `lib/` の内容のハッシュ)で参照し、オブジェクトとして持たない。資料が持つ枠組みは写しであり、 +写した後に資料の側から書き換えない(I2)。 + +### 不変条件 + +| # | 集約 | 条件 | 破れたときの扱い | +| --- | --- | --- | --- | +| I1 | 汎用テンプレート | 枠組みの関数が置く見出し・罫線・ロゴ・著作権表示・ページ番号・注の座標は、トーン&マナーの値に依らず手本と同じ値で固定される | トーン&マナーの項目に座標を持たせない。座標は枠組みのファイルの定数だけが持つ | +| I2 | 資料 | トーン&マナーの差し替えは `slides/theme.json` だけで済み、枠組みのファイル(`slides/lib/`)を書き換えない。枠組みを写し直してもプロジェクトの値が残る | 枠組みを書き換えた資料は、汎用テンプレートの更新を取り込めない。書き換えかどうかは、資料の `slides/lib/` の内容のハッシュを、写した時点の `lib/` の内容のハッシュ(資料の `README.md` に書いた値)と比べて決め、一致しなければ書き換えとみなしてプロジェクトの値を `theme.json` へ移す。NDF の版は基準にしない(版はスプリント単位でしか上がらず、同じ版数のまま develop で `lib/` が変わるため、版では写した元の `lib/` を一意に指せない)。新しい汎用テンプレートの `lib/` を写し直すのは更新の取り込みであり、書き換えとはみなさない。取り込んだら `README.md` のハッシュを写し直した `lib/` の値へ書き換える | +| I3 | 資料 | 生成スクリプトと枠組みの関数の本体に、書体名と色の値を直接書かない。色は `theme.json` の項目名で指す | 直書きが残ると、差し替えた後も既定の値が pptx に残る。テストが落とす | +| I4 | 資料 | スピーカーノートは台本のファイルの読み上げから入れる。生成スクリプトに読み上げを写さない | 写すと台本と食い違う。枠組みが台本を読み込み、ページ番号で対応させる | +| I5 | 資料 | 生成した pptx のページ数から章扉を除いた数は、台本のページ数と等しい。章扉はスピーカーノートを持たず、台本の番号を進めない(決定 14) | 生成スクリプトが終了コード 1 で止まり、台本のページ数と、章扉を除いて作ったページ数と、章扉の数を出す | +| I6 | 資料 | 尺は台本の読み上げの字数を 350 字/分で割った値で、合計はページごとの値の和に等しい | 尺の上限(引数)を超えたら終了コード 1 を返し、構成メモで削るページを決める | +| I7 | 汎用テンプレート・資料 | 汎用テンプレートと資料に、手本の社名・案件名・コーポレートカラーの値・ロゴを入れない | 完了判定で差分の追加行を検索し、1 件でもあれば直す(受け入れ条件 13) | + +### ドメインイベント + +| # | イベント | 発生元 | 受け手 | +| --- | --- | --- | --- | +| E1 | 体裁設計で版面・トーン&マナー・ページの枠組みを決めた | 体裁設計(`design` の `layout-slide.md`) | 生成(`release` の `form-slide.md`) | +| E2 | 汎用テンプレートを写し、トーン&マナーをプロジェクトの値へ差し替えた | 生成 | 生成スクリプト(`theme.json` を読む) | +| E3 | 構成メモと台本を書き、尺を測った | 執筆(構成メモ・台本) | 生成スクリプト(台本を読む)・体裁設計(尺が超えたら削るページを決める) | +| E4 | 生成スクリプトで pptx を作った | 生成スクリプト | 変換(gws) | +| E5 | pptx を Drive へ上げ、Google Slides へ変換した(同じ URL のままの差し替えを含む) | 変換(`google-workspace`) | サムネイルの取得 | +| E6 | ページごとのサムネイルを取り、体裁レビューの記録を残した | 体裁レビュー(`layout-review`) | 差し戻し先(体裁設計・生成・執筆)か、コミット | +| E7 | 生成スクリプト・構成メモ・台本・README をコミットした | 生成の作業をする AI | Pull Request | + +### 用語 + +| 用語 | 意味 | 用語集への反映 | +| --- | --- | --- | +| トーン&マナーのファイル | プロジェクトのトーン&マナーの値を持つ JSON(`slides/theme.json`)。書いた項目だけが汎用テンプレートの既定値を上書きする | 追加(`ndf-slide`) | +| 尺 | 台本の読み上げにかかる時間。読み上げの字数を 350 字/分で割って出す | 追加(`ndf-slide`) | +| 台本 | ページごとの見出しと読み上げの原稿。配分秒数は見出しに付けてよい(任意)。スピーカーノートへ入れる | 意味の変更(`ndf-slide`。配分秒数を任意にした) | + +## 機能一覧 + +| # | 機能 | 誰が使うか | +| --- | --- | --- | +| F1 | 規約から汎用テンプレートと手順へ辿る(体裁設計 → 生成 → 変換 → 体裁レビュー) | スライドを作る AI | +| F2 | 汎用テンプレートを写して生成スクリプトを用意し、表紙・章扉・本編のページを作る | スライドを作る AI | +| F3 | トーン&マナーをプロジェクトの値へ差し替える | スライドを作る AI・プロジェクトの持ち主 | +| F4 | 構成メモと台本を規約の形で書き、台本からスピーカーノートを入れる | スライドを作る AI | +| F5 | 台本の字数と尺をページごとと合計で出し、上限を超えたら知らせる | スライドを作る AI・発表者 | +| F6 | pptx を Google Slides へ変換する(新しく作る・同じ URL のまま差し替える) | スライドを作る AI | +| F7 | Google Slides のページごとのサムネイルを取り、体裁レビューで見る | 体裁レビューをする AI | +| F8 | 発表資料「独自スキルプラグイン開発の薦め」を F1〜F7 の手順で作る | 発表者(利用者) | + +## 構成要素 + +**値の集合(スライドの生成の経路)を 1 つの値に置き換える。** 今の経路は「変換器を使う」「AI エージェントが組む」の 2 つで、 +これを「生成スクリプトを書く」の 1 つにする(決定 12・13。決定 3 を置き換えた)。集合を前提にした規則を、集合の名前と値の名前での検索(`生成の経路`・ +`AI エージェントが組む`・`変換器を使う`・`form-slide`・`layout-slide`)と、新しい経路が通る手順(体裁設計 → 生成 → +取り込み → 体裁レビュー)を読んで集めた。当てはまらない規則は下の表へ載せる。 + +| 要素 | 責務 | 変更の種類 | +| --- | --- | --- | +| 体裁設計の共通規則(`design/references/layout-common.md`) | 生成の経路ごとの渡し方の表に「生成スクリプトを書く → トーン&マナーのファイルとページの枠組み」の行を足す | 変更(経路の集合の規則) | +| スライドの体裁設計(`design/references/layout-slide.md`) | 決める項目の「生成の経路」に生成スクリプトを足し、「トーン&マナー(著作権表示・ロゴ)」「ページの枠組み」の行を足す。生成スクリプトの経路では決めた値を `theme.json` へ書くと示し、`form-slide.md` へ辿らせる | 変更(経路の集合の規則) | +| スライドの生成(`release/references/form-slide.md`) | 経路の表へ「生成スクリプトを書く(決定的)」を足し、起点(生成スクリプトの直前の版)と 1 ページの区切り(台本の `## <番号>.`)を経路ごとに書き分ける。新しい節「生成スクリプトの経路」に資料のファイルの並び・テンプレートの写し方・トーン&マナーの差し替え・構成メモの形・台本の形と尺・生成・変換と確認の順を書く | 変更(経路の集合の規則)+追加 | +| 配布の形の一覧(`release/references/distribution-forms.md`) | 「生成の経路は 2 つ」の行に、スライドは生成スクリプトの経路も持つ(`form-slide.md`)と足す | 変更(経路の集合の規則) | +| 汎用テンプレートの枠組み(`release/assets/slides/lib/deck-frame.js`) | 枠組みの関数(`cover`・`page`・`section`・`box`・`note`・`tbl`・`callout`、表のセル `H`・`C`・`B`)・座標の定数・既定のトーン&マナー・`theme.json` の重ね合わせ・台本の読み込みとページ番号でのノートの付与・ページ数の照合・保存 | 新設 | +| 尺の計算(`release/assets/slides/lib/script-time.js`) | 台本を `deck-frame.js` の読み込みで読み、ページごとと合計の字数と尺を出す。上限を超えたら終了コード 1 | 新設 | +| 汎用テンプレートの見本(`release/assets/slides/build-deck.js` と `script.md`) | 表紙・章扉・本編(表と注を含む)の 3 ページを作る見本。写した後に資料の中身へ書き換える出発点 | 新設 | +| 依存の宣言(`release/assets/slides/package.json`・`package-lock.json`) | pptxgenjs の版を固定する。資料へ写して `npm ci` で入れる | 新設 | +| Google Drive の手順(`document-systems/references/system-gdrive.md`) | 投稿の手段に「pptx を上げて Google Slides へ変換する・同じ URL のまま差し替える」順を、描画して見る手段に「Google Slides はページごとのサムネイル」を書き、コマンドは `google-workspace` の対応表を指す | 変更 | +| gws の操作の対応表(`google-workspace/SKILL.md`) | pptx の変換(`files copy` と `mimeType`)・同じ URL のままの差し替え(`files update --upload`。pptx のあるディレクトリで打つ)・ページの ID の取得(`slides presentations get`)・サムネイルの取得(スクリプト)の行を足す | 変更 | +| サムネイルの取得(`google-workspace/scripts/slides-thumbnails.py`) | Google Slides の全ページの ID を取り、ページごとにサムネイルを取得して PNG を保存し、結果 JSON で保存先を返す | 新設 | +| 体裁レビュー(`layout-review/SKILL.md`) | 描画して見る手段の表の「Google スライド / ドキュメント」の行を分け、Google スライドはページごとのサムネイル(`system-gdrive.md`)とする | 変更 | +| 発表資料の置き場の説明(`docs/presentations/README.md`) | 資料一覧へ発表資料を足し、「新しい資料を追加するとき」に生成スクリプトの資料(ディレクトリの形)を足す | 変更 | +| 発表資料(`docs/presentations/<日付>-skill-plugin-development/`) | README・構成メモ・台本・`slides/`(生成スクリプト・トーン&マナーのファイル・写した枠組み・依存の宣言) | 新設 | +| テストと継続的統合(`release/tests/test_slide_template.py`・`google-workspace/tests/test_slides_thumbnails.py`・`tests/test_presentation_decks.py`・`conftest.py`・`.github/workflows/pytest.yml`・`.gitignore`) | 生成を実行して pptx を検査する。CI に Node.js 22 と `npm ci` を足し、`node` を前提のコマンドに足す。`node_modules/` と生成した pptx を追跡から外す | 新設・変更 | + +**当てはまるため変えない規則。** `layout-review` の「既知の失敗のリストでは足りない」(描画して見る理由は経路に依らない)、 +`document-drafting` と `layout-common.md` の「版面・書体・色をソースへ埋めない」(生成スクリプトの経路では `theme.json` が +受け、I3 がこれを守る)、`layout-common.md` の「図表の種類は生成の経路が決める」(pptxgenjs は Mermaid を描かないため、 +図は図形か画像で組む)、`import.md` の「Markdown → 生成の手段が無い」(Markdown からの変換は今も無い)。 + +### 構成要素図 + +```mermaid +graph TD + subgraph 規約["既存の規約(足す)"] + LC[体裁設計の共通規則] + LS[スライドの体裁設計] + FS[スライドの生成] + DF[配布の形の一覧] + GD[Google Drive の手順] + LR[体裁レビュー] + GW[gws の操作の対応表] + end + subgraph テンプレート["汎用テンプレート(新設)"] + FR[枠組み deck-frame] + ST[尺の計算] + SB[見本 build-deck と台本] + PK[依存の宣言] + end + PR[発表資料の置き場の説明] + subgraph 資料["発表資料(新設)"] + DB[生成スクリプト] + TH[トーン&マナーのファイル] + CP[写した枠組み] + MM[構成メモ] + SC[台本] + end + TS[サムネイルの取得] + T[テストと継続的統合] + LC --> LS --> FS + DF --> FS + FS --> FR + FS --> GD --> GW --> TS + LR --> GD + FR -. 写す .-> CP + PK -. 写す .-> DB + SB --> FR + ST --> FR + DB --> CP + DB --> TH + CP --> SC + MM --> SC + PR --> DB + T --> FR + T --> TS + T --> DB +``` + +### 文脈 + +```mermaid +graph LR + 利用者 --> AI[スライドを作る AI] + AI --> 本体[NDF の規約と汎用テンプレート] + AI --> 資料[プロジェクトの資料] + 資料 --> npm[npm レジストリ pptxgenjs] + AI --> gws[gws] + gws --> Drive[利用者の Google Drive と Google Slides] + 台本の正本[台本の Google ドキュメント] --> AI +``` + +外部の系は npm レジストリ・gws・利用者の Drive・台本の Google ドキュメントで、どれもこの変更では変えない。 + +### 配置 + +```mermaid +graph TD + subgraph 作業の端末 + N[node と pptxgenjs] + P[python3 と gws] + F[作業ディレクトリの pptx と PNG] + end + subgraph Google + D[Drive の非公開の場所] + S[Slides API] + end + N -- pptx を書く --> F + P -- pptx を上げる・変換を頼む --> D + P -- ページの ID とサムネイルを頼む --> S + S -- 一時的な画像の URL --> P + P -- PNG を保存する --> F +``` + +境界をまたぐのは pptx・ページの ID・サムネイルの画像だけである。認証は gws が自分の設定ディレクトリに持ち、 +スクリプトはトークンを読まない(C1)。アップロード先はフォルダを指定しない(非公開)か、共有範囲を確かめた +利用者の既存の非公開フォルダに限る(要求の前提 4、`google-workspace` の手順)。 + +### 置き場所 + +```text +plugins/ndf/skills/ +├── design/references/ +│ ├── layout-common.md # 変更 +│ └── layout-slide.md # 変更 +├── release/ +│ ├── references/ +│ │ ├── distribution-forms.md # 変更 +│ │ └── form-slide.md # 変更 +│ ├── assets/slides/ # 新設(汎用テンプレート) +│ │ ├── build-deck.js +│ │ ├── script.md +│ │ ├── package.json +│ │ ├── package-lock.json +│ │ └── lib/ +│ │ ├── deck-frame.js +│ │ └── script-time.js +│ └── tests/test_slide_template.py # 新設 +├── document-systems/references/system-gdrive.md # 変更 +├── google-workspace/ +│ ├── SKILL.md # 変更 +│ ├── scripts/slides-thumbnails.py # 新設 +│ └── tests/test_slides_thumbnails.py # 新設 +└── layout-review/SKILL.md # 変更 + +docs/presentations/ +├── README.md # 変更 +└── <日付>-skill-plugin-development/ # 新設 + ├── README.md # 成果物・トーン&マナー・写した時点の lib/ の内容のハッシュ・生成と変換の手順・尺の出力 + ├── notes.md # 構成メモ(23 行) + ├── script.md # 台本(第 7 稿の写し) + └── slides/ + ├── build-deck.js + ├── theme.json + ├── package.json + ├── package-lock.json + └── lib/ # release/assets/slides/lib/ の写し + ├── deck-frame.js + └── script-time.js + +tests/test_presentation_decks.py # 新設 +conftest.py・.github/workflows/pytest.yml・.gitignore # 変更 +``` + +検査の手段: `claude plugin validate .`・`python3 scripts/check-skill-frontmatter.py`・`python3 plugins/ndf/scripts/doc-lint.py` +(受け入れ条件 15)と、下のテスト設計の pytest。 + +## 構造 + +`deck-frame.js` が公開する型と関数だけを描く。ページの中身(資料ごとの図形)は描かない。 + +```mermaid +classDiagram + class Theme { + font + colors 項目名と値 + sizes 項目名と pt + copyright + logo + } + class Frame { + <<定数>> + title 0.26 0.15 8.29 0.63 + rule 0.24 0.80 9.52 + logo 8.78 0.24 0.96 0.23 + copyright 0.26 5.18 + pageNo 9.27 5.18 右寄せ + note 0.26 4.88 9.5 0.28 + } + class Script { + pages 番号 見出し 読み上げ 配分秒数 + } + class Deck { + cover(title, sub, meta) + page(title, lead) Slide + section(title, sub) + box(slide, x, y, w, h, fill, line) + note(slide, text) + tbl(slide, rows, opt) + callout(slide, y, h, fill, head, body) + color(name) 値 + save(out) + } + Deck --> Theme : 既定値に theme.json を重ねる + Deck --> Frame : 座標を読む + Deck --> Script : ページ番号でノートを付ける +``` + +- `createDeck({ theme, script, title, author })` が `Deck` を返す。`theme` は `theme.json` のパスか値、`script` は台本のパス(省略するとノートを付けない) +- `fill` は `theme.colors` の項目名で受ける(I3)。項目名でない値を渡したら例外で止める +- 座標は `Frame` の定数だけが持ち、`Theme` は座標を持たない(I1) + +## 入出力の契約 + +### 体裁設計から生成へ渡す値 + +`layout-common.md` の「決めた値の書き先」のとおり、決めた値は設計文書の入出力の契約に書き、生成スクリプトの経路では +`theme.json` へ写す。 + +### トーン&マナーのファイル(`slides/theme.json`) + +書いた項目だけが既定値を上書きする。`colors` と `sizes` は項目ごとに重ね、ほかは値ごと置き換える。知らない項目名は +終了コード 1 で止める(書き損じが既定値のまま通らないようにする)。 + +| 項目 | 型 | 既定値 | 意味 | +| --- | --- | --- | --- | +| `font` | 文字列 | `Noto Sans JP` | 見出しと本文の書体 | +| `colors.accent` | 6 桁の 16 進 | `2B6CB0` | 強調の 1 色(表紙と章扉の帯・アクセントの線) | +| `colors.text` / `colors.ink` / `colors.mute` | 同上 | `000000` / `303030` / `7F7F7F` | 見出しの文字 / 本文の文字 / 注と著作権表示 | +| `colors.rule` | 同上 | `D9D9D9` | 罫線と表の枠 | +| `colors.panel1` / `colors.panel2` | 同上 | `F3F3F3` / `EEEEEE` | 区画の地 | +| `colors.warn` / `colors.caution` | 同上 | `F4CCCC` / `FFF2CC` | 注意を促す地(増加・条件) | +| `colors.header` | 同上 | `303030` | 表の見出し行の地(文字は白) | +| `sizes.title` / `sizes.lead` / `sizes.body` / `sizes.table` / `sizes.note` / `sizes.footer` | pt | `20` / `12` / `10` / `10` / `8` / `9` | 見出し / リード / 本文 / 表 / 注 / 著作権表示とページ番号 | +| `sizes.sectionTitle` / `sizes.sectionSub` / `sizes.coverTitle` / `sizes.coverSub` | pt | `32` / `14` / `28` / `16` | 章扉と表紙 | +| `copyright` | 文字列 | `""` | 著作権表示。空なら描かない | +| `logo` | 文字列か `null` | `null` | ロゴの PNG の、`theme.json` からの相対パス。`null` なら描かない | + +既定値の正本は `deck-frame.js` の既定のトーン&マナーの定数で、規約は項目の意味だけを書き、値を写さない。 + +### 台本(`script.md`) + +| 規則 | 内容 | +| --- | --- | +| ページの区切り | `## <番号>. <見出し>` の行。番号は 1 から欠けずに続く。見出しの末尾に `(<秒>秒)` を付けると配分秒数になる | +| 読み上げ | 区切りの中の `>` で始まる行。`>` だけの行は段落の区切り。スピーカーノートは読み上げの段落を改行でつないだ文 | +| ほかの行 | ページに載せる要素の覚え書き。ノートにも字数にも入れない | +| 最初の区切りより前 | 題・想定・構成の表。読まない | +| 章扉 | 台本に区切りを置かない。生成スクリプトの `section` が作り、ノートを持たず、台本の番号を進めない(決定 14) | +| 失敗 | 区切りが 1 つも無い・番号が欠ける・重なる → 終了コード 2 と、どの番号かを出す | + +### 構成メモ(`notes.md`) + +`## 構成` の節に、構成の考え方(何から逆算し、何を載せず、どこへ回したか)と、次の表を置く。表の行数はページ数と等しい。 + +```markdown +| # | 見出し | 役割 | 根拠 | +| ---: | --- | --- | --- | +| 1 | 表紙 | — | — | +| 2 | <台本の見出し> | <このページで聞き手に何を渡すか。載せる図・表・箇条書き> | <数値と主張の出所> | +``` + +ページに収まらない読み上げの削り方と、尺を超えたときに削るページは同じ文書の節に書く(要求の前提 3)。 + +### コマンド + +| コマンド | 入力 | 出力 | 終了コード | +| --- | --- | --- | --- | +| `node slides/build-deck.js <出力>.pptx []` | 出力のパス。トーン&マナーのファイル(省略時は `slides/theme.json`、無ければ既定値) | pptx | 0 成功 / 1 引数・`theme.json`・ページ数の照合(I5)の失敗 / 2 台本の形の失敗 | +| `node slides/lib/script-time.js <台本> [--wpm 350] [--limit <分>] [--json]` | 台本のパス | 既定は Markdown の表(ページごとの番号・見出し・字数・尺の秒・配分秒数と合計の行)。`--json` は同じ値の JSON | 0 / 1 合計が上限を超えた / 2 台本の形の失敗 | +| `python3 "$SKILL_DIR/scripts/slides-thumbnails.py" <出力のディレクトリ> [--size LARGE]` | Google Slides の ID | `p<番号>.png` をページ順に保存。結果 JSON(`tool`・`status`・`summary`・`items` にページごとの `objectId` とパス・`metrics.pages`) | 0 / 1 gws の失敗(gws の出力を `items` に載せる)/ 2 引数の失敗 / 11 gws が未認証(`gws-check.py` と同じ値) | + +gws の変換と差し替えのコマンドは `google-workspace` の対応表に置く(形は手本の README と同じ。`files update` の +`--upload` は作業ディレクトリの外のファイルを受けないため、pptx のあるディレクトリで打つ)。 + +## 処理の流れ + +```mermaid +sequenceDiagram + participant AI as スライドを作る AI + participant R as 規約(layout-slide → form-slide) + participant T as 汎用テンプレート + participant D as 資料のディレクトリ + participant G as gws と Google + participant L as 体裁レビュー + AI->>R: 体裁設計の項目を決める(E1) + AI->>T: build-deck.js・lib/・package*.json を写す + AI->>D: theme.json を書き、npm ci(E2) + AI->>D: 構成メモと台本を書く + AI->>D: script-time.js で尺を測る(E3) + alt 尺が上限を超える + AI->>D: 構成メモで削るページを決めて台本を直す + end + AI->>D: node build-deck.js で pptx(E4) + AI->>G: 上げて変換、または同じ URL のまま差し替え(E5) + AI->>G: slides-thumbnails.py でページごとの PNG + AI->>L: PNG を見て記録を残す(E6) + alt 文字あふれ・重なり + L-->>AI: 差し戻し(体裁設計・生成・執筆) + end + AI->>D: コミット(E7) +``` + +既にプロジェクトに生成スクリプト(`slides/build-deck.js`)があるときは、汎用テンプレートを写さずにそれに従う +(`form-slide.md` の「対象リポジトリの仕組みを先に探す」と同じ)。 + +## 非機能の実現方式 + +| 大項目 | 要求の条件 | 実現方式 | 確かめ方 | +| --- | --- | --- | --- | +| 運用・保守性 | トーン&マナーの差し替えはプロジェクト側の値だけを書けば済み、汎用テンプレートの更新を取り込むときにプロジェクトの値が上書きされない | プロジェクトの値は `slides/theme.json` だけに置き、枠組みは `slides/lib/` へ写して書き換えない。更新の取り込みは `lib/` の写し直しだけで、`theme.json` に触れない | `theme.json` を置いた資料で `lib/` を写し直して生成し、差し替えた書体と色が pptx に残ることをテストで見る | +| セキュリティ | 生成スクリプト・規約・発表資料に Drive のフォルダ ID・ファイル ID・トークンを書かない。gws の認証情報をリポジトリに置かない(C1) | 規約と README の例は `<フォルダID>` の形にする。Google Slides の ID は引数でだけ受け、発表資料の README には置かない(URL は PR の記録に書く)。スクリプトはトークンを読まない | 完了判定で差分の追加行を `docs.google.com/.*/d/[A-Za-z0-9_-]{20,}` と `"fileId":"[A-Za-z0-9_-]{20,}"` で検索して 0 件 | +| システム環境 | Node.js 22(CI が使う版)と pptxgenjs で動く。依存の宣言の置き方は設計が決める | 依存は汎用テンプレートと資料の `slides/package.json` と `package-lock.json` で pptxgenjs `4.0.1` に固定する。CI の pytest に `actions/setup-node` の 22 と `npm ci` を足す | CI の pytest が Node.js 22 で生成のテストを通す | + +## 決定の記録 + +決定の記録は [issue-2065-design-decisions.md](issue-2065-design-decisions.md) にある(決定 1〜14。決定 3 は決定 12・13 が置き換えた)。 + +## テスト設計 + +| 受け入れ条件・不変条件 | どの振る舞いで縛るか | どう壊したら落ちるべきか | +| --- | --- | --- | +| 受け入れ条件 1 | `plugins/ndf/manifests/*-skills.txt` の行数が develop と同じ(完了判定のコマンド) | manifests に行を足すと差が出る | +| 受け入れ条件 2・9 | `doc-lint.py` が参照切れを見る。`layout-slide.md` → `form-slide.md` → 汎用テンプレートと `system-gdrive.md` → `google-workspace`、`layout-review` → `system-gdrive.md` の経路をレビューで辿る | リンク先のファイル名を変えると `doc-lint.py` が落ちる | +| 受け入れ条件 3 | 汎用テンプレートの見本を生成すると終了コード 0 で、版面が 10 × 5.625 インチ、ページが 3 つ(表紙・章扉・本編)、本編に表と注がある | 版面の指定を外す・章扉を作らない・表を外すと落ちる | +| 受け入れ条件 4・I1 | 本編のページの見出し・罫線・著作権表示・ページ番号・注の位置が手本の座標(インチ)と一致し、`theme.json` で書体と色と文字の大きさを変えても位置が変わらない | 座標の定数を変える・トーン&マナーの値で座標を動かすと落ちる | +| 受け入れ条件 5・I2・I3 | 書体・色・著作権表示・ロゴを差し替えた `theme.json` で生成すると、全ページの XML の書体名と色が差し替えた値だけになり、既定値が残らない。`lib/` を写し直しても同じ結果になる | 枠組みか見本に書体名・色を直書きすると落ちる。`theme.json` の項目を重ねずに丸ごと置き換えると、書いていない既定値が欠けて落ちる | +| `theme.json` の知らない項目 | 知らない項目名を書くと終了コード 1 で、項目名を出す | 知らない項目を読み捨てると落ちる | +| I4・受け入れ条件 11 | 発表資料を生成すると 23 ページすべてにノートがあり、各ノートが `script.md` の同じ番号の読み上げと一致する | ノートを生成スクリプトに写して食い違わせる・番号をずらすと落ちる | +| I5 | 台本のページ数と、章扉を除いて作ったページ数が違うと、生成が終了コード 1 で両方の数と章扉の数を出す。章扉はノートを持たず、章扉の後のページは台本の次の番号の読み上げを受ける。発表資料は章扉を除く 23 ページが台本と一致する | 照合を外す・章扉で台本の番号を進める・章扉にノートを付けると落ちる | +| 台本の形の失敗 | 区切りの番号が欠ける・重なる台本で、生成と尺の計算が終了コード 2 と番号を出す | 欠けた番号を読み飛ばすと落ちる | +| I6・受け入れ条件 7 | 読み上げの字数が分かっている台本で、ページごとの字数と秒(350 字/分)と合計が期待どおりで、合計がページの和に等しい。`--limit` を下回る値にすると終了コード 1 | 空白や強調の記号を字数に数える・`>` 以外の行を数えると落ちる | +| 受け入れ条件 6 | 発表資料の `notes.md` の構成の表が 23 行で、番号が 1〜23(`tests/test_presentation_decks.py` が表を数える) | 行を欠かすと落ちる | +| 受け入れ条件 10 | 発表資料を生成すると終了コード 0 で 23 ページ。生成スクリプトが `./lib/deck-frame.js` を読み、`lib/` が汎用テンプレートの `lib/` と同じ内容で写されている(作った時点で比べる。以後は追随しない) | 枠組みを使わずに組むと落ちる | +| 受け入れ条件 8 | 規約に書いた gws のコマンド(変換・差し替え・ページの ID・サムネイル)を実装の作業で実際に打ち、打ったコマンドと終了コードを実装の PR に残す | — (手作業の記録) | +| サムネイルの取得 | 偽の gws を前にした実行で、ページの ID の順に `p<番号>.png` を保存し、結果 JSON にページ数と保存先が出る。gws が失敗すると終了コード 1 で gws の出力が `items` に載る。未認証は 11 | gws の失敗を成功として返す・ページの順を崩すと落ちる | +| 受け入れ条件 12 | 発表資料を Google Slides へ変換し、23 ページのサムネイルを見た体裁レビューの記録(`layout-review` の形)を PR に残す。差し戻した後のサムネイルで文字あふれと重なりが 0 件 | — (人が見る。承認ゲート 2 で利用者も見る) | +| 受け入れ条件 13・I7 | 完了判定で `git diff develop -- plugins/ docs/presentations/` の追加行を手本の社名・案件名・手本のコーポレートカラーの値で検索して 0 件 | — (完了判定のコマンド) | +| 受け入れ条件 14 | 完了判定で追加・変更した行をページの数え方の和語(受け入れ条件 14 の語)で検索して 0 件 | — (完了判定のコマンド) | +| 受け入れ条件 15 | 受け入れ条件の 5 つのコマンドが終了コード 0。`git diff develop --stat -- docs/presentations/2026-08-06-ai-plugins-intro.*` が空(`build.sh` は決定 12 で消す) | 2026-08-06 の資料に触れると差が出る | + +`.md` の文言を照合するテストは書かない(`AGENTS.md` の DON'T)。`notes.md` の表を数えるのは構成メモの行数(受け入れ条件 6) +の検査であり、文言は照合しない。 + +## 設計の結果 + +| 課題 | 扱い | 取り込み先 | 触るファイル | +| --- | --- | --- | --- | +| #2065 | 実装する | — | `plugins/ndf/skills/design/references/layout-common.md`、`plugins/ndf/skills/design/references/layout-slide.md`、`plugins/ndf/skills/release/references/form-slide.md`、`plugins/ndf/skills/release/references/distribution-forms.md`、`plugins/ndf/skills/release/assets/slides/`、`plugins/ndf/skills/release/tests/`、`plugins/ndf/skills/document-systems/references/system-gdrive.md`、`plugins/ndf/skills/google-workspace/`、`plugins/ndf/skills/layout-review/SKILL.md`、`docs/presentations/README.md`、`docs/presentations/`、`tests/test_presentation_decks.py`、`conftest.py`、`.github/workflows/pytest.yml`、`.gitignore`、`docs/glossary.md`、`docs/glossary/glossary.json` | + +## 未確認のまま残ること + +| 項目 | 内容 | +| --- | --- | +| 発表資料のディレクトリの日付 | 発表日が未定。承認ゲート 1 で利用者が日付を示さなければ、台本を確定した日 `2026-10-17` を使う(`docs/presentations/README.md` の `YYYY-MM-DD-` の形) | +| 発表資料の著作権表示 | 発表資料の `theme.json` の `copyright` の文言(例: `NDF — github.com/devbasex/ai-plugins`)。承認ゲート 1 で利用者が示さなければこの例を使う | +| gws の実際の変換と差し替え | 設計では `--dry-run` で要求の形だけを確かめた。実際に上げて変換・差し替え・サムネイルを取るのは実装の作業で、打ったコマンドと結果を実装の PR に残す(受け入れ条件 8) | +| Noto Sans JP の変換後の見え方 | pptx の書体名 `Noto Sans JP` が Google Slides で代替されずに描かれるかは、変換したサムネイルで確かめる。代替されたら `layout-review` の「書体の代替」の手順で、配布先(Google Slides)で起きる欠けとして生成へ戻し、既定の書体を選び直す | +| サムネイルの一時的な URL の扱い | `getThumbnail` が返す画像の URL を認証なしで取れるかは、実装で 1 度打って確かめる。取れなければ gws の認証付きの取得へ切り替える | +| 台本の写しと正本の一致 | 台本の正本は第 7 稿の Google ドキュメントである。実装でドキュメントをテキストで書き出し、`script.md` の読み上げと突き合わせた結果を PR に残す(要求の前提 3) | +| 図の多いページの組み方 | 台本の 6・8・14 ページなどは図が要る。pptxgenjs は Mermaid を描かないため、図形で組むか画像を貼るかを実装でページごとに決め、構成メモの「役割」に書く | diff --git a/issues/issue-2065-requirements.md b/issues/issue-2065-requirements.md new file mode 100644 index 000000000..f40adf697 --- /dev/null +++ b/issues/issue-2065-requirements.md @@ -0,0 +1,189 @@ +# #2065: スライドを作る実務の手順(生成スクリプト・ページの枠組み・トーン&マナー・構成メモ・gws での変換と確認)を Skill に取り込む + +正は課題の本文(#2065)で、この文書はその写しである。`spec-copy.py write` で作り直す。手で直さない。 + +## 何をしたいか + +スライド(発表資料・提案資料)を作る実務の手順を NDF の Skill に取り込む。手本は nyle-dx(`/work/nyle-dx` の `edp/slides`・`mdx/security/slides`・`fde/presentation`)の作り方で、次を持つ。 + +| 要素 | nyle-dx での形 | +| --- | --- | +| 生成 | pptxgenjs の生成スクリプト `slides/build-deck.js`。版面 10 × 5.625 インチ(16:9) | +| ページの枠組み | 共通の関数 `page` / `box` / `note` / `tbl` / `section`(見出し・罫線・ロゴ・著作権表示・ページ番号の位置を固定) | +| トーン&マナー | 書体・色・文字サイズを定数で持ち、README に表で書く | +| 構成メモ | ページごとの見出し・役割・根拠の表(`04-presentation-notes.md`)と、読み上げの台本。スピーカーノートへ入れる | +| 変換と確認 | `gws drive +upload` → `gws drive files copy`(mimeType を Google Slides に)で変換し、`gws slides presentations pages getThumbnail` でページごとに見る | + +NDF の今の Skill はスライドの原則(`design/references/layout-slide.md`・`release/references/form-slide.md`・`layout-review`)だけを持ち、上の実務の手順を持たない。 + +## 方針(利用者の決定 2026-10-17) + +- 新しい Skill を作る前に、既存のスライドの規約(`layout-slide.md`・`form-slide.md`・`layout-review`・`document-systems` の `system-gdrive.md`)への統合を優先する +- トーン&マナーは会社色を持たない汎用のテンプレートを既定にし、プロジェクトごとに差し替えられるようにする +- 取り込んだ手順で、発表資料「独自スキルプラグイン開発の薦め」(台本は第 7 稿 https://docs.google.com/document/d/13nNf3sLjMtrp7WZKHPFD_KW-sv-v5xv5kno2pICfP3U/edit )を `docs/presentations/` に作る +- (2026-10-18 追加)Marp(Markdown を変換器でスライドにする経路)はこのプラグインから外す。変換後のスライドが編集できないのは致命的なため。外す範囲は規約とリポジトリの両方: `form-slide.md` 等のスライドの生成の経路から「変換器を使う」を外して生成スクリプトを既定にし、`docs/presentations/` の README・`build.sh`・`AGENTS.md` の Marp の記述を生成スクリプトの方式へ書き換える。2026-08-06 の資料は発表日時点の記録として残す +- (2026-10-18 追加)python-pptx も pptxgenjs に比べて利点が無いため外す。スライドの生成の経路は「生成スクリプトを書く(pptxgenjs)」の 1 つだけにし、「AI エージェントが組む」をスライドから外す(文書・表計算・ページの形の経路は変えない)。`official-skills-autoloader` は「スライドを新しく作る」依頼を `release` の `form-slide.md` へ回し、公式の `pptx` Skill は既存の .pptx を読む・直すときだけに使う + +## 由来 + +2026-10-17 の会話で、python-pptx で独自に組んだ資料に利用者が「slide の作り方が違う。/work/nyle-dx を参考に。Skill 化した方が良いかも」と指摘した。 + +--- + +# 要求と受け入れ条件 + +## 依頼(原文) + +> slide の作り方が違う。/work/nyle-dx を参考に。Skill 化した方が良いかも + +(書き手: 利用者、日付: 2026-10-17。python-pptx で組んだ試作への指摘。起票者が「由来」の節に写した文) + +> 新しい Skill を作る前に既存のスライドの規約(plugins/ndf/skills/design/references/layout-slide.md・release/references/form-slide.md・layout-review・document-systems の system-gdrive.md)への統合を優先する。トーン&マナーは会社色の無い汎用テンプレートを既定にし、版面と枠組みの関数(page/box/note/tbl/section)は nyle-dx を踏襲、プロジェクトごとに差し替えられるようにする + +(書き手: 利用者(2026-10-17 の決定)を AI の起動指示が引継ぎ文書 `.ndf/handoff/issue-2065.md` から写したもの、日付: 2026-10-17) + +> 取り込んだ手順で発表資料「独自スキルプラグイン開発の薦め」(社外の勉強会・登壇向け、23 ページ、台本は第 7 稿 https://docs.google.com/document/d/13nNf3sLjMtrp7WZKHPFD_KW-sv-v5xv5kno2pICfP3U/edit )を docs/presentations/ に作る(README・構成メモ・台本・slides/build-deck.js、Google Slides に変換してサムネイルで確かめる)。これも受け入れ条件に含める + +(書き手: AI の起動指示(引継ぎ文書の利用者の決定「先に Skill 化し、それで発表資料を作る」「置き場は ai-plugins の docs/presentations/」「台本は第 7 稿で確定扱い(23 ページ)」を写したもの)、日付: 2026-10-17) + +> 文言は直訳の和語を避け業界の語にする(枚→ページ・課題→Issue)。事例のプロジェクトは種類で呼び、名前(nyle-dx を含む社名・案件名)を Skill 本文と発表資料に出さない +> +> python-pptx で独自に組む方式は利用者が却下した(pptxgenjs の生成スクリプト方式にする) + +(書き手: AI の起動指示、日付: 2026-10-17。「事例のプロジェクトは種類で呼び名前を出さない」は台本第 7 稿の冒頭にもある利用者の方針、「Skill 本文にも名前を出さない」は起動指示が足した範囲) + +## 起票者の推定 + +| # | 条件 | 推した出所 | この要求での扱い | +| --- | --- | --- | --- | +| S1 | 構成メモに「根拠」の列を持たせ、台本をスピーカーノートへ入れる | 課題の本文の表(手本 `mdx/security/04-presentation-notes.md` と `addNotes` から起票者が抜き出した) | 受け入れ条件 6・7 | +| S2 | 変換は `+upload` → `files copy`(mimeType 指定)、確認は `getThumbnail` の経路で行う | 課題の本文の表(手本の README から) | 受け入れ条件 8・9 | +| S3 | 尺は 350 字/分で測る | 起動指示の材料(手本 `fde/presentation/README.md`) | 受け入れ条件 7 | +| S4 | Skill 本文にも事例のプロジェクト名・社名を出さない | 起動指示(台本の方針は発表資料について言っている) | 受け入れ条件 12 | + +## 目的 + +- AI がスライドを作るとき、毎回その場で作り方を組み立てず(python-pptx の自作など)、NDF の規約に従って、生成スクリプト・ページの枠組み・トーン&マナー・構成メモ・台本・変換と確認の手順をそろえた形で作れるようにする +- その手順を実際に通して、発表資料「独自スキルプラグイン開発の薦め」を作り、手順が通ることを示す + +## 前提 + +- 前提 1: 生成は pptxgenjs の生成スクリプト(Node.js)で行う。python-pptx で組む方式と、AI エージェントがその場で組む方式は採らない(方針の 2026-10-18 追加)。Marp の経路はこの課題で外す(方針の 2026-10-18 追加) +- 前提 2: 汎用テンプレートの版面(10 × 5.625 インチ)と枠組みの関数の座標は手本の値を踏襲する。書体・色・著作権表示の文言・ロゴは会社に由来しない値を既定にする(書体は配布先の環境と Google Slides で使える書体から設計で選ぶ) +- 前提 3: 台本の正本は第 7 稿の Google ドキュメントである。作成時はドキュメントから取得し、手元の写し(会話の作業ディレクトリの `script-v7.md`)と食い違えばドキュメントを採る。台本はページごとの見出しと読み上げを持つが、ページに載せる要素(図・表・箇条書き)は持たないため、ページの中身は作成時に台本から起こし、構成メモの「役割」「根拠」に書く +- 前提 4: Google Slides への変換は、利用者の Drive の非公開の場所(フォルダを指定しないアップロード、または利用者の既存の非公開フォルダ)に置く。共有・公開範囲の設定はこの課題で行わない +- 前提 5: gws は利用者のアカウントで認証済みである(引継ぎ文書)。認証が切れていれば `google-workspace` の手順で利用者に再認証を頼み、変換と確認の受け入れ条件はそれまで未達とする +- 前提 6: `docs/presentations/` は `main` へ入ると GitHub Pages で公開される(`docs/presentations/README.md`)。発表資料は社外向けで、公開されてよい内容だけを置く +- 前提 7: 規約の文言のうち、この課題で追加・変更する行は「枚」を「ページ」にする。触らない行の言い換えはこの課題の範囲外とする + +## 対象範囲 + +含む: +- 既存のスライドの規約(`design/references/layout-slide.md`・`release/references/form-slide.md`・`layout-review`・`document-systems/references/system-gdrive.md`)へ、生成スクリプト・ページの枠組み・トーン&マナー・構成メモ・台本・gws での変換とサムネイル確認の手順を足す +- 会社色を持たない汎用テンプレートの生成スクリプト(pptxgenjs)と、トーン&マナーの値をプロジェクトごとに差し替える手段 +- 台本の字数と尺(350 字/分)を数える手段 +- `docs/presentations/` の発表資料「独自スキルプラグイン開発の薦め」一式(README・構成メモ・台本・`slides/build-deck.js`)と、`docs/presentations/README.md` の資料一覧への追加 +- Google Slides への変換と、全ページのサムネイルによる体裁レビューの記録 + +含まない: +- 新しい Skill の新設(統合で足りないと設計が示した場合だけ、設計 PR で理由を添えて扱う) +- python-pptx・LibreOffice による生成の経路の追加と、既存の Marp 資料(`2026-08-06-ai-plugins-intro` の md・html・pdf)の削除や書き換え +- 発表資料の共有設定・登壇先への提出・`index.html`(公開ページの入口)以外の公開操作 +- 台本の内容の書き直し(第 7 稿を確定として使う。ページに収まらない場合の削り方は前提 3 の範囲で構成メモに書く) +- 規約の既存の行の「枚」の言い換え(前提 7) + +## ドメインイベント + +| # | イベント | 引き金 | 失敗したとき | 順序の前提 | +| --- | --- | --- | --- | --- | +| E1 | 体裁設計で版面・トーン&マナー・ページの枠組みを決めた | スライドを作る依頼 | プロジェクトに既存の資料が無い → 汎用テンプレートの既定値を使う | — | +| E2 | 汎用テンプレートから生成スクリプトを用意し、トーン&マナーをプロジェクトの値へ差し替えた | E1 の決定 | 差し替える値が欠ける → 既定値のまま生成し、欠けた項目を README に書く | E1 | +| E3 | 構成メモ(ページごとの見出し・役割・根拠)と台本を書き、尺を測った | 内容の確定 | 尺が持ち時間を超える → 構成メモで削るページを決める | E1 | +| E4 | 生成スクリプトで pptx を作った | E2・E3 の完了 | Node.js か pptxgenjs が無い → 導入の手順を示して止める | E2・E3 | +| E5 | pptx を Drive へ上げ、Google Slides へ変換した(既存の URL のまま差し替える場合を含む) | E4 の完了 | gws が未認証 → `google-workspace` の手順で利用者に認証を頼んで止める | E4 | +| E6 | ページごとのサムネイルを取り、体裁レビューの記録を残した | E5 の完了 | 文字あふれ・重なり → `layout-review` の差し戻し先(体裁設計・生成・執筆)へ戻す。サムネイルが取れない → 止めて人へ渡す | E5 | +| E7 | 生成スクリプト・構成メモ・台本・README をリポジトリへコミットした | E6 の完了 | — | E6 | + +## 用語 + +| 用語 | 意味 | +| --- | --- | +| トーン&マナー | スライドの書体・色・文字サイズ・著作権表示・ロゴの値の組。プロジェクトごとに差し替える | +| ページの枠組み | 見出し・罫線・ロゴ・著作権表示・ページ番号の位置を固定した、ページを作る共通の関数(`page` / `box` / `note` / `tbl` / `section`) | +| 生成スクリプト | pptx を出力するスクリプト(pptxgenjs の `build-deck.js`)。スライドの正本になる | +| 汎用テンプレート | 会社色を持たないトーン&マナーとページの枠組みを持つ、生成スクリプトの出発点 | +| 構成メモ | スライドのページごとの番号・見出し・役割・根拠の表と、構成の考え方を書いた文書 | +| 台本 | ページごとの読み上げの原稿と配分秒数。スピーカーノートへ入れる | + +## 受け入れ条件 + +- [ ] 1. `plugins/ndf/manifests/` の Skill の行が増えない(新設が要ると設計が判断した場合は、設計 PR に理由がある) +- [ ] 2. `layout-slide.md`・`form-slide.md`・`layout-review`・`system-gdrive.md` のどれかから、汎用テンプレート・トーン&マナーの差し替え・構成メモと台本の形・gws での変換とサムネイル確認の手順へ、リンクで辿れる +- [ ] 3. 汎用テンプレートの生成スクリプトを `node <スクリプト> <出力>.pptx` で実行すると、終了コード 0 で 16:9(10 × 5.625 インチ)の pptx が出る。出た pptx は表紙・章扉・本編(表と注を含む)のページを持つ +- [ ] 4. 汎用テンプレートは `page` / `box` / `note` / `tbl` / `section` の 5 つの関数を持ち、見出し・罫線・著作権表示・ページ番号の位置が手本と同じ座標になる +- [ ] 5. トーン&マナーの値(書体・色・著作権表示の文言・ロゴの有無)を、関数の本体を書き換えずに差し替えられる。値を差し替えて生成した pptx の XML(`ppt/slides/*.xml`)の書体名と色が、差し替えた値になる +- [ ] 6. 構成メモの形(ページごとの番号・見出し・役割・根拠の表)が規約にあり、発表資料の構成メモがその形で 23 行を持つ +- [ ] 7. 台本の字数と尺(350 字/分)を 1 つのコマンドでページごとと合計で出せる。発表資料の台本でそのコマンドを実行した出力が README か構成メモに載る +- [ ] 8. gws で pptx を Drive へ上げて Google Slides へ変換する手順と、既存の Google Slides を同じ URL のまま差し替える手順が規約にあり、規約に書いた各コマンドは書く前にこのリポジトリの作業で実行して確かめてある(実行した記録が設計か実装の PR にある) +- [ ] 9. Google Slides のページごとのサムネイルを取る手順が `layout-review` の描画の手段から辿れる +- [ ] 10. `docs/presentations/` に発表資料「独自スキルプラグイン開発の薦め」の README・構成メモ・台本・`slides/build-deck.js` があり、`node slides/build-deck.js <出力>.pptx` が終了コード 0 で 23 ページの pptx を出す。生成スクリプトは汎用テンプレートの枠組みとトーン&マナーを使う +- [ ] 11. 発表資料の 23 ページすべてにスピーカーノートがあり、その文が台本第 7 稿の各ページの読み上げと一致する +- [ ] 12. 発表資料を Google Slides へ変換し、23 ページすべてのサムネイルを見た体裁レビューの記録(`layout-review` の「見た結果を残す」の形)が PR にある。記録に残った文字あふれ・図の重なりは、差し戻して直した後のサムネイルで 0 件である +- [ ] 13. 追加・変更したファイル(規約・汎用テンプレート・発表資料)に、事例のプロジェクトの名前と社名(`nyle` / `Nyle` / `ナイル` と案件名)と手本のコーポレートカラー `95FF00` が出ない(`git diff develop -- <対象>` の追加行を grep して 0 件) +- [ ] 14. 追加・変更した行に、直訳の和語「枚」が出ない(ページの数え方は「ページ」) +- [ ] 15. 退行しない: `python3 scripts/check-skill-frontmatter.py`・`python3 plugins/ndf/scripts/instructions-check.py --root .`・`python3 plugins/ndf/scripts/doc-lint.py`(PR の差分に対して)・`uv run --frozen --project . --all-extras pytest . -q -n 4`・`claude plugin validate .` がすべて終了コード 0。既存の Marp 資料と `build.sh` に差分が無い +- [ ] 16. スライドの生成の経路から「変換器を使う」(Marp)が外れ、`plugins/` の規約と `docs/presentations/README.md`・`AGENTS.md` に Marp を使う手順が残らない(`git grep -i marp -- plugins/ AGENTS.md` はテストのデータを除いて 0 件。`docs/presentations/README.md` は 2026-08-06 の資料が Marp で作った記録だと示す行だけを残してよい)。`docs/presentations/build.sh` は消すか生成スクリプトの方式へ置き換える。2026-08-06 の資料の md・html・pdf は残す +- [ ] 17. スライドの生成の経路が「生成スクリプトを書く」の 1 つになり、`form-slide.md`・`layout-slide.md`・`layout-common.md`・`distribution-forms.md` にスライドを「AI エージェントが組む」経路と python-pptx が残らない(ほかの形の経路は変わらない)。`official-skills-autoloader` の対応表で、スライドの新規作成は `form-slide.md` へ、公式 `pptx` は既存の .pptx を読む・直す依頼だけへ振り分けられる + +## 非機能の条件 + +| 大項目 | 条件 | +| --- | --- | +| 運用・保守性 | トーン&マナーの差し替えはプロジェクト側の値だけを書けば済み、汎用テンプレートの更新を取り込むときにプロジェクトの値が上書きされない | +| セキュリティ | 生成スクリプト・規約・発表資料に、Drive のフォルダ ID・ファイル ID・トークンを書かない(例は `<フォルダID>` の形にする)。gws の認証情報はリポジトリに置かない(C1) | +| システム環境 | 生成は Node.js 22(CI が使う版)と pptxgenjs で動く。依存の宣言の置き方は設計が決める | + +## 影響 + +| 対象 | 影響 | +| --- | --- | +| 公開インタフェース | 変わらない(Skill の追加・削除・名前の変更が無い。規約の参照先が増える) | +| データ | 無し | +| 既存の振る舞い | スライドを作るときに AI が読む規約に、生成スクリプトと変換・確認の手順が加わる。Marp(変換器)の経路はスライドの規約とリポジトリの手順から外れる | + +## 検証手段 + +| 項目 | 手段 | +| --- | --- | +| 起動 | `node <汎用テンプレート> /tmp/t.pptx`、`node docs/presentations/<資料>/slides/build-deck.js /tmp/d.pptx`(パスは設計が決める) | +| テスト | `uv run --frozen --project . --all-extras pytest . -q -n 4`(生成した pptx のページ数・版面・書体と色の差し替えを確かめるテストを置くかは設計が決める) | +| 静的解析・型検査 | `python3 scripts/check-skill-frontmatter.py`・`python3 plugins/ndf/scripts/instructions-check.py --root .`・`python3 plugins/ndf/scripts/doc-lint.py`・`claude plugin validate .` | +| 手動確認(マージ前) | 実装者が Google Slides へ変換した発表資料の 23 ページのサムネイルを見て、体裁レビューの記録を PR に残す(受け入れ条件 12)。承認ゲート 2 で利用者がサムネイルか Google Slides を見る | +| 手動確認 | 利用者が Google Slides のプレゼンター表示でスピーカーノートが読めることを確かめる(リリース後テスト) | + +## 前提とする取り決め + +| 項目 | 参照先 / 決めたこと | +| --- | --- | +| プロジェクト構造 | Skill の手順は `plugins/ndf/skills/` の既存の規約へ足す(`CLAUDE.md` の「NDF の Skill 構成」)。発表資料は `docs/presentations/`(`AGENTS.md` の「ドキュメント」の表)。汎用テンプレートを Skill の配下に置くか `docs/` に置くかは設計が決める | +| コーディング規約 | Skill の frontmatter は `plugins/ndf/skills/AUTHORING.md`。文書は 1 語 1 意味(Value 8)と用語集。`.md` の文言を照合するテストは書かない(`AGENTS.md` の DON'T) | +| テスト戦略 | 生成スクリプトは実行して出力の pptx を検査する(振る舞いのテスト)。規約の文言は検査しない | + +## 境界 + +| 区分 | 内容 | +| --- | --- | +| 常に行う | 規約に書くコマンドを書く前に実行して確かめる。生成した pptx を Google Slides で描画して見る。既存の検査をすべて通す | +| 確認してから行う | 依存パッケージ(pptxgenjs)の宣言の追加、新しい Skill の新設(いずれも承認ゲート 1 の設計で示す) | +| 行わない | python-pptx での生成、台本の内容の書き直し、発表資料の共有設定、手本のリポジトリ(`/work/nyle-dx`)の変更、手本のロゴ・書体・色の複製 | + +## 未決 + +| 項目 | 誰が決めるか | 期限 | +| --- | --- | --- | +| 汎用テンプレートの置き場(Skill の assets か docs か)と、配布物に含めるか | 設計(承認ゲート 1 で利用者) | 設計 PR | +| トーン&マナーの差し替えの手段(設定ファイル・引数・プロジェクト側の定数のファイルなど) | 設計(承認ゲート 1 で利用者) | 設計 PR | +| 既定の書体と色の値 | 設計 | 設計 PR | +| 尺を数える手段をスクリプトにする場所(Value 4) | 設計 | 設計 PR | +| CI で生成スクリプトを実行するか(Node.js と pptxgenjs の導入) | 設計 | 設計 PR | +| 発表資料のファイル名(`YYYY-MM-DD-` の日付。発表日が未定) | 利用者(承認ゲート 1) | 設計 PR | diff --git a/plugins/ndf/.claude-plugin/plugin.json b/plugins/ndf/.claude-plugin/plugin.json index 22c78e780..1fbd97a3a 100644 --- a/plugins/ndf/.claude-plugin/plugin.json +++ b/plugins/ndf/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "ndf", - "version": "10.18.0", - "description": "Claude Code plugin (v10.18.0): 8 specialized agents (plus 2 supervisor agents and 1 worker agent for the 3-layer workflow) and 48 focused NDF skills for PR/review workflows, cross-review, implementation planning, plan-to-spec, Docker container access, statusline, external AI delegation (Codex/agy), transcript retention guard, and optional Slack notifications.", + "version": "10.19.0", + "description": "Claude Code plugin (v10.19.0): 8 specialized agents (plus 2 supervisor agents and 1 worker agent for the 3-layer workflow) and 48 focused NDF skills for PR/review workflows, cross-review, implementation planning, plan-to-spec, Docker container access, statusline, external AI delegation (Codex/agy), transcript retention guard, and optional Slack notifications.", "author": { "name": "takemi-ohama", "url": "https://github.com/takemi-ohama" diff --git a/plugins/ndf/.codex-plugin/plugin.json b/plugins/ndf/.codex-plugin/plugin.json index b43323a5d..669d81aa1 100644 --- a/plugins/ndf/.codex-plugin/plugin.json +++ b/plugins/ndf/.codex-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "ndf", - "version": "10.18.0", - "description": "Codex plugin (v10.18.0): 44 focused NDF skills for PR/review workflows, cross-review, implementation planning, plan-to-spec, Docker container access, external AI delegation (Codex/agy), and optional Slack completion notifications.", + "version": "10.19.0", + "description": "Codex plugin (v10.19.0): 44 focused NDF skills for PR/review workflows, cross-review, implementation planning, plan-to-spec, Docker container access, external AI delegation (Codex/agy), and optional Slack completion notifications.", "skills": [ "./skills/cherry-pick-pr", "./skills/cross-refactoring", diff --git a/plugins/ndf/README.md b/plugins/ndf/README.md index cc34a36aa..a1b4650e6 100644 --- a/plugins/ndf/README.md +++ b/plugins/ndf/README.md @@ -89,7 +89,7 @@ bash plugins/ndf/dev.kiro/install.sh --dry-run ```bash python3 -c "import json;print(json.load(open('.kiro/agents/ndf.json'))['description'])" -# => NDF統合開発エージェント(Kiro CLI用 / v10.18.0) +# => NDF統合開発エージェント(Kiro CLI用 / v10.19.0) ``` ### agy @@ -412,7 +412,7 @@ agy models # 認証確認 ```text # 動く: 実体パスを示して読ませる -~/.codex/plugins/cache/ai-plugins/ndf/10.18.0/skills/deploy/SKILL.md を読んで、その手順どおりに qa/staging へ deploy PR を作成してください。 +~/.codex/plugins/cache/ai-plugins/ndf/10.19.0/skills/deploy/SKILL.md を読んで、その手順どおりに qa/staging へ deploy PR を作成してください。 # 動かない: 明示起動 ($ は展開されない) $deploy qa/staging @@ -434,14 +434,14 @@ marketplace 経由でインストールした場合、Skill の実体は **ワ ```text $CODEX_HOME/plugins/cache////skills//SKILL.md # 既定 ($CODEX_HOME=~/.codex) の例: -# ~/.codex/plugins/cache/ai-plugins/ndf/10.18.0/skills/deploy/SKILL.md +# ~/.codex/plugins/cache/ai-plugins/ndf/10.19.0/skills/deploy/SKILL.md ``` そのため「`deploy` の SKILL.md を探して読んで」のような曖昧な依頼は、Codex のファイル探索がワークスペース内に限られる状況では失敗しえます。**抑止した Skill は `$` が展開されない**ので、`codex plugin list` で実体パスを確認し、絶対パスを渡してください。 ```bash codex plugin list | grep 'ndf@ai-plugins' -# => ndf@ai-plugins installed, enabled 10.18.0 +# => ndf@ai-plugins installed, enabled 10.19.0 ``` 抑止していない Skill(`markdown-writing` など)はキャッシュ配下でも `$` で解決するため、そちらは `$` 起動が使えます。 diff --git a/plugins/ndf/dev.agy/plugin.json b/plugins/ndf/dev.agy/plugin.json index 6605b3baf..e62d5e09f 100644 --- a/plugins/ndf/dev.agy/plugin.json +++ b/plugins/ndf/dev.agy/plugin.json @@ -1,5 +1,5 @@ { "name": "ndf", - "version": "10.18.0", - "description": "Antigravity CLI plugin (v10.18.0): 44 focused NDF skills for PR/review workflows, cross-review, implementation planning, plan-to-spec, Docker container access, external AI delegation, and worktree guidance hooks." + "version": "10.19.0", + "description": "Antigravity CLI plugin (v10.19.0): 44 focused NDF skills for PR/review workflows, cross-review, implementation planning, plan-to-spec, Docker container access, external AI delegation, and worktree guidance hooks." } diff --git a/plugins/ndf/scripts/release-steps.py b/plugins/ndf/scripts/release-steps.py index 92759fe05..dd7906d44 100755 --- a/plugins/ndf/scripts/release-steps.py +++ b/plugins/ndf/scripts/release-steps.py @@ -327,37 +327,6 @@ def base_version(root, pdir, ref): return None -def bump_versioning_doc(ed): - """docs/versioning-and-distribution.md の「版の付け方と開発版の配布」章の正式版の版数。""" - doc = ed.root / "docs" / "versioning-and-distribution.md" - rel = "docs/versioning-and-distribution.md" - ob, nb = base_of(ed.old), base_of(ed.new) - if ob == nb: - return - if not doc.is_file(): - ed.manual.append(f"{rel} が無い(この章は手で直す)") - return - lines = ed.lines(doc) - targets = [ - (re.compile(r"^\| 正式版 \| `([^`]+)` \|"), "正式版の表の行"), - (re.compile(r"`([^`]+)` の次を開発するなら"), "接尾辞の例"), - ] - for rx, what in targets: - hits = [i for i, l in enumerate(lines) if rx.search(l)] - if len(hits) != 1: - ed.manual.append(f"{rel}: 「版の付け方と開発版の配布」章の{what}が特定できない(この章は手で直す)") - continue - i = hits[0] - cur = rx.search(lines[i]).group(1) - if cur == nb: - continue - if cur != ob: - ed.manual.append(f"{rel}: {what}の版が {cur} で旧版 {ob} と違う(この章は手で直す)") - continue - esc = bump.braces(lines[i]) - ed.place(doc, [i], esc.replace(f"`{ob}`", f"`{bump.CUR_BASE}`", 1), esc.replace(f"`{ob}`", f"`{bump.NEW_BASE}`", 1), what) - - def plan_bump(root, pdir, plugin, old, new): """`plugin` の版を `old` から `new` へ上げる箇所を集めた `BumpPlan` を返す(まだ書き換えない)。""" ed = bump.BumpPlan(root, old, new) @@ -396,7 +365,7 @@ def plan_bump(root, pdir, plugin, old, new): ed.sub(nr, r"(Kiro CLI用 / v", "plugins/ndf/README.md の Kiro の確認例") ed.sub(nr, r"/plugins/cache/ai-plugins/ndf/", "plugins/ndf/README.md の Codex のパス例", count=2) ed.sub(nr, r"ndf@ai-plugins\s+installed", "plugins/ndf/README.md の codex plugin list の出力例") - bump_versioning_doc(ed) + bump.versioning_doc(ed) return ed diff --git a/plugins/ndf/scripts/release_lib/bump.py b/plugins/ndf/scripts/release_lib/bump.py index f365b5357..716beaace 100644 --- a/plugins/ndf/scripts/release_lib/bump.py +++ b/plugins/ndf/scripts/release_lib/bump.py @@ -14,8 +14,9 @@ import tempfile from pathlib import Path +import md import versions -from step_result import EXIT_PRECONDITION, StepError, plugin_dir +from step_result import EXIT_PRECONDITION, StepError, base_of, plugin_dir def ver_pat(v): @@ -154,3 +155,75 @@ def require_bumped(root, plugin, ver): raise StepError(f"plugin.json の版を読めない: {e}", EXIT_PRECONDITION) if got != ver: raise StepError(f"{plugin} の plugin.json の版が {got} で、今回の版 {ver} でない(bump が通っていない)", EXIT_PRECONDITION) + + +def _ver_key(v): + return tuple(int(x) for x in v.split(".")) + + +def versioning_doc(ed): + """docs/versioning-and-distribution.md の「版の付け方と開発版の配布」章の正式版の版数。 + + 正式版の行が開発版・公開前の確認版の例の基底に追いついたら、例(表の 2 行・次の開発の例・接尾辞を外す例・ + 順序の例)の基底を次の MINOR へ進める(check-doc-staleness.py の「正式版の行より新しい版」を保つ)。 + """ + doc = ed.root / "docs" / "versioning-and-distribution.md" + rel = "docs/versioning-and-distribution.md" + ob, nb = base_of(ed.old), base_of(ed.new) + if ob == nb: + return + if not doc.is_file(): + ed.manual.append(f"{rel} が無い(この章は手で直す)") + return + lines = ed.lines(doc) + fenced = md.fenced_lines("\n".join(lines)) + head = next((i for i, l in enumerate(lines) if l.startswith("## 版の付け方と開発版の配布")), None) + if head is None: + ed.manual.append(f"{rel}: 「版の付け方と開発版の配布」章が無い(この章は手で直す)") + return + tail = next((i for i in range(head + 1, len(lines)) if lines[i].startswith("## ")), len(lines)) + dev_rows = [l for l in lines[head:tail] if re.match(r"^\| 開発版 \| `", l)] + m = re.search(r"`(\d+\.\d+\.\d+)-dev\.\d+`", dev_rows[0]) if len(dev_rows) == 1 else None + example = m.group(1) if m else None + advance = None + if example is None: + ed.manual.append(f"{rel}: 「版の付け方と開発版の配布」章の開発版の行が特定できない(この章は手で直す)") + elif _ver_key(example) <= _ver_key(nb): + if example == ob: + ed.manual.append(f"{rel}: 開発版の例の基底 {example} が旧版と同じ(この章は手で直す)") + else: + major, minor, _ = _ver_key(nb) + nxt = f"{major}.{minor + 1}.0" + ex_rx = re.compile(r"`" + re.escape(example) + r"((?:-(?:dev|rc)\.\d+)?)`") + advance = (ex_rx, nxt) + + def advanced(line): + return advance[0].sub(lambda mm: f"`{advance[1]}{mm.group(1)}`", line) if advance else line + + targets = [ + (re.compile(r"^\| 正式版 \| `([^`]+)` \|"), "正式版の表の行"), + (re.compile(r"`([^`]+)` の次を開発するなら"), "接尾辞の例"), + ] + done = set() + for rx, what in targets: + hits = [i for i in range(head, tail) if rx.search(lines[i])] + if len(hits) != 1: + ed.manual.append(f"{rel}: 「版の付け方と開発版の配布」章の{what}が特定できない(この章は手で直す)") + continue + i = hits[0] + done.add(i) + cur = rx.search(lines[i]).group(1) + if cur == nb: + continue + if cur != ob: + ed.manual.append(f"{rel}: {what}の版が {cur} で旧版 {ob} と違う(この章は手で直す)") + continue + esc = braces(lines[i]) + to = braces(advanced(lines[i])) + ed.place(doc, [i], esc.replace(f"`{ob}`", f"`{CUR_BASE}`", 1), to.replace(f"`{ob}`", f"`{NEW_BASE}`", 1), what) + if advance is None: + return + for i in range(head, tail): + if i in done or advanced(lines[i]) == lines[i] or (i < len(fenced) and fenced[i]): + continue + ed.place(doc, [i], braces(lines[i]), braces(advanced(lines[i])), "開発版の例") diff --git a/plugins/ndf/skills/design/references/layout-common.md b/plugins/ndf/skills/design/references/layout-common.md index 13b32be00..32b918482 100644 --- a/plugins/ndf/skills/design/references/layout-common.md +++ b/plugins/ndf/skills/design/references/layout-common.md @@ -11,11 +11,12 @@ **版面・書体・色を正本の Markdown へ書かない。** 埋めると生成の経路が 1 つに固定される。 体裁設計の産物は**生成へ渡す入力**である。 -| 生成の経路 | 渡し方 | -| --- | --- | -| 変換器を使う | その変換器の設定(前付け・テーマ・スタイル) | -| **AI エージェントが組む** | **指示として渡す**(版面・書体・色・1 枚あたりの情報量・図表の種類) | -| 既存のテンプレートへ流し込む | テンプレートの識別子 | +| 生成の経路 | 使う形 | 渡し方 | +| --- | --- | --- | +| **生成スクリプトを書く** | **スライド(スライドの経路はこれだけ)** | **トーン&マナーのファイル(`slides/theme.json`)とページの枠組み**(`release` の `form-slide.md`) | +| 変換器を使う | 文書・表計算・ページ | その変換器の設定(前付け・テーマ・スタイル) | +| **AI エージェントが組む** | 文書・表計算・ページ | **指示として渡す**(版面・書体・色・1 ページあたりの情報量・図表の種類) | +| 既存のテンプレートへ流し込む | 文書・表計算・ページ | テンプレートの識別子 | **渡し方が違っても、決める項目は同じである。** 項目は対象形態の参照が持つ。 diff --git a/plugins/ndf/skills/design/references/layout-slide.md b/plugins/ndf/skills/design/references/layout-slide.md index 8d38bf33d..ce0b75cd2 100644 --- a/plugins/ndf/skills/design/references/layout-slide.md +++ b/plugins/ndf/skills/design/references/layout-slide.md @@ -14,10 +14,18 @@ | 書体 | 見出しと本文。**配布先の環境にある書体か**を確かめる | | 文字の大きさ | 見出し / 本文 / 注釈の 3 種類 | | 色 | 地・文字・強調の 3 つ。**強調は 1 色に絞る** | -| 1 枚あたりの情報量 | **1 枚 1 主張。** 行数と図の数の上限 | +| 1 ページあたりの情報量 | **1 ページ 1 メッセージ。** 行数と図の数の上限 | +| ページの文言 | **台本の文を縮めて載せない。** 見出しはそのページの結論を短く、箱・表・箇条書きは短い句か体言止めにする。聞き手が知らない内部の語は、聞き手に通じる語へ言い換えるか、ページの中で一言で説明を添える | +| 章立て | **章扉で構成を見せる。** 章の名前と一言を決める。章扉はスピーカーノートを持たず、台本のページを消費しない | | 図表の種類 | 使う図の種類と、使わない種類 | -| 生成の経路 | 変換器か、AI エージェントか。**選んだ経路が「生成物を保存するか」を決める** | +| トーン&マナー | 上の書体・色・文字の大きさに、著作権表示の文言とロゴの有無を加えた組。**既定は会社色を持たない汎用テンプレートの値**で、変える項目だけを決める | +| ページの枠組み | 見出し・罫線・ロゴ・著作権表示・ページ番号・注の位置。**汎用テンプレートの枠組みの座標を使い、資料ごとに決め直さない** | +| 構成 | ページごとの見出し・役割・根拠と、持ち時間に収まる尺(構成メモと台本。`form-slide.md`) | + +**生成の経路は「生成スクリプトを書く」の 1 つである**(`release` の `form-slide.md`)。決めた値は設計文書の入出力の契約に +書き、トーン&マナーは資料の `slides/theme.json` へ写す。項目名と既定値は汎用テンプレートの枠組み +(`release` の `assets/slides/lib/deck-frame.js` の `DEFAULT_THEME`)が持つ。 ## 関連 -- `release` の `form-slide.md` — 決めた値を生成へ渡す手順 +- `release` の `form-slide.md` — 決めた値を生成へ渡す手順(汎用テンプレート・構成メモと台本・変換と確認) diff --git a/plugins/ndf/skills/document-systems/references/system-gdrive.md b/plugins/ndf/skills/document-systems/references/system-gdrive.md index a60766ae1..f8d65f86c 100644 --- a/plugins/ndf/skills/document-systems/references/system-gdrive.md +++ b/plugins/ndf/skills/document-systems/references/system-gdrive.md @@ -37,11 +37,19 @@ ## 投稿の手段 -**Markdown から Google スライド / ドキュメントを作る手段は、この Skill には無い。** -`google-workspace` が持つのはアップロードと共有で、変換は持たない。生成の経路は -`release` の `form-<出力の形>.md` が決める(変換器を使うか、AI エージェントが組むか)。 +**Markdown から Google スライド / ドキュメントを作る手段は、この Skill には無い。** 生成の経路は +`release` の `form-<出力の形>.md` が決める。 -**アップロードと共有は `google-workspace` が持つ。** 内容をここへコピーしない。 +**アップロード・変換・共有のコマンドは `google-workspace` の対応表が持つ。** 内容をここへコピーしない。 + +**Google スライドは、生成スクリプトが作った pptx を変換して作る**(`release` の `form-slide.md`)。 + +| 場合 | 順 | +| --- | --- | +| 新しく作る | pptx をフォルダを指定せず `ignoreDefaultVisibility` を付けて上げ(非公開。`google-workspace` の対応表の変換の 1 手目)、応答の `id` を `mimeType` を Google Slides に指定して複製する。複製が成果物である | +| 既にある Google スライドを直す | 同じファイルの ID へ pptx を上げ直す。**URL と共有の設定はそのまま残る。** pptx のあるディレクトリで打つ | + +フォルダへ入れるときは、先にフォルダの共有範囲を確かめる(`google-workspace` の手順)。 ## 本文の表現 @@ -59,7 +67,11 @@ Google のネイティブ形式(スライド / ドキュメント / スプレ ## 描画して見る手段 -`google-workspace` の対応表のエクスポート(`mimeType` は `application/pdf`)で PDF にしてから、ページを画像へ変換する。 +| 対象 | 手段 | +| --- | --- | +| Google スライド | **ページごとのサムネイル。** `google-workspace` の `slides-thumbnails.py` が全ページを `p<番号>.png` で保存する。Google Slides の描画をそのまま見られる | +| Google ドキュメント | `google-workspace` の対応表のエクスポート(`mimeType` は `application/pdf`)で PDF にしてから、ページを画像へ変換する | + **体裁レビューはこの経路を使う。** ## 既知の失敗 @@ -69,3 +81,4 @@ Google のネイティブ形式(スライド / ドキュメント / スプレ | gws が未認証で失敗する | `google-workspace` の手順で確かめ、利用者に `gws auth login` を頼む | | 取り込んだテキストがほぼ 1 行になる | 正規化する([import.md](import.md)) | | ページ番号が本文へ連結する | 同上 | +| pptx で差し替えた Google スライドの本文の書体が、pptx の指定でなく Arial になる(同じ pptx の差し替え 4 回のうち 1 回で起きた) | サムネイルで書体が変わっていないかを見る。疑わしければ `gws slides presentations get --params '{"presentationId":"","fields":"slides(pageElements(shape(text(textElements(textRun(style(fontFamily)))))))"}'` で書体を数え、トーン&マナーの書体でなければ差し替えを打ち直す(空白だけの文字列は既定の書体のまま残ってよい) | diff --git a/plugins/ndf/skills/document-systems/references/system-repo.md b/plugins/ndf/skills/document-systems/references/system-repo.md index a8f3cde57..82286ed52 100644 --- a/plugins/ndf/skills/document-systems/references/system-repo.md +++ b/plugins/ndf/skills/document-systems/references/system-repo.md @@ -2,9 +2,10 @@ **生成物を git のリポジトリへ置く形である。** HTML / PDF / Markdown が対象になる。 -**このリポジトリに前例が 1 つある。** `docs/presentations/` は `.md` から `build.sh` で -`.pdf` と `.html` を生成し、両方をコミットしている。**ただし標準ではなく 1 つの実例である** -(README が「発表日時点の記録で、以後の構成変更には追随しない」と明記している)。 +**このリポジトリに前例が 1 つある。** `docs/presentations/2026-08-06-ai-plugins-intro` は、当時の Marp で +`.md` から書き出した `.pdf` と `.html` を記録としてコミットしている。**ただし標準ではなく、作り直す手順も残していない** +(README が「発表日時点の記録で、以後の構成変更には追随しない」と明記している。スライドの作り方は `release` の +`form-slide.md` が持つ)。 ## 認証 diff --git a/plugins/ndf/skills/google-workspace/SKILL.md b/plugins/ndf/skills/google-workspace/SKILL.md index be1a50dc2..1a3ae07ea 100644 --- a/plugins/ndf/skills/google-workspace/SKILL.md +++ b/plugins/ndf/skills/google-workspace/SKILL.md @@ -59,10 +59,16 @@ gws は Google の公式サポート外の CLI で、版は 0.x である。 | ファイルのダウンロード | `gws drive files get --params '{"fileId":"","alt":"media"}' --output <ファイル>` | | アップロード(フォルダを指定しなければ非公開) | `gws drive +upload <ファイル>`(フォルダへは `--parent <フォルダの ID>`、名前は `--name <名前>`) | | フォルダの共有範囲の確認(`--parent` を付ける前に必ず) | `gws drive permissions list --params '{"fileId":"<フォルダの ID>","supportsAllDrives":true,"fields":"nextPageToken,permissions(type,role,domain,emailAddress)"}' --page-all` | +| pptx を Google Slides へ変換する 1 手目(pptx を非公開で上げる。pptx のあるディレクトリで打つ) | `gws drive files create --params '{"ignoreDefaultVisibility":true}' --json '{"name":"<名前>"}' --upload <ファイル>.pptx` | +| pptx を Google Slides へ変換する 2 手目(上げた pptx を `mimeType` を指定して複製する) | `gws drive files copy --params '{"fileId":"<上げた pptx の ID>","ignoreDefaultVisibility":true}' --json '{"name":"<名前>","mimeType":"application/vnd.google-apps.presentation"}'`(フォルダへは `"parents":["<フォルダの ID>"]` を足す) | +| 既存の Google Slides を同じ URL のまま pptx で差し替える(pptx のあるディレクトリで打つ。`--upload` は作業ディレクトリの外のファイルを受けない) | `gws drive files update --params '{"fileId":""}' --upload <ファイル>.pptx --upload-content-type application/vnd.openxmlformats-officedocument.presentationml.presentation` | +| Google Slides のページの ID(ページ順) | `gws slides presentations get --params '{"presentationId":"","fields":"slides(objectId)"}'` | +| Google Slides の全ページのサムネイル(`p<番号>.png`) | `python3 "$SKILL_DIR/scripts/slides-thumbnails.py" <出力のディレクトリ> [--size LARGE]`(終了コード 11 は未認証。3. へ) | | 公開リンクの付与(利用者が公開を明示したときだけ) | `gws drive permissions create --params '{"fileId":""}' --json '{"type":"anyone","role":"reader"}'` | - **フォルダを指定しないアップロードは非公開である。** フォルダへ入れたファイルは、そのフォルダの共有権限を引き継ぐ。`permissions create` を打たなくても、公開のフォルダへ入れれば公開になる - **`--parent` を付ける前に、フォルダの共有範囲を確かめる。** 上の `permissions list` の応答に、利用者が承認した範囲を超える共有(`type` が `anyone`、承認していない `domain`・`group`・`user`)があれば、アップロードせずに止まり、その共有を示して利用者の判断を待つ。応答は 1 ページに 1 行の JSON で出るため、全部の行の `permissions` を合わせて見る。最後の行に `nextPageToken` が残っている(`--page-all` のページの上限で読み切れなかった)ときと、共有範囲を読めない(終了コードが 0 でない)ときも止まる +- **pptx の変換は 2 手で打つ。** `files create` で pptx を上げ、応答の `id` を `files copy` に渡す。複製が Google Slides になり、上げた pptx は残る(消すかは利用者が決める)。`parents` を書かなければ、上げた pptx も複製もマイドライブの直下(非公開)に入る。1 手目に `+upload` を使わない(`ignoreDefaultVisibility` を渡せない)。どちらの手も `ignoreDefaultVisibility` を外さない(外すと Workspace の管理者が決めたドメイン内共有の既定値が付き、非公開にならない) - 公開リンクは、利用者が公開を明示したときだけ、アップロードの応答の `id` に付ける。付与が失敗したら、ファイルの共有範囲はアップロード先のまま(フォルダを指定しなければ非公開、指定したらそのフォルダから引き継いだ範囲)と伝える - 打つ前に要求の形だけを確かめたいときは `--dry-run` を付ける - 対応表の外の操作(Sheets の編集・Gmail・Calendar など)は `gws <サービス> --help` で引数を確かめてから打つ diff --git a/plugins/ndf/skills/google-workspace/scripts/gws-check.py b/plugins/ndf/skills/google-workspace/scripts/gws-check.py index 4eec0b484..8c091234e 100644 --- a/plugins/ndf/skills/google-workspace/scripts/gws-check.py +++ b/plugins/ndf/skills/google-workspace/scripts/gws-check.py @@ -34,21 +34,19 @@ import step_result as sr # noqa: E402 +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) +from gws_common import emit_stopped, output_tail # noqa: E402 + TOOL = "google-workspace" PACKAGE = "@googleworkspace/cli" PACKAGE_URL = "https://www.npmjs.com/package/@googleworkspace/cli" INSTALL_CMD = ["npm", "install", "-g", PACKAGE] PROBE_KEYS = ("credential_source", "auth_method", "client_config_exists") EXIT_UNAUTHENTICATED = sr.EXIT_GATE + 1 -TAIL_LINES = 20 TIMEOUT = 120 INSTALL_TIMEOUT = 600 -def _tail(text: str) -> str: - return "\n".join((text or "").strip().splitlines()[-TAIL_LINES:]) - - def _run(cmd: list[str], timeout: int = TIMEOUT) -> subprocess.CompletedProcess: return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, check=False) @@ -64,7 +62,7 @@ def _npm_prefix(npm: str) -> str | None: def _halt(summary: str, code: int, items=None, next_: str | None = None, metrics=None): - sr.emit(sr.result(TOOL, "stopped", summary, items=items, metrics=metrics, next=next_), code) + emit_stopped(TOOL, summary, code, items=items, next_=next_, metrics=metrics) def _probe(gws: str) -> dict: @@ -73,7 +71,7 @@ def _probe(gws: str) -> dict: cp = _run([gws, "auth", "status"]) except (OSError, subprocess.TimeoutExpired) as e: _halt(f"gws auth status を起動できない: {e}", sr.EXIT_UNREADABLE) - stderr_item = [{"name": "gws auth status の標準エラーの末尾", "result": "stderr", "detail": _tail(cp.stderr)}] + stderr_item = [{"name": "gws auth status の標準エラーの末尾", "result": "stderr", "detail": output_tail(cp.stderr)}] if cp.returncode != 0: _halt(f"gws auth status が終了コード {cp.returncode} で終わった", sr.EXIT_UNREADABLE, items=stderr_item) try: @@ -132,7 +130,7 @@ def _install(npm: str) -> None: _halt( f"npm install -g が終了コード {cp.returncode} で終わった", sr.EXIT_VIOLATION, - items=[{"name": "npm の出力の末尾", "result": "failed", "detail": _tail(cp.stdout + "\n" + cp.stderr)}], + items=[{"name": "npm の出力の末尾", "result": "failed", "detail": output_tail(cp.stdout + "\n" + cp.stderr)}], next_="出力を利用者に示して止まる。sudo で打ち直さない", ) diff --git a/plugins/ndf/skills/google-workspace/scripts/gws_common.py b/plugins/ndf/skills/google-workspace/scripts/gws_common.py new file mode 100644 index 000000000..ecc1c8906 --- /dev/null +++ b/plugins/ndf/skills/google-workspace/scripts/gws_common.py @@ -0,0 +1,27 @@ +"""google-workspace のスクリプトが共有する部品(#2065)。 + +gws の出力の末尾を切り出す関数と、stopped の結果を出して終える関数を 1 か所に置く。 +""" + +from __future__ import annotations + +import pathlib +import sys + +LIB = pathlib.Path(__file__).resolve().parents[3] / "scripts" / "lib" +if str(LIB) not in sys.path: + sys.path.insert(0, str(LIB)) + +import step_result as sr # noqa: E402 + +TAIL_LINES = 20 + + +def output_tail(text: str) -> str: + """出力の末尾の TAIL_LINES 行。""" + return "\n".join((text or "").strip().splitlines()[-TAIL_LINES:]) + + +def emit_stopped(tool: str, summary: str, code: int, items=None, next_: str | None = None, metrics=None): + """stopped の結果を 1 行の JSON で出し、code で終える。""" + sr.emit(sr.result(tool, "stopped", summary, items=items, metrics=metrics, next=next_), code) diff --git a/plugins/ndf/skills/google-workspace/scripts/slides-thumbnails.py b/plugins/ndf/skills/google-workspace/scripts/slides-thumbnails.py new file mode 100644 index 000000000..a27c706f5 --- /dev/null +++ b/plugins/ndf/skills/google-workspace/scripts/slides-thumbnails.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +"""slides-thumbnails.py: Google Slides の全ページのサムネイルを PNG で保存する(#2065)。 + + python3 slides-thumbnails.py <出力のディレクトリ> [--size LARGE] + +1. `gws slides presentations get` でページの ID(`objectId`)をページ順に取る +2. ページごとに `gws slides presentations pages getThumbnail` を打ち、返った `contentUrl` から画像を取る +3. `<出力のディレクトリ>/p<番号>.png` へ保存する(番号は 1 から、ページ順) + +結果は `step_result` の形の 1 行の JSON で標準出力へ出す。`items` にページごとの `objectId` と保存先、 +`metrics.pages` にページ数を載せる。 + +| 終了コード | status | 意味 | +| --- | --- | --- | +| 0 | ok | 全ページを保存した | +| 1 | stopped | gws か画像の取得が失敗した(gws の出力の末尾を `items` に載せる) | +| 2 | stopped | 引数の誤り・gws の応答を読めない | +| 11 | gate | gws が未認証(`gws-check.py` と同じ値)。利用者に `gws auth login` を頼む | + +- 未認証かどうかは、gws が失敗したときだけ `gws auth status` を 1 回打ち、`credential_source` が `none` かで決める + (`gws-check.py` と同じ判定。gws のエラーの文言には頼らない) + +- gws の認証情報は読まない。認証は gws が自分の設定ディレクトリに持つ(C1) +- `contentUrl` は要求した利用者の権限で開ける一時的な URL で、認証なしで curl で取る +""" + +from __future__ import annotations + +import argparse +import json +import pathlib +import shutil +import subprocess +import sys + +LIB = pathlib.Path(__file__).resolve().parents[3] / "scripts" / "lib" +if str(LIB) not in sys.path: + sys.path.insert(0, str(LIB)) + +import step_result as sr # noqa: E402 + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) +from gws_common import emit_stopped, output_tail # noqa: E402 + +TOOL = "slides-thumbnails" +EXIT_UNAUTHENTICATED = sr.EXIT_GATE + 1 +SIZES = ("SMALL", "MEDIUM", "LARGE") +TIMEOUT = 120 +CURL = "curl" + + +def _unauthenticated(gws: str) -> bool: + """`gws auth status` の `credential_source` が `none` なら True。読めなければ False(一般の失敗として扱う)。""" + try: + cp = subprocess.run([gws, "auth", "status"], capture_output=True, text=True, timeout=TIMEOUT, check=False) + data = json.loads(cp.stdout) if cp.returncode == 0 else None + except (OSError, subprocess.TimeoutExpired, json.JSONDecodeError): + return False + return isinstance(data, dict) and data.get("credential_source") == "none" + + +def _gws(gws: str, args: list[str], what: str) -> dict: + """gws を打って応答の JSON を返す。未認証は 11、失敗は 1、読めない応答は 2 で終える。""" + try: + cp = subprocess.run([gws, *args], capture_output=True, text=True, timeout=TIMEOUT, check=False) + except (OSError, subprocess.TimeoutExpired) as e: + emit_stopped(TOOL, f"{what}: gws を起動できない: {e}", sr.EXIT_VIOLATION) + out = output_tail(cp.stdout + "\n" + cp.stderr) + if cp.returncode != 0: + if _unauthenticated(gws): + sr.emit( + sr.result( + TOOL, + "gate", + "gws が未認証", + items=[{"name": what, "result": "unauthenticated", "detail": out}], + metrics={"state": "unauthenticated"}, + next="google-workspace の手順 3. で利用者に gws auth login を頼み、終えたら打ち直す", + ), + EXIT_UNAUTHENTICATED, + ) + emit_stopped( + TOOL, + f"{what}: gws が終了コード {cp.returncode} で終わった", + sr.EXIT_VIOLATION, + items=[{"name": what, "result": "gws-failed", "detail": out}], + ) + try: + return json.loads(cp.stdout) + except json.JSONDecodeError: + emit_stopped( + TOOL, f"{what}: gws の応答が JSON でない", sr.EXIT_UNREADABLE, items=[{"name": what, "result": "unreadable", "detail": out}] + ) + return {} + + +def _page_ids(gws: str, pid: str) -> list[str]: + data = _gws( + gws, + ["slides", "presentations", "get", "--params", json.dumps({"presentationId": pid, "fields": "slides(objectId)"})], + "ページの ID の取得", + ) + slides = data.get("slides") if isinstance(data, dict) else None + ids = [s.get("objectId") for s in slides or [] if isinstance(s, dict)] + if not ids or not all(isinstance(i, str) and i for i in ids): + emit_stopped(TOOL, "ページの ID を読めない(slides[].objectId が無い)", sr.EXIT_UNREADABLE) + return ids + + +def _thumbnail(gws: str, pid: str, oid: str, size: str, dest: pathlib.Path, no: int) -> None: + what = f"p{no}({oid})のサムネイル" + params = {"presentationId": pid, "pageObjectId": oid, "thumbnailProperties.thumbnailSize": size} + data = _gws(gws, ["slides", "presentations", "pages", "getThumbnail", "--params", json.dumps(params)], what) + url = data.get("contentUrl") if isinstance(data, dict) else None + if not isinstance(url, str) or not url: + emit_stopped(TOOL, f"{what}: 応答に contentUrl が無い", sr.EXIT_UNREADABLE) + try: + cp = subprocess.run([CURL, "-fsSL", "--max-time", str(TIMEOUT), "-o", str(dest), url], capture_output=True, text=True, check=False) + except OSError as e: + emit_stopped(TOOL, f"{what}: curl を起動できない: {e}", sr.EXIT_VIOLATION) + if cp.returncode != 0: + emit_stopped( + TOOL, + f"{what}: 画像を取れない(curl の終了コード {cp.returncode})", + sr.EXIT_VIOLATION, + items=[{"name": what, "result": "download-failed", "detail": output_tail(cp.stderr)}], + ) + + +def main(argv: list[str] | None = None) -> None: + ap = argparse.ArgumentParser(description="Google Slides の全ページのサムネイルを PNG で保存する") + ap.add_argument("presentation_id") + ap.add_argument("out_dir") + ap.add_argument("--size", default="LARGE", choices=SIZES) + try: + a = ap.parse_args(argv) + except SystemExit: + emit_stopped(TOOL, "引数の誤り", sr.EXIT_UNREADABLE) + gws = shutil.which("gws") + if not gws: + emit_stopped(TOOL, "gws が無い。google-workspace の手順 1. で確かめる", sr.EXIT_PRECONDITION) + if not shutil.which(CURL): + emit_stopped(TOOL, "curl が無い(サムネイルの画像を取るのに使う)", sr.EXIT_PRECONDITION) + out = pathlib.Path(a.out_dir) + out.mkdir(parents=True, exist_ok=True) + ids = _page_ids(gws, a.presentation_id) + items = [] + for no, oid in enumerate(ids, start=1): + dest = out / f"p{no}.png" + _thumbnail(gws, a.presentation_id, oid, a.size, dest, no) + items.append({"name": f"p{no}", "result": "saved", "objectId": oid, "detail": str(dest)}) + sr.emit( + sr.result( + TOOL, + "ok", + f"{len(ids)} ページのサムネイルを保存した", + items=items, + metrics={"pages": len(ids), "size": a.size, "out_dir": str(out)}, + ), + sr.EXIT_OK, + ) + + +if __name__ == "__main__": + main() diff --git a/plugins/ndf/skills/google-workspace/tests/test_slides_thumbnails.py b/plugins/ndf/skills/google-workspace/tests/test_slides_thumbnails.py new file mode 100644 index 000000000..52e523617 --- /dev/null +++ b/plugins/ndf/skills/google-workspace/tests/test_slides_thumbnails.py @@ -0,0 +1,124 @@ +"""slides-thumbnails.py の振る舞い(#2065)。 + +gws は PATH に置いた偽物で置き換える。偽物は呼ばれた引数を FAKE_CALLS の JSON Lines へ積み、 +`presentations get` には FAKE_IDS のページの ID を、`getThumbnail` にはページの ID ごとの +`file://` の画像の URL を返す。FAKE_FAIL_ON の ID でだけ失敗し、FAKE_AUTH_FAIL で失敗して `auth status` の +`credential_source` に `none` を返す(未認証の判定はエラーの文言でなく `auth status` で行う)。 +""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +SKILL = HERE.parent +SCRIPT = SKILL / "scripts" / "slides-thumbnails.py" +sys.path.insert(0, str(SKILL.parents[1] / "scripts" / "lib")) +from step_result import validate_result # noqa: E402 + +FAKE_GWS = r"""#!{py} +import json, os, sys +a = sys.argv[1:] +with open(os.environ["FAKE_CALLS"], "a", encoding="utf-8") as f: + f.write(json.dumps(a) + "\n") +sys.stderr.write("This is not an officially supported Google product.\n") +if a == ["auth", "status"]: + src = "none" if os.environ.get("FAKE_AUTH_FAIL") else "token_cache" + print(json.dumps({"credential_source": src, "auth_method": "oauth2", "client_config_exists": True})) + sys.exit(0) +if os.environ.get("FAKE_AUTH_FAIL"): + sys.stderr.write("error: request failed (the wording is not relied on)\n") + sys.exit(1) +params = json.loads(a[a.index("--params") + 1]) +if a[:3] == ["slides", "presentations", "get"]: + print(json.dumps({"slides": [{"objectId": i} for i in os.environ["FAKE_IDS"].split(",")]})) + sys.exit(0) +if a[:4] == ["slides", "presentations", "pages", "getThumbnail"]: + oid = params["pageObjectId"] + if oid == os.environ.get("FAKE_FAIL_ON"): + print(json.dumps({"error": {"code": 500, "message": "backend error"}})) + sys.exit(1) + img = os.path.join(os.environ["FAKE_IMG_DIR"], oid + ".png") + with open(img, "wb") as f: + f.write(b"PNG-" + oid.encode()) + print(json.dumps({"width": 1600, "height": 900, "contentUrl": "file://" + img})) + sys.exit(0) +sys.exit(3) +""" + + +@pytest.fixture +def env(tmp_path: Path) -> dict: + bin_ = tmp_path / "bin" + bin_.mkdir() + gws = bin_ / "gws" + gws.write_text(FAKE_GWS.replace("{py}", sys.executable), encoding="utf-8") + gws.chmod(0o755) + imgs = tmp_path / "imgs" + imgs.mkdir() + e = dict(os.environ) + e.update( + PATH=f"{bin_}{os.pathsep}{e['PATH']}", FAKE_CALLS=str(tmp_path / "calls.jsonl"), FAKE_IMG_DIR=str(imgs), FAKE_IDS="g1,p2_x,abc" + ) + return e + + +def _run(env: dict, *args: str): + cp = subprocess.run([sys.executable, str(SCRIPT), *args], env=env, capture_output=True, text=True, check=False) + out = json.loads(cp.stdout.strip().splitlines()[-1]) + assert validate_result(out, cp.returncode) == [] + calls = ( + [json.loads(line) for line in Path(env["FAKE_CALLS"]).read_text(encoding="utf-8").splitlines()] + if Path(env["FAKE_CALLS"]).exists() + else [] + ) + return cp.returncode, out, calls + + +def test_saves_every_page_in_order(env: dict, tmp_path: Path) -> None: + out_dir = tmp_path / "thumbs" + code, out, calls = _run(env, "PRES", str(out_dir)) + assert code == 0 + assert out["metrics"]["pages"] == 3 + assert [(i["name"], i["objectId"]) for i in out["items"]] == [("p1", "g1"), ("p2", "p2_x"), ("p3", "abc")] + for no, oid in enumerate(["g1", "p2_x", "abc"], start=1): + assert (out_dir / f"p{no}.png").read_bytes() == b"PNG-" + oid.encode() + thumbs = [json.loads(c[c.index("--params") + 1]) for c in calls if "getThumbnail" in c] + assert [t["pageObjectId"] for t in thumbs] == ["g1", "p2_x", "abc"] + assert {t["thumbnailProperties.thumbnailSize"] for t in thumbs} == {"LARGE"} + assert all(t["presentationId"] == "PRES" for t in thumbs) + + +def test_gws_failure_stops_with_1_and_shows_output(env: dict, tmp_path: Path) -> None: + env["FAKE_FAIL_ON"] = "p2_x" + code, out, calls = _run(env, "PRES", str(tmp_path / "thumbs")) + assert code == 1 + assert out["status"] == "stopped" + assert ["auth", "status"] in calls + assert "backend error" in out["items"][0]["detail"] + assert not (tmp_path / "thumbs" / "p2.png").exists() + + +def test_unauthenticated_returns_11(env: dict, tmp_path: Path) -> None: + env["FAKE_AUTH_FAIL"] = "1" + code, out, _ = _run(env, "PRES", str(tmp_path / "thumbs")) + assert code == 11 + assert out["status"] == "gate" + assert out["metrics"]["state"] == "unauthenticated" + + +def test_success_never_calls_auth_status(env: dict, tmp_path: Path) -> None: + _, _, calls = _run(env, "PRES", str(tmp_path / "thumbs")) + assert ["auth", "status"] not in calls + + +def test_bad_arguments_return_2(env: dict) -> None: + code, out, calls = _run(env, "PRES") + assert code == 2 + assert calls == [] diff --git a/plugins/ndf/skills/layout-review/SKILL.md b/plugins/ndf/skills/layout-review/SKILL.md index 7aac8bad6..ba4e3b7e5 100644 --- a/plugins/ndf/skills/layout-review/SKILL.md +++ b/plugins/ndf/skills/layout-review/SKILL.md @@ -24,7 +24,7 @@ allowed-tools: | --- | --- | | 事実・数値・出典・表現・構成 | コードレビュー(`cross-review`)と完了判定の事実確認 | | 生成物と正本の内容の一致 | リリースの工程の内容照合(`document-systems` の `import.md`) | -| **版面**(文字あふれ・図の重なり・改ページ・書体) | **この Skill** | +| **版面**(文字あふれ・図の重なり・改ページ・書体・聞き手の目で文言が読めるか) | **この Skill** | **重なる観点を持たない。** 版面の指摘と内容の指摘が混ざると、どちらの工程へ戻すのかが 決まらない。 @@ -41,7 +41,7 @@ allowed-tools: | 見方 | 項目 | | --- | --- | | 機械で測る | 外部への参照が残っていないか / 図の横幅 / ページ数 / 1 枚あたりの文字数 | -| **人が画像を見る** | **枠からの文字あふれ / 図の重なり / 改ページの位置 / 書体の代替** | +| **人が画像を見る** | **枠からの文字あふれ / 図の重なり / 改ページの位置 / 書体の代替 / 聞き手の目で文言が読めるか(1 ページの文字量・文の長さ・説明の無い内部の語)** | **機械で測るものを先に通す。** 落ちたらそこで生成へ戻す。画像を見るのはその後である。 @@ -54,7 +54,8 @@ allowed-tools: | --- | --- | | HTML | `playwright-kit` の `playwright-evidence`(スクリーンショット) | | PDF | ページを画像へ変換する | -| Google スライド / ドキュメント | PDF へ書き出してから画像へ(`document-systems` の `system-gdrive.md`) | +| Google スライド | ページごとのサムネイル(`document-systems` の `system-gdrive.md`) | +| Google ドキュメント | PDF へ書き出してから画像へ(`document-systems` の `system-gdrive.md`) | | システムのページ | **システムごとに違う。** `system-<名前>.md` が持つ。無いシステムもある | **手段は `document-systems` が持つ。** ここで重ねて定めない。 @@ -92,7 +93,8 @@ allowed-tools: | 原因 | 戻す先 | | --- | --- | | 版面の決め方(余白・文字の大きさ・1 枚あたりの情報量) | 体裁設計(`design` の `layout-<出力の形>.md`) | -| 生成の経路(変換器の設定、指示の与え方) | リリースの生成の手順(`release` の `form-<出力の形>.md`) | +| ページの文言(台本の文を縮めて載せた・説明の無い内部の語が残る) | 生成スクリプトのページの文言(`release` の `form-<出力の形>.md`) | +| 生成の経路(変換器の設定、指示の与え方、生成スクリプト) | リリースの生成の手順(`release` の `form-<出力の形>.md`) | | 内容が多すぎて収まらない | **執筆へ戻す**(主張の数を減らす) | ## 見た結果を残す diff --git a/plugins/ndf/skills/official-skills-autoloader/SKILL.md b/plugins/ndf/skills/official-skills-autoloader/SKILL.md index 97e12ee64..f8c6892df 100644 --- a/plugins/ndf/skills/official-skills-autoloader/SKILL.md +++ b/plugins/ndf/skills/official-skills-autoloader/SKILL.md @@ -16,7 +16,7 @@ allowed-tools: | ユーザー要求の例 | 使用するSkill | |---|---| | Word / .docx / 文書 / レポート | `docx` | -| PowerPoint / .pptx / スライド / プレゼン | `pptx` | +| 既存の PowerPoint / .pptx を読む・直す | `pptx` | | Excel / .xlsx / スプレッドシート / 表計算 | `xlsx` | | PDF 生成 / フォーム / .pdf 作成 | `pdf` | | フロントエンド設計 / UI設計 | `frontend-design` | @@ -26,6 +26,8 @@ allowed-tools: | Claude API / SDK開発 | `claude-api` | | MCPサーバー作成 | `mcp-builder` | +**スライド(プレゼン・発表資料)を新しく作る依頼は、公式 Skill を使わない。** `release` の `references/form-slide.md`(生成スクリプトの経路)へ回す。公式の `pptx` は既存の .pptx を読む・直す依頼だけに使う。 + ## 対応ランタイム **Claude Code 専用**。インストール先の `~/.claude/skills/` を読むのは Claude Code だけで、Codex は `.agents/skills/`、Kiro CLI は `.kiro/skills/` を読む。両ランタイムでは公式 Skill の自動読込は行われないため、配布するとしても Claude Code の manifest に限る。 diff --git a/plugins/ndf/skills/release/assets/slides/build-deck.js b/plugins/ndf/skills/release/assets/slides/build-deck.js new file mode 100644 index 000000000..c08879a61 --- /dev/null +++ b/plugins/ndf/skills/release/assets/slides/build-deck.js @@ -0,0 +1,31 @@ +// 生成スクリプト(汎用テンプレートの見本)。表紙・章扉・本編(表と注)の 3 ページを作る。 +// +// node build-deck.js <出力>.pptx [] +// +// 章扉は台本に区切りを置かない(台本は表紙と本編の 2 ページ)。 +// 写した後はページの中身だけを書き換える。書体と色は theme.json で差し替え、ここに値を書かない。 +// 色は theme.colors の項目名(accent・ink・panel1 など)で指す。 +const path = require("path"); +const { run } = require("./lib/deck-frame"); + +// 資料のディレクトリでは台本を直下の script.md に置き、ここを path.join(__dirname, "..", "script.md") にする +const SCRIPT = path.join(__dirname, "script.md"); + +run(__dirname, { script: SCRIPT, title: "<資料の題>", author: "<作成者>" }, (d) => { + // 1. 表紙 + d.cover("<資料の題>", "<副題>", "<日付> <作成者>"); + + // 章扉(台本の番号を進めず、ノートを持たない) + d.section("現状", "数値で見る前の期間との違い"); + + // 2. 本編(表と注) + const s = d.page("現状の数値", "件数は増え、時間は短くなった。費用だけが増えた"); + d.tbl(s, [ + [d.H("指標"), d.H("前の期間"), d.H("今の期間"), d.H("差")], + [d.B("件数"), d.C("120"), d.C("150"), d.C("+25%")], + [d.B("かかる時間"), d.C("40 分"), d.C("32 分"), d.C("−20%")], + [d.B("費用", { fill: { color: "caution" } }), d.C("10 万円"), d.C("13 万円"), d.C("+30%", { bold: true })], + ], { x: 0.26, y: 1.34, w: 9.48, colW: [2.4, 2.36, 2.36, 2.36], rowH: 0.36 }); + d.callout(s, 3.0, 0.9, "panel1", "費用の増え方が件数の増え方を上回る", "1 件あたりの費用は 833 円から 867 円に上がった。理由は次のページで示す。"); + d.note(s, "出典: <数値の出所>"); +}); diff --git a/plugins/ndf/skills/release/assets/slides/lib/deck-frame.js b/plugins/ndf/skills/release/assets/slides/lib/deck-frame.js new file mode 100644 index 000000000..629959582 --- /dev/null +++ b/plugins/ndf/skills/release/assets/slides/lib/deck-frame.js @@ -0,0 +1,433 @@ +"use strict"; +// ページの枠組み(汎用テンプレート)。pptxgenjs で 16:9(10 × 5.625 インチ)の pptx を作る。 +// +// - 座標は FRAME の定数だけが持つ。トーン&マナー(theme.json)は座標を持たない +// - 書体と色の値は DEFAULT_THEME と theme.json だけが持つ。関数に渡す色は theme.colors の項目名で受ける +// - 台本(script.md)を渡すと、ページを作るたびに同じ番号の読み上げをスピーカーノートへ付け、 +// 保存の前に台本のページ数と作ったページ数を照合する +// - 章扉(section)は構成を見せるページで、スピーカーノートを持たず、台本の番号を進めない。 +// ページ数の照合からも外す(ページ番号は章扉も数える) +// +// 資料のディレクトリへ写して使う。写した後に書き換えない(プロジェクトの値は theme.json へ書く)。 + +const fs = require("fs"); +const path = require("path"); + +const EXIT_INPUT = 1; // 引数・theme.json・ページ数の照合の失敗 +const EXIT_SCRIPT = 2; // 台本の形の失敗 + +class DeckError extends Error { + constructor(message, code) { + super(message); + this.code = code; + } +} + +// ---- 座標(インチ)。トーン&マナーに依らない ---- +const FRAME = Object.freeze({ + slide: { w: 10, h: 5.625 }, + title: { x: 0.26, y: 0.15, w: 8.29, h: 0.63 }, + rule: { x: 0.24, y: 0.8, w: 9.52 }, + logo: { x: 8.78, y: 0.24, w: 0.96, h: 0.23 }, + lead: { x: 0.26, y: 0.9, w: 9.5, h: 0.34 }, + copyright: { x: 0.26, y: 5.18, w: 3.2, h: 0.3 }, + pageNo: { x: 9.27, y: 5.18, w: 0.5, h: 0.3 }, + note: { x: 0.26, y: 4.88, w: 9.5, h: 0.28 }, + body: { x: 0.26, y: 1.34, w: 9.48 }, + callout: { x: 0.26, w: 9.48, padX: 0.22, headH: 0.28 }, + section: { + band: { x: 0, y: 0, w: 0.36, h: 5.625 }, + title: { x: 0.9, y: 1.9, w: 8.4, h: 0.8 }, + accent: { x: 0.92, y: 2.82, w: 1.2, width: 4 }, + sub: { x: 0.9, y: 3.0, w: 8.4, h: 0.4 }, + copyright: { x: 0.56, y: 5.18, w: 3.2, h: 0.3 }, + }, + cover: { + band: { x: 0, y: 0, w: 10, h: 0.36 }, + logo: { x: 0.26, y: 0.6, w: 0.96, h: 0.23 }, + title: { x: 1.02, y: 1.3, w: 8.2, h: 0.72 }, + accent: { x: 1.04, y: 2.1, w: 1.2, width: 4 }, + sub: { x: 1.02, y: 2.26, w: 8.2, h: 0.62 }, + meta: { x: 1.02, y: 3.55, w: 8.2, h: 0.4 }, + }, +}); + +// ---- 既定のトーン&マナー(会社色を持たない)。正本はここ ---- +const DEFAULT_THEME = Object.freeze({ + font: "Noto Sans JP", + colors: Object.freeze({ + accent: "2B6CB0", + accentSoft: "E3EDF7", + text: "000000", + ink: "303030", + mute: "7F7F7F", + rule: "D9D9D9", + panel1: "F3F3F3", + panel2: "EEEEEE", + warn: "F4CCCC", + caution: "FFF2CC", + header: "303030", + onHeader: "FFFFFF", + background: "FFFFFF", + }), + sizes: Object.freeze({ + title: 20, + lead: 12, + body: 10, + table: 10, + note: 8, + footer: 9, + calloutHead: 11.5, + calloutBody: 9.5, + sectionTitle: 32, + sectionSub: 14, + coverTitle: 28, + coverSub: 16, + coverMeta: 12, + }), + copyright: "", + logo: null, +}); + +const THEME_KEYS = ["font", "colors", "sizes", "copyright", "logo"]; +const HEX = /^[0-9A-Fa-f]{6}$/; + +// theme.json の値を既定値へ重ねる。colors と sizes は項目ごと、ほかは値ごと置き換える。 +function mergeTheme(override, baseDir) { + const t = { + font: DEFAULT_THEME.font, + colors: { ...DEFAULT_THEME.colors }, + sizes: { ...DEFAULT_THEME.sizes }, + copyright: DEFAULT_THEME.copyright, + logo: DEFAULT_THEME.logo, + }; + if (override == null) return t; + if (typeof override !== "object" || Array.isArray(override)) { + throw new DeckError("theme.json はオブジェクトでなければならない", EXIT_INPUT); + } + const unknown = Object.keys(override).filter((k) => !THEME_KEYS.includes(k)); + if (unknown.length) throw new DeckError(`theme.json に知らない項目がある: ${unknown.join(", ")}`, EXIT_INPUT); + if ("font" in override) { + if (typeof override.font !== "string" || !override.font) throw new DeckError("theme.json の font は空でない文字列", EXIT_INPUT); + t.font = override.font; + } + for (const group of ["colors", "sizes"]) { + if (!(group in override)) continue; + const g = override[group]; + if (typeof g !== "object" || g == null || Array.isArray(g)) throw new DeckError(`theme.json の ${group} はオブジェクト`, EXIT_INPUT); + const bad = Object.keys(g).filter((k) => !(k in DEFAULT_THEME[group])); + if (bad.length) throw new DeckError(`theme.json の ${group} に知らない項目がある: ${bad.join(", ")}`, EXIT_INPUT); + for (const [k, v] of Object.entries(g)) { + if (group === "colors" && !(typeof v === "string" && HEX.test(v))) { + throw new DeckError(`theme.json の colors.${k} は 6 桁の 16 進(# なし): ${v}`, EXIT_INPUT); + } + if (group === "sizes" && !(typeof v === "number" && v > 0)) { + throw new DeckError(`theme.json の sizes.${k} は正の数(pt): ${v}`, EXIT_INPUT); + } + t[group][k] = group === "colors" ? v.toUpperCase() : v; + } + } + if ("copyright" in override) { + if (typeof override.copyright !== "string") throw new DeckError("theme.json の copyright は文字列", EXIT_INPUT); + t.copyright = override.copyright; + } + if ("logo" in override) { + if (override.logo === null) { + t.logo = null; + } else if (typeof override.logo === "string" && override.logo) { + const p = path.resolve(baseDir || process.cwd(), override.logo); + if (!fs.existsSync(p)) throw new DeckError(`theme.json の logo のファイルが無い: ${p}`, EXIT_INPUT); + t.logo = p; + } else { + throw new DeckError("theme.json の logo は PNG の相対パスか null", EXIT_INPUT); + } + } + return t; +} + +function loadTheme(theme) { + if (theme == null) return mergeTheme(null); + if (typeof theme === "object") return mergeTheme(theme, process.cwd()); + if (!fs.existsSync(theme)) return mergeTheme(null); + let data; + try { + data = JSON.parse(fs.readFileSync(theme, "utf8")); + } catch (e) { + throw new DeckError(`theme.json を JSON として読めない: ${theme}: ${e.message}`, EXIT_INPUT); + } + return mergeTheme(data, path.dirname(path.resolve(theme))); +} + +// ---- 台本(script.md)の読み込み ---- +// `## <番号>. <見出し>` がページの区切り。見出しの末尾の `(<秒>秒)` は配分秒数。 +// 区切りの中の `>` で始まる行が読み上げ。`>` だけの行は段落の区切り。ほかの行は読まない。 +const HEAD = /^##\s+(\d+)\.\s*(.*?)\s*$/; +const SECONDS = /[((]\s*(\d+)\s*秒\s*[))]\s*$/; + +function plain(s) { + return s.replace(/\*\*/g, "").replace(/`/g, ""); +} + +function parseScript(text, name) { + const pages = []; + let cur = null; + let para = []; + const flush = () => { + if (cur && para.length) cur.paragraphs.push(para.join("")); + para = []; + }; + for (const raw of text.split(/\r?\n/)) { + const m = raw.match(HEAD); + if (m) { + flush(); + let title = m[2]; + let seconds = null; + const sm = title.match(SECONDS); + if (sm) { + seconds = Number(sm[1]); + title = title.slice(0, sm.index).trim(); + } + cur = { no: Number(m[1]), title, seconds, paragraphs: [] }; + pages.push(cur); + continue; + } + if (/^#{1,2}\s/.test(raw)) { + flush(); + cur = null; // 番号の無い見出しの下は読まない + continue; + } + if (!cur) continue; + const q = raw.match(/^>\s?(.*)$/); + if (q) { + const body = q[1].trim(); + if (body) para.push(plain(body)); + else flush(); + } else { + flush(); + } + } + flush(); + if (!pages.length) throw new DeckError(`台本にページの区切り(## <番号>. <見出し>)が無い: ${name}`, EXIT_SCRIPT); + const seen = new Map(); + for (const p of pages) seen.set(p.no, (seen.get(p.no) || 0) + 1); + const dup = [...seen].filter(([, c]) => c > 1).map(([n]) => n); + if (dup.length) throw new DeckError(`台本のページの番号が重なる: ${dup.join(", ")}(${name})`, EXIT_SCRIPT); + const max = Math.max(...pages.map((p) => p.no)); + const missing = []; + for (let i = 1; i <= max; i++) if (!seen.has(i)) missing.push(i); + if (missing.length) throw new DeckError(`台本のページの番号が欠ける: ${missing.join(", ")}(${name})`, EXIT_SCRIPT); + pages.sort((a, b) => a.no - b.no); + for (const p of pages) p.reading = p.paragraphs.join("\n"); + return pages; +} + +function readScript(file) { + if (!fs.existsSync(file)) throw new DeckError(`台本が無い: ${file}`, EXIT_INPUT); + return parseScript(fs.readFileSync(file, "utf8"), file); +} + +// ---- 表のセル ---- +const H = (t, extra) => ({ text: t, options: Object.assign({ fill: { color: "header" }, color: "onHeader", bold: true, align: "center" }, extra || {}) }); +const C = (t, extra) => ({ text: t, options: Object.assign({ align: "center" }, extra || {}) }); +const B = (t, extra) => ({ text: t, options: Object.assign({ bold: true }, extra || {}) }); + +// ---- 資料 ---- +function createDeck(opts) { + const o = opts || {}; + const PptxGenJS = require(require.resolve("pptxgenjs", { paths: [o.baseDir || process.cwd(), __dirname] })); + const theme = loadTheme(o.theme); + const script = o.script ? readScript(o.script) : null; + const p = new PptxGenJS(); + p.layout = "LAYOUT_16x9"; + if (o.title) p.title = o.title; + if (o.author) p.author = o.author; + if (o.title) p.subject = o.title; + const F = theme.font; + const logo = theme.logo ? "image/png;base64," + fs.readFileSync(theme.logo).toString("base64") : null; + let pageNo = 0; // 作ったページ(章扉を含む)。ページ番号に使う + let scriptNo = 0; // 台本のページを消費したページ(章扉を除く) + let sections = 0; + + function color(name) { + if (!(name in theme.colors)) throw new DeckError(`色は theme.colors の項目名で指す: ${name}`, EXIT_INPUT); + return theme.colors[name]; + } + function size(v) { + if (typeof v === "number") return v; + if (!(v in theme.sizes)) throw new DeckError(`文字の大きさは pt か theme.sizes の項目名で指す: ${v}`, EXIT_INPUT); + return theme.sizes[v]; + } + // 色の項目名を値へ置き換える(color・fill.color・line.color・border.color) + function resolve(options) { + if (!options || typeof options !== "object") return options; + const r = Array.isArray(options) ? options.map(resolve) : { ...options }; + if (Array.isArray(r)) return r; + if (typeof r.color === "string") r.color = color(r.color); + for (const k of ["fill", "line", "border"]) { + if (r[k] && typeof r[k] === "object" && !Array.isArray(r[k]) && typeof r[k].color === "string") r[k] = { ...r[k], color: color(r[k].color) }; + if (Array.isArray(r[k])) r[k] = r[k].map((b) => (b && typeof b.color === "string" ? { ...b, color: color(b.color) } : b)); + } + if (r.fontSize !== undefined) r.fontSize = size(r.fontSize); + return r; + } + function runs(t) { + if (!Array.isArray(t)) return t; + return t.map((x) => (x && typeof x === "object" ? { ...x, options: resolve(x.options) } : x)); + } + + // withScript が false のページ(章扉)は台本の番号を進めず、ノートを持たない + function newSlide(withScript) { + const s = p.addSlide(); + pageNo++; + s.background = { color: color("background") }; + if (withScript === false) { + sections++; + return s; + } + scriptNo++; + if (script) { + const sp = script[scriptNo - 1]; + if (sp && sp.reading) s.addNotes(sp.reading); + } + return s; + } + function footer(s, cx, withNo, textColor) { + if (theme.copyright) { + s.addText(theme.copyright, { ...cx, fontFace: F, fontSize: theme.sizes.footer, color: color(textColor || "mute"), valign: "middle", margin: 0 }); + } + if (withNo) { + s.addText(String(pageNo), { ...FRAME.pageNo, fontFace: F, fontSize: theme.sizes.footer, color: color("mute"), align: "right", valign: "middle", margin: 0 }); + } + } + function putLogo(s, at) { + if (logo) s.addImage({ data: logo, ...at }); + } + + // 文字を置く。既定は本文の書体・大きさ・色(ink)。色は項目名で、大きさは pt か項目名で受ける + function text(s, t, opt) { + s.addText(runs(t), resolve(Object.assign({ fontFace: F, fontSize: theme.sizes.body, color: "ink", margin: 0, valign: "top" }, opt || {}))); + } + + function cover(title, sub, meta) { + const s = newSlide(); + const c = FRAME.cover; + s.addShape(p.ShapeType.rect, { ...c.band, fill: { color: color("accent") }, line: { type: "none" } }); + putLogo(s, c.logo); + s.addText(title, { ...c.title, fontFace: F, fontSize: theme.sizes.coverTitle, bold: true, color: color("text"), valign: "bottom", margin: 0, fit: "shrink" }); + s.addShape(p.ShapeType.line, { x: c.accent.x, y: c.accent.y, w: c.accent.w, h: 0, line: { color: color("accent"), width: c.accent.width } }); + if (sub) s.addText(sub, { ...c.sub, fontFace: F, fontSize: theme.sizes.coverSub, bold: true, color: color("ink"), valign: "top", margin: 0 }); + if (meta) s.addText(meta, { ...c.meta, fontFace: F, fontSize: theme.sizes.coverMeta, color: color("ink"), margin: 0 }); + footer(s, FRAME.copyright, false); + return s; + } + + function page(title, lead) { + const s = newSlide(); + s.addText(title, { ...FRAME.title, fontFace: F, fontSize: theme.sizes.title, bold: true, color: color("text"), valign: "middle", margin: 0, fit: "shrink" }); + putLogo(s, FRAME.logo); + s.addShape(p.ShapeType.line, { x: FRAME.rule.x, y: FRAME.rule.y, w: FRAME.rule.w, h: 0, line: { color: color("rule"), width: 1 } }); + if (lead) s.addText(lead, { ...FRAME.lead, fontFace: F, fontSize: theme.sizes.lead, bold: true, color: color("ink"), valign: "middle", margin: 0 }); + footer(s, FRAME.copyright, true); + return s; + } + + function section(title, sub) { + const s = newSlide(false); + const c = FRAME.section; + s.addShape(p.ShapeType.rect, { ...c.band, fill: { color: color("accent") }, line: { type: "none" } }); + putLogo(s, FRAME.logo); + s.addText(title, { ...c.title, fontFace: F, fontSize: theme.sizes.sectionTitle, bold: true, color: color("text"), margin: 0 }); + s.addShape(p.ShapeType.line, { x: c.accent.x, y: c.accent.y, w: c.accent.w, h: 0, line: { color: color("accent"), width: c.accent.width } }); + if (sub) s.addText(sub, { ...c.sub, fontFace: F, fontSize: theme.sizes.sectionSub, bold: true, color: color("ink"), margin: 0 }); + footer(s, c.copyright, true); + return s; + } + + function box(s, x, y, w, h, fill, line) { + s.addShape(p.ShapeType.rect, { x, y, w, h, fill: { color: color(fill) }, line: line === false ? { type: "none" } : { color: color(line || "rule"), width: 1 } }); + } + + // 箱に文字を中央寄せで載せる(図の部品) + function chip(s, x, y, w, h, t, fill, opt) { + box(s, x, y, w, h, fill, false); + text(s, t, Object.assign({ x, y, w, h, align: "center", valign: "middle", bold: true, color: "text" }, opt || {})); + } + + // 矢印(x, y から w, h だけ進む) + function arrow(s, x, y, w, h, lineColor) { + s.addShape(p.ShapeType.line, { x, y, w, h, line: { color: color(lineColor || "mute"), width: 1.5, endArrowType: "triangle" } }); + } + + function note(s, t) { + s.addText(runs(t), { ...FRAME.note, fontFace: F, fontSize: theme.sizes.note, color: color("mute"), valign: "middle", margin: 0 }); + } + + function tbl(s, rows, opt) { + const r = rows.map((row) => row.map((cell) => (cell && typeof cell === "object" ? { ...cell, options: resolve(cell.options) } : cell))); + s.addTable(r, resolve(Object.assign({ fontFace: F, fontSize: theme.sizes.table, color: "ink", valign: "middle", border: { type: "solid", color: "rule", pt: 1 }, autoPage: false, margin: [2, 4, 2, 4] }, opt || {}))); + } + + function callout(s, y, h, fill, head, body) { + const c = FRAME.callout; + box(s, c.x, y, c.w, h, fill, false); + s.addText(head, { x: c.x + c.padX, y: y + 0.05, w: c.w - 2 * c.padX, h: c.headH, fontFace: F, fontSize: theme.sizes.calloutHead, bold: true, color: color("text"), valign: "middle", margin: 0 }); + if (body) s.addText(runs(body), { x: c.x + c.padX, y: y + 0.05 + c.headH, w: c.w - 2 * c.padX, h: h - c.headH - 0.1, fontFace: F, fontSize: theme.sizes.calloutBody, color: color("ink"), valign: "top", margin: 0 }); + } + + async function save(out) { + if (!out || !/\.pptx$/i.test(out)) throw new DeckError("出力のパスは .pptx で終える", EXIT_INPUT); + if (script && script.length !== scriptNo) { + throw new DeckError(`ページ数が台本と合わない: 台本 ${script.length} ページ / 作ったページ ${scriptNo}(章扉 ${sections} を除く)`, EXIT_INPUT); + } + await p.writeFile({ fileName: out }); + return { out, pages: pageNo, sections }; + } + + return { + pptx: p, + theme, + frame: FRAME, + script, + get pageNo() { + return pageNo; + }, + get scriptNo() { + return scriptNo; + }, + color, + text, + cover, + page, + section, + box, + chip, + arrow, + note, + tbl, + callout, + H, + C, + B, + save, + }; +} + +// 生成スクリプトの入口。`node build-deck.js <出力>.pptx []` +// build(deck) でページを作り、保存まで行う。失敗は DeckError の code で終える +function run(dir, opts, build) { + const [out, themeArg] = process.argv.slice(2); + (async () => { + if (!out) throw new DeckError("usage: node build-deck.js <出力>.pptx []", EXIT_INPUT); + const theme = themeArg ? path.resolve(themeArg) : path.join(dir, "theme.json"); + if (themeArg && !fs.existsSync(theme)) throw new DeckError(`theme.json が無い: ${theme}`, EXIT_INPUT); + const deck = createDeck({ ...opts, theme, baseDir: dir }); + await build(deck); + const r = await deck.save(out); + console.log(`${r.out}: ${r.pages} ページ(章扉 ${r.sections})`); + })().catch((e) => { + console.error(e.message); + process.exit(e instanceof DeckError ? e.code : 1); + }); +} + +module.exports = { createDeck, run, parseScript, readScript, mergeTheme, DeckError, FRAME, DEFAULT_THEME, H, C, B, EXIT_INPUT, EXIT_SCRIPT }; diff --git a/plugins/ndf/skills/release/assets/slides/lib/script-time.js b/plugins/ndf/skills/release/assets/slides/lib/script-time.js new file mode 100644 index 000000000..d89484cbf --- /dev/null +++ b/plugins/ndf/skills/release/assets/slides/lib/script-time.js @@ -0,0 +1,80 @@ +#!/usr/bin/env node +"use strict"; +// 台本の字数と尺を、ページごとと合計で出す。 +// +// node lib/script-time.js <台本> [--wpm 350] [--limit <分>] [--json] +// +// - 字数は読み上げ(`>` の行)の文字の数。空白と強調の記号(** と `)は数えない +// - 尺の秒は 字数 ÷ --wpm(1 分あたりの字数。既定 350)× 60。合計はページごとの値の和 +// - 終了コード: 0 / 1 合計が --limit(分)を超えた・引数の失敗 / 2 台本の形の失敗 +// 台本の読み取りは deck-frame.js と同じ関数を使う(スピーカーノートと同じ文を数える)。 + +const { readScript, DeckError, EXIT_INPUT } = require("./deck-frame"); + +function count(reading) { + return [...reading.replace(/\s/g, "")].length; +} + +function measure(pages, wpm) { + const rows = pages.map((p) => { + const chars = count(p.reading); + return { no: p.no, title: p.title, chars, seconds: Math.round((chars / wpm) * 600) / 10, planned: p.seconds }; + }); + const chars = rows.reduce((a, r) => a + r.chars, 0); + const seconds = Math.round(rows.reduce((a, r) => a + r.seconds, 0) * 10) / 10; + const planned = rows.every((r) => r.planned == null) ? null : rows.reduce((a, r) => a + (r.planned || 0), 0); + return { wpm, pages: rows, total: { pages: rows.length, chars, seconds, planned } }; +} + +function mmss(sec) { + const s = Math.round(sec); + return `${Math.floor(s / 60)}分${String(s % 60).padStart(2, "0")}秒`; +} + +function table(m) { + const lines = [ + `| # | 見出し | 字数 | 尺(秒) | 配分(秒) |`, + `| ---: | --- | ---: | ---: | ---: |`, + ]; + for (const r of m.pages) lines.push(`| ${r.no} | ${r.title.replace(/\|/g, "\\|")} | ${r.chars} | ${r.seconds.toFixed(1)} | ${r.planned == null ? "—" : r.planned} |`); + const t = m.total; + lines.push(`| 合計 | ${t.pages} ページ(${m.wpm} 字/分) | ${t.chars} | ${t.seconds.toFixed(1)}(${mmss(t.seconds)}) | ${t.planned == null ? "—" : t.planned} |`); + return lines.join("\n"); +} + +function parseArgs(argv) { + const a = { file: null, wpm: 350, limit: null, json: false }; + for (let i = 0; i < argv.length; i++) { + const v = argv[i]; + if (v === "--json") a.json = true; + else if (v === "--wpm" || v === "--limit") { + const n = Number(argv[++i]); + if (!(n > 0)) throw new DeckError(`${v} は正の数`, EXIT_INPUT); + a[v.slice(2)] = n; + } else if (v.startsWith("--")) throw new DeckError(`知らない引数: ${v}`, EXIT_INPUT); + else if (!a.file) a.file = v; + else throw new DeckError(`台本は 1 つだけ渡す: ${v}`, EXIT_INPUT); + } + if (!a.file) throw new DeckError("usage: node lib/script-time.js <台本> [--wpm 350] [--limit <分>] [--json]", EXIT_INPUT); + return a; +} + +function main() { + try { + const a = parseArgs(process.argv.slice(2)); + const m = measure(readScript(a.file), a.wpm); + if (a.limit != null) m.limit = { minutes: a.limit, over: m.total.seconds > a.limit * 60 }; + console.log(a.json ? JSON.stringify(m) : table(m)); + if (m.limit && m.limit.over) { + console.error(`尺が上限を超えた: ${mmss(m.total.seconds)} > ${a.limit} 分`); + process.exit(1); + } + } catch (e) { + console.error(e.message); + process.exit(e instanceof DeckError ? e.code : 1); + } +} + +if (require.main === module) main(); + +module.exports = { count, measure, table }; diff --git a/plugins/ndf/skills/release/assets/slides/package-lock.json b/plugins/ndf/skills/release/assets/slides/package-lock.json new file mode 100644 index 000000000..7d73ee931 --- /dev/null +++ b/plugins/ndf/skills/release/assets/slides/package-lock.json @@ -0,0 +1,171 @@ +{ + "name": "slide-deck", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "slide-deck", + "version": "0.0.0", + "dependencies": { + "pptxgenjs": "4.0.1" + } + }, + "node_modules/@types/node": { + "version": "22.20.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.5.tgz", + "integrity": "sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/https": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/https/-/https-1.0.0.tgz", + "integrity": "sha512-4EC57ddXrkaF0x83Oj8sM6SLQHAWXw90Skqu2M4AEWENZ3F02dFJE/GARA8igO79tcgYqGrD7ae4f5L3um2lgg==", + "license": "ISC" + }, + "node_modules/image-size": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/image-size/-/image-size-1.2.1.tgz", + "integrity": "sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw==", + "license": "MIT", + "dependencies": { + "queue": "6.0.2" + }, + "bin": { + "image-size": "bin/image-size.js" + }, + "engines": { + "node": ">=16.x" + } + }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/jszip": { + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.2.tgz", + "integrity": "sha512-3l+rb15IOWtUhU0H5MFqES/T6Kh7abYwjosBey/vD6hDt8zoEffkSC5Ws5SGtgVw3gBx2NEbhTeSW1+kWkpyTQ==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, + "node_modules/pptxgenjs": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pptxgenjs/-/pptxgenjs-4.0.1.tgz", + "integrity": "sha512-TeJISr8wouAuXw4C1F/mC33xbZs/FuEG6nH9FG1Zj+nuPcGMP5YRHl6X+j3HSUnS1f3at6k75ZZXPMZlA5Lj9A==", + "license": "MIT", + "dependencies": { + "@types/node": "^22.8.1", + "https": "^1.0.0", + "image-size": "^1.2.1", + "jszip": "^3.10.1" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "node_modules/queue": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz", + "integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==", + "license": "MIT", + "dependencies": { + "inherits": "~2.0.3" + } + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + } + } +} diff --git a/plugins/ndf/skills/release/assets/slides/package.json b/plugins/ndf/skills/release/assets/slides/package.json new file mode 100644 index 000000000..174763c7d --- /dev/null +++ b/plugins/ndf/skills/release/assets/slides/package.json @@ -0,0 +1,13 @@ +{ + "name": "slide-deck", + "version": "0.0.0", + "private": true, + "description": "pptxgenjs で pptx を作る生成スクリプト", + "scripts": { + "build": "node build-deck.js deck.pptx", + "time": "node lib/script-time.js script.md" + }, + "dependencies": { + "pptxgenjs": "4.0.1" + } +} diff --git a/plugins/ndf/skills/release/assets/slides/script.md b/plugins/ndf/skills/release/assets/slides/script.md new file mode 100644 index 000000000..824dbc686 --- /dev/null +++ b/plugins/ndf/skills/release/assets/slides/script.md @@ -0,0 +1,22 @@ +# 台本: <資料の題> + +- 想定: <場と持ち時間> +- 対象: <聞き手> + +ページの区切りは `## <番号>. <見出し>`、読み上げは `>` で始まる行。見出しの末尾に `(<秒>秒)` を付けると配分秒数になる。 +ほかの行はページに載せる要素の覚え書きで、スピーカーノートにも字数にも入らない。 +章扉(生成スクリプトの `section`)は台本に区切りを置かない。章扉はノートを持たず、台本の番号を進めない。 + +## 1. 表紙(20秒) + +<資料の題> — <副題> + +> 今日は〇〇について話します。最初に結論、次に根拠、最後にお願いしたいことの順に進めます。 + +## 2. 現状の数値(60秒) + +表: 指標・前の期間・今の期間・差 + +> 3 つの指標を前の期間と比べました。件数は増え、かかる時間は短くなりました。費用だけが増えています。 +> +> 費用が増えた理由は次のページで説明します。 diff --git a/plugins/ndf/skills/release/references/distribution-forms.md b/plugins/ndf/skills/release/references/distribution-forms.md index dd7e2d5c7..bf1da6b8b 100644 --- a/plugins/ndf/skills/release/references/distribution-forms.md +++ b/plugins/ndf/skills/release/references/distribution-forms.md @@ -36,8 +36,9 @@ - **承認の前に書き込んでよいのは、`production` が偽の提出先だけである** - **投稿の手順は置き先のシステムが持つ。** 形の参照には書かない (`document-systems` の `system-<名前>.md`) -- **生成の経路は 2 つあり、どちらも既定にしない。** 選んだ経路が「生成物を保存するか」の +- **文書・表計算・ページの生成の経路は 2 つあり、どちらも既定にしない。** 選んだ経路が「生成物を保存するか」の 答えを決める(非決定的な経路では保存する) +- **スライドの生成の経路は「生成スクリプトを書く」の 1 つだけである**([form-slide.md](form-slide.md)) ## 開発版と分かる形で公開する diff --git a/plugins/ndf/skills/release/references/form-slide.md b/plugins/ndf/skills/release/references/form-slide.md index 62d1927e8..894673340 100644 --- a/plugins/ndf/skills/release/references/form-slide.md +++ b/plugins/ndf/skills/release/references/form-slide.md @@ -7,32 +7,152 @@ ## 起点の決め方 -**正本の Markdown の直前の版。** git の履歴が持つ。 +**生成スクリプト・台本・トーン&マナーのファイルの直前の版。** git の履歴が持つ。 ## 生成の経路 -**2 つあり、どちらも既定にしない。** +**生成スクリプトを書く(pptxgenjs)の 1 つだけである。** 変換器(Markdown からスライドを作るもの)・python-pptx・ +AI エージェントがその場で組む経路は使わない。変換器の出力は開いた後に文字と図形を直せず、ほかの 2 つは同じ台本から +同じ資料が出ない。 -| 経路 | 何が起きるか | +| 経路 | 何が起きるか | 生成物 | +| --- | --- | --- | +| 生成スクリプトを書く | 決定的。同じ生成スクリプト・台本・`theme.json` から同じ pptx が出る。Google Slides と PowerPoint で開いて直せる | 保存は任意。正本は生成スクリプトと台本である | + +**対象リポジトリの仕組みを先に探す。** プロジェクトに生成スクリプト(`slides/build-deck.js`)が既にあれば、汎用 +テンプレートを写さずにそれに従う。既存の .pptx を読む・直すだけの依頼は、公式の `pptx` Skill +(`official-skills-autoloader`)が受ける。 + +## 1 ページの区切り + +**台本の `## <番号>. <見出し>` の 1 つが 1 ページに対応する。** 生成スクリプトはページを作るたびに同じ番号の +読み上げをスピーカーノートへ入れ、保存の前に台本のページ数と作ったページ数を照合する。 + +**章扉(`section`)は台本のページを消費しない。** 章扉はスピーカーノートを持たず、台本の番号を進めず、ページ数の +照合から外れる。台本に章扉の区切りを置かない。ページ番号は章扉も数える。 + +## 生成スクリプトの経路 + +汎用テンプレートはこの Skill の [assets/slides/](../assets/slides/) にある。版面は 16:9(10 × 5.625 インチ)、 +ページの枠組みの関数は `cover` / `page` / `section` / `box` / `note` / `tbl` / `callout`(図の部品の `chip` / `arrow` / +`text`、表のセルの `H` / `C` / `B`)である。 + +### 資料のファイルの並び + +```text +<資料>/ +├── README.md # 成果物・トーン&マナー・写した時点の lib/ の内容のハッシュ・生成と確認の手順 +├── notes.md # 構成メモ +├── script.md # 台本(スピーカーノートの正本) +└── slides/ + ├── build-deck.js # 生成スクリプト(スライドの正本) + ├── theme.json # トーン&マナーのファイル + ├── package.json・package-lock.json + └── lib/ # 汎用テンプレートの枠組みの写し。書き換えない +``` + +### 汎用テンプレートを写す + +`$SKILL_DIR` はこの Skill のディレクトリである。 + +```bash +mkdir -p <資料>/slides +cp -r "$SKILL_DIR/assets/slides/lib" "$SKILL_DIR/assets/slides/build-deck.js" \ + "$SKILL_DIR/assets/slides/package.json" "$SKILL_DIR/assets/slides/package-lock.json" <資料>/slides/ +cp "$SKILL_DIR/assets/slides/script.md" <資料>/script.md +(cd <資料>/slides && npm ci) +cat <資料>/slides/lib/*.js | sha256sum +``` + +- `build-deck.js` の `SCRIPT` を `path.join(__dirname, "..", "script.md")` に直し、ページの中身を書き換える +- `package.json` の `scripts.time` を `node lib/script-time.js ../script.md` に直す(`npm run` は `slides/` で動くため、 + 直さないと無い `slides/script.md` を読んで止まる) +- 最後のコマンドの値を、写した時点の `lib/` の内容のハッシュとして `README.md` に書く +- **`lib/` を書き換えない。** 汎用テンプレートの更新を取り込むときは `lib/` を写し直し、`README.md` のハッシュを + 写し直した値へ書き換える。`lib/` のハッシュが `README.md` の値と違えば書き換えた資料であり、プロジェクトの値を + `theme.json` へ移してから写し直す +- 生成スクリプトと `lib/` に書体名と色の値を書かない。色は `theme.json` の `colors` の項目名(`accent`・`ink`・`panel1` + など)で指す。値を直接渡すと生成が終了コード 1 で止まる +- Node.js と npm が無ければ、導入の手順を示して止まる + +### トーン&マナーを差し替える + +**変える項目だけを `slides/theme.json` に書く。** 書かない項目は汎用テンプレートの既定値(会社色を持たない値)になる。 +項目名と既定値の正本は `lib/deck-frame.js` の `DEFAULT_THEME` である。 + +| 項目 | 書き方 | | --- | --- | -| 変換器を使う | 決定的。同じソースから同じものが出る。既知の失敗を列挙できる | -| AI エージェントが組む | **非決定的。** 同じソースから同じものが出ない。見栄えのよい資料になることが増えている | +| `font` | 書体名。**Google Slides と配布先の環境にある書体を選ぶ** | +| `colors` | 項目名ごとに 6 桁の 16 進(`#` なし)。項目ごとに既定値へ重なる | +| `sizes` | 項目名ごとに pt。項目ごとに既定値へ重なる | +| `copyright` | 著作権表示の文言。空なら描かない | +| `logo` | ロゴの PNG の、`theme.json` からの相対パス。`null` なら描かない | -**どちらを使うかは体裁設計が決める**(`design` の `layout-slide.md`)。**選んだ経路が -「生成物を保存するか」の答えを決める。** +知らない項目名と、形の違う値は終了コード 1 で止まる。座標は `theme.json` に書けない(枠組みが固定する)。 -| 経路 | 生成物 | +### 構成メモを書く + +`notes.md` の `## 構成` の節に、構成の考え方(何から逆算し、何を載せず、どこへ回したか)と次の表を置く。 +表の行数はページ数と等しい。 + +```markdown +| # | 見出し | 役割 | 根拠 | +| ---: | --- | --- | --- | +| 1 | 表紙 | — | — | +| 2 | <台本の見出し> | <このページで聞き手に何を渡すか。載せる図・表・箇条書き> | <数値と主張の出所> | +``` + +ページに収まらない読み上げの扱いと、尺を超えたときに削るページは同じ文書の節に書く。 + +### 台本を書き、尺を測る + +| 規則 | 内容 | | --- | --- | -| 決定的 | 保存は任意。ソースと設定から同じものが出る | -| **非決定的** | **保存する。** ソースから復元できないため、生成物が成果物になる | +| ページの区切り | `## <番号>. <見出し>`。番号は 1 から欠けずに続く。見出しの末尾の `(<秒>秒)` は配分秒数(任意) | +| 読み上げ | 区切りの中の `>` で始まる行。`>` だけの行は段落の区切り | +| ほかの行 | ページに載せる要素の覚え書き。ノートにも字数にも入らない | +| 最初の区切りより前 | 題・想定・構成の表。読まない | +| 章扉 | 区切りを置かない(章扉は生成スクリプトの `section` が作る) | + +```bash +node <資料>/slides/lib/script-time.js <資料>/script.md --limit <持ち時間の分> +``` + +ページごとと合計の字数・尺(350 字/分)・配分秒数の表を出す。出力を構成メモか `README.md` に載せる。合計が +`--limit` を超えたら終了コード 1 で、構成メモで削るページを決めて台本を直す。台本の番号が欠ける・重なると終了コード 2。 + +### ページの文言を書く + +**読み上げはスピーカーノートが持つ。ページには要点だけを置く。** + +- 台本の文を縮めて載せない。1 ページ 1 メッセージにする +- 見出しはそのページの結論を短く書く。リードは 1 行にする +- 箱・表・箇条書きは短い句か体言止めにする(「〜する」「〜である」の文を並べない) +- 聞き手が知らない内部の語は、聞き手に通じる語へ言い換えるか、ページの中で一言で説明を添える。Skill やコマンドの + 名前を出すときは、何をするものかを添える +- 章扉(`section`)で構成を見せる。章の名前と一言は構成メモに書く + +### 生成する + +```bash +node <資料>/slides/build-deck.js <出力>.pptx [] +``` + +終了コード 0 で pptx が出る。1 は引数・`theme.json`・ページ数の照合の失敗、2 は台本の形の失敗である。pptx は +作り直せるためコミットしない。 + +### 変換と確認 -**変換器を使う経路では、対象リポジトリの仕組みを先に探す。** 生成の手順を新しく作る前に、 -既にある仕組み(ビルドの定義・スクリプト)を探して従う。 +**Google Slides へ変換するのは、下書き先(`.ndf/document.json` の `draft`)の `system` が `gdrive` のときだけである。** +ほかの下書き先では pptx を Google Drive へ上げず、Google の認証も求めない(利用者が指定していないサービスへ資料を +送らない)。描画の手段は下書き先の `system-<名前>.md` が持ち、手段が無ければ `layout-review` のとおり止めて人へ渡す。 -## 1 枚の区切り +`gdrive` のとき: -**見出しの階層が区切りを表す。** `##` の見出し 1 つが 1 枚に対応する。 -**特定の変換器の記法を正本へ書かない**(書くと、その変換器を使わない経路で読めなくなる)。 +1. pptx を Google Slides へ変換する。新しく作るときは pptx を上げて `mimeType` を指定して複製し、既にある + Google Slides は同じ URL のまま差し替える(置き場所と手段は `document-systems` の `system-gdrive.md`) +2. ページごとのサムネイルを取り、全ページを見る(`layout-review`) +3. 文字あふれ・重なりは `layout-review` の差し戻し先へ戻し、作り直してから 1. の差し替えで同じ URL に上げ直す ## 検証リリース diff --git a/plugins/ndf/skills/release/tests/slide_deck_support.py b/plugins/ndf/skills/release/tests/slide_deck_support.py new file mode 100644 index 000000000..8f34c5e8e --- /dev/null +++ b/plugins/ndf/skills/release/tests/slide_deck_support.py @@ -0,0 +1,189 @@ +"""生成スクリプト(pptxgenjs)で作った pptx を検査する補助(#2065)。 + +汎用テンプレートのテストと発表資料のテストが同じ読み方で pptx を読むため、ここに 1 つだけ置く。 +pptx は zip と XML なので、標準ライブラリだけで読む。 +""" + +from __future__ import annotations + +import fcntl +import hashlib +import re +import shutil +import subprocess +import tempfile +import zipfile +from dataclasses import dataclass +from pathlib import Path +from xml.etree import ElementTree as ET + +EMU = 914400 # 1 インチ +NS = { + "a": "http://schemas.openxmlformats.org/drawingml/2006/main", + "p": "http://schemas.openxmlformats.org/presentationml/2006/main", + "r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships", +} +TEMPLATE = Path(__file__).resolve().parents[1] / "assets" / "slides" + + +def ensure_node_modules(slides_dir: Path) -> None: + """`slides/` に `npm ci` で依存を入れる。並列の実行どうしはロックで順に回す。""" + key = hashlib.sha256(str(slides_dir.resolve()).encode()).hexdigest()[:16] + lock = Path(tempfile.gettempdir()) / f"ndf-npm-ci-{key}.lock" + with open(lock, "w") as fh: + fcntl.flock(fh, fcntl.LOCK_EX) + try: + if not (slides_dir / "node_modules" / "pptxgenjs").is_dir(): + subprocess.run( + ["npm", "ci", "--no-audit", "--no-fund"], + cwd=slides_dir, + check=True, + capture_output=True, + text=True, + ) + finally: + fcntl.flock(fh, fcntl.LOCK_UN) + + +def copy_template(dest: Path) -> Path: + """汎用テンプレートを依存抜きで写し、依存は元の node_modules を指す。""" + ensure_node_modules(TEMPLATE) + shutil.copytree(TEMPLATE, dest, ignore=shutil.ignore_patterns("node_modules")) + (dest / "node_modules").symlink_to(TEMPLATE / "node_modules", target_is_directory=True) + return dest + + +def run_node(*args: str | Path, cwd: Path | None = None) -> subprocess.CompletedProcess: + return subprocess.run(["node", *map(str, args)], cwd=cwd, capture_output=True, text=True) + + +@dataclass +class Shape: + kind: str # sp / cxnSp / pic / graphicFrame + x: float + y: float + w: float + h: float + text: str + prst: str | None + + +@dataclass +class Slide: + xml: str + shapes: list[Shape] + notes: str | None + + +def _inch(v: str) -> float: + return round(int(v) / EMU, 2) + + +def _texts(el) -> str: + paras = [] + for p in el.iter(f"{{{NS['a']}}}p"): + paras.append("".join(t.text or "" for t in p.iter(f"{{{NS['a']}}}t"))) + return "\n".join(paras) + + +def _shapes(root) -> list[Shape]: + out = [] + tree = root.find("p:cSld/p:spTree", NS) + for el in list(tree): + kind = el.tag.split("}")[1] + if kind not in ("sp", "cxnSp", "pic", "graphicFrame"): + continue + off = el.find(".//a:off", NS) + ext = el.find(".//a:ext[@cx]", NS) + if off is None or ext is None: + off = el.find(".//p:xfrm/a:off", NS) + ext = el.find(".//p:xfrm/a:ext", NS) + geom = el.find(".//a:prstGeom", NS) + out.append( + Shape( + kind=kind, + x=_inch(off.get("x")), + y=_inch(off.get("y")), + w=_inch(ext.get("cx")), + h=_inch(ext.get("cy")), + text=_texts(el), + prst=geom.get("prst") if geom is not None else None, + ) + ) + return out + + +def _notes_text(xml: str) -> str: + root = ET.fromstring(xml) + for sp in root.iter(f"{{{NS['p']}}}sp"): + ph = sp.find(".//p:nvPr/p:ph", NS) + if ph is not None and ph.get("type") == "body": + return _texts(sp) + return "" + + +def read_pptx(path: Path) -> dict: + """版面(インチ)・ページ・書体名・色を返す。""" + with zipfile.ZipFile(path) as z: + pres = ET.fromstring(z.read("ppt/presentation.xml")) + sz = pres.find("p:sldSz", NS) + names = sorted( + (n for n in z.namelist() if re.fullmatch(r"ppt/slides/slide\d+\.xml", n)), + key=lambda n: int(re.search(r"(\d+)", n.rsplit("/", 1)[1]).group(1)), + ) + slides = [] + for n in names: + xml = z.read(n).decode("utf-8") + rels_name = n.replace("slides/", "slides/_rels/") + ".rels" + notes = None + if rels_name in z.namelist(): + rels = z.read(rels_name).decode("utf-8") + m = re.search(r'Target="\.\./notesSlides/(notesSlide\d+\.xml)"', rels) + if m: + notes = _notes_text(z.read("ppt/notesSlides/" + m.group(1)).decode("utf-8")) + slides.append(Slide(xml=xml, shapes=_shapes(ET.fromstring(xml)), notes=notes)) + media = [n for n in z.namelist() if n.startswith("ppt/media/")] + all_xml = "".join(s.xml for s in slides) + return { + "size": (_inch(sz.get("cx")), _inch(sz.get("cy"))), + "slides": slides, + "fonts": set(re.findall(r'typeface="([^"]*)"', all_xml)), + "colors": set(v.upper() for v in re.findall(r'srgbClr val="([0-9A-Fa-f]{6})"', all_xml)), + "media": media, + } + + +HEAD = re.compile(r"^##\s+(\d+)\.\s*(.*?)\s*$") + + +def script_readings(path: Path) -> dict[int, str]: + """台本の読み上げをページ番号ごとに返す(`>` の行。`>` だけの行は段落の区切り)。""" + pages: dict[int, list[str]] = {} + cur: int | None = None + para: list[str] = [] + + def flush() -> None: + if cur is not None and para: + pages[cur].append("".join(para)) + para.clear() + + for line in path.read_text(encoding="utf-8").splitlines(): + m = HEAD.match(line) + if m: + flush() + cur = int(m.group(1)) + pages[cur] = [] + continue + if re.match(r"^#{1,2}\s", line): + flush() + cur = None + continue + if cur is None: + continue + q = re.match(r"^>\s?(.*)$", line) + if q and q.group(1).strip(): + para.append(q.group(1).strip().replace("**", "").replace("`", "")) + else: + flush() + flush() + return {n: "\n".join(ps) for n, ps in pages.items()} diff --git a/plugins/ndf/skills/release/tests/test_slide_template.py b/plugins/ndf/skills/release/tests/test_slide_template.py new file mode 100644 index 000000000..279c38083 --- /dev/null +++ b/plugins/ndf/skills/release/tests/test_slide_template.py @@ -0,0 +1,294 @@ +"""スライドの汎用テンプレート(`release/assets/slides/`)の振る舞い(#2065)。 + +生成スクリプトを node で実行し、出た pptx を zip と XML で読んで確かめる。 +""" + +from __future__ import annotations + +import json +import shutil +import struct +import sys +import zlib +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from slide_deck_support import TEMPLATE, copy_template, read_pptx, run_node, script_readings # noqa: E402 + +# 手本の枠組みの座標(インチ)。トーン&マナーに依らず固定される(I1) +TITLE = (0.26, 0.15, 8.29, 0.63) +RULE = (0.24, 0.8, 9.52) +COPYRIGHT = (0.26, 5.18, 3.2, 0.3) +PAGE_NO = (9.27, 5.18, 0.5, 0.3) +NOTE = (0.26, 4.88, 9.5, 0.28) +LOGO = (8.78, 0.24, 0.96, 0.23) + +DEFAULT_COLORS = {"2B6CB0", "E3EDF7", "000000", "303030", "7F7F7F", "D9D9D9", "F3F3F3", "EEEEEE", "F4CCCC", "FFF2CC", "FFFFFF"} +OVERRIDE_COLORS = { + "accent": "112233", + "accentSoft": "112234", + "text": "112235", + "ink": "112236", + "mute": "112237", + "rule": "112238", + "panel1": "112239", + "panel2": "11223A", + "warn": "11223B", + "caution": "11223C", + "header": "11223D", + "onHeader": "11223E", + "background": "11223F", +} + + +def _png(path: Path) -> None: + """1 × 1 の PNG を書く。""" + raw = b"\x00\xff\x00\x00" + + def chunk(tag: bytes, data: bytes) -> bytes: + return struct.pack(">I", len(data)) + tag + data + struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF) + + path.write_bytes( + b"\x89PNG\r\n\x1a\n" + + chunk(b"IHDR", struct.pack(">IIBBBBB", 1, 1, 8, 2, 0, 0, 0)) + + chunk(b"IDAT", zlib.compress(raw)) + + chunk(b"IEND", b"") + ) + + +@pytest.fixture +def deck(tmp_path: Path) -> Path: + return copy_template(tmp_path / "slides") + + +def _build(d: Path, out: Path, *theme: Path): + return run_node(d / "build-deck.js", out, *theme, cwd=d) + + +def _find(slide, text: str): + hits = [s for s in slide.shapes if s.text == text] + assert hits, f"{text!r} の図形が無い: {[s.text for s in slide.shapes]}" + return hits[0] + + +def _box(shape) -> tuple: + return (shape.x, shape.y, shape.w, shape.h) + + +def test_sample_builds_three_pages_on_16_9(deck: Path, tmp_path: Path) -> None: + out = tmp_path / "t.pptx" + cp = _build(deck, out) + assert cp.returncode == 0, cp.stderr + info = read_pptx(out) + assert info["size"] == (10.0, 5.62) # 5.625 インチ(EMU を 2 桁へ丸めた値) + slides = info["slides"] + assert len(slides) == 3 + cover, section, body = slides + assert any(s.text == "<資料の題>" for s in cover.shapes) + assert any(s.text == "現状" for s in section.shapes) + assert any(s.kind == "graphicFrame" and "" in body.xml for s in body.shapes) + assert _box(_find(body, "出典: <数値の出所>")) == NOTE + + +def _frame_positions(info) -> dict: + body = info["slides"][2] + rule = [s for s in body.shapes if s.prst == "line" and s.y == RULE[1]] + return { + "title": _box(_find(body, "現状の数値")), + "rule": (rule[0].x, rule[0].y, rule[0].w) if rule else None, + "copyright": _box(_find(body, "© Example")), + "page": _box(_find(body, "3")), + "note": _box(_find(body, "出典: <数値の出所>")), + } + + +def test_frame_positions_match_reference_and_ignore_theme(deck: Path, tmp_path: Path) -> None: + plain = tmp_path / "plain.json" + plain.write_text(json.dumps({"copyright": "© Example"}), encoding="utf-8") + big = tmp_path / "big.json" + big.write_text( + json.dumps( + {"copyright": "© Example", "font": "Other Font", "sizes": {"title": 28, "footer": 12, "note": 11}, "colors": {"rule": "123456"}} + ), + encoding="utf-8", + ) + got = [] + for theme in (plain, big): + out = tmp_path / f"{theme.stem}.pptx" + cp = _build(deck, out, theme) + assert cp.returncode == 0, cp.stderr + got.append(_frame_positions(read_pptx(out))) + assert got[0] == {"title": TITLE, "rule": RULE, "copyright": COPYRIGHT, "page": PAGE_NO, "note": NOTE} + assert got[0] == got[1] + + +def _override_theme(dir_: Path) -> Path: + _png(dir_ / "logo.png") + theme = dir_ / "theme.json" + theme.write_text( + json.dumps({"font": "Replaced Sans", "colors": OVERRIDE_COLORS, "copyright": "© Replaced", "logo": "logo.png"}), + encoding="utf-8", + ) + return theme + + +def _assert_replaced(info) -> None: + assert info["fonts"] == {"Replaced Sans"} + assert info["colors"] <= set(OVERRIDE_COLORS.values()) + assert not info["colors"] & DEFAULT_COLORS + assert info["media"], "ロゴの画像が入っていない" + body = info["slides"][2] + pics = [s for s in body.shapes if s.kind == "pic"] + assert [_box(p) for p in pics] == [LOGO] + assert any(s.text == "© Replaced" for s in body.shapes) + + +def test_theme_json_replaces_fonts_colors_copyright_and_logo(deck: Path, tmp_path: Path) -> None: + _override_theme(deck) # 既定の置き場(slides/theme.json) + out = tmp_path / "r.pptx" + cp = _build(deck, out) + assert cp.returncode == 0, cp.stderr + _assert_replaced(read_pptx(out)) + + # 枠組みを写し直しても(更新の取り込み)、theme.json の値が残る(I2) + shutil.rmtree(deck / "lib") + shutil.copytree(TEMPLATE / "lib", deck / "lib") + out2 = tmp_path / "r2.pptx" + cp = _build(deck, out2) + assert cp.returncode == 0, cp.stderr + _assert_replaced(read_pptx(out2)) + + +def test_partial_theme_keeps_other_defaults(deck: Path, tmp_path: Path) -> None: + theme = tmp_path / "accent.json" + theme.write_text(json.dumps({"colors": {"accent": "AA0000"}}), encoding="utf-8") + out = tmp_path / "a.pptx" + cp = _build(deck, out, theme) + assert cp.returncode == 0, cp.stderr + info = read_pptx(out) + assert "AA0000" in info["colors"] + assert "2B6CB0" not in info["colors"] + assert {"303030", "D9D9D9"} <= info["colors"] # 書いていない項目は既定値のまま + assert info["fonts"] == {"Noto Sans JP"} + + +@pytest.mark.parametrize( + ("theme", "word"), + [({"fonts": "X"}, "fonts"), ({"colors": {"brand": "000000"}}, "brand"), ({"colors": {"accent": "#2B6CB0"}}, "accent")], +) +def test_bad_theme_stops_with_1(deck: Path, tmp_path: Path, theme: dict, word: str) -> None: + path = tmp_path / "bad.json" + path.write_text(json.dumps(theme), encoding="utf-8") + cp = _build(deck, tmp_path / "x.pptx", path) + assert cp.returncode == 1 + assert word in cp.stderr + assert not (tmp_path / "x.pptx").exists() + + +def test_raw_color_value_in_build_script_stops(deck: Path, tmp_path: Path) -> None: + src = (deck / "build-deck.js").read_text(encoding="utf-8") + (deck / "build-deck.js").write_text(src.replace('"panel1"', '"F3F3F3"'), encoding="utf-8") + cp = _build(deck, tmp_path / "x.pptx") + assert cp.returncode == 1 + assert "F3F3F3" in cp.stderr + + +def test_notes_come_from_script_and_section_takes_none(deck: Path, tmp_path: Path) -> None: + out = tmp_path / "n.pptx" + assert _build(deck, out).returncode == 0 + readings = script_readings(deck / "script.md") + assert len(readings) == 2 # 表紙と本編。章扉は台本に区切りを持たない + cover, section, body = read_pptx(out)["slides"] + assert cover.notes == readings[1] + assert not section.notes # 章扉はノートを持たない + assert body.notes == readings[2] # 章扉の後のページは、台本の次の番号を受ける + assert "\n" in body.notes # `>` だけの行が段落を分ける + + +def test_page_numbers_count_sections(deck: Path, tmp_path: Path) -> None: + out = tmp_path / "p.pptx" + assert _build(deck, out).returncode == 0 + _, section, body = read_pptx(out)["slides"] + assert any(s.text == "2" for s in section.shapes) + assert any(s.text == "3" for s in body.shapes) + + +def _add_section_after_cover(deck: Path) -> None: + src = (deck / "build-deck.js").read_text(encoding="utf-8") + anchor = ' d.section("現状"' + assert anchor in src + (deck / "build-deck.js").write_text(src.replace(anchor, ' d.section("前置き", "");\n' + anchor, 1), encoding="utf-8") + + +def test_sections_are_left_out_of_page_count(deck: Path, tmp_path: Path) -> None: + _add_section_after_cover(deck) + out = tmp_path / "s.pptx" + cp = _build(deck, out) + assert cp.returncode == 0, cp.stderr + slides = read_pptx(out)["slides"] + assert len(slides) == 4 + readings = script_readings(deck / "script.md") + assert [bool(s.notes) for s in slides] == [True, False, False, True] + assert slides[3].notes == readings[2] + + +def test_page_count_must_match_script(deck: Path, tmp_path: Path) -> None: + with open(deck / "script.md", "a", encoding="utf-8") as fh: + fh.write("\n## 3. 増やしたページ\n\n> 3 ページ目の読み上げ。\n") + cp = _build(deck, tmp_path / "x.pptx") + assert cp.returncode == 1 + # 作ったのは 3 ページだが、章扉を除くと 2 ページで、台本の 3 ページと合わない + assert "台本 3 ページ" in cp.stderr and "作ったページ 2" in cp.stderr and "章扉 1" in cp.stderr + assert not (tmp_path / "x.pptx").exists() + + +@pytest.mark.parametrize(("swap", "to", "message"), [("## 2.", "## 4.", "欠ける: 2"), ("## 1.", "## 2.", "重なる: 2")]) +def test_broken_script_numbers_stop_with_2(deck: Path, tmp_path: Path, swap: str, to: str, message: str) -> None: + script = deck / "script.md" + script.write_text(script.read_text(encoding="utf-8").replace(swap, to), encoding="utf-8") + for cp in (_build(deck, tmp_path / "x.pptx"), run_node(deck / "lib" / "script-time.js", script)): + assert cp.returncode == 2, cp.stderr + assert message in cp.stderr + + +SCRIPT = """# 台本 + +> 最初の区切りより前は読まない。 + +## 1. 表紙(30秒) + +覚え書きの行は数えない。 + +> あいう **えお** かき。 +> +> `くけ` こ + +## 2. 本編 + +> さしすせそ たちつてと +""" + + +def test_script_time_counts_per_page_and_total(deck: Path, tmp_path: Path) -> None: + script = tmp_path / "s.md" + script.write_text(SCRIPT, encoding="utf-8") + cp = run_node(deck / "lib" / "script-time.js", script, "--json") + assert cp.returncode == 0, cp.stderr + m = json.loads(cp.stdout) + pages = m["pages"] + assert [(p["no"], p["title"], p["chars"], p["planned"]) for p in pages] == [(1, "表紙", 11, 30), (2, "本編", 10, None)] + assert pages[0]["seconds"] == round(11 / 350 * 60, 1) + assert m["total"]["chars"] == 21 + assert m["total"]["seconds"] == round(pages[0]["seconds"] + pages[1]["seconds"], 1) + + table = run_node(deck / "lib" / "script-time.js", script) + assert table.returncode == 0 + assert "| 合計 |" in table.stdout + + over = run_node(deck / "lib" / "script-time.js", script, "--limit", "0.01") + assert over.returncode == 1 + fast = run_node(deck / "lib" / "script-time.js", script, "--wpm", "700", "--json") + assert json.loads(fast.stdout)["total"]["seconds"] == pytest.approx(m["total"]["seconds"] / 2, abs=0.11) diff --git a/tests/test_presentation_decks.py b/tests/test_presentation_decks.py new file mode 100644 index 000000000..9d0d47d0d --- /dev/null +++ b/tests/test_presentation_decks.py @@ -0,0 +1,87 @@ +"""`docs/presentations/` の生成スクリプトの資料が作れること(#2065)。 + +資料のディレクトリ(`<日付>-/`)ごとに、生成スクリプトを実行して pptx を検査する。 +- 生成が終了コード 0 で、章扉を除いたページ数が台本と構成メモの表の行数に等しい +- 章扉を除く全ページのスピーカーノートが、台本の同じ番号の読み上げと一致する。章扉はノートを持たない +- 生成スクリプトが写した枠組み(`slides/lib/`)を使い、その内容が README に書いた写した時点のハッシュと一致する +""" + +from __future__ import annotations + +import hashlib +import re +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "plugins" / "ndf" / "skills" / "release" / "tests")) + +from slide_deck_support import ensure_node_modules, read_pptx, run_node, script_readings # noqa: E402 + +# 章扉の帯(枠組みの FRAME.section.band。インチ)。章扉だけがこの図形を持つ +SECTION_BAND = (0.0, 0.0, 0.36, 5.62) + +DECKS = sorted(p.parents[1] for p in (ROOT / "docs" / "presentations").glob("*/slides/build-deck.js")) + + +def _notes_rows(notes: Path) -> list[int]: + """構成メモの `## 構成` の節の表の番号の列。""" + text = notes.read_text(encoding="utf-8") + section = text.split("## 構成", 1)[1].split("\n## ", 1)[0] + return [int(m.group(1)) for m in re.finditer(r"^\|\s*(\d+)\s*\|", section, re.M)] + + +def _is_section(slide) -> bool: + return any(s.kind == "sp" and (s.x, s.y, s.w, s.h) == SECTION_BAND for s in slide.shapes) + + +def _lib_hash(lib: Path) -> str: + return hashlib.sha256(b"".join(p.read_bytes() for p in sorted(lib.glob("*.js")))).hexdigest() + + +def test_decks_exist() -> None: + assert DECKS, "docs/presentations/ に生成スクリプトの資料が無い" + + +@pytest.mark.parametrize("deck", DECKS, ids=[d.name for d in DECKS]) +def test_deck_builds_with_notes_from_script(deck: Path, tmp_path: Path) -> None: + slides = deck / "slides" + ensure_node_modules(slides) + out = tmp_path / "deck.pptx" + cp = run_node(slides / "build-deck.js", out, cwd=deck) + assert cp.returncode == 0, cp.stderr + + readings = script_readings(deck / "script.md") + info = read_pptx(out) + sections = [p for p in info["slides"] if _is_section(p)] + pages = [p for p in info["slides"] if not _is_section(p)] + assert len(pages) == len(readings) + assert _notes_rows(deck / "notes.md") == list(range(1, len(pages) + 1)) + for page in sections: + assert not page.notes, "章扉にスピーカーノートがある" + for no, page in enumerate(pages, start=1): + assert page.notes, f"台本の {no} ページ目にスピーカーノートが無い" + assert page.notes == readings[no], f"台本の {no} ページ目のノートが台本と違う" + + +@pytest.mark.parametrize("deck", DECKS, ids=[d.name for d in DECKS]) +def test_deck_uses_copied_frame(deck: Path) -> None: + src = (deck / "slides" / "build-deck.js").read_text(encoding="utf-8") + assert 'require("./lib/deck-frame")' in src + recorded = re.search(r"`([0-9a-f]{64})`", (deck / "README.md").read_text(encoding="utf-8")) + assert recorded, "README に写した時点の lib/ のハッシュが無い" + assert _lib_hash(deck / "slides" / "lib") == recorded.group(1) + + +def test_skill_plugin_development_has_21_pages_and_6_sections(tmp_path: Path) -> None: + deck = ROOT / "docs" / "presentations" / "2026-10-17-skill-plugin-development" + assert len(script_readings(deck / "script.md")) == 21 + assert _notes_rows(deck / "notes.md") == list(range(1, 22)) + ensure_node_modules(deck / "slides") + out = tmp_path / "deck.pptx" + assert run_node(deck / "slides" / "build-deck.js", out, cwd=deck).returncode == 0 + slides = read_pptx(out)["slides"] + assert len(slides) == 27 + assert sum(_is_section(p) for p in slides) == 6