diff --git a/issues/ndf-development-skills/01-overview.md b/issues/ndf-development-skills/01-overview.md index f3116c53..540fa391 100644 --- a/issues/ndf-development-skills/01-overview.md +++ b/issues/ndf-development-skills/01-overview.md @@ -37,9 +37,9 @@ 1. **既存 Skill の棚卸** — 49 個ある Skill の重複統合、利用実績の乏しい Skill の整理、frontmatter の見直し 2. **開発方法論レイヤーの追加** — 8 個の Skill を新設し、要求定義から検証までの工程を埋める 3. **既存 Skill の改修** — 新レイヤーへの接続 -4. **一気通貫実行の整備** — ランタイム組み込みの `/goal` ループを土台に、設計確定後からリリース直前までを自動で進める `execute-plan` を新設する +4. **一気通貫実行の整備** — ランタイム組み込みの `/goal` ループを土台に、設計確定後からリリース直前までを自動で進める `execute-goal` を新設する -新設する Skill は、項目 2 の開発方法論レイヤー 8 個と項目 4 の `execute-plan` 1 個をあわせた計 9 個である。段階ごとの Skill 総数は [02-skill-inventory.md](02-skill-inventory.md)「Skill 総数の推移」を唯一の基準とし、本文書では数値を書き下さない。 +新設する Skill は、項目 2 の開発方法論レイヤー 8 個と項目 4 の `execute-goal` 1 個をあわせた計 9 個である。段階ごとの Skill 総数は [02-skill-inventory.md](02-skill-inventory.md)「Skill 総数の推移」を唯一の基準とし、本文書では数値を書き下さない。 外部リポジトリは submodule やコピーで取り込まず、独自の Skill として再執筆し、参照元は `upstream-skills.lock.yaml` で固定する。 diff --git a/issues/ndf-development-skills/02-skill-inventory.md b/issues/ndf-development-skills/02-skill-inventory.md index dec2a3be..e3e65876 100644 --- a/issues/ndf-development-skills/02-skill-inventory.md +++ b/issues/ndf-development-skills/02-skill-inventory.md @@ -65,10 +65,14 @@ review-pr-comments, skill-stats ### 測定ツールの不具合 -`skill-stats` は次の 2 点でこの測定に使えず、集計は個別に実装した。Task 0-1 の前提となるため、修正するか置き換えるかの判断が要る。 +`skill-stats` は次の 2 点でこの測定に使えず、上表の集計は個別に実装した。置き換えではなく `skill-stats` 自体を修正し、以降の計測はこのツールへ一本化する(Task 0-1)。 -- 49 個中 48 個で `when_to_use` からのトリガ抽出に失敗し、ヒット率が算出されない -- 利用者のスラッシュコマンドを数えず、エージェントの自動起動しか数えない。`cross-review` を 14 と報告するが実際は 285 +| 不具合 | 原因 | +| --- | --- | +| 49 個中 48 個でトリガ抽出に失敗し、ヒット率が算出されない | 抽出対象が `description` に限られ、トリガ語を列挙している `when_to_use` を読まない。トリガ語が `description` にあるのは 1 個だけで、34 個は `when_to_use` に置かれている(残る 14 個は `when_to_use` 自体がない)。加えて抽出パターンが `Triggers:` 表記に限られるため、`明示トリガ:` と書いている `cross-review` は `when_to_use` を読んでも拾えない | +| 利用者の明示起動を数えず、`cross-review` を 14 と報告する(実測は 285) | 明示起動は会話ログに `/ndf:cross-review` を含む利用者メッセージとして残るが、これをシステム由来の記述として除外している。数えているのはエージェントによる `Skill` ツール呼び出しだけである | + +トリガ語の所在は `grep -l '^description:.*[Tt]riggers\?:' */SKILL.md`(1 件)と `grep '^when_to_use:' */SKILL.md | grep -ci 'triggers\?:'`(34 件)で確認した。明示起動の形式は会話ログ 1,938 セッションから `` を集計して確認しており、`/ndf:cross-review` 273 件、`/ndf:merged` 249 件が現れる。 ## 整理の判断基準 @@ -146,7 +150,7 @@ review-pr-comments, skill-stats | 区分 | 個数 | 定義場所 | | --- | --- | --- | | 開発方法論レイヤー | 8 | [04-development-skills.md](04-development-skills.md) | -| 一気通貫実行(`execute-plan`) | 1 | [05-goal-workflow.md](05-goal-workflow.md) | +| 一気通貫実行(`execute-goal`) | 1 | [05-goal-workflow.md](05-goal-workflow.md) | ## 発動改善 diff --git a/issues/ndf-development-skills/04-development-skills.md b/issues/ndf-development-skills/04-development-skills.md index fbbfc4c5..147a93ca 100644 --- a/issues/ndf-development-skills/04-development-skills.md +++ b/issues/ndf-development-skills/04-development-skills.md @@ -2,7 +2,7 @@ 用語は [01-overview.md](01-overview.md) を参照。 -本文書が扱うのは開発方法論レイヤーの 8 個である。新設する Skill はこれに一気通貫実行の `execute-plan`([05-goal-workflow.md](05-goal-workflow.md))1 個を加えた計 9 個であり、段階ごとの Skill 総数は [02-skill-inventory.md](02-skill-inventory.md)「Skill 総数の推移」を参照する。 +本文書が扱うのは開発方法論レイヤーの 8 個である。新設する Skill はこれに一気通貫実行の `execute-goal`([05-goal-workflow.md](05-goal-workflow.md))1 個を加えた計 9 個であり、段階ごとの Skill 総数は [02-skill-inventory.md](02-skill-inventory.md)「Skill 総数の推移」を参照する。 ## 新設する Skill @@ -130,9 +130,9 @@ ramziddin/solid-skills の「メソッド 10 行未満」「インスタンス | `skills/pr-tests/SKILL.md` | 限定的な検証と全体テストを区別。実行コマンド・終了コード・実行時刻を証跡として残す | 1-7 | | `skills/plan-to-spec/SKILL.md` | ドメイン用語集、不変条件、公開インタフェース、設計判断記録の結論を確定仕様へ引き継ぐ | 1-7 | | `skills/investigation-rules/SKILL.md` | トリガ `'調査'` を具体化し、`problem-solving` との境界を明記 | 1-7 | -| `skills/cross-review/SKILL.md` | 起動対象を `architecture` モード相当の高リスク変更に限定。同じ基準で `execute-plan` のレビュー段階も分岐する([05-goal-workflow.md](05-goal-workflow.md)) | 2-4 | +| `skills/cross-review/SKILL.md` | 起動対象を `architecture` モード相当の高リスク変更に限定。同じ基準で `execute-goal` のレビュー段階も分岐する([05-goal-workflow.md](05-goal-workflow.md)) | 2-4 | | `plugins/ndf-claude/agents/director.md` | モード判定は自前で持たず、`development-workflow` を呼んで判定させる。返ったモードに対応する Skill を起動する手順だけを書く | 2-4 | -| `skills/issue-plan-strategy/SKILL.md` | `execute-plan` から呼ばれる手順として整理し、責務の境界を明記。組み込みの `/goal` ループから直接駆動される実績(8 回)があるため、ターンをまたいで再開できる記述にする | 3-1 | +| `skills/issue-plan-strategy/SKILL.md` | `execute-goal` から呼ばれる手順として整理し、責務の境界を明記。組み込みの `/goal` ループから直接駆動される実績(8 回)があるため、ターンをまたいで再開できる記述にする | 3-1 | `review` の二段構成: diff --git a/issues/ndf-development-skills/05-goal-workflow.md b/issues/ndf-development-skills/05-goal-workflow.md index 3b649803..b47bcfc0 100644 --- a/issues/ndf-development-skills/05-goal-workflow.md +++ b/issues/ndf-development-skills/05-goal-workflow.md @@ -1,12 +1,12 @@ -# 一気通貫実行 +# 設計確定後の一気通貫実行 用語は [01-overview.md](01-overview.md) を参照。 -設計が確定した実装計画を、リリース直前まで自動で進める仕組みを用意する。 +設計が固まった実装計画を、リリース直前まで通しで実行する Skill `execute-goal` を新設する。計画ができた後も実装・検証・レビュー・マージの各段階を利用者が指示し続けている状態([01-overview.md](01-overview.md)「設計確定後も逐次指示が要る」)を解消することが目的である。 -## ランタイム組み込みの `/goal` を土台にする +## 土台にするランタイム機能 -Claude Code と Codex は、完了条件を設定してターンをまたいで作業を継続させる `/goal` を組み込みで持つ。**この継続ループを Skill として作り直さない。** NDF が担うのは、条件文の生成と、ループが回す工程の中身である。 +ターンをまたいで作業を継続させる仕組みは、Claude Code と Codex が `/goal` として組み込みで備えている。`execute-goal` はこれを実行基盤として使い、そこへ渡す完了条件と、ループが回す工程の中身を受け持つ。 | ランタイム | `/goal` | 動作 | | --- | --- | --- | @@ -14,20 +14,29 @@ Claude Code と Codex は、完了条件を設定してターンをまたいで | Codex | あり | セッションに目的を永続化し、継続・状況確認・完了検証を行う。`/goal pause` `/goal resume` `/goal clear` を持つ | | Kiro | **なし** | `/goal` は `unrecognized subcommand` で拒否される(kiro-cli 2.16.1 で確認) | -条件文の上限は Claude Code / Codex とも 4,000 文字である。 +組み込みの `/goal` から NDF の Skill を直接駆動する使い方は、すでに 8 回記録されている(`/goal /ndf:cross-review <プルリクエスト番号>`、`/goal /ndf:issue-plan-strategy <計画ファイル> 実装開始` など)。`execute-goal` はこの使い方を正式な手順として整備したものにあたる。 -すでに `/goal /ndf:cross-review 14256` や `/goal /issue-plan-strategy <計画ファイル> 実装開始 ...` のように、組み込みの `/goal` から NDF の Skill を駆動する使い方が 8 回記録されている。この使い方を正式な手順として整備する。 +`/goal` には次の制約があり、`execute-goal` の設計はこれに従う。 -## 名前 +| 制約 | 設計への影響 | +| --- | --- | +| 条件文は 4,000 文字まで(Claude Code / Codex 共通) | 計画の内容をそのまま貼らず、終了状態と証明方法へ圧縮する | +| Claude Code の評価器はツールを呼ばず、会話に現れた内容だけで判定する | 条件はエージェント自身の出力で証明できる形に限る | +| 実行権限は変わらない | 無人で走らせるには自動承認モードとの併用が要る | + +## `execute-goal` が担う範囲 + +Skill 名は、目的を表す `goal` を含めつつ、その語で始めない `execute-goal` とする。`goal` は Claude Code と Codex の組み込みコマンド名であり、同名の Skill は衝突する。先頭一致する名前もスラッシュコマンドのタブ補完で組み込み側と競合するため避ける。 -新設する Skill の名前は **`execute-plan`** とする。`goal` は Claude Code と Codex の組み込みコマンド名であり、同名を避ける。 +担うのは次の 3 つで、継続ループそのものと個々の開発手順は既存の仕組みへ委ねる。 -## 責務の境界 +1. 起動前に計画ファイルを検査し、実行へ進めてよいかを判定する +2. 計画の内容から完了条件の文面を組み立て、`/goal` に渡す形で提示する +3. 各段階で呼ぶ既存 Skill を並べ、実行順とレビュー段階の分岐を決める -`execute-plan` は**新しい手順もループも定義しない**。次の 2 つだけを担う。 +## 実行の流れ -1. 計画ファイルを読み、その内容に応じた完了条件の文面を組み立てる -2. 各段階で呼ぶ既存 Skill を並べる +各段階の実体は既存 Skill である。 | 段階 | 呼び出す Skill | | --- | --- | @@ -35,80 +44,88 @@ Claude Code と Codex は、完了条件を設定してターンをまたいで | リリースブランチと個別プルリクエストの作成 | `issue-plan-strategy` | | 実装 | `tdd-cycle` | | 検証 | `quality-gates` | -| レビュー | モード判定の結果で分岐(下表) | +| レビュー | モード判定の結果で分岐(後述) | | 指摘対応 | `fix` | -レビュー段階だけは、最初のモード判定の結果で呼び先が変わる。相互レビューは外部 AI を複数回起動する重い工程であり、[04-development-skills.md](04-development-skills.md) で起動対象を高リスク変更に限定する方針としているため、`execute-plan` も同じ基準に従う。 - -| モード | 呼び出す Skill | 理由 | -| --- | --- | --- | -| `light` | `review` | 振る舞いを変えない局所変更。単独レビューで足りる | -| `standard` | `review` | 一般的な機能追加・バグ修正。必須工程にレビューは含むが相互レビューは含まない | -| `legacy-refactor` | `review` | 振る舞い維持型の改善。退行検証の結果とあわせて確認する | -| `architecture` | `cross-review` | 公開インタフェース・スキーマ・認証・複数モジュールにまたがる高リスク変更 | - -モードが `architecture` 以外でも、実装の途中でデータベース移行・認証・公開インタフェースの破壊的変更を検出した場合は高リスク変更として扱い、その計画単位のレビューを `cross-review` へ切り替える。切り替えた事実は完了条件の証跡として出力に残す。 - ```mermaid flowchart TD - A["/goal <条件> を設定"] --> B[execute-plan が計画を読む] - B --> C[モード判定] - C --> D[リリースブランチと個別プルリクエスト作成] - D --> E[失敗するテスト → 最小実装 → 整理] - E --> F[完了の定義に沿った検証] - F --> G{architecture モードか
高リスク変更を検出したか} - G -->|いいえ| H[単独レビュー] - G -->|はい| I[相互レビュー] - H --> J{レビューが通ったか} - I --> J - J -->|指摘あり| K[指摘対応] - K --> G - J -->|通った| L[リリースブランチへマージ] - L --> M{残りの計画があるか} - M -->|ある| E - M -->|ない| N[リリース用プルリクエスト本文の最終化] - N --> O([条件充足。ループ終了。下書きのまま引き渡す]) - O -.ターンごとに判定.-> P["/goal の評価器"] - P -.未充足なら次ターン.-> E + subgraph LOOP["/goal のループ(ターン終了ごとに条件の充足を判定し、未充足なら次のターンを開始する)"] + B[execute-goal が計画を読む] --> C[モード判定] + C --> D[リリースブランチと個別プルリクエスト作成] + D --> E[失敗するテスト → 最小実装 → 整理] + E --> F[完了の定義に沿った検証] + F --> G{architecture モードか
高リスク変更を検出したか} + G -->|いいえ| H[単独レビュー] + G -->|はい| I[相互レビュー] + H --> J{レビューが通ったか} + I --> J + J -->|指摘あり| K[指摘対応] + K --> G + J -->|通った| L[リリースブランチへマージ] + L --> M{残りの計画があるか} + M -->|ある| E + M -->|ない| N[リリース用プルリクエストを下書きのまま最終化] + end + A["完了条件を /goal へ設定"] --> B + LOOP --> Z([条件充足。下書きのまま利用者へ引き渡す]) ``` -## 完了条件の書き方 +### レビュー段階の分岐 + +相互レビューは外部 AI を複数回起動する重い工程であり、[04-development-skills.md](04-development-skills.md) は起動対象を高リスク変更に限定する。`execute-goal` も同じ基準で呼び先を決める。 + +| 呼び出す Skill | 適用するモード | 基準 | +| --- | --- | --- | +| `review`(単独レビュー) | `light` / `standard` / `legacy-refactor` | 公開インタフェース・スキーマ・認証にまたがらない変更。単独レビューで足りる | +| `cross-review`(相互レビュー) | `architecture` | 複数モジュールや公開インタフェースに及ぶ高リスク変更。外部 AI を複数回起動する費用に見合う | -Claude Code の評価器は**ツールを呼ばず、会話に現れた内容だけで判定する**。したがって条件は、エージェント自身の出力で証明できる形で書く必要がある。これは `quality-gates` が要求する証跡(コマンド、終了コード、実行時刻)と同じ性質であり、両者を組み合わせて設計する。 +実装の途中で、`architecture` 以外のモードでもデータベース移行・認証・公開インタフェースの破壊的変更が見つかることがある。その場合は高リスク変更として扱い、以降その計画単位のレビューを `cross-review` へ切り替える。切り替えた事実と理由は、完了条件の判定材料として会話へ出力する。 -条件に含める要素: +## 完了条件の組み立て + +評価器が会話の内容だけで判定する以上、条件は「何が終われば完了か」と「それをどう示すか」を対にして書く。これは `quality-gates` が完了宣言に求める証跡(コマンド、終了コード、実行時刻)と同じ性質であり、両者を組み合わせて設計する。 | 要素 | 例 | | --- | --- | | 測定可能な終了状態 | 計画中の全プルリクエストがリリースブランチへマージ済み | | 証明方法 | `gh pr list --base release/ --state open` の出力が空 | | 変えてはならない制約 | リリース用プルリクエストを下書きのままにする | -| 上限 | 40 ターンを超えたら停止して状況を報告する | +| 上限 | レビューの手戻りが 3 巡を超えたら停止して状況を報告する | -`execute-plan` は計画ファイルから上記を組み立て、`/goal` に渡す文面として提示する。 +`execute-goal` は計画ファイルからこれらを組み立て、`/goal` に渡す文面として提示する。条件文の型と記入例は `skills/execute-goal/references/goal-conditions.md` に置く([07-tasks.md](07-tasks.md) Task 3-1)。 -## 停止させたい境界 +## 利用者が判断する境界 -組み込みの `/goal` では「停止」は条件の充足で表現する。利用者の判断が要る境界は、条件文の側へ書き込む。 +`/goal` は条件が充足するまでターンを繰り返すため、利用者の判断を挟みたい箇所はあらかじめ止まるように書いておく。手段は、その境界を完了状態または制約として条件文へ書き込むか、そもそも `/goal` を設定しないかの 2 つである。 -| 停止させたい状況 | 条件文への書き方 | +| 止めたい状況 | 実現方法 | | --- | --- | -| リリース用プルリクエストをレビュー依頼可能にする直前 | 下書きのままであることを完了状態の一部にする | -| 計画に受け入れ条件がない | `execute-plan` が起動時に検出し、`requirements-design` へ差し戻して `/goal` を設定させない | -| データベース移行・認証・公開インタフェースの破壊的変更を検出 | 検出したら報告して停止する旨を制約に含める | -| 相互レビューが 3 巡で収束しない | ターン上限を条件に含める | -| 想定外のファイルに変更が及んだ | 変更してはならないパスを制約に列挙する | +| リリース用プルリクエストをレビュー依頼可能にする直前 | 下書きのままであることを完了状態の一部として条件文に書く | +| 計画に受け入れ条件がない | 起動前の検査で検出し、`requirements-design` へ差し戻して `/goal` を設定しない | +| データベース移行・認証・公開インタフェースの破壊的変更を検出 | 検出したら報告して指示を待つ旨を制約として条件文に書く | +| 相互レビューが収束しない | 手戻りの上限回数を条件文に書き、超えたら状況を報告して終える | +| 想定外のファイルに変更が及んだ | 変更してはならないパスを制約として条件文に列挙する | + +起動前の検査では、計画ファイルの存在と受け入れ条件の記載を確認する。`--dry-run` を指定した場合は、組み立てた完了条件と、作成予定のブランチおよびプルリクエストの一覧だけを出力し、`/goal` は設定しない。 + +無人実行に要る自動承認モードは、マージやプッシュを含む破壊的操作をそのまま通す。**併用するかは利用者が判断する**前提で手順を書き、`execute-goal` の側から併用を促さない。 -`/goal` は権限を変更しない。無人で走らせるには自動承認モードとの併用が要る。破壊的操作を含むため、**併用するかは利用者が判断する**前提で手順を書く。 +## ランタイム別の差分 -## Kiro での扱い +Kiro には継続ループがないため、`execute-goal` は段階ごとに利用者の続行指示を要する手順書として動作する。この差は `plugins/ndf-kiro/README.md` に明記する。 -Kiro には継続ループがないため、`execute-plan` は段階ごとに利用者の続行指示を要する手順書として動作する。この差は `plugins/ndf-kiro/README.md` に明記する。 +計画ファイルの受け取り方もランタイムごとに異なる([03-runtime-conformance.md](03-runtime-conformance.md))。 + +| ランタイム | 受け取り方 | +| --- | --- | +| Claude Code | `arguments` で宣言した名前を本文から `$plan` として参照する | +| Codex | `argument-hint` / `arguments` とも読まれないため、`execute-goal/agents/openai.yaml` の `interface.default_prompt` で受け取る | +| Kiro | 引数の受け渡し機構がないため、本文中で利用者に尋ねる | -## 設定 +frontmatter は次のとおり。 ```yaml -name: execute-plan +name: execute-goal description: "Read a finalized implementation plan and drive it through coding, review, and merge up to just before release. Emits a completion condition for the runtime's built-in /goal loop. Use when the design is settled and the plan file is ready (計画を実装して / リリース直前まで進めて)." argument-hint: "[計画ファイルパス | 計画 ID]" arguments: plan @@ -116,18 +133,10 @@ disable-model-invocation: true # 破壊的操作を含むため明示指示専 effort: high ``` -`arguments` は名前付き引数を宣言する項目で、宣言した名前を本文で `$plan` として参照する。Claude Code 独自の項目であり Agent Skills 仕様には含まれないため、Codex と Kiro では無視される([03-runtime-conformance.md](03-runtime-conformance.md))。両ランタイムでは `argument-hint` が示す形式のまま引数全体を本文側で解釈する前提で書く。 - -`context: fork` は使わない。Claude Code の `/goal` はセッション単位の Stop フックとして動くため、分離した実行単位では評価器が働かない。 - -## 事前チェックと確認モード - -起動時に計画ファイルの存在と受け入れ条件の記載を確認する。なければ `requirements-design` へ差し戻し、`/goal` を設定せずに停止する。 - -`--dry-run` を指定した場合は、組み立てた完了条件と、作成予定のブランチおよびプルリクエストの一覧だけを出力する。 +`context: fork` は使わない。Claude Code の `/goal` はセッション単位の Stop フックとして動くため、分離した実行単位では評価器が働かない([02-skill-inventory.md](02-skill-inventory.md)「未使用項目の導入」)。 ## 中断と再開 継続ループの状態はランタイムが持つ。Claude Code は `--resume` で条件を復元する(ターン数と使用量の集計はリセットされる)。Codex は `/goal pause` と `/goal resume` を持つ。 -NDF 側で重複して状態管理を持たない。再開時は `git branch -a` と `gh pr list` から現在地を判断し、既存のブランチとプルリクエストを重複作成しない手順を本文に書く。 +NDF 側で状態を二重に持たない。再開時は `git branch -a` と `gh pr list` から現在地を判断し、既存のブランチとプルリクエストを重複作成しない手順を本文に書く。 diff --git a/issues/ndf-development-skills/06-release-plan.md b/issues/ndf-development-skills/06-release-plan.md index 2358a50c..152ed1b5 100644 --- a/issues/ndf-development-skills/06-release-plan.md +++ b/issues/ndf-development-skills/06-release-plan.md @@ -17,7 +17,7 @@ flowchart TD 棚卸を最初に置くのは、整理されていない 49 個の上に新設 9 個を積むとトリガ衝突とコンテキスト肥大が悪化するためである。一気通貫実行を最後に置くのは、呼び出す工程 Skill がすべて揃ってからでないと調整役として成立しないためである。 -新設 9 個は Release 1 と Release 2 の開発方法論レイヤー 8 個(`requirements-design` / `tdd-cycle` / `safe-refactoring` / `quality-gates` / `development-workflow` / `design-review` / `domain-modeling` / `object-design`)と、Release 3 の `execute-plan` 1 個からなる。段階ごとの Skill 総数は [02-skill-inventory.md](02-skill-inventory.md)「Skill 総数の推移」を参照する。 +新設 9 個は Release 1 と Release 2 の開発方法論レイヤー 8 個(`requirements-design` / `tdd-cycle` / `safe-refactoring` / `quality-gates` / `development-workflow` / `design-review` / `domain-modeling` / `object-design`)と、Release 3 の `execute-goal` 1 個からなる。段階ごとの Skill 総数は [02-skill-inventory.md](02-skill-inventory.md)「Skill 総数の推移」を参照する。 ## バージョンの扱い @@ -67,7 +67,7 @@ PR 1-6 を後段に置くのは、`scripts/check-markdown-links.py` がリンク | PR # | branch 名 | 概要 | 依存 | 並行可否 | | --- | --- | --- | --- | --- | -| 3-1 | `feature/execute-plan-skill` | `execute-plan` 本体と完了条件の組み立て | なし | ○ | +| 3-1 | `feature/execute-goal-skill` | `execute-goal` 本体と完了条件の組み立て | なし | ○ | | 3-2 | `feature/devskills-eval-harness` | Skill 挙動評価 12 シナリオ + 継続的インテグレーション設定 | なし | ○ | | 3-3 | `feature/devskills-spec-consistency` | 仕様・設計・タスクの整合性チェックを `plan-to-spec` へ追加 | なし | ○ | | 3-4 | `feature/devskills-docs` | 確定仕様書、`AGENTS.md`、各 README 更新、version bump | 3-1〜3-3 | × | diff --git a/issues/ndf-development-skills/07-tasks.md b/issues/ndf-development-skills/07-tasks.md index 7ba9ba47..a30a3371 100644 --- a/issues/ndf-development-skills/07-tasks.md +++ b/issues/ndf-development-skills/07-tasks.md @@ -11,7 +11,11 @@ - **対象ファイル:** `docs/specifications/ndf-skill-inventory.md`、`plugins/ndf-shared/skills/README.md`、`plugins/ndf-shared/skills/skill-stats/scripts/skill-stats.py` - **変更内容:** - 実測結果を台帳へ転記し、Skill ごとに「行数 / frontmatter 設定 / 起動数 / 機会数 / 判定 / 判定根拠」を記録する - - `skill-stats` の測定機能を修正する。現状は `when_to_use` からのトリガ抽出が 49 個中 48 個で失敗し、利用者のスラッシュコマンドも数えないため、実際の起動数と大きく乖離する + - `skill-stats` を修正する(不具合と原因は [02-skill-inventory.md](02-skill-inventory.md)「測定ツールの不具合」) + - トリガ抽出の対象に `when_to_use` を加え、見出し語として `Triggers:` と `明示トリガ:` の双方を受ける + - `` を含む利用者メッセージから明示起動を数え、エージェントの `Skill` ツール呼び出しと合算する。トリガ一致の判定からは従来どおり除外する(明示起動は「機会」ではない) + - 出力に「計 / 自動 / 明示」の 3 列を持たせ、台帳へそのまま転記できる形にする + - Task 0-7 で `cross-review` の明示トリガを `description` へ移すため、修正後も `when_to_use` の有無どちらでも同じ結果になることを確認する - frontmatter 規約([02-skill-inventory.md](02-skill-inventory.md))を明文化する - トリガ語の一意性ルールと、広すぎるトリガの禁止例を記載する @@ -198,21 +202,22 @@ - **変更内容:** - `director` の要求理解フェーズで `development-workflow` を呼び、返ったモードを受け取る手順を記載する。判定基準と振り分け表を `director` 側へ写さない([04-development-skills.md](04-development-skills.md)「ワークフローの 4 モード」) - `development-workflow` を判定の唯一の置き場所として維持する。モードの追加・変更はこの Skill だけを直せば全ランタイムへ効く - - `cross-review` の起動条件を高リスク変更に限定する。この基準は Task 3-1 の `execute-plan` のレビュー段階の分岐と共有する([05-goal-workflow.md](05-goal-workflow.md)) + - `cross-review` の起動条件を高リスク変更に限定する。この基準は Task 3-1 の `execute-goal` のレビュー段階の分岐と共有する([05-goal-workflow.md](05-goal-workflow.md)) - `development-workflow` の `architecture` モードを有効化する ## Release 3 ### Task 3-1: 一気通貫実行 -- **対象ファイル:** `skills/execute-plan/SKILL.md`、`references/goal-conditions.md`、`skills/issue-plan-strategy/SKILL.md` +- **対象ファイル:** `skills/execute-goal/SKILL.md`、`skills/execute-goal/references/goal-conditions.md`、`skills/issue-plan-strategy/SKILL.md`、`plugins/ndf-kiro/README.md` - **変更内容:** [05-goal-workflow.md](05-goal-workflow.md) に記載のとおり - - `goal` という名前は使わない。Claude Code と Codex の組み込みコマンド名と衝突する + - Skill 名は `goal` で始めない。組み込みコマンド `/goal` と同名になる名前、および先頭一致してタブ補完で競合する名前を避ける - 継続ループを実装しない。組み込みの `/goal` へ渡す完了条件を組み立てる - `goal-conditions.md` に、評価器がツールを呼ばない前提で書く条件文の型と例を置く - Kiro には継続ループがないため、段階ごとに続行指示を要する手順として動く旨を明記する - レビュー段階は最初のモード判定の結果で呼び先を分ける。`light` / `standard` / `legacy-refactor` は `review`、`architecture` と途中で検出した高リスク変更は `cross-review`。Task 2-4 で限定した `cross-review` の起動条件と同じ基準にする - - `issue-plan-strategy` を `execute-plan` から呼ばれる手順として整理する。詳細は [04-development-skills.md](04-development-skills.md) の「既存 Skill の改修」に記載のとおり + - `issue-plan-strategy` を `execute-goal` から呼ばれる手順として整理する。詳細は [04-development-skills.md](04-development-skills.md) の「既存 Skill の改修」に記載のとおり + - Codex で引数を受け取る `skills/execute-goal/agents/openai.yaml` は、Task 0-8 の生成処理が `disable-model-invocation: true` を持つ Skill を対象にビルド時へ出力する。このタスクの対象ファイルには含めない ### Task 3-2: Skill 挙動評価 diff --git a/issues/ndf-development-skills/08-verification.md b/issues/ndf-development-skills/08-verification.md index 37b6f897..a1a355de 100644 --- a/issues/ndf-development-skills/08-verification.md +++ b/issues/ndf-development-skills/08-verification.md @@ -6,7 +6,7 @@ | 対象 | 影響 | | --- | --- | -| Skill 総数 | [02-skill-inventory.md](02-skill-inventory.md)「Skill 総数の推移」のとおり、統合と削除で 29 まで減り、新設 9 個(開発方法論レイヤー 8 個 + `execute-plan` 1 個)を加えて最終 38 | +| Skill 総数 | [02-skill-inventory.md](02-skill-inventory.md)「Skill 総数の推移」のとおり、統合と削除で 29 まで減り、新設 9 個(開発方法論レイヤー 8 個 + `execute-goal` 1 個)を加えて最終 38 | | コマンド名 | 起動実績のない `/ndf:clean` `/ndf:review-pr-comments` `/ndf:resolve-pr-comments` `/ndf:git-gh-operations` などが消える。起動上位 5 個(`fix` `cross-review` `merged` `pr` `issue-plan-strategy`、計 1,145 回)は改名しない。`/ndf:codex` `/ndf:gemini`(計 5 回) と `/ndf:branch-fix-strategy`(4 回) `/ndf:sync-main`(1 回) `/ndf:review-branch`(3 回) が変わる | | 自動発動の挙動 | `merged` / `review` / `pr` / `pr-tests` が自然文で起動するようになる。起動しない前提の運用があれば変わる | | 常時注入されるコンテキスト | 棚卸で削減、新規追加で増加。合計サイズを継続的インテグレーションで監視 | @@ -62,6 +62,8 @@ ### Release 0 固有 +- [ ] 修正後の `skill-stats` が、[02-skill-inventory.md](02-skill-inventory.md)「利用実績の実測」と同じ期間で同じ「計 / 自動 / 明示」を出力する +- [ ] 修正後の `skill-stats` でトリガ抽出に失敗する Skill が 13 個だけになる(`when_to_use` を持たない 14 個のうち、`plan-to-spec` は `description` にトリガを持つ) - [ ] 統合前後で機能が減っていない(統合元の手順が統合先に残っている) - [ ] `grep -rn '<削除した Skill 名>'` がリポジトリ全体でヒットしない - [ ] `merged` / `pr` / `review` / `pr-tests` が自然文の依頼で起動する diff --git a/plugins/ndf-shared/skills/skill-stats/SKILL.md b/plugins/ndf-shared/skills/skill-stats/SKILL.md index f89b6135..d500b0a6 100644 --- a/plugins/ndf-shared/skills/skill-stats/SKILL.md +++ b/plugins/ndf-shared/skills/skill-stats/SKILL.md @@ -59,11 +59,15 @@ transcript JSONL 先頭の `cwd` フィールドを優先してプロジェク | 項目 | 定義 | |---|---| -| **呼び出し数** (invocations) | `assistant` メッセージ内の `tool_use.name=="Skill"` で `input.skill=="ndf:"` の件数 | -| **関連話題数** (triggers) | `user` メッセージのテキストに、skillの `description` / Triggers キーワードが含まれる件数 | -| **ヒット数** (hits) | 関連話題を含むユーザーメッセージの直後 (次のユーザーメッセージまでの間) に該当skillが呼ばれた件数 | +| **自動起動数** (auto) | `assistant` メッセージ内の `tool_use.name=="Skill"` で `input.skill=="ndf:"` の件数 | +| **明示起動数** (explicit) | `user` メッセージの `/ndf:` の件数。プラグイン接頭辞のない `/name` 形式も同じ skill として数える | +| **呼び出し数** (invocations) | 自動起動数 + 明示起動数 | +| **関連話題数** (triggers) | `user` メッセージのテキストに、skillの `description` / `when_to_use` に列挙された Triggers キーワードが含まれる件数 | +| **ヒット数** (hits) | 関連話題を含むユーザーメッセージの直後 (次のユーザーメッセージまでの間) に該当skillが**自動起動**した件数。間にスラッシュコマンドが入った場合はそこで打ち切る (利用者が自分で打った時点でトリガは発火しなかったため) | | **ヒット率** (hit_rate) | `hits / triggers` (%) | +Triggers キーワードは `Triggers:` と `明示トリガ:` のどちらの見出しでも抽出する。 + ### ヒット率の解釈 - **高い (80%+)**: description/triggers が適切で、該当文脈で正しく起動できている @@ -73,12 +77,12 @@ transcript JSONL 先頭の `cwd` フィールドを優先してプロジェク ## 出力例 (Markdown) ``` -| skill | 呼び出し数 | 関連話題 | ヒット | ヒット率 | -|---|---:|---:|---:|---:| -| ndf:pr | 12 | 25 | 10 | 40.0% | -| ndf:fix | 3 | 8 | 3 | 37.5% | +| skill | triggers源 | 計 | 自動 | 明示 | 関連話題 | ヒット | ヒット率 | +|---|---|---:|---:|---:|---:|---:|---:| +| ndf:pr | none | 12 | 2 | 10 | - | - | - | +| ndf:fix | explicit | 11 | 3 | 8 | 8 | 3 | 37.5% | ... -| **合計** | **56** | **142** | **45** | **31.7%** | +| **合計** | | **56** | **14** | **42** | **142** | **45** | **31.7%** | ``` ## 前提条件 @@ -89,8 +93,8 @@ transcript JSONL 先頭の `cwd` フィールドを優先してプロジェク ## 制限事項 -- **モデル起動型以外は関連話題数が計算できない場合がある**: `disable-model-invocation: true` の skill (例: `/ndf:pr` などのワークフロー系) は、ユーザーが明示的にスラッシュコマンドで呼び出すのが通常。triggers キーワードが description に明示されていなければ「関連話題」が 0 となり、ヒット率も計算不能となる -- **ユーザーメッセージのパース**: ``, ``, `` タグは除外する。tool_result ブロックも除外 +- **モデル起動型以外は関連話題数が計算できない場合がある**: `disable-model-invocation: true` の skill (例: `/ndf:pr` などのワークフロー系) は、ユーザーが明示的にスラッシュコマンドで呼び出すのが通常。triggers キーワードが `description` / `when_to_use` のどちらにも明示されていなければ「関連話題」が 0 となり、ヒット率も計算不能となる +- **ユーザーメッセージのパース**: 関連話題の判定では ``, ``, `` タグを除外する (tool_result ブロックも除外)。明示起動数だけは `` を対象に数える - **日本語キーワードマッチ**: 単純な部分一致 (case-insensitive) のため、文脈を考慮した判定ではない ## 関連スキル diff --git a/plugins/ndf-shared/skills/skill-stats/scripts/skill-stats.py b/plugins/ndf-shared/skills/skill-stats/scripts/skill-stats.py index 069488ad..1742bc5c 100755 --- a/plugins/ndf-shared/skills/skill-stats/scripts/skill-stats.py +++ b/plugins/ndf-shared/skills/skill-stats/scripts/skill-stats.py @@ -2,11 +2,15 @@ """NDF skill usage statistics from Claude Code transcripts. Scans ~/.claude/projects/**/*.jsonl and counts, for each NDF skill: - - invocations: tool_use where name="Skill" and input.skill="ndf:" + - auto: tool_use where name="Skill" and input.skill="ndf:" + - explicit: user slash commands, recorded as /ndf: + - invocations: auto + explicit - triggers: user messages whose text contains keywords from the skill's - description / "Triggers:" line + description / when_to_use "Triggers:" line - hits: user messages that (a) matched a trigger AND (b) were followed - by an invocation of the same skill before the next user turn + by an auto invocation of the same skill before the next user + turn. A slash command ends the window: typing the command + means the trigger did not fire on its own. - hit_rate: hits / triggers (percent) Supports project-level breakdown and date-range filtering. @@ -94,6 +98,10 @@ def iter_events(path: pathlib.Path) -> Iterable[dict]: _FRONT_MATTER_RE = re.compile(r"\A---\s*\n(.*?)\n---\s*\n", re.DOTALL) _QUOTED_RE = re.compile(r"['\"]([^'\"]+)['\"]") +_TRIGGER_LABEL_RE = re.compile( + r"(?:Triggers?|明示トリガ|トリガー?)\s*[::]\s*(.+)", + re.IGNORECASE | re.DOTALL, +) _JA_WORD_RE = re.compile(r"[一-龥ぁ-んァ-ヶー]{2,}|[A-Za-z][A-Za-z0-9_-]{2,}") _STOPWORDS = { "true", "false", "null", "none", "when", "triggers", "trigger", @@ -123,17 +131,37 @@ def parse_front_matter(text: str) -> dict[str, str]: return out -def extract_triggers(description: str, include_fallback: bool = False) -> tuple[list[str], str]: +def extract_triggers( + description: str, + when_to_use: str = "", + include_fallback: bool = False, +) -> tuple[list[str], str]: + """Collect trigger keywords declared in description / when_to_use. + + Almost every skill lists its triggers in `when_to_use`, not `description`, + and the label is written either as "Triggers:" or in Japanese + ("明示トリガ:"). Both fields and both labels are accepted. + + Each field is searched independently: `_TRIGGER_LABEL_RE` uses DOTALL, so + matching against the two fields joined together would let a label found in + `description` swallow the whole of `when_to_use` and pick up unrelated + quoted strings from it. + """ triggers: list[str] = [] - m = re.search(r"Triggers?:\s*(.+)", description, re.IGNORECASE | re.DOTALL) - if m: + for field in (description, when_to_use): + if not field: + continue + m = _TRIGGER_LABEL_RE.search(field) + if not m: + continue for q in _QUOTED_RE.findall(m.group(1)): triggers.append(q.strip()) if triggers: return _dedupe_ci(triggers), "explicit" if not include_fallback: return [], "none" - flat = description.replace('"', " ").replace("'", " ") + text = "\n".join(t for t in (description, when_to_use) if t) + flat = text.replace('"', " ").replace("'", " ") seen: set[str] = set() for w in _JA_WORD_RE.findall(flat): w = w.strip() @@ -179,7 +207,10 @@ def load_skills(plugin_root: pathlib.Path, include_fallback: bool = False) -> li fm = parse_front_matter(text) name = fm.get("name", d.name).strip().strip('"') desc = fm.get("description", "").strip().strip('"') - triggers, source = extract_triggers(desc, include_fallback=include_fallback) + when = fm.get("when_to_use", "").strip().strip('"') + triggers, source = extract_triggers( + desc, when, include_fallback=include_fallback + ) out.append({ "name": name, "qualified": f"ndf:{name}", @@ -191,6 +222,37 @@ def load_skills(plugin_root: pathlib.Path, include_fallback: bool = False) -> li _SYSTEM_TAG_RE = re.compile(r"^\s*<(local-command|command-name|command-message|command-args|system-reminder)") +_COMMAND_NAME_RE = re.compile(r"\s*/([^<\s]+)\s*") + + +def raw_user_text(ev: dict) -> str: + """Return the user message text without dropping system-tagged content.""" + msg = ev.get("message") or {} + c = msg.get("content") + if isinstance(c, str): + return c + if isinstance(c, list): + return "\n".join( + b.get("text", "") for b in c + if isinstance(b, dict) and b.get("type") == "text" + ) + return "" + + +def extract_slash_invocation(ev: dict, skill_names: set[str]) -> str | None: + """Return the skill a user invoked by slash command, if this event is one. + + Explicit invocations never appear as a Skill tool_use; they are recorded as + a user message carrying /ndf:review. Installs + that predate the plugin prefix record the bare name (/review). + """ + if ev.get("type") != "user": + return None + m = _COMMAND_NAME_RE.search(raw_user_text(ev)) + if not m: + return None + name = m.group(1).split(":")[-1] + return f"ndf:{name}" if name in skill_names else None def extract_user_text(ev: dict) -> str: @@ -252,7 +314,10 @@ def detect_project(path: pathlib.Path, first_cwd: str | None) -> str: return parent -def build_timeline(path: pathlib.Path) -> tuple[list[tuple[str, object]], str]: +def build_timeline( + path: pathlib.Path, + skill_names: set[str], +) -> tuple[list[tuple[str, object]], str]: """Return (timeline, project_label).""" timeline: list[tuple[str, object]] = [] first_cwd: str | None = None @@ -263,6 +328,10 @@ def build_timeline(path: pathlib.Path) -> tuple[list[tuple[str, object]], str]: first_cwd = cwd t = ev.get("type") if t == "user": + slash = extract_slash_invocation(ev, skill_names) + if slash: + timeline.append(("slash", slash)) + continue text = extract_user_text(ev) if text: timeline.append(("user", text)) @@ -276,22 +345,37 @@ def build_timeline(path: pathlib.Path) -> tuple[list[tuple[str, object]], str]: def aggregate_by_project( transcripts: list[pathlib.Path], skills: list[dict], + all_skill_names: set[str] | None = None, lookahead_cap: int = 100, -) -> dict[str, tuple[Counter, Counter, Counter]]: - """Return { project: (invocations, triggers_hits, hits) }.""" - result: dict[str, tuple[Counter, Counter, Counter]] = defaultdict( - lambda: (Counter(), Counter(), Counter()) +) -> dict[str, tuple[Counter, Counter, Counter, Counter]]: + """Return { project: (auto, explicit, triggers_hits, hits) }. + + `skills` may already be narrowed by `--skill`; it only decides which rows + are counted. Slash-command detection must stay based on the *unfiltered* + skill set (`all_skill_names`), because every slash command closes the + hit-lookahead window. Deriving the boundary set from a filtered `skills` + would hide other skills' slash commands and over-count hits. + """ + result: dict[str, tuple[Counter, Counter, Counter, Counter]] = defaultdict( + lambda: (Counter(), Counter(), Counter(), Counter()) + ) + skill_names = ( + all_skill_names if all_skill_names is not None + else {s["name"] for s in skills} ) skill_triggers = [ (s["qualified"], [t.lower() for t in s["triggers"] if t]) for s in skills ] for path in transcripts: - tl, project = build_timeline(path) - inv, trig_h, hits = result[project] + tl, project = build_timeline(path, skill_names) + auto, explicit, trig_h, hits = result[project] for i, (kind, data) in enumerate(tl): if kind == "skill": - inv[data] += 1 + auto[data] += 1 + continue + if kind == "slash": + explicit[data] += 1 continue if kind != "user": continue @@ -304,7 +388,7 @@ def aggregate_by_project( end = min(i + 1 + lookahead_cap, len(tl)) for j in range(i + 1, end): k2, d2 = tl[j] - if k2 == "user": + if k2 in ("user", "slash"): break if k2 == "skill" and d2 == qualified: hits[qualified] += 1 @@ -313,48 +397,57 @@ def aggregate_by_project( def merge_counters( - per_project: dict[str, tuple[Counter, Counter, Counter]], -) -> tuple[Counter, Counter, Counter]: - inv_total: Counter = Counter() + per_project: dict[str, tuple[Counter, Counter, Counter, Counter]], +) -> tuple[Counter, Counter, Counter, Counter]: + auto_total: Counter = Counter() + explicit_total: Counter = Counter() trig_total: Counter = Counter() hits_total: Counter = Counter() - for inv, trig, hits in per_project.values(): - inv_total.update(inv) + for auto, explicit, trig, hits in per_project.values(): + auto_total.update(auto) + explicit_total.update(explicit) trig_total.update(trig) hits_total.update(hits) - return inv_total, trig_total, hits_total + return auto_total, explicit_total, trig_total, hits_total def build_rows( skills: list[dict], - invocations: Counter, + auto: Counter, + explicit: Counter, triggers_hits: Counter, hits: Counter, ) -> tuple[list[dict], dict]: rows: list[dict] = [] - total_inv = total_trig = total_hit = 0 + total_auto = total_explicit = total_trig = total_hit = 0 for s in sorted(skills, key=lambda x: x["name"]): q = s["qualified"] - inv = invocations.get(q, 0) + a = auto.get(q, 0) + e = explicit.get(q, 0) trig = triggers_hits.get(q, 0) hit = hits.get(q, 0) rate = round(hit / trig * 100, 1) if trig else 0.0 rows.append({ "skill": q, "triggers_source": s["triggers_source"], - "invocations": inv, + "invocations": a + e, + "auto": a, + "explicit": e, "triggers": trig, "hits": hit, "hit_rate_pct": rate, "trigger_keywords": s["triggers"], }) - total_inv += inv + total_auto += a + total_explicit += e if s["triggers_source"] == "explicit": total_trig += trig total_hit += hit total_rate = round(total_hit / total_trig * 100, 1) if total_trig else 0.0 total = { - "invocations": total_inv, + "invocations": total_auto + total_explicit, + "auto": total_auto, + "explicit": total_explicit, "triggers": total_trig, "hits": total_hit, "hit_rate_pct": total_rate, @@ -367,8 +460,8 @@ def format_markdown(rows: list[dict], total: dict, heading: str | None = None) - if heading: lines.append(heading) lines.extend([ - "| skill | triggers源 | 呼び出し数 | 関連話題 | ヒット | ヒット率 |", - "|---|---|---:|---:|---:|---:|", + "| skill | triggers源 | 計 | 自動 | 明示 | 関連話題 | ヒット | ヒット率 |", + "|---|---|---:|---:|---:|---:|---:|---:|", ]) for r in rows: src = r["triggers_source"] @@ -381,10 +474,13 @@ def format_markdown(rows: list[dict], total: dict, heading: str | None = None) - trig = str(r["triggers"]) hit = str(r["hits"]) lines.append( - f"| {r['skill']} | {src} | {r['invocations']} | {trig} | {hit} | {rate} |" + f"| {r['skill']} | {src} | {r['invocations']} | {r['auto']} | " + f"{r['explicit']} | {trig} | {hit} | {rate} |" ) lines.append( - f"| **合計** | | **{total['invocations']}** | **{total['triggers']}** | **{total['hits']}** | **{total['hit_rate_pct']}%** |" + f"| **合計** | | **{total['invocations']}** | **{total['auto']}** | " + f"**{total['explicit']}** | **{total['triggers']}** | " + f"**{total['hits']}** | **{total['hit_rate_pct']}%** |" ) return "\n".join(lines) @@ -426,6 +522,9 @@ def main() -> int: effective_days = args.days if (date_from is None and date_to is None) else None skills = load_skills(plugin_root, include_fallback=args.include_fallback) + # Slash boundaries must be detected across every skill, not just the ones + # left after --skill narrowing. + all_skill_names = {s["name"] for s in skills} if args.skill: skills = [s for s in skills if args.skill in s["name"]] @@ -445,7 +544,7 @@ def main() -> int: file=sys.stderr, ) - per_project = aggregate_by_project(transcripts, skills) + per_project = aggregate_by_project(transcripts, skills, all_skill_names) if args.project: needle = args.project.lower() per_project = { @@ -457,15 +556,14 @@ def main() -> int: if args.format == "json": projects_json = [] - for project, (inv, trig, hits) in sorted(per_project.items()): - rows, total = build_rows(skills, inv, trig, hits) + for project, (auto, explicit, trig, hits) in sorted(per_project.items()): + rows, total = build_rows(skills, auto, explicit, trig, hits) projects_json.append({ "project": project, "total": total, "skills": rows, }) - all_inv, all_trig, all_hits = merge_counters(per_project) - grand_rows, grand_total = build_rows(skills, all_inv, all_trig, all_hits) + grand_rows, grand_total = build_rows(skills, *merge_counters(per_project)) out = { "meta": { "days": effective_days, @@ -483,20 +581,18 @@ def main() -> int: print(json.dumps(out, ensure_ascii=False, indent=2)) else: if args.by_project: - for project, (inv, trig, hits) in sorted(per_project.items()): - rows, total = build_rows(skills, inv, trig, hits) + for project, (auto, explicit, trig, hits) in sorted(per_project.items()): + rows, total = build_rows(skills, auto, explicit, trig, hits) if total["invocations"] == 0 and total["triggers"] == 0: continue # skip silent projects print() print(format_markdown(rows, total, heading=f"## {project}")) # grand total - all_inv, all_trig, all_hits = merge_counters(per_project) - grand_rows, grand_total = build_rows(skills, all_inv, all_trig, all_hits) + grand_rows, grand_total = build_rows(skills, *merge_counters(per_project)) print() print(format_markdown(grand_rows, grand_total, heading="## 全プロジェクト合計")) else: - all_inv, all_trig, all_hits = merge_counters(per_project) - rows, total = build_rows(skills, all_inv, all_trig, all_hits) + rows, total = build_rows(skills, *merge_counters(per_project)) print(format_markdown(rows, total)) if args.show_keywords: