diff --git a/docs/specifications/README.md b/docs/specifications/README.md
index 6844855a..eb5ace2e 100644
--- a/docs/specifications/README.md
+++ b/docs/specifications/README.md
@@ -27,5 +27,6 @@
| [ndf-execution-plan-and-parallel-capacity.md](ndf-execution-plan-and-parallel-capacity.md) | 並列の実行計画(依存を工程の対で書く・重なりの 3 区分・開いている間はコミットしない)、マイルストーンの組、メモリで見る本数(`parallel-measure.py`)。手順は `issue-plan-strategy` と `development-workflow` の `references/` が正 |
| [ndf-instruction-files-check.md](ndf-instruction-files-check.md) | エージェント向け指示書の検査(`instructions-check.py`)。宣言 `.ndf/instructions.json` で決まる判定の強さ、即時読み込みと出た版の段落の判定、扱いの印、観点の調べ直し。呼び方と宣言の書き方は `release` の `references/instruction-files.md` が正 |
| [test-monitor-env-isolation.md](test-monitor-env-isolation.md) | テストの実行中だけ監視の上限を指す環境変数(接頭辞 `MONITOR_`)をリポジトリの根の共通の前提で外すこと、外す時点と戻す時点、根の設定ファイルで基準のディレクトリを固定すること |
+| [ndf-token-waits-and-context-cut.md](ndf-token-waits-and-context-cut.md) | 待つ間の問い合わせ(前景の `sleep` の待ち・変わらないファイルの読み直し)と、文脈が上限を超えた conductor の工程の起動を止める hook(`token-guard.sh`)の判定・記録の形・入出力の契約、引き継ぎの 1 行、4 ランタイムの扱い。規約は `development-workflow` の `references/waiting.md` と `context-window.md` が正 |
Skill の挙動仕様はここに置かない。Skill に関する詳細は対象 Skill の `SKILL.md` を参照する。
diff --git a/docs/specifications/ndf-token-waits-and-context-cut.md b/docs/specifications/ndf-token-waits-and-context-cut.md
new file mode 100644
index 00000000..3fabf662
--- /dev/null
+++ b/docs/specifications/ndf-token-waits-and-context-cut.md
@@ -0,0 +1,373 @@
+# 待つ間の問い合わせを止め、conductor の会話を工程の切れ目で切る
+
+Claude Code の PreToolUse hook(`plugins/ndf/scripts/token-guard.sh`)が、待つ間に文脈を
+読み直す呼び出し(前景の `sleep` の待ちと、変わらないファイルの読み直し)と、文脈が上限を
+超えた conductor が工程へ入る起動を止める。止めたときは理由の欄に代わりの手段を示す。
+この文書は、判定の条件・記録の形・入出力の契約と、それぞれをそう決めた理由を残す。
+
+**待ち方と会話の切り方の規約は Skill の文書が正である。** 許す待ち方・待つ相手ごとの手・
+新しい会話で状態を戻す手順をここへ書き写さない。
+
+| 何を読むか | 正本 |
+| --- | --- |
+| 待ちの費用、許す待ち方と禁じる待ち方、待つ相手ごとの手、hook の止め方、4 ランタイムの扱い | `plugins/ndf/skills/development-workflow/references/waiting.md` |
+| 会話を切る 4 つの切れ目、上限を超えたら hook が止めること、新しい会話で戻す手順 | `plugins/ndf/skills/development-workflow/references/context-window.md` の「context window は工程の切れ目で切る」「上限を超えたら hook が止める」「新しい会話で戻す」 |
+| conductor が引き継ぎの 1 行を出す時点 | `plugins/ndf/skills/development-workflow/SKILL.md`(「工程は 1 つの context window で通し切らなくてよい」の段落) |
+| supervisor と worker が待ち方に従う規則 | `plugins/ndf/skills/development-workflow/references/agent-layers.md` |
+| `sleep` の判定の字句の規則 | `plugins/ndf/scripts/lib/token_guard_sleep.py` の docstring |
+
+## 概要
+
+**例(#829)。** サブエージェントが `codex exec` を背景で起動し、`sleep 60 && tail -5 /tmp/x.log`
+を 30 回繰り返すと、30 回とも文脈の全体を読み直す。hook は 1 回目の `sleep 60 && tail` を止め、
+「待ちの条件を until ループにして `run_in_background: true` で起動し、完了通知を待つ」よう示す。
+
+**例(#830)。** conductor の文脈が 41 万のまま `/ndf:implementation-plan #829` を起動する
+(3 層では `実装: #829` の supervisor を起動する)と、hook が起動を 1 度止め、「新しい会話で
+`/ndf:development-workflow #829` を打つ」よう示す。conductor はその 1 行を利用者へ示して止まる。
+
+**待ちの費用は「呼び出しの回数 × その時点の文脈」で決まる。** 背景で待って通知を 1 回受ける
+なら、待つ時間の長さは費用を増やさない。#827 の実測では、待つ間の繰り返しの問い合わせ
+(ポーリング)が全体の費用の 16%(2026-09-20 以降)、ai-plugins の 30 日間では 19%(258M)を
+占めた。conductor の会話を工程の開始ごとに切っていれば、conductor の再読込量は 58%(30 日間
+では 62%)減る見込みだった。
+
+**規定を書くだけでは守られなかったため、hook で止める。** `context-window.md` は以前から
+「遅くとも 20 万で切る」と定めていたが、#827 の実測で守られていなかった。Claude Code 本体も
+前景の `sleep` を本体の会話でしか止めず、サブエージェントの中の `sleep 12 && echo` は 12 秒
+待って成功した(Claude Code 2.1.280、2026-09-23)。本体の仕組みには頼れない。
+
+## 用語
+
+| 用語 | 意味 |
+| --- | --- |
+| ポーリング | 待つ間に、状態を確かめるための呼び出しを繰り返すこと |
+| 前景の Bash | `run_in_background` を付けずに実行する Bash。終わるまで呼び出しが返らない |
+| 文脈量 | 1 回の API 呼び出しで読んだトークン数。`input_tokens + cache_read_input_tokens + cache_creation_input_tokens` |
+| 工程 Skill | `context-window.md` の 4 つの切れ目の直後に始まる工程の Skill と、入口の `development-workflow` / `issue-plan-strategy`(下の「工程 Skill の一覧」) |
+| 引き継ぎの 1 行 | 新しい会話の最初に打てば、その工程から再開できるコマンド 1 行。`/ndf:development-workflow #<課題> [#<課題> ...]` |
+
+## 構成要素
+
+| 要素 | 責務 |
+| --- | --- |
+| `plugins/ndf/scripts/token-guard.sh` | PreToolUse の入口。`tool_name` で 3 つの判定(`Bash` → sleep / `Read` → 連続 Read / `Skill`・`Agent`・`Task` → 文脈量)へ振り分け、拒否か通過を返す。排他は `scripts/lib/lock-common.sh` を読み込んで使う |
+| `plugins/ndf/scripts/lib/token_guard_sleep.py` | sleep の判定。標準入力にコマンド、第 1 引数に秒数の上限を受け、拒否なら 1、通すなら 0 で終わる |
+| `plugins/ndf/scripts/lib/token-guard-stages.txt` | 工程 Skill の名前の一覧(1 行 1 名、13 個) |
+| `plugins/ndf/hooks/claude.json` | PreToolUse に matcher `Bash\|Read\|Skill\|Agent\|Task` で `token-guard.sh` を登録する(既存の `worktree-guard.sh` の登録と順序は変えない) |
+| `development-workflow/references/waiting.md` | 待ち方の規約の唯一の置き場所 |
+| `external-ai/references/cli-codex.md`・`cli-agy.md`・`qa-security-scan/03-report-template.md`・`release/references/completion-check.md` | 前景の待ちのループの直前に「Claude Code では、このループを `run_in_background: true` で実行して完了通知を待つ」の 1 行を置き、`waiting.md` を指す |
+
+**sleep の判定は `python3` で書く。** 入れ子の `do` / `done` の対応と引用の除去を bash の
+正規表現では読める形で書けないためである。`python3` が無いときは判定を通す。
+
+```mermaid
+graph TB
+ AG["エージェント
conductor / supervisor / worker"]
+ subgraph HK["hooks/claude.json の PreToolUse"]
+ WG["worktree-guard.sh"]
+ TG["token-guard.sh"]
+ end
+ subgraph ST["状態"]
+ RS["連続 Read の控えと案内の印
guards/"]
+ TR["会話の記録
transcript_path"]
+ SL["token-guard-stages.txt"]
+ end
+ subgraph DOC["development-workflow の文書"]
+ WT["references/waiting.md"]
+ CW["references/context-window.md"]
+ end
+ AG -->|"Bash / Read / Skill / Agent・Task"| TG
+ AG -->|"編集系 / Bash"| WG
+ TG -->|"Read の判定"| RS
+ TG -->|"Skill・Agent の判定"| TR
+ TG -->|"Skill の判定"| SL
+ TG -. "拒否の理由が指す" .-> WT
+ TG -. "拒否の理由が指す" .-> CW
+```
+
+## 仕様
+
+### 常に成り立つ条件
+
+- **hook の終了コードは常に 0 である。** 拒否は `permissionDecision: deny` で返し、通すときは
+ 何も出さない
+- **判定が失敗したときは通す。** 入力が読めない・`jq` や `python3` が無い・`guards/` を作れない・
+ 控えを書けない・記録を読めない・ロックを 1 秒で取れない、のいずれでもツールの実行を止めない。
+ sleep の判定は状態を持たないため、`guards/` が使えなくても続ける
+- **拒否の理由の欄は、代わりの手段と規約の場所と止める環境変数を必ず含む。** エージェントが
+ 理由の欄だけで次の手を決められるようにするためである
+
+### sleep の判定
+
+**前景の Bash で、コマンドの位置の `sleep` が次のどちらかに当たると拒否する。**
+
+- `while` / `until` のループの本体(`do` と対応する `done` の間)にある。秒数が変数でも止める
+- 秒数が数で、上限(既定 5 秒)を超える。ループの本体の外の `sleep` はこれだけで見る
+
+| 見方 | 規則 |
+| --- | --- |
+| 見ない部分 | コメント(引用の外の `#` 以降)・引用の中・ヒアドキュメントの本文 |
+| 中身を取り出して同じ規則で見る | コマンドの位置にある `bash -c` / `sh -c` / `zsh -c` / `dash -c` / `eval` の実行される引数。入れ子も 1 段ずつ見る |
+| コマンドの位置 | 行頭・`;` `&&` `\|\|` `\|` `&` `(` `do` `then` `else` の直後。先頭の代入語(`X=1`)と前置き(`timeout 590` / `nohup` / `env` など)の後ろも含む |
+| 背景とみなして見ない | `run_in_background: true`、`&` で終わる `sleep`(リダイレクトを挟む形、sleep を含むリスト、`( )` / `{ }` / ループで囲んだ全体が背景になる形、外側が背景の `bash -c` / `eval` を含む)。`2>&1` / `&>` の `&` は背景と読まない |
+
+**ループの本体の `sleep` を止めるのは、そこに費用の大半があるためである。** 2026-08-23 以降の
+全プロジェクトの記録(6,713 本、Bash 74,223 件)で、`sleep <数>` を含む前景の Bash は次のとおり
+だった。
+
+| 形 | 件数 | 費用(input 換算) |
+| --- | ---: | ---: |
+| 前景・ループの中 | 1,543 | 67.3M |
+| 前景・ループなし | 743 | 9.9M |
+| 背景(`run_in_background`) | 441 | 5.3M |
+
+ループの本体の外の短い `sleep`(サーバの起動を待つ間など)と、`for` のループで 5 秒以下の
+`sleep` を挟む形(API の照会の間隔)は通す。代わりの手段が無いためである。`while` と `sleep`
+が同じコマンドにあるだけでは止めない。ループの後の短い間まで止めることになる。
+
+**文字列の中の `sleep` は止めない。** `echo sleep 30` や `git commit -m "sleep 60"` を止めない
+ため、引用を除いた後の語の位置で判定する。ただし引用の中でも `bash -c 'sleep 30'` は実行される
+ため、除く前に中身を取り出す。判定は字句による近似で、`case` の囲みは数えない。
+
+### 連続 Read の判定
+
+**同じ `file_path`・`offset`・`limit` の Read が、ファイルの大きさ・更新時刻・inode が変わらない
+まま、その会話で続けて上限の回数(既定 3)に達すると拒否する。** 別の引数の Read が挟まるか、
+ファイルが変われば数え直す。拒否した Read も回数を進める。
+
+- **「空ファイル」ではなく「変わっていない」で見る。** hook は実行の前に呼ばれ、読んだ中身を
+ 知らない。空ファイルの読み直しはこれに含まれ、書き込みが進むログの読み直しは含まれない
+- **更新時刻はナノ秒の精度で持ち、inode も比べる。** 同じ秒に同じ大きさの内容で置き換えた
+ (`mv`)ファイルを、変わっていないと取り違えないためである
+- **`offset` と `limit` を鍵に入れる。** 大きなファイルを範囲を変えて読み進める使い方を止めない
+- **3 回目にしたのは実測による。** 2026-08-23 以降の記録で、同じ引数の Read が 3 回以上続いた
+ のは 6 本で、うち 5 本が `tasks/*.output` の読み直し(最長 1,168 回)、残る 1 本は画像を見直す
+ 3 回だった
+- 間に他のツールが挟まったら数え直す形は採らない。hook は Read の呼び出しにしか登録されず、
+ 間のツールを見られない
+
+### 文脈量の判定
+
+**conductor が工程へ入る起動で、会話の文脈量が上限(既定 200,000)を超えていると、1 度拒否
+して引き継ぎの 1 行を示す。** 工程へ入る起動は経路によって違うツールに現れるため、両方を見る。
+
+| 経路 | 見る入力 | 印の鍵 |
+| --- | --- | --- |
+| 対話 | `Skill`。名前(`ndf:` を外したもの)が `token-guard-stages.txt` にある | `skill`・`args` |
+| 3 層 | `Agent`(旧名 `Task`)。`description` の `:` の前が持ち場の語彙(`設計` / `実装` / `検査` / `取り込み` / `仕上げ`) | `description` |
+
+- **サブエージェントの中の起動は見ない。** 入力に `agent_id` が付くか、`transcript_path` が
+ `/subagents/` を含めば見ない。Claude Code 2.1.280 の実測では、`agent_id` はサブエージェントの
+ 中でだけ付き、サブエージェントの `transcript_path` は親の記録を指すため、区別は `agent_id` で
+ つく。supervisor は 1 つの持ち場の中で複数の工程を通すため、工程の起動で止めると持ち場が
+ 途中で途切れる
+- **工程でない Skill と、先頭語が作業の種類(`調査` など)の Agent は見ない。** 工程の途中で
+ 起動されるため切れ目にならない
+- **拒否の後、次に工程へ入る起動が同じ鍵なら 1 度だけ通し、印を消す。** 間に他のツールや
+ 工程でない Skill・Agent が挟まっても印は残る。次の起動が別の鍵なら、上限を超えていれば印を
+ 置き換えて再び拒否する。これで工程の切れ目ごとに 1 度ずつ止まり、「このまま続ける」と決めた
+ 利用者は同じ起動をもう一度行えば続けられる。毎回拒否すると同じ工程をやり直せず、会話ごとに
+ 1 度にすると以後の切れ目で止まらない。案内だけを足す形(`additionalContext`)は、規定が読み
+ 流された実測があるため採らない
+- **文脈量を読めない(記録が無い・`usage` が無い)ときは通す**
+
+**上限の既定は 200,000 で、`context-window.md` の「遅くとも 20 万」と `skill-stats.py` の
+`DEFAULT_WINDOW_LIMIT` と同じ値にする。** 測る側と止める側の上限を食い違わせないためである。
+10 万(目安の側)にしないのは、#827 で固定費だけで約 4 万あり、1 工程の途中で止まる回数が
+増えるためである。
+
+**文脈量は `transcript_path` の末尾 200 行の、最後の assistant 行の `message.usage` から読む。**
+`transcript_agents.py` の `_input_total` と `statusline.sh` と同じ足し方である。PreToolUse の時点で
+その呼び出しを出した assistant 行はまだ記録に書かれていないため、1 つ以上前の呼び出しの値に
+なる(差は 1 回分の出力と結果)。末尾だけを読むのは、大きな記録でも速く終えるためである。
+
+**拒否の理由の欄の `<課題>` は、Skill の `args`(Agent なら `description`)から取り出す。**
+`#<数>` と数だけの語を課題番号とし、日付・版数・小数(`2026-09-23` / `v10.16.1` / `2.0.3`)は
+番号と読まない。範囲(`#829-830`)は `#829 #830` として示す。番号が無ければ `<課題番号>` の
+文字のまま示し、通過工程の控えから推測しない。並行して別の課題を進めていると、最新の控えは
+別の課題を指すためである。
+
+### 工程 Skill の一覧
+
+`token-guard-stages.txt` は工程表から機械的に抽出しない。次の 13 個を正とする。
+
+| 切れ目 | 直後に始まる工程の Skill |
+| --- | --- |
+| 1 ドキュメントレビューのマージの後 | `implementation-plan` / `document-drafting` |
+| 2 構造改善と実装レビューの前後 | `cross-refactoring` / `cross-review` / `pr-review` / `quality-gates` |
+| 3 Pull Request を出した後 | `plan-to-spec` / `merged` |
+| 4 配布の後 | `layout-review` / `release-verification` / `retrospective` |
+| 入口 | `development-workflow` / `issue-plan-strategy` |
+
+`worktree` など切れ目の内側の工程は含めない。`cross-review` は切れ目 1 の前(ドキュメント
+レビュー)でも起動されるが、その時点で上限を超えていれば止めてよいので含める。
+
+### 引き継ぎの 1 行
+
+**形は `/ndf:development-workflow #<課題> [#<課題> ...]` とする。** 工程 Skill を直接起動する形
+(`/ndf:implementation-plan #829`)は採らない。工程 Skill はモード・作業ツリー・承認の状態を戻す
+手順を持たず、戻す手順を持つのは `development-workflow` の側だからである。経由すると固定費に
+約 1 万トークンが足されるが、切る前の会話の文脈(#827 で平均 41 万)に比べて小さい。Codex と
+Kiro では、それぞれの README が示す Skill の起動の書き方に読み替える。
+
+**conductor は `context-window.md` の 4 つの切れ目と、文脈量の hook が拒否したときにこの 1 行を
+出す。** 3 層では supervisor の持ち場の境がこの切れ目に当たるため、conductor が `## 持ち場の報告`
+を受け取った時点で出し、supervisor は出さない。報告が `結果: 関門` のときは、関門の承認と
+取り込み(設計 Pull Request のマージなど)が済んだ後に出す。関門の前に会話を切らないためである。
+
+**新しい会話の `development-workflow` は、課題の本文の `## 進行`・通過工程の控え・Pull Request
+から、モード・作業ツリー・現在の工程を戻す。** Pull Request は番号の全文検索で引かず、作業
+ツリーのブランチ名と課題の `closedByPullRequestsReferences` で引く。設計の Pull Request は閉じる
+語を持たないため、ブランチ名でしか引けない。手順は `context-window.md` の「新しい会話で戻す」が
+持つ。
+
+### 待ち方
+
+**1 回で足りる待ちは `Monitor` ではなく、`run_in_background` で起動した until ループにする。**
+`Monitor` は出来事のたびに通知が届き、既定 5 分・最長 30 分で打ち切られて張り直しが要る。
+終わりだけを知りたい待ちでは、通知が 1 回で済む `run_in_background` のほうが呼び出しが少ない。
+
+**サブエージェントは背景の処理を残したまま応答を終えない。** Claude Code 2.1.280 の実測では、
+`codex exec` を `run_in_background` で起動して応答を終えたサブエージェントは、完了通知(約 2 秒後)
+で再開して報告を出し直した。ただし親には応答を終えた時点で 1 度「終わった」と通知が届き、途中の
+文面が結果として渡った。親が 1 回目を結果と読むと、報告の無い持ち場を受け取る。
+
+**待ち方の規約は `waiting.md` の新しいファイルに置く。** `agent-layers.md` の節にすると、
+`external-ai` などの文書から参照するたびに 3 層の規約の全体を読ませる。
+
+**配布物の文書にある前景の待ちのループは、ループを書き換えずに案内の 1 行を足す。** 拒否の
+理由が「同じループを `run_in_background: true` で」と案内するため、Claude Code では 1 回の
+回り道で済む。hook と案内の行を同じ版で配布するため、拒否と文書の順序が食い違わない。ループの
+書き換え(道具の共通化)は #731 が扱う。
+
+## データ・設定
+
+### 控えの置き場所
+
+**`guards/` の場所は `token-guard.sh` が自前で解決する。** 次の順で先に使えたものの下に置く。
+順は `workflow-common.sh` の `wf_state_dir` と同じである。`workflow-common.sh` は読み込まない。
+末尾で通信の層まで読み込むため毎回の hook には重く、その層の変更が hook へ波及する。
+
+1. `$CLAUDE_PLUGIN_DATA/guards`
+2. `$XDG_STATE_HOME/ndf/guards`
+3. `$HOME/.local/state/ndf/guards`
+4. `${TMPDIR:-/tmp}/ndf-guards`(上が作れない・書けないときも使う)
+
+| ファイル | 中身 |
+| --- | --- |
+| `read-.json` | 連続 Read の控え(下の表) |
+| `context-.json` | 文脈量の案内の印。`{"key": "<鍵>"}`。鍵は Skill なら `skill\t<名前>\t`、Agent なら `agent\t` |
+| `.lock` | session ごとのロック |
+
+連続 Read の控え:
+
+| キー | 型 | 意味 |
+| --- | --- | --- |
+| `key` | 文字列 | 直前の Read の `file_path`・`offset`・`limit` を `\t` でつないだもの |
+| `size` | 整数 | 直前の Read の時点のファイルの大きさ(バイト)。無いファイルは `-1` |
+| `mtime` | 文字列 | 同じく更新時刻(ナノ秒の精度。GNU の `stat -c %.9Y`、BSD の `stat -f %Fm`) |
+| `inode` | 整数 | 同じく inode 番号。無いファイルは `-1` |
+| `count` | 整数 | `key`・`size`・`mtime`・`inode` が変わらないまま続いた Read の回数 |
+
+- **書き込みは置き換えで行う**(一時ファイルへ書いて `mv`)。途中で落ちても壊れた JSON を残さない
+- **控えと印の読み・判定・書き込みは session ごとのロックの中で行う。** 同じ session の hook が
+ 並列に走ると、置き換えだけでは `count` の更新や印が失われる。ロックは `lock-common.sh` の
+ `ndf_lock_acquire 1` / `ndf_lock_release` で取り、1 秒で取れなければ判定せず通す。
+ sleep の判定はロックを取らない
+- **7 日より古い控えは、書き込みのついでに消す。** 会話が終わった合図を hook は受け取らない
+
+### 環境変数
+
+| 変数 | 既定 | 意味 |
+| --- | --- | --- |
+| `NDF_SLEEP_GUARD` | `1` | `0` で sleep の判定を止める |
+| `NDF_SLEEP_MAX_SEC` | `5` | `sleep` の秒数の上限(ループの本体の外ではこれだけで見る) |
+| `NDF_READ_REPEAT_GUARD` | `1` | `0` で連続 Read の判定を止める |
+| `NDF_READ_REPEAT_LIMIT` | `3` | 連続 Read を拒否する回数 |
+| `NDF_CONTEXT_GUARD` | `1` | `0` で文脈量の判定を止める |
+| `NDF_CONTEXT_LIMIT` | `200000` | 文脈量の上限(トークン) |
+
+## 外部連携
+
+### hook の入力(Claude Code の PreToolUse)
+
+| キー | 使う判定 | 無いとき |
+| --- | --- | --- |
+| `tool_name` | 振り分け | 通す |
+| `tool_input.command` / `tool_input.run_in_background` | sleep | 通す |
+| `tool_input.file_path` / `offset` / `limit` | 連続 Read | 通す |
+| `tool_input.skill` / `tool_input.args` | 文脈量(対話の経路) | 通す |
+| `tool_input.description` | 文脈量(3 層の経路) | 通す |
+| `session_id` | 連続 Read の控え・案内の印 | 通す |
+| `transcript_path` | 文脈量 | 通す |
+| `agent_id` | 文脈量(付いていれば見ない) | conductor とみなす |
+
+### hook の出力
+
+```json
+{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny",
+ "permissionDecisionReason":"<理由>"}}
+```
+
+| 判定 | 理由の欄が示すこと |
+| --- | --- |
+| sleep | 同じ条件の until ループを `run_in_background: true` で起動して完了通知を待つこと。出来事を 1 つずつ受けるなら `Monitor`。規約 `waiting.md`。`NDF_SLEEP_GUARD=0` |
+| 連続 Read | 書き終わりを待つなら until ループを `run_in_background: true` で起動するか、背景の処理の完了通知を待つこと。`tasks/*.output` は読まない。規約 `waiting.md`。`NDF_READ_REPEAT_GUARD=0` |
+| 文脈量 | 文脈量と上限、利用者へ示す引き継ぎの 1 行(3 層なら新しい会話の `/goal` に渡す)、続けるなら同じ起動をもう一度行うこと。規約 `context-window.md`。`NDF_CONTEXT_GUARD=0` と `NDF_CONTEXT_LIMIT` |
+
+### 4 ランタイム
+
+**hook は Claude Code にだけ登録し、他の 3 ランタイムは規約で守る。** 拒否の理由が案内する
+代わりの手段(`Monitor` / `run_in_background` の通知)は Claude Code にしか無く、文脈量も
+Claude Code の `transcript_path` からしか読めない。#827 の実測も Claude Code の記録だけである。
+ランタイムごとの扱いの表は `waiting.md` の「hook」と `plugins/ndf/README.md` にある。Codex / Kiro /
+agy の CLI 側の消費を測った後に、登録するかを改めて決める。
+
+## 運用
+
+- **止める:** 環境変数で判定ごとに止める。hook そのものを外すなら `hooks/claude.json` の登録を
+ 1 つ外す。データの移行は無い
+- **続ける:** 文脈量の拒否の後、利用者がこのまま続けると決めたら、同じ起動をもう一度行う
+- **性能:** 1 回の実行は、50 MB の記録でも競合しないとき 1 秒以内、ロックを待つときは 2 秒以内に
+ 終わる。記録は末尾 200 行だけを読み、Bash と Read の判定は記録を読まない。登録の `timeout` は
+ 5 秒で、`continueOnError: true` を付ける
+
+## テスト観点
+
+テストは `plugins/ndf/scripts/tests/test_token_guard.py` にあり、入力 JSON と記録の見本を与えて
+終了コードと出力を見る。
+
+- 前景の `sleep` の待ち(`sleep 30 && tail`・`while` / `until` の本体の `sleep`・`bash -c` / `sh -c` /
+ `timeout ... bash -c` の中身・`for` の本体の上限超え・入れ子の `while`)を拒否し、理由の欄に
+ `run_in_background` と `waiting.md` を含むこと
+- 背景の `sleep`・`Monitor`・`sleep` を含まない Bash・本体の外の上限以下の `sleep`・`for` の本体の
+ 上限以下の `sleep`・文字列やコメントやヒアドキュメントの中の `sleep` を通すこと
+- 同じ範囲の変わらない Read の 3 回目を拒否し、追記・`offset` の変更・同じ大きさの `mv` の置き換えで
+ 数え直すこと。同じ session の並列の hook で更新が失われないこと
+- 文脈量が上限を超えた conductor の工程 Skill と持ち場の Agent を拒否し、引き継ぎの 1 行に課題番号を
+ 示すこと。番号が無ければ `<課題番号>` のまま示すこと
+- サブエージェントの中の起動・工程でない Skill・作業の種類の Agent を通すこと
+- 拒否の後の同じ起動を 1 度だけ通し(間に Bash と Read が挟まっても)、別の起動を再び拒否すること。
+ 同じ起動の 2 回目を並列に起動しても通るのは 1 本だけであること
+- 壊れた入力・`jq` の無い `PATH`・書けない控えの場所・取れないロック・読めない記録で、出力なし・
+ 終了コード 0 で通すこと。`guards/` の親が `wf_state_dir` の親と一致すること
+- 環境変数で判定ごとに止まり、上限が変わること
+- `token-guard-stages.txt` の名前が上の 13 個と一致し、どれも `plugins/ndf/manifests/` の Skill 一覧に
+ あること
+- `waiting.md` が 1 か所にあって `agent-layers.md` の supervisor と worker の規則から参照され、
+ Claude Code 向けに前景の `sleep` のループを勧める例が無いこと。`context-window.md` に #827 の
+ 実測値・上限の値・戻す手順があり、`SKILL.md` に引き継ぎの 1 行の規約があること。README に
+ 4 ランタイムの表があること
+- Codex / Kiro / agy の既存の hook の動作が変わらないこと(既存のテスト)
+
+効果の数値(ポーリングの費用の割合、conductor の最大文脈と再読込量)は、配布後に #827 の
+`measure.py` / `poll.py` / `extra.py` を変更前と同じ条件で回して比べる。変更前の値は、ポーリング
+が全体の 16%、conductor の最大文脈 683k、再読込の削減見込み 58% である。
+
+## 関連リンク
+
+- [#829](https://github.com/devbasex/ai-plugins/issues/829) / [#830](https://github.com/devbasex/ai-plugins/issues/830)(親は [#827](https://github.com/devbasex/ai-plugins/issues/827))
+- [#731](https://github.com/devbasex/ai-plugins/issues/731) — 待ちの道具(`bg-wait.sh`)を共通層へ移す
+- [ndf-context-window-metrics.md](ndf-context-window-metrics.md) — 会話の記録から文脈量を測る部品
+- [ndf-agent-layers-unattended-run.md](ndf-agent-layers-unattended-run.md) — 3 層の運転
diff --git a/issues/old/README.md b/issues/old/README.md
index 4ea63519..7ebeca88 100644
--- a/issues/old/README.md
+++ b/issues/old/README.md
@@ -44,6 +44,7 @@
| [#312](https://github.com/devbasex/ai-plugins/issues/312) / [#315](https://github.com/devbasex/ai-plugins/issues/315) / [#313](https://github.com/devbasex/ai-plugins/issues/313) / [#573](https://github.com/devbasex/ai-plugins/issues/573) / [#610](https://github.com/devbasex/ai-plugins/issues/610) / [#495](https://github.com/devbasex/ai-plugins/issues/495) | 作業ツリー運用の残課題(まとまり「04 worktree 運用の残課題」)。確定仕様は [ndf-worktree-declaration-and-entry-points.md](../../docs/specifications/ndf-worktree-declaration-and-entry-points.md) と [ndf-testenv-lock-and-registry.md](../../docs/specifications/ndf-testenv-lock-and-registry.md) | [milestone-04-worktree/](milestone-04-worktree/issue-312-315-requirements.md) |
| [#561](https://github.com/devbasex/ai-plugins/issues/561) / [#623](https://github.com/devbasex/ai-plugins/issues/623) / [#550](https://github.com/devbasex/ai-plugins/issues/550) / [#657](https://github.com/devbasex/ai-plugins/issues/657) / [#540](https://github.com/devbasex/ai-plugins/issues/540) / [#541](https://github.com/devbasex/ai-plugins/issues/541) / [#621](https://github.com/devbasex/ai-plugins/issues/621) / [#554](https://github.com/devbasex/ai-plugins/issues/554) | 無人運転と工程の測定(まとまり「10 無人運転と工程の測定」、マイルストーン 18)。確定仕様は [ndf-cleanup-and-bundle-closing.md](../../docs/specifications/ndf-cleanup-and-bundle-closing.md) / [ndf-agent-layers-unattended-run.md](../../docs/specifications/ndf-agent-layers-unattended-run.md) / [ndf-context-window-metrics.md](../../docs/specifications/ndf-context-window-metrics.md) / [ndf-execution-plan-and-parallel-capacity.md](../../docs/specifications/ndf-execution-plan-and-parallel-capacity.md) / [ndf-instruction-files-check.md](../../docs/specifications/ndf-instruction-files-check.md) | [milestone-18-unattended/](milestone-18-unattended/issue-561-623-requirements.md)(要求・設計・契約・決定・計画・調査の 24 本。#762 の要件は下の行) |
| [#762](https://github.com/devbasex/ai-plugins/issues/762) | `agent-layers.md` の「並行の本数」の節で、実行計画の持ち主を `issue-plan-strategy` の `execution-plan.md` へ向ける(`light`、マイルストーン 18 の続き。#550 の AC60「`light` の課題 1 件を無人で通す」の確認に使った) | [milestone-18-unattended/issue-762-requirements.md](milestone-18-unattended/issue-762-requirements.md) |
+| [#829](https://github.com/devbasex/ai-plugins/issues/829) / [#830](https://github.com/devbasex/ai-plugins/issues/830) | 待つ間の問い合わせを止め、conductor の会話を工程の切れ目で切る(マイルストーン 26「17 トークン消費の削減」のまとまり 1)。確定仕様は [ndf-token-waits-and-context-cut.md](../../docs/specifications/ndf-token-waits-and-context-cut.md) | [milestone-26-token-waits/](milestone-26-token-waits/issue-829-830-requirements.md)(要求・設計・決定・計画の 4 本) |
## 計画と調査資料
diff --git a/issues/issue-829-830-design-decisions.md b/issues/old/milestone-26-token-waits/issue-829-830-design-decisions.md
similarity index 100%
rename from issues/issue-829-830-design-decisions.md
rename to issues/old/milestone-26-token-waits/issue-829-830-design-decisions.md
diff --git a/issues/issue-829-830-design.md b/issues/old/milestone-26-token-waits/issue-829-830-design.md
similarity index 100%
rename from issues/issue-829-830-design.md
rename to issues/old/milestone-26-token-waits/issue-829-830-design.md
diff --git a/issues/old/milestone-26-token-waits/issue-829-830-implementation-plan.md b/issues/old/milestone-26-token-waits/issue-829-830-implementation-plan.md
new file mode 100644
index 00000000..17fe9798
--- /dev/null
+++ b/issues/old/milestone-26-token-waits/issue-829-830-implementation-plan.md
@@ -0,0 +1,132 @@
+# #829 / #830: 待つ間の問い合わせをやめ、conductor の会話を工程の切れ目で切る — 実装計画
+
+## 関連リンク
+
+- 要求と受け入れ条件: [issue-829-830-requirements.md](issue-829-830-requirements.md)(AC1〜AC26)
+- 設計: [issue-829-830-design.md](issue-829-830-design.md)
+- 決定の記録: [issue-829-830-design-decisions.md](issue-829-830-design-decisions.md)(決定 1〜10)
+- 設計 Pull Request: https://github.com/devbasex/ai-plugins/pull/843 (マージ済み)
+- 課題: #829 / #830(親は #827、マイルストーン 26「17 トークン消費の削減」)
+
+## モード
+
+standard(hook の新設と複数の Skill 文書の変更。本番の系へ届く操作を含まず、配布は別の工程)。
+
+## 目的と非目的
+
+達成したい状態:
+
+- Claude Code で、前景の `sleep` の待ちと変わらないファイルの読み直しを hook が止め、代わりの待ち方を示す
+- 文脈が上限を超えた conductor が工程へ入る起動を 1 度止め、新しい会話で打つ 1 行を示す
+- 待ち方の規約と、新しい会話で状態を戻す手順が 1 か所ずつにある
+
+やらないこと:
+
+- **AC25 / AC26(効果の数値)はこの持ち場では確かめない。** 本番へ配布した後に `release-verification` で #827 の `measure.py` / `poll.py` / `extra.py` を回して確かめる(要求の前提 5)
+- #731 / #656 / #345 / #828 / #680 の範囲、supervisor / worker の文脈量の上限(#768 / #773)
+- Codex / Kiro / agy の hook の登録(決定 5)
+
+## 前提
+
+- 前提 1: 設計の「未確認」5 件のうち、hook の入力(`agent_id` の有無・`transcript_path` の指す先・記録の書き込みの時点)は Task 0 で実測して決める。実測できなければ設計の既定(`agent_id` が無く `/subagents/` を含まなければ conductor とみなす)で進める
+- 前提 2: 「Codex / Kiro の起動の書き方」は各ランタイムの README の記載に合わせる(Task 6)
+- 前提 3: 「通知の届き方」(AC11)と「背景の Bash の上限」は Task 7 の実機確認で決める
+
+## 受け入れ条件
+
+要求の AC1〜AC24 をこの Pull Request で満たす。AC11 と AC20 は実機の確認で、手順と結果を各 issue に残す。
+AC25 / AC26 は配布後(上の「やらないこと」)。条件ごとの検証手段は設計の「テスト設計」の表に従う。
+
+## 代替案と採否
+
+設計の決定 1〜10 のとおり。実装で新たに選ぶものは次の 1 つ。
+
+| 案 | 内容 | 採否 | 理由 |
+| --- | --- | --- | --- |
+| A | `sleep` の判定(引用・コメント・ヒアドキュメントの除去、`-c` / `eval` の中身の取り出し、ループの本体の対応)を hook の中の `python3` で書く | 採用 | bash の正規表現では入れ子の `do` / `done` の対応と引用の除去を読める形で書けない。`python3` は既存のスクリプト(`progress-record.sh` など)が既に使っている |
+| B | すべて bash と `jq` で書く | 不採用 | 上記。`python3` が無いときは判定を通す(AC9 の扱い)ため可用性は落ちない |
+
+## 修正対象
+
+- 新設: `plugins/ndf/scripts/token-guard.sh`、`plugins/ndf/scripts/lib/token_guard_sleep.py`(sleep の判定。案 A)、`plugins/ndf/scripts/lib/token-guard-stages.txt`、`plugins/ndf/scripts/tests/test_token_guard.py`、`plugins/ndf/skills/development-workflow/references/waiting.md`
+- 変更: `plugins/ndf/hooks/claude.json`、`development-workflow/SKILL.md`、`development-workflow/references/agent-layers.md`、`development-workflow/references/context-window.md`、`external-ai/references/cli-codex.md`、`external-ai/references/cli-agy.md`、`qa-security-scan/03-report-template.md`、`release/references/completion-check.md`、`plugins/ndf/README.md`
+
+## タスク分解
+
+### Task 0: hook の入力を実測する(未確認 1・2)
+
+- **変更内容:** 入力を書き出すだけの hook を `claude -p --settings` で一時的に登録し、本体とサブエージェントの PreToolUse の入力(`agent_id`・`transcript_path`)と、その時点で記録に呼び出しの assistant 行が書かれているかを見る。利用者の設定は書き換えない
+- **満たす受け入れ条件:** AC13 の判定方法の根拠
+- **進め方:** 調査(テスト駆動の対象外)
+- **結果(2026-09-23、Claude Code 2.1.280、`claude -p --settings` に入力を書き出す hook を登録):**
+ - サブエージェントの中の PreToolUse の入力には `agent_id` と `agent_type` が付く。本体の入力には付かない
+ - サブエージェントの `transcript_path` は**親の記録を指す**(`/subagents/` を含まない)。区別は `agent_id` で行う
+ - PreToolUse の時点で、その呼び出しを出した assistant 行はまだ記録に書かれていない。hook は 1 つ以上前の呼び出しの文脈量を読む(設計の既定どおり)
+
+### Task 1: sleep の判定
+
+- **対象ファイル:** `token-guard.sh`、`test_token_guard.py`
+- **変更内容:** `Bash` の入力で、背景でない・`-c` / `eval` の中身も含め・コメントと引用とヒアドキュメントを除いた残りで、コマンドの位置の `sleep <数>` がループの本体にあるか上限を超えれば拒否する
+- **満たす受け入れ条件:** AC5 / AC6 / AC8 / AC9 / AC10(sleep の分)
+- **進め方:** 設計の AC5 / AC6 の例を失敗するテストとして書く → 最小実装 → 整理
+
+### Task 2: 連続 Read の判定
+
+- **対象ファイル:** 同上
+- **変更内容:** `guards/` の解決(`wf_state_dir` と同じ順)、session ごとのロック、`read-.json` の控えの置き換え、7 日より古い控えの削除
+- **満たす受け入れ条件:** AC7 / AC8 / AC9 / AC10(Read の分)
+- **進め方:** テスト先行
+
+### Task 3: 文脈量の判定と工程 Skill の一覧
+
+- **対象ファイル:** `token-guard.sh`、`token-guard-stages.txt`、`test_token_guard.py`
+- **変更内容:** `Skill`(一覧にある工程 Skill)と `Agent` / `Task`(先頭語が持ち場の語彙)で、conductor の文脈量が上限を超えれば拒否し、印で次の同じ起動を 1 度通す
+- **満たす受け入れ条件:** AC12〜AC17、AC22(既定値)
+- **進め方:** テスト先行
+
+### Task 4: hook の登録
+
+- **対象ファイル:** `hooks/claude.json`
+- **変更内容:** PreToolUse に matcher `Bash|Read|Skill|Agent|Task` で `token-guard.sh` を足す。既存の `worktree-guard.sh` の登録と順序は変えない
+- **満たす受け入れ条件:** AC5 / AC7 / AC12 の実行経路、AC24
+- **進め方:** 登録の形を確かめるテスト → 変更 → `claude plugin validate .`
+
+### Task 5: 待ち方の規約
+
+- **対象ファイル:** `waiting.md`、`agent-layers.md`、external-ai の 2 文書、`qa-security-scan/03-report-template.md`、`release/references/completion-check.md`
+- **満たす受け入れ条件:** AC1〜AC4
+- **進め方:** 文書の検査を先に書く → 文書を書く
+
+### Task 6: 会話を切る規約と README
+
+- **対象ファイル:** `context-window.md`、`development-workflow/SKILL.md`、`plugins/ndf/README.md`
+- **満たす受け入れ条件:** AC18 / AC19 / AC21 / AC22 / AC23
+- **進め方:** 文書の検査を先に書く → 文書を書く
+
+### Task 7: 実機の確認
+
+- **変更内容:** AC11(サブエージェントが背景の処理を残して応答を終えたとき、完了通知で再開されるか)と AC20(1 行だけで新しい会話から戻せるか)を実機で確かめ、手順と結果を #829 / #830 に残す
+- **進め方:** 実機(テスト駆動の対象外)
+
+## 影響範囲
+
+- Claude Code の全層の Bash / Read / Skill / Agent の起動の前に hook が 1 本増える
+- Codex / Kiro / agy の配布物の hook は変わらない(AC24)
+
+## リスクと対処
+
+| リスク | 対処 |
+| --- | --- |
+| sleep の判定の誤検知で通常の Bash が止まる | タスクごとにテストを通す。通す例(AC6)を拒否の例と同数以上そろえ、環境変数で種類ごとに止められる(AC10) |
+| hook の失敗でツールが止まる | 判定の失敗は常に 0 で通す(AC9)。登録に `continueOnError: true` |
+| 実装が触る対象の構造 | 新設のスクリプトが中心で、既存の構造に手を入れない。実装の後の構造改善で足りる |
+
+## 切り戻し手順
+
+- `hooks/claude.json` の登録を 1 つ外せば hook は動かなくなる。利用者は環境変数(`NDF_SLEEP_GUARD=0` など)で種類ごとに止められる。データの移行は無い
+
+## 完了の定義
+
+- [ ] AC1〜AC24 を満たし、条件ごとに検証手段と結果が対応している(AC11 / AC20 は issue に記録)
+- [ ] `uv run --with pytest pytest scripts/tests plugins/ndf -q`、`python3 scripts/check-skill-frontmatter.py`、`claude plugin validate .` が終了コード 0
+- [ ] AC25 / AC26 は配布後の `release-verification` へ引き継ぐことを Pull Request の本文に書く
diff --git a/issues/issue-829-830-requirements.md b/issues/old/milestone-26-token-waits/issue-829-830-requirements.md
similarity index 100%
rename from issues/issue-829-830-requirements.md
rename to issues/old/milestone-26-token-waits/issue-829-830-requirements.md
diff --git a/plugins/ndf/README.md b/plugins/ndf/README.md
index e13d1e94..68910240 100644
--- a/plugins/ndf/README.md
+++ b/plugins/ndf/README.md
@@ -222,6 +222,27 @@ bash <プラグインのパス>/scripts/worktree-setup.sh init
手順は `/ndf:worktree` にあります。
+### 待ちの問い合わせと長い会話を止める(Claude Code だけ)
+
+`scripts/token-guard.sh` が PreToolUse の `Bash` / `Read` / `Skill` / `Agent` で動き、3 つを
+止めます。止めたときは、代わりの手段を理由の欄に出します。
+
+| 止めるもの | 止め方 | 上限 |
+| --- | --- | --- |
+| 前景の `sleep` の待ち(`while` / `until` のループの本体、または上限を超える秒数) | `NDF_SLEEP_GUARD=0` | `NDF_SLEEP_MAX_SEC`(既定 5) |
+| 変わらないファイルの同じ範囲を続けて読む Read | `NDF_READ_REPEAT_GUARD=0` | `NDF_READ_REPEAT_LIMIT`(既定 3) |
+| 文脈が上限を超えた conductor が工程へ入る起動(1 度だけ止め、新しい会話で打つ 1 行を示す) | `NDF_CONTEXT_GUARD=0` | `NDF_CONTEXT_LIMIT`(既定 200000) |
+
+| ランタイム | 待ち方 | 会話を切る |
+| --- | --- | --- |
+| Claude Code | hook + 規約 | hook + 引き継ぎの 1 行 |
+| Codex | 規約だけ | 引き継ぎの 1 行だけ |
+| Kiro CLI | 規約だけ | 引き継ぎの 1 行だけ |
+| agy | 規約だけ | 引き継ぎの 1 行だけ |
+
+規約は `skills/development-workflow/references/waiting.md`(待ち方)と
+`skills/development-workflow/references/context-window.md`(会話を切る)にあります。
+
### その他
Claude Code の SessionStart hook(`hooks/claude.json`)は上記に加えて次を行います。
diff --git a/plugins/ndf/hooks/claude.json b/plugins/ndf/hooks/claude.json
index f526d9e8..eb379eb4 100644
--- a/plugins/ndf/hooks/claude.json
+++ b/plugins/ndf/hooks/claude.json
@@ -13,6 +13,19 @@
"suppressOutput": false
}
]
+ },
+ {
+ "matcher": "Bash|Read|Skill|Agent|Task",
+ "hooks": [
+ {
+ "type": "command",
+ "command": "bash ${PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT}}/scripts/token-guard.sh",
+ "description": "NDF: stop polling waits and ask a long conductor to continue in a new session",
+ "timeout": 5,
+ "continueOnError": true,
+ "suppressOutput": false
+ }
+ ]
}
],
"SessionStart": [
diff --git a/plugins/ndf/scripts/lib/token-guard-stages.txt b/plugins/ndf/scripts/lib/token-guard-stages.txt
new file mode 100644
index 00000000..7666d9ff
--- /dev/null
+++ b/plugins/ndf/scripts/lib/token-guard-stages.txt
@@ -0,0 +1,16 @@
+# 文脈量の hook(token-guard.sh)が見る工程 Skill の一覧(#830)。1 行 1 名。
+# 正は docs/specifications/ndf-token-waits-and-context-cut.md の「工程 Skill の一覧」(context-window.md の 4 つの切れ目の直後の工程と入口)。
+# worktree など切れ目の内側の工程は載せない(理由は同じ仕様書の「文脈量の判定」)。
+implementation-plan
+document-drafting
+cross-refactoring
+cross-review
+pr-review
+quality-gates
+plan-to-spec
+merged
+layout-review
+release-verification
+retrospective
+development-workflow
+issue-plan-strategy
diff --git a/plugins/ndf/scripts/lib/token_guard_sleep.py b/plugins/ndf/scripts/lib/token_guard_sleep.py
new file mode 100644
index 00000000..cd4536b5
--- /dev/null
+++ b/plugins/ndf/scripts/lib/token_guard_sleep.py
@@ -0,0 +1,198 @@
+"""前景の `sleep` で待つ Bash を見分ける(#829)。`token-guard.sh` から呼ぶ。
+
+標準入力にコマンドの文字列を受け、第 1 引数に秒数の上限を受ける。拒否するなら終了コード 1、
+通すなら 0 で終わる。**読めないコマンドは通す**(hook の失敗でツールを止めない)。
+
+拒否するのは、コマンドの位置にある `sleep <引数>` が次のどちらかに当たるときだけである。
+`&` で終わる(バックグラウンドで動く)`sleep` は前景を待たせないため見ない。`&` の判定は
+sleep の引数の後ろから、sleep を含むリスト(`&&` / `||` / `|` でつながる範囲)の終わりまでを見る
+(`sleep 30 >/tmp/x &` も背景)。sleep を囲む `( )` / `{ }` / ループ / `if` があれば、その閉じの
+後ろの `&` まで見る(`(sleep 30) &`・`while ...; do sleep 1; done &` も背景)。リダイレクトの
+`>&` / `2>&1` / `&>` の `&` は背景と読まない。字句による近似で、`case` の囲みは数えない。
+
+- `while` / `until` のループの本体(`do` と対応する `done` の間)にある(秒数が変数でも止める)
+- 秒数が数で、上限を超える
+
+コメント・引用の中・ヒアドキュメントの本文は見ない。コマンドの位置にある `bash -c` / `sh -c` /
+`zsh -c` / `dash -c` / `eval` の実行される引数は、取り出して同じ規則で見る(`echo bash -c ...` の
+ような引数の中の語は見ない)。外側が `&` で背景になる形(`bash -c 'sleep 30' &`)は中身を見ない。`timeout 590` / `nohup` / `env` などの前置きの後ろもコマンドの位置とする。
+"""
+from __future__ import annotations
+
+import re
+import shlex
+import sys
+
+SHELLS = {"bash", "sh", "zsh", "dash"}
+# コマンドの位置を作る語。この後ろの語はコマンドとして読む
+OPENERS = {"do", "then", "else", "elif", "if", "while", "until", "{", "!", "time"}
+UNIT = {"": 1, "s": 1, "m": 60, "h": 3600, "d": 86400}
+# 後ろの語をコマンドとして実行する前置き。オプションと数の引数(`timeout 590` / `nice -n 10`)を読み飛ばす
+WRAPPERS = {"exec", "command", "nohup", "env", "nice", "timeout"}
+# 引数を取る shell のオプション(`-o` / `+O` と、末尾が o / O の結合形 `-euo`)。引数を読み飛ばして `-c` を探す
+SHELL_OPT_WITH_ARG = re.compile(r"[-+][A-Za-bd-z]*[oO]")
+ASSIGN = re.compile(r"[A-Za-z_][A-Za-z0-9_]*(\[[^]]*\])?\+?=")
+# 複合コマンドを開く語・閉じる語(コマンドの位置にあるときだけ)。`( )` は区切りの文字で数える
+GROUP_OPEN = {"{", "while", "until", "for", "select", "if"}
+GROUP_CLOSE = {"}", "done", "fi"}
+HEREDOC = re.compile(r"<<(-?)\s*(['\"]?)([A-Za-z_][A-Za-z0-9_]*)\2")
+
+
+def strip_heredocs(text: str) -> str:
+ """ヒアドキュメントの本文を取り除く。開始の行は残す。"""
+ out, pending = [], []
+ for line in text.split("\n"):
+ if pending:
+ dash, word = pending[0]
+ if (line.lstrip("\t") if dash else line) == word:
+ pending.pop(0)
+ continue
+ out.append(line)
+ for m in HEREDOC.finditer(line.replace("<<<", " ")):
+ pending.append((m.group(1) == "-", m.group(3)))
+ return "\n".join(out)
+
+
+def tokens(text: str) -> list[str]:
+ lex = shlex.shlex(text, posix=True, punctuation_chars=";&|()\n")
+ lex.whitespace = " \t\r"
+ lex.commenters = "#"
+ lex.wordchars += "$:@%+,[]{}!^=-/.~*?"
+ return list(lex)
+
+
+def seconds(word: str) -> float | None:
+ m = re.fullmatch(r"(\d+(?:\.\d+)?)([smhd]?)", word)
+ return float(m.group(1)) * UNIT[m.group(2)] if m else None
+
+
+def is_separator(tok: str) -> bool:
+ return bool(tok) and all(c in ";&|()\n" for c in tok)
+
+
+def is_background(toks: list[str], j: int, enclosing: int) -> bool:
+ """toks[j] から sleep を含むリストの終わりまでに、背景実行の `&` があるか。
+
+ `enclosing` は sleep を囲む複合コマンド(`( )` / `{ }` / ループ / `if`)の数。囲みの中では
+ `;` で終わっても囲みの閉じまで進み、閉じの後ろの `&` を見る(`(sleep 30) &` も背景)。
+ """
+ depth, ended, cmd_pos = 0, False, False
+ while j < len(toks):
+ tok = toks[j]
+ if tok in ("&&", "||", "|", "|&"):
+ cmd_pos = True
+ elif is_separator(tok):
+ cmd_pos = True
+ redirect = tok == "&" and (toks[j - 1] in (">", "<") or (j + 1 < len(toks) and toks[j + 1] == ">"))
+ for c in tok:
+ if c == "(":
+ depth += 1
+ elif c == ")":
+ if depth:
+ depth -= 1
+ elif enclosing:
+ enclosing, ended = enclosing - 1, False
+ else:
+ return False
+ elif depth:
+ continue
+ elif c in ";\n":
+ if not enclosing:
+ return False
+ ended = True
+ elif c == "&" and not redirect:
+ if not ended:
+ return True
+ ended = True
+ else:
+ if cmd_pos and tok in GROUP_OPEN:
+ depth += 1
+ elif cmd_pos and tok in GROUP_CLOSE:
+ if depth:
+ depth -= 1
+ elif enclosing:
+ enclosing, ended = enclosing - 1, False
+ else:
+ return False
+ cmd_pos = tok in OPENERS
+ j += 1
+ return False
+
+
+def should_deny(text: str, limit: float, in_loop: bool = False, depth: int = 0) -> bool:
+ if depth > 5:
+ return False
+ toks = tokens(strip_heredocs(text))
+ # 各要素は "cond"(while/until の条件)/ "body"(while/until の本体)/ "for" / "forbody"
+ stack: list[str] = []
+ groups = 0 # いまの位置を囲む複合コマンドの数
+ cmd_pos = True
+ i = 0
+ while i < len(toks):
+ tok = toks[i]
+ at_cmd = cmd_pos
+ looping = in_loop or "body" in stack
+ if is_separator(tok):
+ groups = max(0, groups + tok.count("(") - tok.count(")"))
+ cmd_pos = True
+ i += 1
+ continue
+ if cmd_pos:
+ if tok in GROUP_OPEN:
+ groups += 1
+ elif tok in GROUP_CLOSE:
+ groups = max(0, groups - 1)
+ if tok in ("while", "until"):
+ stack.append("cond")
+ elif tok in ("for", "select"):
+ stack.append("for")
+ elif tok == "do" and stack:
+ stack[-1] = "body" if stack[-1] == "cond" else "forbody"
+ elif tok == "done" and stack:
+ stack.pop()
+ elif tok == "sleep" and i + 1 < len(toks) and not is_separator(toks[i + 1]):
+ sec = seconds(toks[i + 1])
+ if (looping or (sec is not None and sec > limit)) and not is_background(toks, i + 2, groups):
+ return True
+ elif tok in WRAPPERS:
+ while i + 1 < len(toks) and (toks[i + 1][:1] == "-" or seconds(toks[i + 1]) is not None):
+ i += 1
+ i += 1
+ continue
+ # 先頭の代入語(`X=1 sleep 30`)の後ろもコマンドの位置のまま
+ cmd_pos = tok in OPENERS or bool(ASSIGN.match(tok))
+ # 実行される引数を取り出して同じ規則で見る
+ if at_cmd and tok in SHELLS:
+ j = i + 1
+ while j < len(toks) and toks[j][:1] in "-+" and not is_separator(toks[j]):
+ if SHELL_OPT_WITH_ARG.fullmatch(toks[j]):
+ j += 2
+ continue
+ if toks[j].startswith("-") and "c" in toks[j].lstrip("-") and not toks[j].startswith("--"):
+ # `bash -c 'sleep 30' &` は外側ごと背景で動くので中身を見ない
+ if (j + 1 < len(toks) and not is_background(toks, j + 2, groups)
+ and should_deny(toks[j + 1], limit, looping, depth + 1)):
+ return True
+ break
+ j += 1
+ elif at_cmd and tok == "eval":
+ j, words = i + 1, []
+ while j < len(toks) and not is_separator(toks[j]):
+ words.append(toks[j])
+ j += 1
+ if not is_background(toks, j, groups) and should_deny(" ".join(words), limit, looping, depth + 1):
+ return True
+ i += 1
+ return False
+
+
+def main() -> int:
+ try:
+ limit = float(sys.argv[1]) if len(sys.argv) > 1 else 5.0
+ return 1 if should_deny(sys.stdin.read(), limit) else 0
+ except Exception: # 読めないコマンドは通す
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/plugins/ndf/scripts/tests/test_token_guard.py b/plugins/ndf/scripts/tests/test_token_guard.py
new file mode 100644
index 00000000..57f1a907
--- /dev/null
+++ b/plugins/ndf/scripts/tests/test_token_guard.py
@@ -0,0 +1,727 @@
+"""待ちの hook と文脈量の hook(#829 / #830)。
+
+`token-guard.sh` は Claude Code の PreToolUse で動き、3 つを判定する。
+
+- 前景の `sleep` で待つ Bash(ループの本体にあるか、秒数が上限を超える)
+- 変わらないファイルの同じ範囲を続けて読み直す Read
+- 文脈が上限を超えた conductor が工程へ入る起動(工程 Skill・持ち場の supervisor)
+
+**判定が失敗してもツールを止めない。** 拒否は `permissionDecision: deny` で返し、終了コードは
+常に 0 にする。
+"""
+from __future__ import annotations
+
+import json
+import os
+import pathlib
+import shutil
+import subprocess
+import threading
+
+import pytest
+
+ROOT = pathlib.Path(__file__).resolve().parents[2]
+SCRIPT = ROOT / "scripts" / "token-guard.sh"
+STAGES = ROOT / "scripts" / "lib" / "token-guard-stages.txt"
+WF_DOCS = ROOT / "skills" / "development-workflow"
+WAITING = WF_DOCS / "references" / "waiting.md"
+LAYERS = WF_DOCS / "references" / "agent-layers.md"
+CONTEXT = WF_DOCS / "references" / "context-window.md"
+WF_SKILL = WF_DOCS / "SKILL.md"
+README = ROOT / "README.md"
+HOOKS = ROOT / "hooks" / "claude.json"
+MANIFESTS = ROOT / "manifests"
+
+STAGE_SKILLS = {
+ "implementation-plan", "document-drafting",
+ "cross-refactoring", "cross-review", "pr-review", "quality-gates",
+ "plan-to-spec", "merged",
+ "layout-review", "release-verification", "retrospective",
+ "development-workflow", "issue-plan-strategy",
+}
+
+
+@pytest.fixture()
+def state(tmp_path, monkeypatch):
+ """状態の置き場所をテストごとに分ける。"""
+ base = tmp_path / "plugin-data"
+ return base
+
+
+def run(payload, state_dir, env=None, raw=None):
+ e = {k: v for k, v in os.environ.items() if not k.startswith("NDF_")}
+ e.pop("CLAUDE_PLUGIN_DATA", None)
+ e["CLAUDE_PLUGIN_DATA"] = str(state_dir)
+ if env:
+ e.update(env)
+ data = raw if raw is not None else json.dumps(payload)
+ return subprocess.run(["bash", str(SCRIPT)], input=data, capture_output=True,
+ text=True, env=e, timeout=20)
+
+
+def denied(proc):
+ assert proc.returncode == 0, proc.stderr
+ if not proc.stdout.strip():
+ return None
+ out = json.loads(proc.stdout)
+ spec = out["hookSpecificOutput"]
+ assert spec["hookEventName"] == "PreToolUse"
+ assert spec["permissionDecision"] == "deny"
+ return spec["permissionDecisionReason"]
+
+
+def bash(cmd, **extra):
+ ti = {"command": cmd}
+ ti.update(extra)
+ return {"tool_name": "Bash", "tool_input": ti, "session_id": "s1"}
+
+
+# ---------------------------------------------------------------- sleep(AC5 / AC6)
+
+DENY_SLEEP = [
+ "sleep 30 && tail -5 x.log",
+ "while ! test -s x; do sleep 5; done",
+ "until [ -s f ]; do sleep 1; done",
+ "bash -c 'sleep 30'",
+ 'timeout 590 bash -c "until [ -s f ]; do sleep 5; done"',
+ "sh -c 'until test -s x; do sleep 1; done'",
+ "zsh -c 'sleep 30'",
+ "zsh -c 'until [ -s f ]; do sleep 5; done'",
+ "dash -c 'sleep 30'",
+ "dash -c 'until [ -s f ]; do sleep 5; done'",
+ "for i in 1 2; do sleep 10; done",
+ "select x in a b; do sleep 10; done",
+ "while a; do while b; do sleep 1; done; done",
+ "while a; do\n for i in 1 2; do sleep 1; done\ndone",
+ 'eval "sleep 30"',
+ "echo start\nsleep 60\necho end",
+ "sleep 1m",
+ "X=1 sleep 30",
+ "bash -O extglob -c 'sleep 30'",
+ "until [ -s f ]; do sleep $X; done",
+ "until [ -s f ]; do sleep $(cat n); done",
+ "bash -euo pipefail -c 'sleep 100'",
+ "bash -eo pipefail -c 'sleep 100'",
+ "sleep 30 2>&1 | tee x",
+ "sleep 30 &>/dev/null",
+ "sleep 30 >&2",
+ "nohup sleep 30",
+ "echo a; bash -c 'sleep 30'",
+ # 囲みの複合コマンドが前景のまま待つ形
+ "(sleep 30)",
+ "{ sleep 30; }",
+ "(sleep 30) && echo",
+ "while test ! -s f; do sleep 1; done; echo x",
+ "{ sleep 30; (x) & }",
+ "(sleep 30);(x) &",
+ # 外側の & が別のコマンドのもの
+ "bash -c 'sleep 30'; x &",
+ "eval 'sleep 30'; x &",
+]
+
+ALLOW_SLEEP = [
+ "while read l; do echo \"$l\"; done < f; sleep 1",
+ "python3 -m http.server & sleep 2",
+ "for p in 1 2; do gh api x; sleep 1; done",
+ "for i in 1 2; do sleep 3; done",
+ "select x in a b; do sleep 3; done",
+ "echo sleep 30",
+ 'git commit -m "sleep 60"',
+ "# sleep 30",
+ "ls # sleep 30",
+ 'echo "while x; do sleep 9; done"',
+ "cat <<'EOF'\nsleep 60\nwhile true; do sleep 1; done\nEOF",
+ "cat <<-EOF > f\n\tsleep 60\n\tEOF\necho ok",
+ "sleep 5",
+ "sleep $X",
+ "ls -la",
+ "while read l; do echo $l; done < f",
+ "sleep 30 & echo done",
+ "echo X=1 sleep 30",
+ "sleep 30 >/tmp/x &",
+ "sleep 100 >/dev/null &",
+ "sleep 30 >>x 2>&1 & echo started",
+ "echo bash -c 'sleep 30'",
+ "printf '%s' eval sleep 30",
+ # sleep を囲む複合コマンドや and-or リスト全体が末尾の & で背景になる形
+ "(sleep 30) &",
+ "(sleep 30)&",
+ "{ sleep 30; } &",
+ "while test ! -s f; do sleep 1; done &",
+ "if true; then sleep 30; fi &",
+ "( (sleep 30) ) & echo started",
+ "sleep 30 && echo x &",
+ # bash -c / eval の外側が背景になる形
+ "bash -c 'sleep 30' &",
+ "bash -c 'sleep 30' >/tmp/x 2>&1 & echo started",
+ "eval 'sleep 30' &",
+ "(bash -c 'sleep 30') &",
+]
+
+
+@pytest.mark.parametrize("cmd", DENY_SLEEP)
+def test_sleep_denied(cmd, state):
+ reason = denied(run(bash(cmd), state))
+ assert reason, cmd
+ assert "run_in_background" in reason
+ assert "waiting.md" in reason
+ assert "Monitor" in reason
+
+
+@pytest.mark.parametrize("cmd", ALLOW_SLEEP)
+def test_sleep_allowed(cmd, state):
+ assert denied(run(bash(cmd), state)) is None, cmd
+
+
+def test_sleep_in_here_string_is_allowed(state):
+ # 現状固定: here-string の内容はヒアドキュメント本文ではなく、sleep 判定の対象外になる。
+ assert denied(run(bash('grep x <<< "sleep 60"'), state)) is None
+
+
+@pytest.mark.parametrize("cmd", ["sleep 0.1h", "sleep 1d"])
+def test_sleep_denied_on_hour_and_day_units(cmd, state):
+ # 現状固定: DENY_SLEEP は 'sleep 1m'(分)だけを固定していたが、時間・日の単位と
+ # 小数の秒換算の経路は固定されていなかった。既定の上限 5 秒に対し 'sleep 0.1h' は
+ # 0.1*3600=360 秒、'sleep 1d' は 86400 秒へ換算され、いずれも拒否される(deny)。
+ # 拒否理由は waiting.md への部分一致だけで確かめ、文言全体には結合しない。
+ reason = denied(run(bash(cmd), state))
+ assert reason, cmd
+ assert "waiting.md" in reason
+
+
+def test_background_bash_is_allowed(state):
+ p = bash("sleep 30 && tail x", run_in_background=True)
+ assert denied(run(p, state)) is None
+
+
+def test_monitor_is_not_judged(state):
+ p = {"tool_name": "Monitor", "tool_input": {"command": "while true; do sleep 1; done"}}
+ assert denied(run(p, state)) is None
+
+
+def test_sleep_guard_env(state):
+ assert denied(run(bash("sleep 30"), state, {"NDF_SLEEP_GUARD": "0"})) is None
+ assert denied(run(bash("sleep 10"), state, {"NDF_SLEEP_MAX_SEC": "30"})) is None
+ assert denied(run(bash("sleep 40"), state, {"NDF_SLEEP_MAX_SEC": "30"}))
+
+
+# ---------------------------------------------------------------- 連続 Read(AC7)
+
+def read(path, session="s1", **extra):
+ ti = {"file_path": str(path)}
+ ti.update(extra)
+ return {"tool_name": "Read", "tool_input": ti, "session_id": session}
+
+
+def test_repeat_read_denied_on_third(tmp_path, state):
+ f = tmp_path / "out.txt"
+ f.write_text("")
+ assert denied(run(read(f), state)) is None
+ assert denied(run(read(f), state)) is None
+ reason = denied(run(read(f), state))
+ assert reason and "run_in_background" in reason and "waiting.md" in reason
+ # 拒否の後も同じなら拒否が続く
+ assert denied(run(read(f), state))
+
+
+def test_repeat_read_denied_on_third_when_file_is_missing(tmp_path, state):
+ # 現状固定: 存在しないファイルも file_stat の sentinel 値で同じ状態として数える。
+ missing = tmp_path / "missing.txt"
+ assert denied(run(read(missing), state)) is None
+ assert denied(run(read(missing), state)) is None
+ reason = denied(run(read(missing), state))
+ assert reason and "3 回" in reason
+
+
+def test_repeat_read_resets_when_file_changes(tmp_path, state):
+ f = tmp_path / "out.txt"
+ f.write_text("a")
+ run(read(f), state)
+ run(read(f), state)
+ f.write_text("ab")
+ assert denied(run(read(f), state)) is None
+ assert denied(run(read(f), state)) is None
+ assert denied(run(read(f), state))
+
+
+def test_repeat_read_resets_on_other_range(tmp_path, state):
+ f = tmp_path / "out.txt"
+ f.write_text("a\nb\n")
+ run(read(f), state)
+ run(read(f), state)
+ assert denied(run(read(f, offset=2), state)) is None
+ assert denied(run(read(f), state)) is None
+
+
+def test_repeat_read_resets_on_replaced_file(tmp_path, state):
+ f = tmp_path / "out.txt"
+ f.write_text("aaaa")
+ run(read(f), state)
+ run(read(f), state)
+ g = tmp_path / "new.txt"
+ g.write_text("bbbb")
+ os.utime(g, ns=(f.stat().st_atime_ns, f.stat().st_mtime_ns))
+ os.replace(g, f)
+ assert denied(run(read(f), state)) is None
+
+
+def test_repeat_read_recovers_from_broken_state(tmp_path, state):
+ # 現状固定: 既存の read 状態 JSON が壊れているとき、読み直し判定は 0 から数え直す。
+ # 最初の Read は通り、状態は有効な JSON(count=1)へ置き換わる。以後は同じ範囲を
+ # 続けて読むと現在の上限(既定 3)回で拒否される。
+ f = tmp_path / "out.txt"
+ f.write_text("")
+ guards = state / "guards"
+ guards.mkdir(parents=True)
+ broken = guards / "read-s1.json"
+ broken.write_text("{not json")
+ assert denied(run(read(f), state)) is None
+ saved = json.loads(broken.read_text())
+ assert saved["count"] == 1
+ assert denied(run(read(f), state)) is None
+ reason = denied(run(read(f), state))
+ assert reason and "3 回" in reason
+
+
+def test_repeat_read_is_per_session(tmp_path, state):
+ f = tmp_path / "out.txt"
+ f.write_text("")
+ run(read(f), state)
+ run(read(f), state)
+ assert denied(run(read(f, session="s2"), state)) is None
+
+
+def test_repeat_read_env(tmp_path, state):
+ f = tmp_path / "out.txt"
+ f.write_text("")
+ env = {"NDF_READ_REPEAT_LIMIT": "2"}
+ assert denied(run(read(f), state, env)) is None
+ assert denied(run(read(f), state, env))
+ g = tmp_path / "g.txt"
+ g.write_text("")
+ off = {"NDF_READ_REPEAT_GUARD": "0"}
+ for _ in range(4):
+ assert denied(run(read(g), state, off)) is None
+
+
+def test_parallel_reads_do_not_lose_updates(tmp_path, state):
+ f = tmp_path / "out.txt"
+ f.write_text("")
+ env = {"NDF_READ_REPEAT_LIMIT": "10"}
+ run(read(f), state, env)
+ threads = [threading.Thread(target=run, args=(read(f), state, env)) for _ in range(2)]
+ for t in threads:
+ t.start()
+ for t in threads:
+ t.join()
+ saved = json.loads((state / "guards" / "read-s1.json").read_text())
+ assert saved["count"] == 3
+
+
+# ---------------------------------------------------------------- 可用性(AC9)
+
+def test_broken_json_passes(state):
+ p = run(None, state, raw="{not json")
+ assert p.returncode == 0 and p.stdout == ""
+
+
+def test_without_jq_passes(tmp_path, state):
+ bindir = tmp_path / "bin"
+ bindir.mkdir()
+ for tool in ("bash", "cat", "tail", "stat", "mkdir", "date", "mv", "rm", "find", "python3"):
+ src = shutil.which(tool)
+ if src:
+ (bindir / tool).symlink_to(src)
+ p = run(bash("sleep 30"), state, {"PATH": str(bindir)})
+ assert p.returncode == 0 and p.stdout == ""
+
+
+def test_unwritable_state_skips_read_but_keeps_sleep(tmp_path):
+ ro = tmp_path / "ro"
+ ro.mkdir()
+ ro.chmod(0o500)
+ try:
+ env = {"CLAUDE_PLUGIN_DATA": "", "XDG_STATE_HOME": str(ro), "HOME": str(ro),
+ "TMPDIR": str(ro)}
+ f = tmp_path / "x.txt"
+ f.write_text("")
+ for _ in range(4):
+ assert denied(run(read(f), ro / "none", env)) is None
+ assert denied(run(bash("sleep 30"), ro / "none", env))
+ finally:
+ ro.chmod(0o700)
+
+
+def test_lock_held_passes(tmp_path, state):
+ f = tmp_path / "x.txt"
+ f.write_text("")
+ guards = state / "guards"
+ guards.mkdir(parents=True)
+ lock = guards / "s1.lock"
+ lock.mkdir()
+ (lock / "held").write_text("")
+ (lock / "pid").write_text(str(os.getpid()))
+ (lock / "token").write_text("t")
+ for _ in range(4):
+ assert denied(run(read(f), state)) is None
+
+
+@pytest.mark.parametrize("env,expect", [
+ ({"CLAUDE_PLUGIN_DATA": "{d}/pd"}, "{d}/pd/guards"),
+ ({"CLAUDE_PLUGIN_DATA": "", "XDG_STATE_HOME": "{d}/xdg"}, "{d}/xdg/ndf/guards"),
+ ({"CLAUDE_PLUGIN_DATA": "", "XDG_STATE_HOME": "", "HOME": "{d}/home"},
+ "{d}/home/.local/state/ndf/guards"),
+ ({"CLAUDE_PLUGIN_DATA": "", "XDG_STATE_HOME": "", "HOME": "", "TMPDIR": "{d}/tmp"},
+ "{d}/tmp/ndf-guards"),
+])
+def test_guards_dir_follows_wf_state_dir(tmp_path, env, expect):
+ env = {k: v.format(d=tmp_path) for k, v in env.items()}
+ (tmp_path / "tmp").mkdir()
+ f = tmp_path / "x.txt"
+ f.write_text("")
+ e = {k: v for k, v in os.environ.items() if not k.startswith("NDF_")}
+ e.update(env)
+ subprocess.run(["bash", str(SCRIPT)], input=json.dumps(read(f)), text=True,
+ capture_output=True, env=e, check=True)
+ assert (pathlib.Path(expect.format(d=tmp_path)) / "read-s1.json").is_file()
+ wf = subprocess.run(
+ ["bash", "-c", f". '{WF_DOCS}/scripts/lib/workflow-common.sh'; wf_state_dir"],
+ text=True, capture_output=True, env=e).stdout.strip()
+ assert pathlib.Path(wf).parent == pathlib.Path(expect.format(d=tmp_path)).parent
+
+
+# ---------------------------------------------------------------- 文脈量(AC12〜AC16)
+
+def transcript(tmp_path, total, name="t.jsonl", usage=True):
+ path = tmp_path / name
+ path.parent.mkdir(parents=True, exist_ok=True)
+ lines = [{"type": "user", "message": {"content": "x"}}]
+ msg = {"role": "assistant", "content": []}
+ if usage:
+ msg["usage"] = {"input_tokens": 10, "cache_read_input_tokens": total - 110,
+ "cache_creation_input_tokens": 100, "output_tokens": 5}
+ lines.append({"type": "assistant", "message": msg})
+ lines.append({"type": "attachment"})
+ path.write_text("\n".join(json.dumps(x) for x in lines) + "\n")
+ return path
+
+
+def transcript_multi(tmp_path, totals, name="t.jsonl"):
+ """複数の assistant usage を順に持つ transcript。最後の usage が判定に使われる。"""
+ path = tmp_path / name
+ path.parent.mkdir(parents=True, exist_ok=True)
+ lines = [{"type": "user", "message": {"content": "x"}}]
+ for total in totals:
+ lines.append({"type": "assistant", "message": {
+ "role": "assistant", "content": [],
+ "usage": {"input_tokens": 10, "cache_read_input_tokens": total - 110,
+ "cache_creation_input_tokens": 100, "output_tokens": 5}}})
+ lines.append({"type": "attachment"})
+ path.write_text("\n".join(json.dumps(x) for x in lines) + "\n")
+ return path
+
+
+def skill(tp, name="ndf:implementation-plan", args="#829", session="s1", **extra):
+ p = {"tool_name": "Skill", "tool_input": {"skill": name, "args": args},
+ "session_id": session, "transcript_path": str(tp)}
+ p.update(extra)
+ return p
+
+
+def agent(tp, desc="設計: #829 #830", session="s1", tool="Agent", **extra):
+ p = {"tool_name": tool, "tool_input": {"description": desc, "prompt": "x"},
+ "session_id": session, "transcript_path": str(tp)}
+ p.update(extra)
+ return p
+
+
+def test_context_over_limit_denies_stage_skill(tmp_path, state):
+ tp = transcript(tmp_path, 250_000)
+ reason = denied(run(skill(tp), state))
+ assert reason and "/ndf:development-workflow #829" in reason
+ assert "context-window.md" in reason
+ assert "250000" in reason or "250,000" in reason
+
+
+def test_context_over_limit_denies_supervisor(tmp_path, state):
+ tp = transcript(tmp_path, 250_000)
+ reason = denied(run(agent(tp), state))
+ assert reason and "/ndf:development-workflow #829 #830" in reason
+ assert "/goal" in reason
+ assert denied(run(agent(tp, desc="実装: #1", session="s9", tool="Task"), state))
+
+
+def test_context_issue_placeholder(tmp_path, state):
+ tp = transcript(tmp_path, 250_000)
+ reason = denied(run(skill(tp, args=""), state))
+ assert "/ndf:development-workflow <課題番号>" in reason
+
+
+def test_context_within_limit_passes(tmp_path, state):
+ tp = transcript(tmp_path, 150_000)
+ assert denied(run(skill(tp), state)) is None
+
+
+def test_context_uses_last_assistant_usage(tmp_path, state):
+ # 現状固定: assistant の usage が複数あるとき、最後の値で上限判定する。
+ # 上限超過の後に上限以内が来れば通り、順序を逆にすると拒否される。
+ over_then_under = transcript_multi(tmp_path, [250_000, 150_000], name="ou.jsonl")
+ assert denied(run(skill(over_then_under, session="sou"), state)) is None
+ under_then_over = transcript_multi(tmp_path, [150_000, 250_000], name="uo.jsonl")
+ assert denied(run(skill(under_then_over, session="suo"), state))
+
+
+def test_context_only_reads_last_200_lines(tmp_path, state):
+ # 現状固定: 上限超過の usage が末尾 200 行から外れると通り、範囲内なら拒否される。
+ outside_tail = transcript(tmp_path, 250_000, name="outside.jsonl")
+ with outside_tail.open("a") as fh:
+ for _ in range(201):
+ fh.write(json.dumps({"type": "attachment"}) + "\n")
+ assert denied(run(skill(outside_tail, session="sout"), state)) is None
+
+ inside_tail = transcript(tmp_path, 250_000, name="inside.jsonl")
+ with inside_tail.open("a") as fh:
+ for _ in range(198):
+ fh.write(json.dumps({"type": "attachment"}) + "\n")
+ assert denied(run(skill(inside_tail, session="sin"), state))
+
+
+def test_context_malformed_transcript_passes(tmp_path, state):
+ # 現状固定: usage が上限超過でも、壊れた JSON 行がある記録は読めず fail-open する。
+ tp = transcript(tmp_path, 250_000)
+ with tp.open("a") as fh:
+ fh.write("{not json\n")
+
+ proc = run(skill(tp), state)
+ assert proc.returncode == 0
+ assert proc.stdout == ""
+
+
+def test_context_subagent_passes(tmp_path, state):
+ tp = transcript(tmp_path, 250_000)
+ assert denied(run(skill(tp, agent_id="a1"), state)) is None
+ assert denied(run(agent(tp, agent_id="a1"), state)) is None
+ sub = transcript(tmp_path, 250_000, name="sess/subagents/agent-1.jsonl")
+ assert denied(run(skill(sub), state)) is None
+ assert denied(run(agent(sub), state)) is None
+
+
+def test_context_non_stage_passes(tmp_path, state):
+ tp = transcript(tmp_path, 250_000)
+ for name in ("ndf:markdown-writing", "ndf:worktree", "ndf:progress-tracking",
+ "ndf:out-of-scope"):
+ assert denied(run(skill(tp, name=name), state)) is None, name
+ assert denied(run(agent(tp, desc="調査: 既存の規約"), state)) is None
+ assert denied(run(agent(tp, desc="何かの説明"), state)) is None
+
+
+def test_context_once_then_pass_same_key(tmp_path, state):
+ tp = transcript(tmp_path, 250_000)
+ f = tmp_path / "x.txt"
+ f.write_text("")
+ assert denied(run(skill(tp), state))
+ run(bash("ls"), state)
+ run(read(f), state)
+ run(skill(tp, name="ndf:markdown-writing"), state)
+ assert denied(run(skill(tp), state)) is None
+ assert denied(run(skill(tp, name="ndf:merged"), state))
+
+
+def test_context_other_key_replaces_mark(tmp_path, state):
+ tp = transcript(tmp_path, 250_000)
+ assert denied(run(skill(tp), state))
+ assert denied(run(skill(tp, args="#830"), state))
+ assert denied(run(skill(tp, args="#830"), state)) is None
+
+
+def test_context_agent_once_then_pass(tmp_path, state):
+ tp = transcript(tmp_path, 250_000)
+ assert denied(run(agent(tp, desc="設計: #829"), state))
+ assert denied(run(agent(tp, desc="設計: #829"), state)) is None
+ assert denied(run(agent(tp, desc="実装: #829"), state))
+
+
+@pytest.mark.parametrize("desc", ["設計: v10.16.1 のリリース作業", "実装: リリース 2.0.3", "設計: 2026-09-23 の作業"])
+def test_context_version_is_not_issue_number(tmp_path, state, desc):
+ # 版数・小数を課題番号と読まない。番号が無ければ <課題番号> へ落ちる
+ tp = transcript(tmp_path, 250_000)
+ reason = denied(run(agent(tp, desc=desc, session="sver" + desc[:1]), state))
+ assert reason and "/ndf:development-workflow <課題番号>" in reason
+
+
+@pytest.mark.parametrize("desc", ["検査: 829", "取り込み: 829", "仕上げ: 829"])
+def test_context_agent_bare_issue_number_is_normalized(tmp_path, state, desc):
+ # 現状固定: description の課題番号が # を付けない裸の番号でも、案内は
+ # sed 's/^/#/' で # 付きへ整えられる。各入力は終了コード 0 の deny になり、
+ # 案内は /ndf:development-workflow #829 を示す(session を分けて 1 回目で拒否)。
+ tp = transcript(tmp_path, 250_000)
+ session = "sbare" + desc[:1]
+ reason = denied(run(agent(tp, desc=desc, session=session), state))
+ assert reason and "/ndf:development-workflow #829" in reason
+
+
+def test_context_date_is_not_issue_number(tmp_path, state):
+ # ハイフン区切りの日付を #2026 #09 #23 と読まず、# 付きの番号だけを示す
+ tp = transcript(tmp_path, 250_000)
+ reason = denied(run(agent(tp, desc="設計: 2026-09-23 の作業 #844", session="sdate"), state))
+ assert reason and "/ndf:development-workflow #844(" in reason
+
+
+@pytest.mark.parametrize("desc, expect", [
+ ("設計: v10.16.1 のリリース #844", "#844("),
+ ("設計: 2026-09-23 の作業 v10.16.1", "<課題番号>("),
+ ("検査: #829-830", "#829 #830("),
+ ("設計: 829-830 の作業", "#829 #830("),
+ ("実装: 2026-09-23 に #829-830 を v10.16.1 へ", "#829 #830("),
+])
+def test_context_issue_extraction(tmp_path, state, desc, expect):
+ # 版数・日付を課題番号と読まず、範囲は両端の番号として案内する
+ tp = transcript(tmp_path, 250_000)
+ reason = denied(run(agent(tp, desc=desc, session="sx" + str(abs(hash(desc)))), state))
+ assert reason and "/ndf:development-workflow " + expect in reason, reason
+
+
+def test_context_guard_env(tmp_path, state):
+ tp = transcript(tmp_path, 250_000)
+ assert denied(run(skill(tp), state, {"NDF_CONTEXT_GUARD": "0"})) is None
+ assert denied(run(skill(tp), state, {"NDF_CONTEXT_LIMIT": "300000"})) is None
+
+
+def test_context_unreadable_passes(tmp_path, state):
+ assert denied(run(skill(tmp_path / "missing.jsonl"), state)) is None
+ tp = transcript(tmp_path, 250_000, usage=False)
+ assert denied(run(skill(tp), state)) is None
+ p = skill(tp)
+ del p["transcript_path"]
+ assert denied(run(p, state)) is None
+
+
+def test_context_parallel_second_call_passes_once(tmp_path, state):
+ tp = transcript(tmp_path, 250_000)
+ assert denied(run(skill(tp), state))
+ results = []
+
+ def go():
+ results.append(denied(run(skill(tp), state)))
+
+ threads = [threading.Thread(target=go) for _ in range(2)]
+ for t in threads:
+ t.start()
+ for t in threads:
+ t.join()
+ assert sum(1 for r in results if r is None) == 1
+
+
+def test_context_large_transcript_is_fast(tmp_path, state):
+ import time
+ tp = tmp_path / "big.jsonl"
+ line = json.dumps({"type": "user", "message": {"content": "y" * 1000}}) + "\n"
+ with tp.open("w") as fh:
+ for _ in range(50_000):
+ fh.write(line)
+ transcript_tail = transcript(tmp_path, 250_000, name="tail.jsonl").read_text()
+ with tp.open("a") as fh:
+ fh.write(transcript_tail)
+ start = time.monotonic()
+ assert denied(run(skill(tp), state))
+ assert time.monotonic() - start < 1.5
+
+
+# ---------------------------------------------------------------- 一覧と登録(AC14 / AC24)
+
+def test_stage_list_matches_design():
+ names = {l.strip() for l in STAGES.read_text().splitlines()
+ if l.strip() and not l.startswith("#")}
+ assert names == STAGE_SKILLS
+ listed = set()
+ for m in MANIFESTS.glob("*-skills.txt"):
+ listed |= {l.strip() for l in m.read_text().splitlines() if l.strip()}
+ assert names <= listed
+
+
+def test_hook_registered_for_claude():
+ hooks = json.loads(HOOKS.read_text())["hooks"]["PreToolUse"]
+ ours = [h for h in hooks if any("token-guard.sh" in x["command"] for x in h["hooks"])]
+ assert len(ours) == 1
+ assert set(ours[0]["matcher"].split("|")) == {"Bash", "Read", "Skill", "Agent", "Task"}
+ entry = ours[0]["hooks"][0]
+ assert entry["continueOnError"] is True and entry["timeout"] == 5
+ assert "worktree-guard.sh" in hooks[0]["hooks"][0]["command"]
+
+
+def test_hook_not_registered_for_other_runtimes():
+ for f in (ROOT / "hooks").glob("*.json"):
+ if f.name != "claude.json":
+ assert "token-guard" not in f.read_text(), f
+
+
+# ---------------------------------------------------------------- 文書(AC1〜AC4 / AC18〜AC23)
+
+def test_waiting_doc_lists_methods():
+ text = WAITING.read_text()
+ for word in ("run_in_background", "Monitor", "token-guard.sh", "NDF_SLEEP_GUARD",
+ "NDF_READ_REPEAT_GUARD", "tasks/*.output"):
+ assert word in text, word
+
+
+def _code_blocks(text):
+ import re
+ return re.findall(r"```[a-z]*\n(.*?)```", text, re.S)
+
+
+def test_no_foreground_sleep_loop_examples():
+ import re
+ for doc in (WAITING, LAYERS):
+ for block in _code_blocks(doc.read_text()):
+ if "run_in_background" in block:
+ continue
+ assert not re.search(r"\b(while|until)\b.*\bdo\b[^`]*\bsleep\b", block, re.S), doc
+
+
+def test_agent_layers_refers_waiting():
+ text = LAYERS.read_text()
+ sup = text.split("supervisor が守る規則:")[1].split("\n\n")[1]
+ wrk = text.split("worker が守る規則:")[1].split("\n\n")[1]
+ assert "waiting.md" in sup
+ assert "waiting.md" in wrk
+
+
+def test_foreground_loop_docs_point_to_waiting():
+ skills = ROOT / "skills"
+ for rel in ("external-ai/references/cli-codex.md", "external-ai/references/cli-agy.md",
+ "qa-security-scan/03-report-template.md",
+ "release/references/completion-check.md"):
+ assert "development-workflow/references/waiting.md" in (skills / rel).read_text(), rel
+
+
+def test_context_window_doc():
+ text = CONTEXT.read_text()
+ assert "実測ではない" not in text
+ assert "#827" in text
+ assert "NDF_CONTEXT_LIMIT" in text
+ assert "200,000" in text or "200000" in text
+ assert "/ndf:development-workflow #" in text
+ assert "stage-check.sh report" in text
+ assert "closedByPullRequestsReferences" in text
+
+
+def test_context_limit_default_matches_doc(tmp_path, state):
+ tp = transcript(tmp_path, 200_001)
+ assert denied(run(skill(tp), state))
+ tp2 = transcript(tmp_path, 200_000, name="t2.jsonl")
+ assert denied(run(skill(tp2, session="s2"), state)) is None
+
+
+def test_workflow_skill_handover_rule():
+ text = WF_SKILL.read_text()
+ assert "引き継ぎの 1 行" in text
+ assert "## 持ち場の報告" in text
+ assert "結果: 関門" in text
+ assert "context-window.md" in text
+
+
+def test_readme_runtime_table():
+ text = README.read_text()
+ assert "token-guard.sh" in text
+ for rt in ("Claude Code", "Codex", "Kiro", "agy"):
+ assert rt in text
diff --git a/plugins/ndf/scripts/token-guard.sh b/plugins/ndf/scripts/token-guard.sh
new file mode 100755
index 00000000..f9e597d5
--- /dev/null
+++ b/plugins/ndf/scripts/token-guard.sh
@@ -0,0 +1,176 @@
+#!/usr/bin/env bash
+# NDF plugin: 待ちの呼び出しと、文脈が上限を超えた conductor の工程の起動を止める
+# PreToolUse hook(#829 / #830)。Claude Code にだけ登録する。
+#
+# | tool_name | 判定 |
+# | ------------------ | ---------------------------------------------------------- |
+# | Bash | 前景の `sleep` で待つ(ループの本体にあるか、上限を超える) |
+# | Read | 変わらないファイルの同じ範囲を続けて読み直す |
+# | Skill / Agent・Task | 文脈が上限を超えた conductor が工程へ入る |
+#
+# **拒否は `permissionDecision: deny` で返し、終了コードは常に 0 にする。** 通すときは何も
+# 出さない。判定が失敗したとき(入力が読めない・jq が無い・控えを書けない・記録を読めない・
+# ロックを 1 秒で取れない)は通す。hook の失敗でツールの実行を止めないためである。
+#
+# 規約は skills/development-workflow/references/waiting.md(待ち方)と
+# context-window.md(会話を切る)にある。
+
+HERE=$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd) || exit 0
+WAITING_DOC="development-workflow/references/waiting.md"
+CONTEXT_DOC="development-workflow/references/context-window.md"
+
+command -v jq >/dev/null 2>&1 || exit 0
+INPUT=$(cat) || exit 0
+TOOL=$(printf '%s' "$INPUT" | jq -r '.tool_name // empty' 2>/dev/null) || exit 0
+[ -n "$TOOL" ] || exit 0
+
+field() {
+ printf '%s' "$INPUT" | jq -r "$1 // empty | tostring" 2>/dev/null
+}
+
+deny() {
+ jq -cn --arg r "$1" '{hookSpecificOutput:{hookEventName:"PreToolUse",
+ permissionDecision:"deny", permissionDecisionReason:$r}}'
+ exit 0
+}
+
+# 控えの置き場所。順は workflow-common.sh の wf_state_dir と同じ(あちらは stages/、
+# こちらは guards/ を置く)。workflow-common.sh は通信の層まで読み込むため、毎回の hook
+# では読み込まない。
+guards_dir() {
+ local base fallback="${TMPDIR:-/tmp}/ndf-guards"
+ if [ -n "${CLAUDE_PLUGIN_DATA:-}" ]; then
+ base="$CLAUDE_PLUGIN_DATA/guards"
+ elif [ -n "${XDG_STATE_HOME:-}" ]; then
+ base="$XDG_STATE_HOME/ndf/guards"
+ elif [ -n "${HOME:-}" ]; then
+ base="$HOME/.local/state/ndf/guards"
+ else
+ base="$fallback"
+ fi
+ if mkdir -p "$base" 2>/dev/null && [ -w "$base" ]; then
+ printf '%s\n' "$base"
+ return 0
+ fi
+ mkdir -p "$fallback" 2>/dev/null && [ -w "$fallback" ] || return 1
+ printf '%s\n' "$fallback"
+}
+
+# session ごとのロックを取る。取れなければ 1(呼び出し側は判定せずに通す)。
+LOCK=
+take_lock() {
+ local dir="$1" sid="$2"
+ # shellcheck source=lib/lock-common.sh
+ . "$HERE/lib/lock-common.sh" 2>/dev/null || return 1
+ LOCK="$dir/$sid.lock"
+ ndf_lock_acquire "$LOCK" 1 || { LOCK=; return 1; }
+ trap 'ndf_lock_release "$LOCK"' EXIT
+}
+
+# 置き換えで書く。途中で落ちても壊れた JSON を残さない。
+write_json() {
+ local path="$1" body="$2" tmp
+ tmp="$path.$$.tmp"
+ printf '%s\n' "$body" >"$tmp" 2>/dev/null && mv -f "$tmp" "$path" 2>/dev/null
+ find "$(dirname "$path")" -maxdepth 1 -type f -name '*.json' -mtime +7 -delete 2>/dev/null
+ return 0
+}
+
+guard_sleep() {
+ [ "${NDF_SLEEP_GUARD:-1}" = 0 ] && exit 0
+ [ "$(field '.tool_input.run_in_background')" = true ] && exit 0
+ local cmd max
+ cmd=$(field '.tool_input.command')
+ [ -n "$cmd" ] || exit 0
+ case "$cmd" in *sleep*) ;; *) exit 0 ;; esac
+ command -v python3 >/dev/null 2>&1 || exit 0
+ max=${NDF_SLEEP_MAX_SEC:-5}
+ printf '%s' "$cmd" | python3 "$HERE/lib/token_guard_sleep.py" "$max" >/dev/null 2>&1 && exit 0
+ deny "前景で sleep を使って待つと、待つ呼び出しのたびに会話の文脈の全体を読み直す(ループの本体の sleep と、${max} 秒を超える sleep を止めている)。同じ条件の until ループ(例: until [ -s <ファイル> ]; do sleep 5; done)を Bash の run_in_background: true で起動し、完了通知を待つ(通知は 1 回で、待つ間は呼び出しが増えない)。出来事を 1 つずつ受けるなら Monitor を使う。規約: ${WAITING_DOC}(止めるなら NDF_SLEEP_GUARD=0)"
+}
+
+file_stat() {
+ # 大きさ・更新時刻(ナノ秒)・inode。無いファイルは -1。
+ stat -c '%s %.9Y %i' "$1" 2>/dev/null || stat -f '%z %Fm %i' "$1" 2>/dev/null || echo "-1 -1 -1"
+}
+
+guard_read() {
+ [ "${NDF_READ_REPEAT_GUARD:-1}" = 0 ] && exit 0
+ local sid path key limit dir st size mtime inode prev count state
+ sid=$(field '.session_id')
+ path=$(field '.tool_input.file_path')
+ [ -n "$sid" ] && [ -n "$path" ] || exit 0
+ key="$path"$'\t'"$(field '.tool_input.offset')"$'\t'"$(field '.tool_input.limit')"
+ limit=${NDF_READ_REPEAT_LIMIT:-3}
+ dir=$(guards_dir) || exit 0
+ take_lock "$dir" "$sid" || exit 0
+ read -r size mtime inode <<<"$(file_stat "$path")"
+ state="$dir/read-$sid.json"
+ prev=$(jq -r --arg k "$key" --arg s "$size" --arg m "$mtime" --arg i "$inode" \
+ 'if .key == $k and (.size|tostring) == $s and .mtime == $m and (.inode|tostring) == $i
+ then .count else 0 end' "$state" 2>/dev/null) || prev=0
+ count=$(( ${prev:-0} + 1 ))
+ write_json "$state" "$(jq -cn --arg k "$key" --argjson s "$size" --arg m "$mtime" \
+ --argjson i "$inode" --argjson c "$count" \
+ '{key:$k, size:$s, mtime:$m, inode:$i, count:$c}')"
+ [ "$count" -ge "$limit" ] || exit 0
+ deny "同じファイルの同じ範囲を、変わらないまま ${count} 回続けて読もうとした(${path})。書き終わりを待つなら until [ -s <ファイル> ]; do sleep 1; done を Bash の run_in_background: true で起動して完了通知を待つか、背景の処理そのものの完了通知を待つ。サブエージェントの tasks/*.output は読まずに完了通知を待つ。規約: ${WAITING_DOC}(止めるなら NDF_READ_REPEAT_GUARD=0)"
+}
+
+# 最後の assistant 行の usage から文脈量を読む。末尾だけを読むのは大きな記録でも速く終えるため。
+context_tokens() {
+ tail -n 200 "$1" 2>/dev/null | jq -rs '
+ [ .[] | select(.type == "assistant" and (.message.usage | type) == "object")
+ | .message.usage
+ | (.input_tokens // 0) + (.cache_read_input_tokens // 0) + (.cache_creation_input_tokens // 0)
+ ] | last // empty' 2>/dev/null
+}
+
+guard_context() {
+ [ "${NDF_CONTEXT_GUARD:-1}" = 0 ] && exit 0
+ # サブエージェントの中の起動は見ない。agent_id はサブエージェントの中でだけ付く
+ # (Claude Code 2.1.280 で実測。サブエージェントの transcript_path は親の記録を指す)
+ [ -n "$(field '.agent_id')" ] && exit 0
+ local tp sid key words total limit dir mark issues skill
+ tp=$(field '.transcript_path')
+ case "$tp" in */subagents/*) exit 0 ;; esac
+ sid=$(field '.session_id')
+ [ -n "$tp" ] && [ -n "$sid" ] || exit 0
+ if [ "$TOOL" = Skill ]; then
+ skill=$(field '.tool_input.skill')
+ skill=${skill#ndf:}
+ grep -qxF "$skill" "$HERE/lib/token-guard-stages.txt" 2>/dev/null || exit 0
+ words=$(field '.tool_input.args')
+ key="skill"$'\t'"$skill"$'\t'"$words"
+ else
+ words=$(field '.tool_input.description')
+ case "${words%%:*}" in 設計|実装|検査|取り込み|仕上げ) ;; *) exit 0 ;; esac
+ case "$words" in *:*) ;; *) exit 0 ;; esac
+ key="agent"$'\t'"$words"
+ fi
+ total=$(context_tokens "$tp")
+ case "$total" in ''|*[!0-9]*) exit 0 ;; esac
+ limit=${NDF_CONTEXT_LIMIT:-200000}
+ dir=$(guards_dir) || exit 0
+ take_lock "$dir" "$sid" || exit 0
+ mark="$dir/context-$sid.json"
+ if [ "$(jq -r '.key // empty' "$mark" 2>/dev/null)" = "$key" ]; then
+ rm -f "$mark" 2>/dev/null
+ exit 0
+ fi
+ [ "$total" -gt "$limit" ] || exit 0
+ write_json "$mark" "$(jq -cn --arg k "$key" '{key:$k}')"
+ # 日付・版数・小数(2026-09-23 / v10.16.1 / 2.0.3)は課題番号ではないので先に取り除く。
+ # 範囲(#829-830 / 829-830)は残し、#829 #830 として案内する
+ issues=$(printf '%s\n' "$words" | sed -E 's/[0-9]{4}-[0-9]{1,2}-[0-9]{1,2}//g; s/[0-9]+(\.[0-9]+)+//g' | grep -oE '(^|[^0-9A-Za-z_/])#?[0-9]+\b' \
+ | grep -oE '[0-9]+' | sed 's/^/#/' | tr '\n' ' ')
+ issues=${issues% }
+ deny "会話の文脈が ${total} トークンで、上限 ${limit} を超えた。この工程は新しい会話で始める。利用者へ次の 1 行を示して応答を終える: /ndf:development-workflow ${issues:-<課題番号>}(3 層で進めているなら、新しい会話で /goal に同じ 1 行を渡す)。<課題番号> のままなら、進めている課題の番号を補って示す。このまま続けると利用者が決めたら、同じ起動をもう一度行うと 1 度だけ通る。規約: ${CONTEXT_DOC}(止めるなら NDF_CONTEXT_GUARD=0、上限は NDF_CONTEXT_LIMIT)"
+}
+
+case "$TOOL" in
+ Bash) guard_sleep ;;
+ Read) guard_read ;;
+ Skill|Agent|Task) guard_context ;;
+esac
+exit 0
diff --git a/plugins/ndf/skills/development-workflow/SKILL.md b/plugins/ndf/skills/development-workflow/SKILL.md
index d46118d3..cef475b0 100644
--- a/plugins/ndf/skills/development-workflow/SKILL.md
+++ b/plugins/ndf/skills/development-workflow/SKILL.md
@@ -202,6 +202,14 @@ mode: standard
残っているのに後の工程の判断だけが悪くなる。** 切れ目・委譲してよい対象・残量の見方は
[references/context-window.md](references/context-window.md) にある。
+**conductor は、`context-window.md` の 4 つの切れ目で、次の工程を始める引き継ぎの 1 行
+(`/ndf:development-workflow #<課題>`)を出す。** 3 層では conductor が `## 持ち場の報告` を
+受け取った時点で出し、supervisor は出さない。持ち場の境がこの切れ目に当たるためである。
+文脈量の hook(`token-guard.sh`)が起動を止めたときも出す。ただし報告が `結果: 関門` なら
+受け取った時点では出さず、関門の承認と取り込み(設計 Pull Request のマージなど)の後に出す。
+関門の前に会話を切らないためである。**その 1 行で始めた新しい会話が状態を戻す手順は、
+`context-window.md` の「新しい会話で戻す」にある。**
+
## 範囲外の課題を見つけたとき
この変更の受け入れ条件にも、直す対象にも含まれない課題は、**見つけたその場で `out-of-scope` が
diff --git a/plugins/ndf/skills/development-workflow/references/agent-layers.md b/plugins/ndf/skills/development-workflow/references/agent-layers.md
index 7f563e98..e9ef9457 100644
--- a/plugins/ndf/skills/development-workflow/references/agent-layers.md
+++ b/plugins/ndf/skills/development-workflow/references/agent-layers.md
@@ -122,6 +122,7 @@ supervisor が守る規則:
`merged` は削除の一覧を示す。supervisor は人間へ問えないため、**確認待ちで止まらない**
3. 工程に入った時点で `progress-tracking` を呼ぶ。記録のコマンドは 1 回の Bash 実行に 1 件
4. 待ちで応答を終えない。待ちの道具から戻った後、同じ応答の中で次の段へ進む
+ 待ち方は [waiting.md](waiting.md) に従う(`sleep` を挟んだ問い合わせの繰り返しと、出力ファイルの読み直しをしない)
5. 外部へ書く前に、既に書いたものがあるかを確かめる(Pull Request・コメント・進行の記録)
6. 範囲外の課題は `out-of-scope` で起票する
7. 委譲してよい作業は worker へ出す。委譲しない 5 つは自分で行う(「委譲の線」)
@@ -152,6 +153,7 @@ worker が守る規則:
3. 収束の判定・設計の決定・受け入れ条件の書き換えを行わない。判断が要るときは
`結果: 判断が要る` で返す
4. 最後の応答の末尾に `## 作業の報告` を置く
+5. 待ち方は [waiting.md](waiting.md) に従う。背景の処理を残したまま応答を終えない
## 報告の形
diff --git a/plugins/ndf/skills/development-workflow/references/context-window.md b/plugins/ndf/skills/development-workflow/references/context-window.md
index e9742248..a4aea648 100644
--- a/plugins/ndf/skills/development-workflow/references/context-window.md
+++ b/plugins/ndf/skills/development-workflow/references/context-window.md
@@ -38,13 +38,20 @@ worker の 3 層)は [agent-layers.md](agent-layers.md) が持つ。
**目安は 1 工程あたり 10 万トークンで、遅くとも 20 万で切る。** モデルが持つ上限まで
詰めない。
-**前提: この数値は公開された評価の設定と第三者のベンチマークからの推定であり、この
-リポジトリでの実測ではない。** 実測が出た時点で書き換える。根拠は「出典」にある。
+**この上限は実測で守られていなかった**(#827。会話の記録から conductor の文脈量を数えた)。
+
+| 範囲 | conductor の文脈 | 20 万を超えた会話 | 工程の開始ごとに切ったときの再読込の削減見込み |
+| --- | --- | --- | --- |
+| 2026-09-20 以降の全プロジェクト | 最大 68 万 | 7 件中 4 件 | 58% |
+| ai-plugins の 30 日間 | 平均 41 万 | 45 件中 37 件 | 62% |
+
+目安の値そのものは、公開された評価の設定と第三者のベンチマークからの推定である(「出典」)。
+**書いた規定だけでは守られないため、上限を超えたら hook が止める**(次の節)。
**要約して詰め直すより、切って入り直すほうを既定にする。** 要約は落ちた情報を残さない
ため、**落ちたことに気づけない**。工程の状態は会話の外にあり(課題の本文・盤面・通過工程の
-控え・Pull Request の差分)、**捨てても復元できる**。復元の手順を持つのは各工程の
-Skill であって、この文書ではない。
+控え・Pull Request の差分)、**捨てても復元できる**。新しい会話で戻す手順は、この文書の
+「新しい会話で戻す」節が持つ。
### 切ってよい点は 4 つある
@@ -70,6 +77,58 @@ Skill であって、この文書ではない。
**切れ目でないところで尽きたときは、尽きた側に合わせる。** 残量が足りないまま次の
工程へ入るより、進行を記録して切るほうが安い。
+## 上限を超えたら hook が止める
+
+**Claude Code では、文脈が上限を超えた conductor が工程へ入る起動を hook が 1 度止める**
+(`scripts/token-guard.sh`)。上限の既定は **200,000** トークンで、上の「遅くとも 20 万」と
+同じ値である。環境変数 `NDF_CONTEXT_LIMIT` で変えられ、`NDF_CONTEXT_GUARD=0` で止められる。
+
+| 経路 | 止める起動 |
+| --- | --- |
+| 対話 | 工程 Skill(上の 4 つの切れ目の直後に始まる工程と、入口の `development-workflow` / `issue-plan-strategy`。一覧は `scripts/lib/token-guard-stages.txt`) |
+| 3 層 | `description` の先頭語が持ち場(`設計` / `実装` / `検査` / `取り込み` / `仕上げ`)の Agent |
+
+- **文脈量は、会話の記録の最後の assistant 呼び出しの `usage` から読む**
+ (`input_tokens + cache_read_input_tokens + cache_creation_input_tokens`)。読めなければ止めない
+- **サブエージェントの中の起動は止めない。** 見るのは conductor だけである
+- **止めるのは工程の切れ目ごとに 1 度である。** 止めた直後に、工程へ入る次の起動が同じもの
+ (Skill なら名前と引数、Agent なら `description`)であれば 1 度だけ通す。**続けると決めたら、
+ 同じ起動をもう一度行う。** 別の起動なら、上限を超えている限り再び止める
+- 止めたときの理由の欄が、利用者へ示す 1 行(次の節)を持つ。conductor はその 1 行を示して
+ 応答を終える
+
+Codex / Kiro / agy には hook を置かない。4 つの切れ目で 1 行を出す規約だけで守る
+([waiting.md](waiting.md) の「hook」節の表)。
+
+## 新しい会話で戻す
+
+**conductor は、4 つの切れ目と hook に止められたときに、次の工程を始める 1 行を出す。**
+
+```text
+/ndf:development-workflow #829 #830
+```
+
+- **形は `development-workflow` を起動する 1 行である。** 工程 Skill はモードと作業ツリーを戻す
+ 手順を持たないため、入口から入り直す。Codex と Kiro では、それぞれの README が示す Skill の
+ 起動の書き方に読み替える
+- **3 層では、conductor が `## 持ち場の報告` を受け取った時点で出す**(supervisor は出さない)。
+ 持ち場の境が切れ目に当たるためである。ただし報告が `結果: 関門` なら、関門の承認と取り込み
+ (設計 Pull Request のマージなど)が済んだ後に出す。関門の前に会話を切らないためで、
+ 切れ目 1 はこの形で満たす。新しい会話では `/goal` に同じ 1 行を渡す
+
+**新しい会話の `development-workflow` は、次の順で状態を戻す。**
+
+| # | 読むもの | 戻すもの |
+| --- | --- | --- |
+| 1 | 課題の本文の `## 進行`(`gh issue view <番号> --json body`) | モード・作業ツリー・計画ファイル・通った工程 |
+| 2 | `bash <この Skill のディレクトリ>/scripts/stage-check.sh report <番号>`(プラグインの `scripts/` ではなく、`development-workflow` の `scripts/` にある) | 通過工程の控え。本文と食い違えば控えを正とする |
+| 3 | 1 の作業ツリー(`.worktrees/<ブランチ名>`)のブランチ名で `gh pr list --head <ブランチ名> --state all`。実装の Pull Request は `gh issue view <番号> --json closedByPullRequestsReferences` でも引く | 設計・実装の Pull Request と状態 |
+| 4 | 1〜3 から、チェックの付いていない最初の必須の工程 | 次に起動する工程 Skill |
+
+**Pull Request は番号の全文検索で引かない。** 同じ番号に触れただけの別の Pull Request も返す
+ためである。設計の Pull Request は閉じる語を持たないため、課題との結び付きでは引けず、
+ブランチ名で引く。
+
## 粒度は比で決める
**基準は値ではなく比である。** 固定費はリポジトリとモデルで変わるため、閾値を書くと
@@ -148,6 +207,7 @@ supervisor と配下の worker の固定費の合計が、その supervisor の
| 要約より新しい文脈を選ぶ助言 | モデル提供者の公式ドキュメントの「プロンプトの実践」の節 |
| 残量の自己認識を持つモデルが限られること | 同上の「コンテキストの自己認識」の節 |
| 入力長に対する劣化が全モデルで単調であること | 第三者が 18 モデルを比べた公開の研究 |
+| conductor の最大文脈・20 万を超えた会話の数・再読込の削減見込み | #827(会話の記録からの実測) |
**値そのものを写さない。** 版が変わると数字が変わり、写した側だけが古くなる。この文書が
持つのは**運用の目安**であって、モデルの性能値ではない。
diff --git a/plugins/ndf/skills/development-workflow/references/waiting.md b/plugins/ndf/skills/development-workflow/references/waiting.md
new file mode 100644
index 00000000..a42c5922
--- /dev/null
+++ b/plugins/ndf/skills/development-workflow/references/waiting.md
@@ -0,0 +1,83 @@
+# 待ち方
+
+**待つ間に状態を問い合わせる呼び出しを繰り返さない。** 待ち方の規約はこの文書だけが持ち、
+他の文書は写さずにここを指す。
+
+## 待ちの費用
+
+**呼び出しは 1 回ごとに、その時点の会話の文脈の全体を読み直す。** `sleep 60 && tail -5 x.log`
+を 30 回繰り返すと、30 回とも文脈の全体を読む。背景で待って通知を 1 回受けるなら、待つ時間が
+長くても呼び出しは増えない。
+
+#827 の実測では、待つ間の繰り返しの問い合わせ(ポーリング)が全体の費用の 16%(2026-09-20
+以降)、ai-plugins の 30 日間では 19% を占めた。
+
+| 待ち方 | 待つ間の呼び出し | 費用 |
+| --- | --- | --- |
+| 前景の `sleep` を挟んで状態を問い合わせ直す | 待つ時間 ÷ 間隔 | 回数 × その時点の文脈 |
+| 出力ファイルを読み直す | 読み直した回数 | 同上 |
+| `run_in_background` で起動し、完了通知を待つ | 0(通知が 1 回) | 待つ時間に依らない |
+| `Monitor` で出来事を 1 つずつ受ける | 出来事の数 | 出来事の数 × 文脈 |
+
+## 許す待ち方
+
+| ランタイム | 待ち方 |
+| --- | --- |
+| Claude Code | **条件の until ループを Bash の `run_in_background: true` で起動し、完了通知を 1 回受ける。** 出来事を 1 つずつ受けるなら `Monitor`。サブエージェントは完了通知を待つ |
+| Codex / Kiro / agy | 1 回の前景の until ループ。600 秒を超えるなら `bg-wait.sh` |
+
+**1 回で足りる待ちは `Monitor` ではなく `run_in_background` にする。** `Monitor` は出来事の
+たびに通知が届き、その都度文脈を読む。終わりだけを知りたい待ちでは通知が 1 回で済む
+`run_in_background` のほうが安い。
+
+## 禁じる待ち方
+
+- **`sleep` を挟んだ呼び出しの繰り返し。** `sleep 30 && tail x.log` を何度も打つ形と、前景の
+ `while` / `until` のループの本体で `sleep` する形
+- **出力ファイルの繰り返しの読み直し。** 変わっていないファイルの同じ範囲を続けて読む形
+- **サブエージェントの `tasks/*.output` を読むこと。** 会話の記録の全体で、読むと文脈を埋める。
+ 完了通知を待つ
+
+## 待つ相手ごとの手
+
+| 待つ相手 | 手(Claude Code) |
+| --- | --- |
+| サブエージェント | 完了通知を待つ。途中の出力を読まない |
+| 背景で動かす CLI(`codex exec` など) | CLI そのものを `run_in_background: true` で起動し、完了通知を待つ |
+| 既に起動したプロセス・書き終わりを待つファイル | 終わりを待つ until ループ(例: `until [ -s out.md ]; do sleep 5; done`)を `run_in_background: true` で起動する |
+| Pull Request の検査 | `gh pr checks <番号> --watch` を `run_in_background: true` で起動する |
+| 新しいコメントを 1 件ずつ | `Monitor` |
+
+**サブエージェントは、背景の処理を残したまま応答を終えない。** 完了通知で再開はされるが、
+**親には応答を終えた時点で 1 度「終わった」と通知が届き、途中の文面が結果として渡る**
+(Claude Code 2.1.280 で実測。`codex exec` を背景で起動して応答を終えたサブエージェントは、
+約 2 秒後の完了通知で再開して報告を出し直し、親には通知が 2 回届いた)。親が 1 回目を
+結果と読むと、報告の無い持ち場を受け取る。supervisor と worker は待ちで応答を終えない
+([agent-layers.md](agent-layers.md) の規則)。背景の処理を起動した後は、同じ応答の中で
+他の作業を進め、通知を受けてから次の段へ進む。他の作業が無いまま待つときの手は #656 が扱う。
+**親の側は、この 2 回目の通知を待ってから報告を読む**(1 回目の通知の注記に「再開しうる」と出る)。
+
+## hook
+
+**Claude Code では、禁じる待ち方を hook が止める**(`scripts/token-guard.sh`。PreToolUse の
+`Bash` と `Read` で動く)。止めたときは理由の欄に代わりの待ち方が出る。
+
+| 判定 | 止める条件 | 止め方 | 上限を変える |
+| --- | --- | --- | --- |
+| sleep | 前景の Bash で、コマンドの位置(先頭の代入語 `X=1` と、`timeout 590` / `nohup` / `env` などの前置きの後ろを含む)の `sleep` が `while` / `until` のループの本体にある(秒数が変数でも止める)か、秒数が上限を超える。コメント・引用・ヒアドキュメントの本文は見ず、コマンドの位置にある `bash -c` / `sh -c` / `zsh -c` / `dash -c` / `eval` の中身は見る(`echo bash -c ...` のような引数の中の語は見ない) | `NDF_SLEEP_GUARD=0` | `NDF_SLEEP_MAX_SEC`(既定 5) |
+| 連続 Read | 同じ `file_path`・`offset`・`limit` の Read が、ファイルの大きさ・更新時刻・inode が変わらないまま上限の回数に達する | `NDF_READ_REPEAT_GUARD=0` | `NDF_READ_REPEAT_LIMIT`(既定 3) |
+
+- **止めないもの:** `run_in_background: true` の Bash、`Monitor` の中の `sleep`、ループの本体の
+ 外の上限以下の `sleep`、`for` のループの中の上限以下の `sleep`、末尾の `&` でバックグラウンドになる `sleep`(`sleep 30 >/tmp/x &` のようにリダイレクトを挟んでもよい。`sleep 30 && echo x &` のようなリストや、`(sleep 30) &`・`{ sleep 30; } &`・`while ...; do sleep 1; done &` のように sleep を囲む複合コマンドの全体が背景になる形も含む。`bash -c 'sleep 30' &`・`eval 'sleep 30' &` のように `bash -c` / `eval` の外側が背景になる形も含む。`2>&1` / `&>` の `&` は背景と読まない)
+- **判定が失敗したときは止めない**(入力が読めない・`jq` や `python3` が無い・控えを書けない)
+- 同じ hook が、文脈が上限を超えた conductor の工程の起動も止める([context-window.md](context-window.md)
+ の「上限を超えたら hook が止める」)
+
+**hook を置くのは Claude Code だけである。**
+
+| ランタイム | 待ち方(#829) | 会話を切る(#830) | 理由 |
+| --- | --- | --- | --- |
+| Claude Code | hook + この規約 | hook + 引き継ぎの 1 行 | 代わりの待ち方(`Monitor` / `run_in_background` の通知)と会話の記録の場所を持つ |
+| Codex | この規約だけ | 引き継ぎの 1 行だけ | 背景の起動と完了通知が無く、1 回の前景のループが待ち方になる |
+| Kiro | この規約だけ | 引き継ぎの 1 行だけ | 実行前の hook は拒否しか返せず、既存の設計も実行前の hook を置いていない |
+| agy | この規約だけ | 引き継ぎの 1 行だけ | 実行前の hook は案内を控えへ積む形で、拒否の口を使っていない |
diff --git a/plugins/ndf/skills/external-ai/references/cli-agy.md b/plugins/ndf/skills/external-ai/references/cli-agy.md
index 290d0397..a527a38d 100644
--- a/plugins/ndf/skills/external-ai/references/cli-agy.md
+++ b/plugins/ndf/skills/external-ai/references/cli-agy.md
@@ -120,6 +120,8 @@ $ agy --output-format json -p="1+1は?数字だけ答えて"
sentinel を出さないため、**プロセスの終了**を見る。
+**Claude Code では、このループを Bash の `run_in_background: true` で実行して完了通知を待つ**(前景で回すと hook が止める。規約は `development-workflow/references/waiting.md`)。
+
```bash
until ! kill -0 $PID 2>/dev/null; do
sleep 30
diff --git a/plugins/ndf/skills/external-ai/references/cli-codex.md b/plugins/ndf/skills/external-ai/references/cli-codex.md
index 295edf0c..13c78594 100644
--- a/plugins/ndf/skills/external-ai/references/cli-codex.md
+++ b/plugins/ndf/skills/external-ai/references/cli-codex.md
@@ -106,6 +106,8 @@ Codex は最終 message を返さなくても `apply_patch` でファイルを
`ps -p $PID` は zombie (defunct) にも 0 を返すため、**PID watch は永久ループになりうる**。
stderr 末尾の sentinel を脱出条件にする。
+**Claude Code では、このループを Bash の `run_in_background: true` で実行して完了通知を待つ**(前景で回すと hook が止める。規約は `development-workflow/references/waiting.md`)。
+
```bash
# ❌ 永久ループ化しうる
until ! ps -p $PID; do sleep 30; done
diff --git a/plugins/ndf/skills/qa-security-scan/03-report-template.md b/plugins/ndf/skills/qa-security-scan/03-report-template.md
index 6ad9f7ec..2ab5ab0b 100644
--- a/plugins/ndf/skills/qa-security-scan/03-report-template.md
+++ b/plugins/ndf/skills/qa-security-scan/03-report-template.md
@@ -125,6 +125,8 @@ codex exec --dangerously-bypass-approvals-and-sandbox \
2> /tmp/sec-scan-err.log &
# === 3. 完了確認(^tokens used$ sentinel を待つ。`ps -p` は zombie を生存と誤判定する) ===
+# Claude Code では、このループを Bash の run_in_background: true で実行して完了通知を待つ
+# (前景で回すと hook が止める。規約は development-workflow/references/waiting.md)
until grep -q '^tokens used$' /tmp/sec-scan-err.log 2>/dev/null; do
sleep 30
done
diff --git a/plugins/ndf/skills/release/references/completion-check.md b/plugins/ndf/skills/release/references/completion-check.md
index ccca865f..1dabeb7b 100644
--- a/plugins/ndf/skills/release/references/completion-check.md
+++ b/plugins/ndf/skills/release/references/completion-check.md
@@ -35,6 +35,8 @@
そのまま貼って使う。**再起動・pidfile・結果ファイルの規約は持たず、ログの読み取りだけを行う。**
+**Claude Code では、このループを Bash の `run_in_background: true` で実行して完了通知を待つ**(前景で回すと hook が止める。規約は `development-workflow/references/waiting.md`)。
+
```bash
# $LOG は追記されるログ、$DONE と $FAIL は先に流して確かめた語、$IDLE と $LIMIT は秒
# 未設定の変数と作れないログはここで止める。空の値のまま進むと比較が構文誤りを出し続ける