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/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/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..92de0ad33 --- /dev/null +++ b/docs/presentations/2026-10-17-skill-plugin-development/README.md @@ -0,0 +1,47 @@ +# 独自スキルプラグイン開発の薦め + +社外の勉強会・登壇で話す 23 ページのスライド。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/` の内容のハッシュ | `86c3acf6f8b74c80bb92ffa3a1dfe17cde6ee17d199e6f76d029744f7d6a9133`(`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..0f80066c7 --- /dev/null +++ b/docs/presentations/2026-10-17-skill-plugin-development/notes.md @@ -0,0 +1,75 @@ +# 構成メモ: 独自スキルプラグイン開発の薦め + +台本([script.md](script.md)。第 7 稿の写し)を、社外の勉強会・登壇で話す 23 ページのスライドにした構成。 +各ページの読み上げはスピーカーノートに入り、生成スクリプト([slides/build-deck.js](slides/build-deck.js))が台本から読み込む。 + +## 構成 + +キーメッセージ「AI に自分のやり方を教えるのは 1 回でよい。スキルプラグインにして、フォークして育てる」から逆算し、 +課題(1〜4)→ NDF の機能(5〜18)→ 事例(19〜20)→ 転換と実践(21〜22)→ まとめ(23)の順に並べた。 +台本はページに載せる要素を持たないため、各ページの図・表・箇条書きは読み上げから起こした。読み上げの文をそのまま +ページに写さず、ページには構造(表・流れ・数値)だけを置く。事例のプロジェクトは種類で呼び、名前は出さない。 + +| # | 見出し | 役割 | 根拠 | +| ---: | --- | --- | --- | +| 1 | 表紙 | — | — | +| 2 | AI に同じことを何度も言っていませんか | 繰り返す 3 つの指摘を吹き出しで並べ、「セッションが切れると忘れる」を注意の地で示す | 台本 2 | +| 3 | CLAUDE.md への追記だけでは足りない理由 | 4 つの壁(コンテキスト・強制力・手順・ポータビリティ)を 2 × 2 の区画で示す | 台本 3 | +| 4 | スキルプラグインとは | 構成要素 4 つと役割の表と、4 つのランタイムの並び | 台本 4 | +| 5 | NDF: 1 人の開発スタイルをプラグインにした | リリース 102 回・Skill 48 個・ランタイム 4 つ・導入 10 リポジトリの数値と、リポジトリの URL | 台本 5(2026-10-17 の実測) | +| 6 | NDF の全体像 | マイルストーン → グループ → スプリント(グループごとに並列)→ リリースの図と、2 つの承認ゲート。直前のリリースの例は注 | 台本 6 | +| 7 | /ndf:development-workflow | 5 つのモードと対象の表。モードごとに通る Skill が違うことを下の区画で示す | 台本 7、`development-workflow` の `references/workflow-modes.md` | +| 8 | standard モードの全体の流れ | 設計・実装・リリースの 3 行に工程を並べ、ゲート 1・2 を注意の地で示す | 台本 8 | +| 9 | 実績のある開発技法を、AI が守れる形で組み込む | DDD・テストファースト・アジャイルの 3 列。用語集 580 語と WSJF を載せる | 台本 9 | +| 10 | /ndf:requirements-design と /ndf:design | 「何を満たせば完了か」と「どう作るか」の 2 列。設計 PR とゲート 1 を下の区画で示す | 台本 10 | +| 11 | /ndf:document-restructuring | 書いた順と読み手の順の対比、内容を足さない 5 つの手順 | 台本 11 | +| 12 | /ndf:cross-review | 繰り返しの流れと、指摘の再現率(1 つの AI 0.21〜0.49、2 つで 0.91)の棒 | 台本 12(手元の実測) | +| 13 | /ndf:cross-refactoring | 提案 → 選ぶ → 時間内に実施 → 取り消しの流れと、逆算と内訳の 2 区画 | 台本 13 | +| 14 | 3 層のエージェント | conductor・supervisor・worker の階段の図と、再開とセッションの切り替えの 2 区画 | 台本 14 | +| 15 | 判断基準: MVV と 2 つの承認ゲート | Mission・Vision・Value の 3 区画(Value の例 2 つ)と、必ず人の承認が要る操作 | 台本 15、`.ndf/` のプロジェクト MVV | +| 16 | /ndf:quality-gates と /ndf:investigation-rules | AI の言葉 2 つと、それぞれを受け止める Skill の対応 | 台本 16 | +| 17 | 自分を直すループ | 3 つの Skill の流れと、棚卸しの数値 2 つ(56%・49 件) | 台本 17(棚卸しの記録と 2026-10-17 の調査) | +| 18 | その他の特徴的な機能 | estimate・release・hook の表 | 台本 18 | +| 19 | 事例: 他のプロジェクトでも動く | 4 つのプロジェクトの種類と結果の表。インフラの注記(社内ツールの移行と重なる)を表の中に残す | 台本 19、事例の材料(インフラ・業務システム・機械学習と価格推定) | +| 20 | 事例から分かったこと | 詰まりが次の版で直る例(17 件の Issue と 3 つの前提の違い)と、プロジェクトごとに足したルールの表 | 台本 20、事例の材料 | +| 21 | だから「フォークして自分用に」 | NDF のルールの例と、そのまま使う仕組みの対比、メリット 3 つ | 台本 21 | +| 22 | 始め方と育て方 | フォークする・削る・育てるの 3 列と、気をつける点 | 台本 22 | +| 23 | まとめ: 明日からの 3 ステップ | 3 つの手順とリポジトリの URL | 台本 23 | + +## 尺 + +`node slides/lib/script-time.js script.md --limit 35` の出力(350 字/分)。合計 5,317 字・15 分 12 秒で、 +持ち時間(30〜35 分)に収まる。削るページは無い。 + +| # | 見出し | 字数 | 尺(秒) | 配分(秒) | +| ---: | --- | ---: | ---: | ---: | +| 1 | タイトル | 108 | 18.5 | — | +| 2 | AI に同じことを何度も言っていませんか | 142 | 24.3 | — | +| 3 | CLAUDE.md への追記だけでは足りない理由 | 160 | 27.4 | — | +| 4 | スキルプラグインとは | 216 | 37.0 | — | +| 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 — コードより先に「何を」「どう」を決める | 230 | 39.4 | — | +| 11 | /ndf:document-restructuring — 書いた順ではなく、読み手が知りたい順へ | 190 | 32.6 | — | +| 12 | /ndf:cross-review — 2 つの AI で、新しい指摘が出なくなるまで | 223 | 38.2 | — | +| 13 | /ndf:cross-refactoring — 全員で提案し、1 人が時間内に実施する | 206 | 35.3 | — | +| 14 | 3 層のエージェント — 長いタスクを止めずに、止まっても再開できる | 249 | 42.7 | — | +| 15 | 判断基準: MVV と 2 つの承認ゲート | 193 | 33.1 | — | +| 16 | /ndf:quality-gates と /ndf:investigation-rules — 完了も「無い」も証拠で | 182 | 31.2 | — | +| 17 | 自分を直すループ — /ndf:out-of-scope・/ndf:retrospective・/ndf:backlog-refinement | 299 | 51.3 | — | +| 18 | その他の特徴的な機能 | 243 | 41.7 | — | +| 19 | 事例: 他のプロジェクトでも動く | 328 | 56.2 | — | +| 20 | 事例から分かったこと: 詰まったところが次のバージョンに、ルールはプロジェクトごとに違う | 244 | 41.8 | — | +| 21 | だから「フォークして自分用に」 | 225 | 38.6 | — | +| 22 | 始め方と育て方 | 312 | 53.5 | — | +| 23 | まとめ: 明日からの 3 ステップ | 180 | 30.9 | — | +| 合計 | 23 ページ(350 字/分) | 5317 | 911.5(15分12秒) | — | + +## ページに収まらない読み上げの扱い + +読み上げはスピーカーノートに全文が入る。ページには読み上げの要点だけを置き、例(6 ページの直前のリリース、 +17 ページの「放っておくと…」)は注へ回した。見出しが 1 行に収まらないページ(6・7・9〜14・16・17・20)は、 +ページの見出しを短くし、台本の見出しの後半をリードの文へ回した。 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..c82c5aff7 --- /dev/null +++ b/docs/presentations/2026-10-17-skill-plugin-development/script.md @@ -0,0 +1,112 @@ +# 台本(第 7 稿): 独自スキルプラグイン開発の薦め — NDF を例に + +- 想定: 社外の勉強会・登壇。30〜35 分・23 ページ +- 対象: Claude Code / Codex などの AI コーディングエージェントを使っている、または使い始めたエンジニア +- キーメッセージ: **AI に自分のやり方を教えるのは 1 回でよい。スキルプラグインにして、フォークして育てる** +- 数値は 2026-10-17 時点の実測。事例のプロジェクトは種類で呼び、名前は出さない + +## 構成 + +| パート | ページ | 問い | +| --- | --- | --- | +| 1. 課題 | 1〜4 | AI に同じことを何度も言っていないか。なぜ CLAUDE.md への追記では足りないのか | +| 2. NDF の機能 | 5〜18 | 自分のやり方をプラグインにすると何ができるのか(ワークフロー全体・組み込んだ開発技法・主な Skill) | +| 3. 事例 | 19〜20 | 他のプロジェクトでも動くのか | +| 4. 転換と実践 | 21〜22 | なぜフォークして自分用にするのか。どう始めて、どう育てるか | +| 5. まとめ | 23 | 明日から何をするか | + +--- + +## 1. タイトル + +独自スキルプラグイン開発の薦め — NDF を例に、自分の開発スタイルを AI に持たせる + +> 今日は「自分用のスキルプラグインを作って育てよう」という話をします。例として私が開発している NDF プラグインの機能を中心に紹介します。ゴールは NDF を使ってもらうことではなく、皆さんが自分のプラグインを持つことです。 + +## 2. AI に同じことを何度も言っていませんか + +> 「PR は develop 宛てで」「テストを先に書いて」「無いと言う前に検索結果を見せて」。AI エージェントを毎日使っていると、同じ指摘を何度もしていることに気づきます。セッションが切れるたびに AI は忘れ、指摘は消えていきます。指摘の回数は、自分のやり方がまだ AI に伝わっていない量です。 + +## 3. CLAUDE.md への追記だけでは足りない理由 + +> まず CLAUDE.md や AGENTS.md に追記しますが、4 つの壁に当たります。コンテキスト: 毎回すべてを読み込むので、書くほどコストが上がり、重要なルールが埋もれる。強制力: 書いても守られないことがある。手順: 「どの順に何を実行するか」は文章では伝わりにくい。ポータビリティ: リポジトリやランタイムを変えると失われる。 + +## 4. スキルプラグインとは + +> そこでスキルプラグインです。構成要素は 4 つ。Skill は「いつ・何を・どの順で」を書いたワークフローで、必要なときだけ読み込まれます。スクリプトは決まった処理を確実に実行します。hook はルールを強制します。サブエージェントは役割を分けます。これをまとめて配布でき、Claude Code・Codex・Kiro・agy で同じものを使えます。判断基準とワークフローをパッケージにして、どのリポジトリでも再利用できる。これがポイントです。 + +## 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 — コードより先に「何を」「どう」を決める + +> 実装の前に 2 つの文書を書きます。requirements-design は「何を満たせば完了か」を観測できる受け入れ条件にします。「いい感じに」「速く」のような評価語を、確かめられる条件に変えるのが仕事です。design は「どう作るか」、つまりデータ構造・入出力の契約・処理の流れ・決定の理由を書きます。担当する AI が変わっても同じ構成の成果物になるよう、書く内容を規約で決めています。設計は PR として出し、主題ごとに並列で開いて、ゲート 1 でまとめて承認します。 + +## 11. /ndf:document-restructuring — 書いた順ではなく、読み手が知りたい順へ + +> AI が書いた文書は、調べた順に並びがちです。「問題に気づく → 測る → 結論」の順に書かれますが、読み手が最初に知りたいのは結論です。document-restructuring は、書き上げた文書を測り、章立てを読み手の問いの順に並べ替え、長い文を分け、対比を表にして、もう一度測ります。内容は足しません。レビューの前に通すことで、レビューが構成の指摘と内容の指摘で混ざらなくなります。 + +## 12. /ndf:cross-review — 2 つの AI で、新しい指摘が出なくなるまで + +> 同じ AI に書かせてレビューさせると、同じ見落としをします。cross-review は、claude・codex・kiro などから 2 つを選んでレビューさせ、指摘を修正し、新しい指摘が出なくなるまで繰り返します。組み合わせはラウンドごとに入れ替えます。手元の実測では、1 つの AI だけの指摘の再現率は 0.21〜0.49 でしたが、2 つを合わせると 0.91 になりました。レビューの観点は PR の中身で切り替わり、リポジトリごとにレビューの基準を設定できます。 + +## 13. /ndf:cross-refactoring — 全員で提案し、1 人が時間内に実施する + +> リファクタリングは、どこを直すかの発見と、直した結果の検証が難しい作業です。cross-refactoring では、参加する AI 全員が 1 回ずつ多面的にリファクタリングを提案し、選ばれた 1 つの AI が、指定した時間(既定 30 分)に収まる分だけを計画・テスト追加・実装・検証します。時間に関わる数値はすべてその時間から逆算するので、終わりが見えます。テストが壊れた変更は取り消し、採用と取り消しの内訳を報告に残します。 + +## 14. 3 層のエージェント — 長いタスクを止めずに、止まっても再開できる + +> 長いタスクは 1 つのセッションでは終わりません。NDF は conductor・supervisor・worker の 3 層で進めます。conductor は人と話して全体を指揮し、supervisor は設計・実装・テストといった 1 つのフェーズを通し、worker は調査や修正といった 1 つの作業を行います。フェーズは JSON のプランとしてスクリプトが実行するので、途中で止まっても続きから再開できます。セッションのコンテキストが大きくなったら、次に実行するコマンドを残して自動で新しいセッションに切り替えます。 + +## 15. 判断基準: MVV と 2 つの承認ゲート + +> AI は判断のたびに迷います。指摘を直すか見送るか、人の承認を省いてよいか。NDF はプロジェクトの Mission・Vision・Value を明文化し、AI がそれを根拠に判断して、根拠の番号をログに残します。例えば「まず届ける」「処理の改善に寄与しない指摘は直さない」。本番デプロイのように必ず人の承認が必要な操作もここに定義します。どこまで AI に任せるかも、進め方の設定で事前に選べます。 + +## 16. /ndf:quality-gates と /ndf:investigation-rules — 完了も「無い」も証拠で + +> AI の「完了しました」「該当のコードはありません」は、そのまま信じると危険です。quality-gates は、実行したコマンド・終了コード・実行時刻・対象範囲がそろわない限り完了と認めません。investigation-rules は、「無い」という結論には必ず検索のコマンドと結果を添えさせます。AI はもっともらしいが間違った推論で「無い」と断定しがちだからです。 + +## 17. 自分を直すループ — /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 件は本文が古くなっていました。 + +## 18. その他の特徴的な機能 + +> 他にも特徴的な機能があります。estimate は、AI エージェントが実施する前提で工数を見積もります。人日では見積もらず、AI の実行時間・人の作業時間・完了までの時間・費用の 4 つを、似た過去の実績から別々に出します。release は、パッケージ・Web サービス・スライドや文書まで、届け方の違う成果物を同じ骨格でリリースします。hook は、メインディレクトリの直接編集の警告、sleep による待ちの禁止、同じファイルの繰り返し読み込みの防止など、AI がやりがちなことを強制的に止めます。 + +## 19. 事例: 他のプロジェクトでも動く + +> ここまでは 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 数は変わらず、レビューを必ず通す開発プロセスに変わりました。 + +## 20. 事例から分かったこと: 詰まったところが次のバージョンに、ルールはプロジェクトごとに違う + +> 事例から分かったことは 2 つです。1 つ目、どのプロジェクトでも最初は詰まり、それが NDF の Issue として上がって次のバージョンで直りました。PHP のプロジェクトでは worktree に vendor/ が無い、全テストに 64 分かかる、マージがそのまま本番リリースになる、といった前提の違いから 17 件の Issue が上がりました。2 つ目、どのプロジェクトも独自のルールを追加していました。インフラは本番デプロイの承認、業務システムは「マージ = 本番リリース」の承認、価格推定は全 PR へのレビューです。 + +## 21. だから「フォークして自分用に」 + +> NDF 自体も、承認は 2 回だけ、ドキュメントでは「道具」でなく「Tool」と書く、文言を照合するテストは書かない、といった私のルールのかたまりです。他人のルールは、自分にとってはノイズになります。だから NDF をそのまま導入するより、フォークしてください。ワークフロー・3 層のエージェント・クロスレビュー・判断基準といった仕組みはそのまま使い、ルールを自分のものに差し替える。メリットは 3 つ。指摘が資産になる、ランタイムに依存しない、計測して改善できる。 + +## 22. 始め方と育て方 + +> 始め方は 3 段階です。まずフォークして、プラグイン名とマーケットプレイス名を自分のものに変え、自分のリポジトリからインストールします。次に削ります。配布する Skill はマニフェストで決まるので、使わない Skill は外すだけ。どれを残すかはセッションのログから呼び出し回数を計測して決めます。最後に育てます。同じ指摘を 2 回したら、手順なら Skill、決まった処理ならスクリプト、必ず守らせたいなら hook にします。気をつける点は、description を増やしすぎないこと(すべてのセッションで読み込まれる)、思いつきは experimental に置いて効いたものだけ本体に移すこと、開発版を自分で使ってから正式版にすることです。 + +## 23. まとめ: 明日からの 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..7f2200292 --- /dev/null +++ b/docs/presentations/2026-10-17-skill-plugin-development/slides/build-deck.js @@ -0,0 +1,364 @@ +// 発表資料「独自スキルプラグイン開発の薦め」の生成スクリプト(23 ページ)。 +// +// node build-deck.js <出力>.pptx [] +// +// 枠組みは lib/(NDF の release の assets/slides/lib/ の写し。書き換えない)、 +// トーン&マナーは theme.json、スピーカーノートは ../script.md(台本)から入る。 +// ページの役割と根拠は ../notes.md にある。 +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("独自スキルプラグイン開発の薦め", "NDF を例に、自分の開発スタイルを AI に持たせる", "github.com/devbasex/ai-plugins"); + + // 2. AI に同じことを何度も言っていませんか + s = d.page("AI に同じことを何度も言っていませんか", "指摘の回数は、自分のやり方がまだ 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.76, "caution", "セッションが切れるたびに AI は忘れ、指摘は消えていく", "毎日使うほど、同じ指摘を何度もしていることに気づく。"); + + // 3. CLAUDE.md への追記だけでは足りない理由 + s = d.page("CLAUDE.md への追記だけでは足りない理由", "まず CLAUDE.md や AGENTS.md に追記するが、4 つの壁に当たる"); + [ + ["コンテキスト", "毎回すべてを読み込む。書くほどコストが上がり、重要なルールが埋もれる"], + ["強制力", "書いても守られないことがある"], + ["手順", "「どの順に何を実行するか」は文章では伝わりにくい"], + ["ポータビリティ", "リポジトリやランタイムを変えると失われる"], + ].forEach((r, i) => { + const c = cols(2)[i % 2]; + card(d, s, c.x, 1.42 + Math.floor(i / 2) * 1.62, c.w, 1.46, r[0], r[1], "panel1", { band: "accent", headSize: 15, bodySize: 12, headH: 0.42 }); + }); + + // 4. スキルプラグインとは + s = d.page("スキルプラグインとは", "判断基準とワークフローをパッケージにして、どのリポジトリでも再利用する"); + d.tbl(s, [ + [d.H("構成要素"), d.H("役割")], + [d.B("Skill"), "「いつ・何を・どの順で」を書いたワークフロー。必要なときだけ読み込まれる"], + [d.B("スクリプト"), "決まった処理を確実に実行する"], + [d.B("hook"), "ルールを強制する"], + [d.B("サブエージェント"), "役割を分ける"], + ], { x: X0, y: 1.4, w: W, colW: [2.2, 7.28], rowH: [0.32, 0.42, 0.42, 0.42, 0.42], fontSize: 11 }); + d.text(s, "まとめて配布し、同じものを 4 つのランタイムで使える", { x: X0, y: 3.62, w: W, h: 0.3, fontSize: 11, bold: true, color: "text" }); + cols(4).forEach((c, i) => d.chip(s, c.x, 4.0, c.w, 0.62, ["Claude Code", "Codex", "Kiro", "agy"][i], "accentSoft", { fontSize: 13 })); + + // 5. NDF: 1 人の開発スタイルをプラグインにした + s = d.page("NDF: 1 人の開発スタイルをプラグインにした", "GitHub で公開している。ほぼ 1 人と AI で開発した"); + [ + ["102", "回", "リリース(2026 年 4 月に開発を始めて半年)"], + ["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 時点の実測"); + + // 6. NDF の全体像 + s = d.page("NDF の全体像", "マイルストーンを渡せば、スプリントとして並列に進む"); + 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: 設計の承認", "スプリントの設計 PR をまとめて 1 回"], + ["ゲート 2: 本番リリースの承認", "開発版で確かめた後に 1 回"], + ][i]; + card(d, s, c.x, 3.52, c.w, 0.82, g[0], g[1], "caution", { headSize: 11, bodySize: 10, headH: 0.28 }); + }); + d.text(s, "人が判断するのは 2 か所だけ。承認には決めたこと・未検証の点・判断の根拠がそろって届く", { x: X0, y: 4.44, w: W, h: 0.3, fontSize: 10, bold: true, color: "text", valign: "middle" }); + d.note(s, "例: 直前のリリースでは、致命的な Issue 18 件を 2 つのスプリントに分け、1 つのスプリントの中で 3 つのグループの設計と実装を並列に進めた"); + + // 7. /ndf:development-workflow + s = d.page("/ndf:development-workflow", "変更を 5 つのモードに分類し、モードごとに通る Skill を切り替える"); + 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.4, 7.08], rowH: 0.36, fontSize: 11 }); + d.callout(s, 3.78, 0.72, "panel1", "通る Skill はモードごとの表で決まっている", "light なら実装してレビューするだけ。standard なら要件定義から振り返りまで通る。すべての変更にフルのワークフローを課すと遅くなる。"); + + // 8. standard モードの全体の流れ + s = d.page("standard モードの全体の流れ", "スプリント単位で 1 周する。中の設計と実装は Issue のグループごとに並列に進む"); + [ + ["設計", ["要件定義", "worktree", "設計", "ドキュメントの\n再構成", "クロスレビュー", { text: "ゲート 1\n設計の承認" }]], + ["実装", ["実装計画", "テストファースト\nの実装", "クロス\nリファクタリング", "クロスレビュー", "完了判定(証跡)", "マージ"]], + ["リリース", ["仕様書へ変換", "開発版リリース", { text: "ゲート 2\n本番の承認" }, "本番リリース", "リリース後テスト", "振り返り", "Issue の棚卸し"]], + ].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: 8.5 }); + // 段の続きは左の見出しの列で示す(工程の箱の間に置くと、箱どうしの流れに読める) + if (i < 2) d.arrow(s, X0 + 0.3, y + 0.76, 0, 0.26); + }); + d.note(s, "クロスレビューの指摘は修正して繰り返す。開発版は自分で使って確かめてからゲート 2 へ出す"); + + // 9. 実績のある開発技法を組み込む + s = d.page("実績のある開発技法を、AI が守れる形で組み込む", "新しい方法論の発明ではない。人間のチームで実績のある技法を、AI が毎回守れる手順にした"); + [ + ["DDD: ユビキタス言語", ["要件定義の前に用語集を作り、語・意味・コンテキストを決める", "同じ「注文」でも受注と配送では意味が違う。コンテキストごとに 1 つの意味に決め、AI はその語で設計とコードを書く", "このリポジトリの用語集は 580 語。CI が食い違いを検査する"]], + ["テストファースト", ["失敗するテストを書く → 通す最小の実装 → 整理、のサイクル", "テストが薄いコードは、先に今の振る舞いを固定するテストを書いてからリファクタリングする"]], + ["アジャイル", ["マイルストーンをバックログとして扱い、スプリントで進める", "スプリントごとに振り返りとバックログリファインメントを行う", "優先順位は WSJF(遅延コスト ÷ 大きさ)で付ける"]], + ].forEach((r, i) => { + const c = cols(3)[i]; + card(d, s, c.x, 1.42, c.w, 2.75, r[0], bullets(r[1], 11.5), "panel1", { band: "accent", headSize: 13 }); + }); + + // 10. /ndf:requirements-design と /ndf:design + s = d.page("/ndf:requirements-design と /ndf:design", "コードより先に「何を」「どう」を決める"); + [ + ["requirements-design: 何を満たせば完了か", ["観測できる受け入れ条件にする", "「いい感じに」「速く」のような評価語を、確かめられる条件に変える"]], + ["design: どう作るか", ["データ構造・入出力の契約・処理の流れ・決定の理由を書く", "担当する AI が変わっても同じ構成の成果物になるよう、書く内容を規約で決めている"]], + ].forEach((r, i) => { + const c = cols(2)[i]; + card(d, s, c.x, 1.42, c.w, 1.9, r[0], bullets(r[1], 12.5), "panel1", { band: "accent", headSize: 13 }); + }); + d.callout(s, 3.56, 0.72, "caution", "設計は PR として出す", "主題ごとに並列で開き、ゲート 1 でまとめて承認する。"); + + // 11. /ndf:document-restructuring + s = d.page("/ndf:document-restructuring", "書いた順ではなく、読み手が知りたい順へ並べ替える"); + d.text(s, "AI が書いた順(調べた順)", { x: X0, y: 1.4, w: 4.3, h: 0.3, fontSize: 11, bold: true, color: "text" }); + flow(d, s, X0, 1.76, 4.3, 0.56, ["問題に気づく", "測る", "結論"], { fill: "panel2", size: 10 }); + d.arrow(s, 4.66, 2.04, 0.5, 0, "accent"); + d.text(s, "読み手が知りたい順", { x: 5.32, y: 1.4, w: 4.42, h: 0.3, fontSize: 11, bold: true, color: "text" }); + flow(d, s, 5.32, 1.76, 4.42, 0.56, ["結論", "根拠(測った結果)", "経緯"], { size: 10 }); + d.text(s, "手順(内容は足さない)", { x: X0, y: 2.62, w: W, h: 0.3, fontSize: 11, bold: true, color: "text" }); + flow(d, s, X0, 2.98, W, 0.62, ["測る", "章立てを読み手の\n問いの順に並べる", "長い文を分ける", "対比を表にする", "もう一度測る"], { size: 10 }); + d.callout(s, 3.86, 0.7, "panel1", "レビューの前に通す", "レビューの指摘が、構成の指摘と内容の指摘で混ざらなくなる。"); + + // 12. /ndf:cross-review + s = d.page("/ndf:cross-review", "2 つの AI で、新しい指摘が出なくなるまでレビューする"); + flow(d, s, X0, 1.5, 5.0, 0.7, ["AI を 2 つ選ぶ\nclaude・codex・kiro", "レビュー", "指摘を修正"], { size: 9 }); + d.arrow(s, 2.9, 2.32, 0, 0.36); + d.chip(s, 1.5, 2.72, 2.8, 0.62, "新しい指摘が出たか", "panel2", { fontSize: 10 }); + d.text(s, "出た → 組み合わせを入れ替えて繰り返す\n出ない → 終える", { x: X0, y: 3.46, w: 5.0, h: 0.56, fontSize: 10 }); + d.text(s, "レビューの観点は PR の中身で切り替わり、リポジトリごとにレビューの基準を設定できる", { x: X0, y: 4.1, w: 5.0, h: 0.6, fontSize: 10 }); + d.text(s, "指摘の再現率(手元の実測)", { x: 5.6, y: 1.42, w: 4.14, h: 0.3, fontSize: 11, bold: true, color: "text" }); + [ + ["1 つの AI(低い方)", 0.21, "panel2"], + ["1 つの AI(高い方)", 0.49, "panel2"], + ["2 つの AI を合わせる", 0.91, "accent"], + ].forEach((r, i) => { + const y = 1.86 + i * 0.86; + d.text(s, r[0], { x: 5.6, y, w: 4.14, h: 0.28, fontSize: 10 }); + d.box(s, 5.6, y + 0.3, 3.4 * r[1], 0.36, r[2], false); + d.text(s, r[1].toFixed(2), { x: 5.66 + 3.4 * r[1], y: y + 0.3, w: 0.6, h: 0.36, fontSize: 13, bold: true, color: "text", valign: "middle" }); + }); + + // 13. /ndf:cross-refactoring + s = d.page("/ndf:cross-refactoring", "全員で提案し、1 人が時間内に実施する"); + flow(d, s, X0, 1.5, W, 0.86, ["参加する AI 全員が\n1 回ずつ多面的に提案", "1 つの AI を選ぶ", "指定した時間(既定 30 分)に\n収まる分だけ実施", "テストが壊れた変更は\n取り消す"], { size: 10 }); + d.text(s, "実施の中身: 計画 → テスト追加 → 実装 → 検証", { x: 5.0, y: 2.48, w: 4.74, h: 0.3, fontSize: 10, align: "center" }); + card(d, s, X0, 3.0, 4.66, 1.2, "終わりが見える", "時間に関わる数値は、すべて指定した時間から逆算する", "panel1", { band: "accent", headSize: 13, bodySize: 12 }); + card(d, s, 5.08, 3.0, 4.66, 1.2, "結果が追える", "採用と取り消しの内訳を報告に残す", "panel1", { band: "accent", headSize: 13, bodySize: 12 }); + + // 14. 3 層のエージェント + s = d.page("3 層のエージェント", "長いタスクを止めずに進め、止まっても再開できる"); + [ + ["conductor", "人と話して全体を指揮する"], + ["supervisor", "1 つのフェーズ(設計・実装・テストなど)を通す"], + ["worker", "1 つの作業(調査・修正など)を行う"], + ].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: 3.0, h: 0.74, fontSize: 10.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 }); + card(d, s, 5.7, 2.96, 4.04, 1.5, "セッションを自動で切り替える", "コンテキストが大きくなったら、次に実行するコマンドを残して新しいセッションへ移る", "panel1", { band: "accent", headSize: 13, bodySize: 12 }); + + // 15. 判断基準: MVV と 2 つの承認ゲート + s = d.page("判断基準: MVV と 2 つの承認ゲート", "AI はプロジェクトの MVV を根拠に判断し、根拠の番号をログに残す"); + d.text(s, "AI が迷う場面: 指摘を直すか見送るか、人の承認を省いてよいか", { x: X0, y: 1.38, w: W, h: 0.3, fontSize: 10.5 }); + [ + ["Mission", "何のためにやるか"], + ["Vision", "目指す姿"], + ["Value", "判断の基準\n例: 「まず届ける」\n「処理の改善に寄与しない指摘は直さない」"], + ].forEach((r, i) => { + const c = cols(3, 0.16, X0, 5.9)[i]; + card(d, s, c.x, 1.8, c.w, 1.9, r[0], r[1], "accentSoft", { headSize: 15, bodySize: 11 }); + }); + card(d, s, 6.4, 1.8, 3.34, 1.9, "必ず人の承認が要る操作", "本番デプロイのような操作は、MVV に照らしても AI が省かない。ここに定義する", "caution", { headSize: 12, bodySize: 11.5 }); + d.callout(s, 3.9, 0.4, "panel1", "どこまで AI に任せるかは、進め方の設定で事前に選べる", null); + + // 16. /ndf:quality-gates と /ndf:investigation-rules + s = d.page("/ndf:quality-gates と /ndf:investigation-rules", "完了も「無い」も、証拠がそろって初めて認める"); + [ + ["「完了しました」", "quality-gates", "実行したコマンド・終了コード・実行時刻・対象範囲がそろわない限り、完了と認めない"], + ["「該当のコードはありません」", "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: 13, bodySize: 12.5 }); + }); + d.text(s, "AI はもっともらしいが間違った推論で「無い」と断定しがちである", { x: X0, y: 4.3, w: W, h: 0.34, fontSize: 11, bold: true, color: "text", align: "center", valign: "middle" }); + + // 17. 自分を直すループ + s = d.page("自分を直すループ", "out-of-scope・retrospective・backlog-refinement で、NDF 自身を直し続ける"); + [ + ["out-of-scope", "作業中に見つけた範囲外の問題を、その場で Issue にする"], + ["retrospective", "リリースのたびに進め方を見直す"], + ["backlog-refinement", "スプリントの終わりに Issue の一覧を現状に合わせ、優先順位を付け直す"], + ].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: 10, 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 43 件のうち 24 件の本文が現状と食い違っていた"); + stat(d, s, 5.4, 3.0, 4.34, 1.5, "49", "件", "今日 71 件を 6 つの worker で並列に調べたところ、49 件は本文が古く、2 件はすでに修正済みだった"); + d.note(s, "放っておくと、すでに直った Issue を直すことになる"); + + // 18. その他の特徴的な機能 + s = d.page("その他の特徴的な機能", "見積もり・届け方の違う成果物のリリース・AI がやりがちなことの強制的な停止"); + d.tbl(s, [ + [d.H("機能"), d.H("何をするか")], + [d.B("estimate"), "AI エージェントが実施する前提で工数を見積もる。人日では見積もらず、AI の実行時間・人の作業時間・完了までの時間・費用の 4 つを、似た過去の実績から別々に出す"], + [d.B("release"), "パッケージ・Web サービス・スライドや文書まで、届け方の違う成果物を同じ骨格でリリースする"], + [d.B("hook"), "メインディレクトリの直接編集の警告、sleep による待ちの禁止、同じファイルの繰り返し読み込みの防止など、AI がやりがちなことを強制的に止める"], + ], { x: X0, y: 1.42, w: W, colW: [1.8, 7.68], rowH: [0.36, 1.0, 0.76, 1.0], fontSize: 12 }); + + // 19. 事例: 他のプロジェクトでも動く + s = d.page("事例: 他のプロジェクトでも動く", "言語もチームも違う 4 つのプロジェクトで使った結果"); + d.tbl(s, [ + [d.H("プロジェクトの種類"), d.H("結果")], + [d.B("AWS のインフラ"), "導入前後の 37 日で、1 日あたりのマージ PR 数が 0.95 から 3.38 に増えた(社内ツールの移行と重なっており、すべてが NDF の効果ではない)"], + [d.B("PHP の業務システム\n(20 人で開発)"), "設計 PR 1 本の承認から 51 時間で、31 本の実装 PR が入った"], + [d.B("機械学習のモデル"), "精度改善の調査で 4 か月分のデータ欠損を見つけ、修正からバックフィルまでを 1 つのスプリントで本番に出した。相関係数は 0.73 から 0.954 に改善した"], + [d.B("価格推定"), "PR 数は変わらず、レビューを必ず通す開発プロセスに変わった"], + ], { x: X0, y: 1.42, w: W, colW: [2.3, 7.18], rowH: [0.32, 0.72, 0.56, 0.72, 0.5], fontSize: 11 }); + d.note(s, "プロジェクトは種類で呼び、名前は出さない。数値は 2026-10-17 時点の実測"); + + // 20. 事例から分かったこと + s = d.page("事例から分かったこと", "詰まったところが次のバージョンになり、ルールはプロジェクトごとに違う"); + card(d, s, X0, 1.42, 4.66, 2.6, "1. どこでも最初は詰まり、次の版で直る", bullets([ + "詰まったところが NDF の Issue として上がり、次のバージョンで直った", + "PHP のプロジェクトでは前提の違いから 17 件の Issue が上がった", + "- worktree に vendor/ が無い", + "- 全テストに 64 分かかる", + "- マージがそのまま本番リリースになる", + ], 12), "panel1", { band: "accent", headSize: 12.5 }); + d.text(s, "2. どのプロジェクトも独自のルールを足した", { x: 5.08, y: 1.48, w: 4.66, h: 0.32, fontSize: 12, 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 }); + + // 21. だから「フォークして自分用に」 + s = d.page("だから「フォークして自分用に」", "仕組みはそのまま使い、ルールを自分のものに差し替える"); + card(d, s, X0, 1.42, 4.66, 1.86, "NDF も私のルールのかたまり", bullets(["承認は 2 回だけ", "ドキュメントでは「道具」でなく「Tool」と書く", "文言を照合するテストは書かない"], 12), "warn", { headSize: 13 }); + card(d, s, 5.08, 1.42, 4.66, 1.86, "そのまま使う仕組み", bullets(["ワークフロー", "3 層のエージェント", "クロスレビュー", "判断基準"], 12), "accentSoft", { headSize: 13 }); + d.text(s, "他人のルールは、自分にとってはノイズになる。そのまま導入するより、フォークする", { x: X0, y: 3.36, w: W, h: 0.3, fontSize: 10.5, 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 })); + + // 22. 始め方と育て方 + s = d.page("始め方と育て方", "フォークする → 削る → 育てる"); + [ + ["1. フォークする", ["プラグイン名とマーケットプレイス名を自分のものに変える", "自分のリポジトリからインストール"]], + ["2. 削る", ["配布する Skill はマニフェストで決まる。使わない Skill は外すだけ", "残すものはセッションのログの呼び出し回数で決める"]], + ["3. 育てる", ["同じ指摘を 2 回したら形にする", "手順なら Skill", "決まった処理ならスクリプト", "必ず守らせたいなら hook"]], + ].forEach((r, i) => { + const c = cols(3)[i]; + card(d, s, c.x, 1.42, c.w, 2.12, r[0], bullets(r[1], 11), "accentSoft", { headSize: 13 }); + }); + d.callout(s, 3.7, 1.04, "caution", "気をつける点", bullets([ + "description を増やしすぎない(すべてのセッションで読み込まれる)", + "思いつきは experimental に置き、効いたものだけを本体に移す", + "開発版を自分で使ってから正式版にする", + ], 9.5)); + + // 23. まとめ: 明日からの 3 ステップ + s = d.page("まとめ: 明日からの 3 ステップ", "AI に自分のやり方を教えるのは 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..1301748a6 --- /dev/null +++ b/docs/presentations/2026-10-17-skill-plugin-development/slides/lib/deck-frame.js @@ -0,0 +1,420 @@ +"use strict"; +// ページの枠組み(汎用テンプレート)。pptxgenjs で 16:9(10 × 5.625 インチ)の pptx を作る。 +// +// - 座標は FRAME の定数だけが持つ。トーン&マナー(theme.json)は座標を持たない +// - 書体と色の値は DEFAULT_THEME と theme.json だけが持つ。関数に渡す色は theme.colors の項目名で受ける +// - 台本(script.md)を渡すと、ページを作るたびに同じ番号の読み上げをスピーカーノートへ付け、 +// 保存の前に台本のページ数と作ったページ数を照合する +// +// 資料のディレクトリへ写して使う。写した後に書き換えない(プロジェクトの値は 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; + + 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)); + } + + function newSlide() { + const s = p.addSlide(); + pageNo++; + s.background = { color: color("background") }; + if (script) { + const sp = script[pageNo - 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(); + 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 !== pageNo) { + throw new DeckError(`ページ数が台本と合わない: 台本 ${script.length} ページ / 作ったページ ${pageNo}`, EXIT_INPUT); + } + await p.writeFile({ fileName: out }); + return { out, pages: pageNo }; + } + + return { + pptx: p, + theme, + frame: FRAME, + script, + get pageNo() { + return pageNo; + }, + 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} ページ`); + })().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..174763c7d --- /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..18383b0a9 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分 / 23ページ | | [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/issues/issue-2065-design-decisions.md b/issues/issue-2065-design-decisions.md index 04057dc98..9426949c8 100644 --- a/issues/issue-2065-design-decisions.md +++ b/issues/issue-2065-design-decisions.md @@ -119,3 +119,27 @@ 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) diff --git a/issues/issue-2065-design.md b/issues/issue-2065-design.md index 6d3488402..f4bd99d61 100644 --- a/issues/issue-2065-design.md +++ b/issues/issue-2065-design.md @@ -95,8 +95,8 @@ ## 構成要素 -**値の集合(スライドの生成の経路)へ値を足す。** 今の経路は「変換器を使う」「AI エージェントが組む」の 2 つで、 -これに「生成スクリプトを書く」を足す(決定 3)。集合を前提にした規則を、集合の名前と値の名前での検索(`生成の経路`・ +**値の集合(スライドの生成の経路)を 1 つの値に置き換える。** 今の経路は「変換器を使う」「AI エージェントが組む」の 2 つで、 +これを「生成スクリプトを書く」の 1 つにする(決定 12・13。決定 3 を置き換えた)。集合を前提にした規則を、集合の名前と値の名前での検索(`生成の経路`・ `AI エージェントが組む`・`変換器を使う`・`form-slide`・`layout-slide`)と、新しい経路が通る手順(体裁設計 → 生成 → 取り込み → 体裁レビュー)を読んで集めた。当てはまらない規則は下の表へ載せる。 @@ -407,7 +407,7 @@ sequenceDiagram ## 決定の記録 -決定の記録は [issue-2065-design-decisions.md](issue-2065-design-decisions.md) にある(決定 1〜11)。 +決定の記録は [issue-2065-design-decisions.md](issue-2065-design-decisions.md) にある(決定 1〜13。決定 3 は決定 12・13 が置き換えた)。 ## テスト設計 @@ -430,7 +430,7 @@ sequenceDiagram | 受け入れ条件 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.* docs/presentations/build.sh` が空 | Marp の資料や `build.sh` に触れると差が出る | +| 受け入れ条件 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) の検査であり、文言は照合しない。 diff --git a/issues/issue-2065-requirements.md b/issues/issue-2065-requirements.md index 4e086266e..f40adf697 100644 --- a/issues/issue-2065-requirements.md +++ b/issues/issue-2065-requirements.md @@ -21,6 +21,8 @@ NDF の今の Skill はスライドの原則(`design/references/layout-slide.m - 新しい 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 を読む・直すときだけに使う ## 由来 @@ -66,7 +68,7 @@ NDF の今の Skill はスライドの原則(`design/references/layout-slide.m ## 前提 -- 前提 1: 生成は pptxgenjs の生成スクリプト(Node.js)で行う。python-pptx で組む方式と、既存の Marp の経路(`docs/presentations/build.sh`)はこの課題で手を入れない +- 前提 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 の非公開の場所(フォルダを指定しないアップロード、または利用者の既存の非公開フォルダ)に置く。共有・公開範囲の設定はこの課題で行わない @@ -85,7 +87,7 @@ NDF の今の Skill はスライドの原則(`design/references/layout-slide.m 含まない: - 新しい Skill の新設(統合で足りないと設計が示した場合だけ、設計 PR で理由を添えて扱う) -- python-pptx・Marp・LibreOffice による生成の経路の追加と、既存の Marp 資料(`2026-08-06-ai-plugins-intro`)・`build.sh` の変更 +- python-pptx・LibreOffice による生成の経路の追加と、既存の Marp 資料(`2026-08-06-ai-plugins-intro` の md・html・pdf)の削除や書き換え - 発表資料の共有設定・登壇先への提出・`index.html`(公開ページの入口)以外の公開操作 - 台本の内容の書き直し(第 7 稿を確定として使う。ページに収まらない場合の削り方は前提 3 の範囲で構成メモに書く) - 規約の既存の行の「枚」の言い換え(前提 7) @@ -130,6 +132,8 @@ NDF の今の Skill はスライドの原則(`design/references/layout-slide.m - [ ] 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 を読む・直す依頼だけへ振り分けられる ## 非機能の条件 @@ -145,7 +149,7 @@ NDF の今の Skill はスライドの原則(`design/references/layout-slide.m | --- | --- | | 公開インタフェース | 変わらない(Skill の追加・削除・名前の変更が無い。規約の参照先が増える) | | データ | 無し | -| 既存の振る舞い | スライドを作るときに AI が読む規約に、生成スクリプトと変換・確認の手順が加わる。Marp の経路は変わらない | +| 既存の振る舞い | スライドを作るときに AI が読む規約に、生成スクリプトと変換・確認の手順が加わる。Marp(変換器)の経路はスライドの規約とリポジトリの手順から外れる | ## 検証手段 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..692f57a69 100644 --- a/plugins/ndf/skills/design/references/layout-slide.md +++ b/plugins/ndf/skills/design/references/layout-slide.md @@ -16,8 +16,14 @@ | 色 | 地・文字・強調の 3 つ。**強調は 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..46f89d5c5 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 をフォルダを指定せずに上げ(非公開)、応答の `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/google-workspace/SKILL.md b/plugins/ndf/skills/google-workspace/SKILL.md index be1a50dc2..0c225f833 100644 --- a/plugins/ndf/skills/google-workspace/SKILL.md +++ b/plugins/ndf/skills/google-workspace/SKILL.md @@ -59,10 +59,15 @@ 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 へ変換する(上げた pptx を `mimeType` を指定して複製する) | `gws drive files copy --params '{"fileId":"<上げた pptx の ID>"}' --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 手で打つ。** `+upload` で pptx を上げ、応答の `id` を `files copy` に渡す。複製が Google Slides になり、上げた pptx は残る(消すかは利用者が決める)。`files copy` に `parents` を書かなければ、複製はマイドライブの直下(非公開)に入る - 公開リンクは、利用者が公開を明示したときだけ、アップロードの応答の `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..ed27dfc31 --- /dev/null +++ b/plugins/ndf/skills/google-workspace/scripts/slides-thumbnails.py @@ -0,0 +1,152 @@ +#!/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 が自分の設定ディレクトリに持つ(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 _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 "error[auth]" in cp.stderr or "No credentials" in cp.stderr: + 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..c83de47c4 --- /dev/null +++ b/plugins/ndf/skills/google-workspace/tests/test_slides_thumbnails.py @@ -0,0 +1,113 @@ +"""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 で未認証を返す。 +""" + +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 os.environ.get("FAKE_AUTH_FAIL"): + sys.stderr.write("error[auth]: Access denied. No credentials provided.\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, _ = _run(env, "PRES", str(tmp_path / "thumbs")) + assert code == 1 + assert out["status"] == "stopped" + 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_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..61cb5e830 100644 --- a/plugins/ndf/skills/layout-review/SKILL.md +++ b/plugins/ndf/skills/layout-review/SKILL.md @@ -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,7 @@ allowed-tools: | 原因 | 戻す先 | | --- | --- | | 版面の決め方(余白・文字の大きさ・1 枚あたりの情報量) | 体裁設計(`design` の `layout-<出力の形>.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..d11fa1eb3 --- /dev/null +++ b/plugins/ndf/skills/release/assets/slides/build-deck.js @@ -0,0 +1,30 @@ +// 生成スクリプト(汎用テンプレートの見本)。表紙・章扉・本編(表と注)の 3 ページを作る。 +// +// node build-deck.js <出力>.pptx [] +// +// 写した後はページの中身だけを書き換える。書体と色は 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("<資料の題>", "<副題>", "<日付> <作成者>"); + + // 2. 章扉 + d.section("現状", "数値で見る前の期間との違い"); + + // 3. 本編(表と注) + 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..1301748a6 --- /dev/null +++ b/plugins/ndf/skills/release/assets/slides/lib/deck-frame.js @@ -0,0 +1,420 @@ +"use strict"; +// ページの枠組み(汎用テンプレート)。pptxgenjs で 16:9(10 × 5.625 インチ)の pptx を作る。 +// +// - 座標は FRAME の定数だけが持つ。トーン&マナー(theme.json)は座標を持たない +// - 書体と色の値は DEFAULT_THEME と theme.json だけが持つ。関数に渡す色は theme.colors の項目名で受ける +// - 台本(script.md)を渡すと、ページを作るたびに同じ番号の読み上げをスピーカーノートへ付け、 +// 保存の前に台本のページ数と作ったページ数を照合する +// +// 資料のディレクトリへ写して使う。写した後に書き換えない(プロジェクトの値は 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; + + 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)); + } + + function newSlide() { + const s = p.addSlide(); + pageNo++; + s.background = { color: color("background") }; + if (script) { + const sp = script[pageNo - 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(); + 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 !== pageNo) { + throw new DeckError(`ページ数が台本と合わない: 台本 ${script.length} ページ / 作ったページ ${pageNo}`, EXIT_INPUT); + } + await p.writeFile({ fileName: out }); + return { out, pages: pageNo }; + } + + return { + pptx: p, + theme, + frame: FRAME, + script, + get pageNo() { + return pageNo; + }, + 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} ページ`); + })().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..fdce47a13 --- /dev/null +++ b/plugins/ndf/skills/release/assets/slides/script.md @@ -0,0 +1,25 @@ +# 台本: <資料の題> + +- 想定: <場と持ち時間> +- 対象: <聞き手> + +ページの区切りは `## <番号>. <見出し>`、読み上げは `>` で始まる行。見出しの末尾に `(<秒>秒)` を付けると配分秒数になる。 +ほかの行はページに載せる要素の覚え書きで、スピーカーノートにも字数にも入らない。 + +## 1. 表紙(20秒) + +<資料の題> — <副題> + +> 今日は〇〇について話します。最初に結論、次に根拠、最後にお願いしたいことの順に進めます。 + +## 2. 章扉: 現状(10秒) + +> まず現状です。 + +## 3. 現状の数値(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..7080dfcd4 100644 --- a/plugins/ndf/skills/release/references/form-slide.md +++ b/plugins/ndf/skills/release/references/form-slide.md @@ -7,32 +7,129 @@ ## 起点の決め方 -**正本の 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 ページに対応する。** 生成スクリプトはページを作るたびに同じ番号の +読み上げをスピーカーノートへ入れ、保存の前に台本のページ数と作ったページ数を照合する。 + +## 生成スクリプトの経路 + +汎用テンプレートはこの 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")` に直し、ページの中身を書き換える +- 最後のコマンドの値を、写した時点の `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` なら描かない | + +知らない項目名と、形の違う値は終了コード 1 で止まる。座標は `theme.json` に書けない(枠組みが固定する)。 + +### 構成メモを書く + +`notes.md` の `## 構成` の節に、構成の考え方(何から逆算し、何を載せず、どこへ回したか)と次の表を置く。 +表の行数はページ数と等しい。 + +```markdown +| # | 見出し | 役割 | 根拠 | +| ---: | --- | --- | --- | +| 1 | 表紙 | — | — | +| 2 | <台本の見出し> | <このページで聞き手に何を渡すか。載せる図・表・箇条書き> | <数値と主張の出所> | +``` -**どちらを使うかは体裁設計が決める**(`design` の `layout-slide.md`)。**選んだ経路が -「生成物を保存するか」の答えを決める。** +ページに収まらない読み上げの扱いと、尺を超えたときに削るページは同じ文書の節に書く。 -| 経路 | 生成物 | +### 台本を書き、尺を測る + +| 規則 | 内容 | | --- | --- | -| 決定的 | 保存は任意。ソースと設定から同じものが出る | -| **非決定的** | **保存する。** ソースから復元できないため、生成物が成果物になる | +| ページの区切り | `## <番号>. <見出し>`。番号は 1 から欠けずに続く。見出しの末尾の `(<秒>秒)` は配分秒数(任意) | +| 読み上げ | 区切りの中の `>` で始まる行。`>` だけの行は段落の区切り | +| ほかの行 | ページに載せる要素の覚え書き。ノートにも字数にも入らない | +| 最初の区切りより前 | 題・想定・構成の表。読まない | + +```bash +node <資料>/slides/lib/script-time.js <資料>/script.md --limit <持ち時間の分> +``` + +ページごとと合計の字数・尺(350 字/分)・配分秒数の表を出す。出力を構成メモか `README.md` に載せる。合計が +`--limit` を超えたら終了コード 1 で、構成メモで削るページを決めて台本を直す。台本の番号が欠ける・重なると終了コード 2。 + +### 生成する + +```bash +node <資料>/slides/build-deck.js <出力>.pptx [] +``` -**変換器を使う経路では、対象リポジトリの仕組みを先に探す。** 生成の手順を新しく作る前に、 -既にある仕組み(ビルドの定義・スクリプト)を探して従う。 +終了コード 0 で pptx が出る。1 は引数・`theme.json`・ページ数の照合の失敗、2 は台本の形の失敗である。pptx は +作り直せるためコミットしない。 -## 1 枚の区切り +### 変換と確認 -**見出しの階層が区切りを表す。** `##` の見出し 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..c9ee0d1da --- /dev/null +++ b/plugins/ndf/skills/release/tests/test_slide_template.py @@ -0,0 +1,263 @@ +"""スライドの汎用テンプレート(`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(deck: Path, tmp_path: Path) -> None: + out = tmp_path / "n.pptx" + assert _build(deck, out).returncode == 0 + readings = script_readings(deck / "script.md") + notes = [s.notes for s in read_pptx(out)["slides"]] + assert notes == [readings[i] for i in range(1, 4)] + assert "\n" in notes[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## 4. 増やしたページ\n\n> 4 ページ目の読み上げ。\n") + cp = _build(deck, tmp_path / "x.pptx") + assert cp.returncode == 1 + assert "4" in cp.stderr and "3" in cp.stderr + assert not (tmp_path / "x.pptx").exists() + + +@pytest.mark.parametrize(("swap", "to", "message"), [("## 2.", "## 4.", "欠ける: 2"), ("## 3.", "## 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..b863e472b --- /dev/null +++ b/tests/test_presentation_decks.py @@ -0,0 +1,71 @@ +"""`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 + +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 _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) + pages = info["slides"] + assert len(pages) == len(readings) + assert _notes_rows(deck / "notes.md") == list(range(1, len(pages) + 1)) + 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_23_pages() -> None: + deck = ROOT / "docs" / "presentations" / "2026-10-17-skill-plugin-development" + assert len(script_readings(deck / "script.md")) == 23 + assert _notes_rows(deck / "notes.md") == list(range(1, 24))