From 07f097455d0545267f4ab4fab0f2aa63fc0289f0 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 23 Sep 2026 02:08:26 +0000 Subject: [PATCH 01/18] =?UTF-8?q?Add:=20=E5=BE=85=E3=81=A1=E3=81=AE?= =?UTF-8?q?=E5=95=8F=E3=81=84=E5=90=88=E3=82=8F=E3=81=9B=E3=81=A8=E9=95=B7?= =?UTF-8?q?=E3=81=84=20conductor=20=E3=81=AE=E5=B7=A5=E7=A8=8B=E3=81=AE?= =?UTF-8?q?=E8=B5=B7=E5=8B=95=E3=82=92=20hook=20=E3=81=A7=E6=AD=A2?= =?UTF-8?q?=E3=82=81=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - token-guard.sh(PreToolUse の Bash / Read / Skill / Agent)を新設し、Claude Code にだけ登録する - 前景の sleep の待ち(while / until の本体、または 5 秒を超える秒数)を止める - 変わらないファイルの同じ範囲を 3 回続けて読む Read を止める - 文脈が 200,000 を超えた conductor が工程へ入る起動を 1 度止め、新しい会話で打つ 1 行を示す - 待ち方の規約 waiting.md を新設し、agent-layers.md と前景の待ちのループを持つ 4 文書から指す - context-window.md の「実測ではない」を #827 の実測へ置き換え、hook と新しい会話で戻す手順の節を足す - development-workflow/SKILL.md に、切れ目で conductor が引き継ぎの 1 行を出す規約を足す - README に hook と 4 ランタイムの扱いの表を足す - 実装計画 issues/issue-829-830-implementation-plan.md Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01LdEztjNvLXF6EasAvTjAfk --- issues/issue-829-830-implementation-plan.md | 132 +++++ plugins/ndf/README.md | 21 + plugins/ndf/hooks/claude.json | 13 + .../ndf/scripts/lib/token-guard-stages.txt | 16 + plugins/ndf/scripts/lib/token_guard_sleep.py | 117 ++++ plugins/ndf/scripts/tests/test_token_guard.py | 547 ++++++++++++++++++ plugins/ndf/scripts/token-guard.sh | 174 ++++++ .../ndf/skills/development-workflow/SKILL.md | 8 + .../references/agent-layers.md | 2 + .../references/context-window.md | 68 ++- .../references/waiting.md | 83 +++ .../skills/external-ai/references/cli-agy.md | 2 + .../external-ai/references/cli-codex.md | 2 + .../qa-security-scan/03-report-template.md | 2 + .../release/references/completion-check.md | 2 + 15 files changed, 1185 insertions(+), 4 deletions(-) create mode 100644 issues/issue-829-830-implementation-plan.md create mode 100644 plugins/ndf/scripts/lib/token-guard-stages.txt create mode 100644 plugins/ndf/scripts/lib/token_guard_sleep.py create mode 100644 plugins/ndf/scripts/tests/test_token_guard.py create mode 100755 plugins/ndf/scripts/token-guard.sh create mode 100644 plugins/ndf/skills/development-workflow/references/waiting.md diff --git a/issues/issue-829-830-implementation-plan.md b/issues/issue-829-830-implementation-plan.md new file mode 100644 index 00000000..17fe9798 --- /dev/null +++ b/issues/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/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..716a5123 --- /dev/null +++ b/plugins/ndf/scripts/lib/token-guard-stages.txt @@ -0,0 +1,16 @@ +# 文脈量の hook(token-guard.sh)が見る工程 Skill の一覧(#830)。1 行 1 名。 +# 正は issues/issue-829-830-design.md の「工程 Skill の一覧」(context-window.md の 4 つの切れ目の直後の工程と入口)。 +# worktree など切れ目の内側の工程は載せない(決定 6)。 +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..23bb07e3 --- /dev/null +++ b/plugins/ndf/scripts/lib/token_guard_sleep.py @@ -0,0 +1,117 @@ +"""前景の `sleep` で待つ Bash を見分ける(#829)。`token-guard.sh` から呼ぶ。 + +標準入力にコマンドの文字列を受け、第 1 引数に秒数の上限を受ける。拒否するなら終了コード 1、 +通すなら 0 で終わる。**読めないコマンドは通す**(hook の失敗でツールを止めない)。 + +拒否するのは、コマンドの位置にある `sleep <数>` が次のどちらかに当たるときだけである。 + +- `while` / `until` のループの本体(`do` と対応する `done` の間)にある +- 秒数が上限を超える + +コメント・引用の中・ヒアドキュメントの本文は見ない。`bash -c` / `sh -c` / `zsh -c` / `eval` の +実行される引数は、取り出して同じ規則で見る。 +""" +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} +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 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] = [] + cmd_pos = True + i = 0 + while i < len(toks): + tok = toks[i] + looping = in_loop or "body" in stack + if is_separator(tok): + cmd_pos = True + i += 1 + continue + if cmd_pos: + 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): + sec = seconds(toks[i + 1]) + if sec is not None and (looping or sec > limit): + return True + cmd_pos = tok in OPENERS + # 実行される引数を取り出して同じ規則で見る + if tok in SHELLS: + j = i + 1 + while j < len(toks) and toks[j].startswith("-") and not is_separator(toks[j]): + if "c" in toks[j].lstrip("-") and not toks[j].startswith("--"): + if j + 1 < len(toks) and should_deny(toks[j + 1], limit, looping, depth + 1): + return True + break + j += 1 + elif tok == "eval": + j, words = i + 1, [] + while j < len(toks) and not is_separator(toks[j]): + words.append(toks[j]) + j += 1 + if 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..3443b0ba --- /dev/null +++ b/plugins/ndf/scripts/tests/test_token_guard.py @@ -0,0 +1,547 @@ +"""待ちの 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'", + "for i in 1 2; 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", +] + +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", + "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", +] + + +@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_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_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_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 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_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)) + + +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..48a4eaf7 --- /dev/null +++ b/plugins/ndf/scripts/token-guard.sh @@ -0,0 +1,174 @@ +#!/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}')" + issues=$(printf '%s\n' "$words" | 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..97849e2c 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 | `stage-check.sh report <番号>` | 通過工程の控え。本文と食い違えば控えを正とする | +| 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..bd831159 --- /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 で、コマンドの位置の `sleep <数>` が `while` / `until` のループの本体にあるか、秒数が上限を超える。コメント・引用・ヒアドキュメントの本文は見ず、`bash -c` / `sh -c` / `eval` の中身は見る | `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` +- **判定が失敗したときは止めない**(入力が読めない・`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 は秒 # 未設定の変数と作れないログはここで止める。空の値のまま進むと比較が構文誤りを出し続ける From b9c0e05d54ee394a7dfd9888b06e02c7c205c347 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 23 Sep 2026 02:09:42 +0000 Subject: [PATCH 02/18] =?UTF-8?q?Docs:=20=E6=96=B0=E3=81=97=E3=81=84?= =?UTF-8?q?=E4=BC=9A=E8=A9=B1=E3=81=A7=E6=88=BB=E3=81=99=E6=89=8B=E9=A0=86?= =?UTF-8?q?=E3=81=AE=20stage-check.sh=20=E3=81=AE=E5=A0=B4=E6=89=80?= =?UTF-8?q?=E3=82=92=E6=AD=A3=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AC20 の実機の確認で、プラグインの scripts/ にあると読めて探す手間が出た。 実体は development-workflow の scripts/ にある。 Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01LdEztjNvLXF6EasAvTjAfk --- .../skills/development-workflow/references/context-window.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/ndf/skills/development-workflow/references/context-window.md b/plugins/ndf/skills/development-workflow/references/context-window.md index 97849e2c..bf9665ab 100644 --- a/plugins/ndf/skills/development-workflow/references/context-window.md +++ b/plugins/ndf/skills/development-workflow/references/context-window.md @@ -121,7 +121,7 @@ Codex / Kiro / agy には hook を置かない。4 つの切れ目で 1 行を | # | 読むもの | 戻すもの | | --- | --- | --- | | 1 | 課題の本文の `## 進行`(`gh issue view <番号> --json body`) | モード・作業ツリー・計画ファイル・通った工程 | -| 2 | `stage-check.sh report <番号>` | 通過工程の控え。本文と食い違えば控えを正とする | +| 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 | From c0bbfb8192b42c6a0e1cdea1eb5650af3aa8b1b2 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 23 Sep 2026 02:10:15 +0000 Subject: [PATCH 03/18] =?UTF-8?q?Docs:=20=E5=AE=9F=E6=B8=AC=E3=81=AE?= =?UTF-8?q?=E8=A1=A8=E3=81=AE=E5=88=97=E3=82=92=E6=9C=80=E5=A4=A7=E3=81=A8?= =?UTF-8?q?=E5=B9=B3=E5=9D=87=E3=81=8C=E4=B8=A6=E3=81=B6=E5=BD=A2=E3=81=AB?= =?UTF-8?q?=E7=9B=B4=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01LdEztjNvLXF6EasAvTjAfk --- .../skills/development-workflow/references/context-window.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/ndf/skills/development-workflow/references/context-window.md b/plugins/ndf/skills/development-workflow/references/context-window.md index bf9665ab..a4aea648 100644 --- a/plugins/ndf/skills/development-workflow/references/context-window.md +++ b/plugins/ndf/skills/development-workflow/references/context-window.md @@ -40,9 +40,9 @@ worker の 3 層)は [agent-layers.md](agent-layers.md) が持つ。 **この上限は実測で守られていなかった**(#827。会話の記録から conductor の文脈量を数えた)。 -| 範囲 | conductor の最大文脈 | 20 万を超えた会話 | 工程の開始ごとに切ったときの再読込の削減見込み | +| 範囲 | conductor の文脈 | 20 万を超えた会話 | 工程の開始ごとに切ったときの再読込の削減見込み | | --- | --- | --- | --- | -| 2026-09-20 以降の全プロジェクト | 68 万 | 7 件中 4 件 | 58% | +| 2026-09-20 以降の全プロジェクト | 最大 68 万 | 7 件中 4 件 | 58% | | ai-plugins の 30 日間 | 平均 41 万 | 45 件中 37 件 | 62% | 目安の値そのものは、公開された評価の設定と第三者のベンチマークからの推定である(「出典」)。 From 9f12b34166e9a687b7c8db6007de9eb660aae59f Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 23 Sep 2026 02:29:43 +0000 Subject: [PATCH 04/18] =?UTF-8?q?Test:=20token=5Fguard=5Fsleep.py=20?= =?UTF-8?q?=E3=81=AE=20main=20=E3=81=AB=20zsh=20-c=20/=20dash=20-c=20?= =?UTF-8?q?=E3=81=AE=E7=8F=BE=E7=8A=B6=E5=9B=BA=E5=AE=9A=E3=83=86=E3=82=B9?= =?UTF-8?q?=E3=83=88=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 再帰的に実行文字列を検査する対象として明示された shell のうち、bash と sh は DENY_SLEEP で固定済みだが zsh と dash の -c 経路は未固定だった。上限超過の sleep と ループ本体の sleep を含む zsh -c / dash -c 入力を DENY_SLEEP に足し、いずれも拒否 (deny)になる現状を固定する。対象コードは変更していない。 Item-Id: R1-001 Round: 1 Impl-Runtime: kiro Impl-Model: default --- plugins/ndf/scripts/tests/test_token_guard.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/plugins/ndf/scripts/tests/test_token_guard.py b/plugins/ndf/scripts/tests/test_token_guard.py index 3443b0ba..a542c48b 100644 --- a/plugins/ndf/scripts/tests/test_token_guard.py +++ b/plugins/ndf/scripts/tests/test_token_guard.py @@ -85,6 +85,10 @@ def bash(cmd, **extra): "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", "while a; do while b; do sleep 1; done; done", "while a; do\n for i in 1 2; do sleep 1; done\ndone", From edfde0109e1da5e7852fba3b265a1f7d1a7d6841 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 23 Sep 2026 02:38:10 +0000 Subject: [PATCH 05/18] =?UTF-8?q?Test:=20select=20=E3=83=AB=E3=83=BC?= =?UTF-8?q?=E3=83=97=E5=86=85=20sleep=20=E3=81=AE=E7=8F=BE=E7=8A=B6?= =?UTF-8?q?=E3=82=92=E5=9B=BA=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit select ループ本体の上限超過と上限以下の sleep 判定を既存のパラメータ化テストへ追加する。 Item-Id: R1-002 Round: 1 Impl-Runtime: codex Impl-Model: default --- plugins/ndf/scripts/tests/test_token_guard.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/plugins/ndf/scripts/tests/test_token_guard.py b/plugins/ndf/scripts/tests/test_token_guard.py index a542c48b..f811bc59 100644 --- a/plugins/ndf/scripts/tests/test_token_guard.py +++ b/plugins/ndf/scripts/tests/test_token_guard.py @@ -90,6 +90,7 @@ def bash(cmd, **extra): "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"', @@ -102,6 +103,7 @@ def bash(cmd, **extra): "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", From 547e8de53248c6c61d3c34a5672b2ef50ee0b78a Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 23 Sep 2026 02:46:36 +0000 Subject: [PATCH 06/18] =?UTF-8?q?Test:=20characterization=20=E2=80=94=20pl?= =?UTF-8?q?ugins/ndf/scripts/token-guard.sh#guard=5Fcontext?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit assistant の usage が複数あるとき、最後の値で上限判定する経路を現状固定する。 上限超過の後に上限以内の usage が来ると通り、順序を逆にすると拒否されることを 比較する現状固定テストを追加。対象コードは変更しない。 Item-Id: R1-003 Round: 1 Impl-Runtime: kiro Impl-Model: default --- plugins/ndf/scripts/tests/test_token_guard.py | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/plugins/ndf/scripts/tests/test_token_guard.py b/plugins/ndf/scripts/tests/test_token_guard.py index f811bc59..6acd645c 100644 --- a/plugins/ndf/scripts/tests/test_token_guard.py +++ b/plugins/ndf/scripts/tests/test_token_guard.py @@ -322,6 +322,21 @@ def transcript(tmp_path, total, name="t.jsonl", usage=True): 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)} @@ -363,6 +378,15 @@ def test_context_within_limit_passes(tmp_path, state): 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_subagent_passes(tmp_path, state): tp = transcript(tmp_path, 250_000) assert denied(run(skill(tp, agent_id="a1"), state)) is None From 222bd9a9d9f8be8bb88404dbcf9d9ba1b402c1fc Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 23 Sep 2026 02:55:02 +0000 Subject: [PATCH 07/18] =?UTF-8?q?Test:=20=E5=A3=8A=E3=82=8C=E3=81=9Ftransc?= =?UTF-8?q?ript=E3=81=AEfail-open=E3=82=92=E5=9B=BA=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上限超過のusageに壊れたJSON行が続く場合、公開hookが拒否せず終了する現状を結合テストで固定する。 Item-Id: R1-004 Round: 1 Impl-Runtime: codex Impl-Model: default --- plugins/ndf/scripts/tests/test_token_guard.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/plugins/ndf/scripts/tests/test_token_guard.py b/plugins/ndf/scripts/tests/test_token_guard.py index 6acd645c..01e0a30b 100644 --- a/plugins/ndf/scripts/tests/test_token_guard.py +++ b/plugins/ndf/scripts/tests/test_token_guard.py @@ -387,6 +387,17 @@ def test_context_uses_last_assistant_usage(tmp_path, state): assert denied(run(skill(under_then_over, session="suo"), 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 From 12ef269116c74f67a2d3bef0cc377dcd74b75cc2 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 23 Sep 2026 03:03:30 +0000 Subject: [PATCH 08/18] =?UTF-8?q?Test:=20characterization=20=E2=80=94=20pl?= =?UTF-8?q?ugins/ndf/scripts/token-guard.sh#guard=5Fread?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 既存の session 読み直し状態 JSON が壊れている場合の、guard_read の 読み直し判定と状態の回復を現状固定する。壊れた状態では 0 から数え直し、 最初の Read が通って状態が有効な JSON(count=1)へ置き換わり、以後は 現在の上限回で拒否されることを固定する。 Item-Id: R1-005 Round: 1 Impl-Runtime: kiro Impl-Model: default --- plugins/ndf/scripts/tests/test_token_guard.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/plugins/ndf/scripts/tests/test_token_guard.py b/plugins/ndf/scripts/tests/test_token_guard.py index 01e0a30b..19e47e2d 100644 --- a/plugins/ndf/scripts/tests/test_token_guard.py +++ b/plugins/ndf/scripts/tests/test_token_guard.py @@ -199,6 +199,24 @@ def test_repeat_read_resets_on_replaced_file(tmp_path, state): 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("") From bb24659b36054d5b6902d86615b34203d0db83c4 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 23 Sep 2026 03:18:02 +0000 Subject: [PATCH 09/18] =?UTF-8?q?Test:=20context=20guard=20=E3=81=AE?= =?UTF-8?q?=E6=9C=AB=E5=B0=BE200=E8=A1=8C=E5=A2=83=E7=95=8C=E3=82=92?= =?UTF-8?q?=E5=9B=BA=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上限超過 usage が末尾200行の外側と内側にある場合の現状挙動を比較する。 Item-Id: R2-001 Round: 2 Impl-Runtime: codex Impl-Model: default --- plugins/ndf/scripts/tests/test_token_guard.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/plugins/ndf/scripts/tests/test_token_guard.py b/plugins/ndf/scripts/tests/test_token_guard.py index 19e47e2d..d026b735 100644 --- a/plugins/ndf/scripts/tests/test_token_guard.py +++ b/plugins/ndf/scripts/tests/test_token_guard.py @@ -405,6 +405,21 @@ def test_context_uses_last_assistant_usage(tmp_path, state): 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) From 8959ebee0f1109f70afe5ba47d048d0c4c8b12c4 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 23 Sep 2026 03:26:32 +0000 Subject: [PATCH 10/18] =?UTF-8?q?Test:=20characterization=20=E2=80=94=20pl?= =?UTF-8?q?ugins/ndf/scripts/token-guard.sh#guard=5Fcontext?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit guard_context の agent 分岐で、description の課題番号が # を付けない裸の番号でも 案内が sed 's/^/#/' で # 付きへ整えられる振る舞いを現状固定する。検査/取り込み/仕上げ の 3 入力が終了コード 0 の deny になり、/ndf:development-workflow #829 を示すことを固定。 Item-Id: R2-002 Round: 2 Impl-Runtime: kiro Impl-Model: default --- plugins/ndf/scripts/tests/test_token_guard.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/plugins/ndf/scripts/tests/test_token_guard.py b/plugins/ndf/scripts/tests/test_token_guard.py index d026b735..ee8872d4 100644 --- a/plugins/ndf/scripts/tests/test_token_guard.py +++ b/plugins/ndf/scripts/tests/test_token_guard.py @@ -475,6 +475,17 @@ def test_context_agent_once_then_pass(tmp_path, state): assert denied(run(agent(tp, desc="実装: #829"), state)) +@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_guard_env(tmp_path, state): tp = transcript(tmp_path, 250_000) assert denied(run(skill(tp), state, {"NDF_CONTEXT_GUARD": "0"})) is None From b97b7dacef82ed83e2f3699119cb2e4e29e5a680 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 23 Sep 2026 03:35:06 +0000 Subject: [PATCH 11/18] =?UTF-8?q?Test:=20=E6=AC=A0=E8=90=BD=E3=83=95?= =?UTF-8?q?=E3=82=A1=E3=82=A4=E3=83=AB=E3=81=AE=E9=80=A3=E7=B6=9ARead?= =?UTF-8?q?=E3=82=92=E7=8F=BE=E7=8A=B6=E5=9B=BA=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 存在しない同一ファイルへのReadが3回目に拒否される境界経路を固定する。 Item-Id: R2-003 Round: 2 Impl-Runtime: codex Impl-Model: default --- plugins/ndf/scripts/tests/test_token_guard.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/plugins/ndf/scripts/tests/test_token_guard.py b/plugins/ndf/scripts/tests/test_token_guard.py index ee8872d4..2ab995dd 100644 --- a/plugins/ndf/scripts/tests/test_token_guard.py +++ b/plugins/ndf/scripts/tests/test_token_guard.py @@ -167,6 +167,15 @@ def test_repeat_read_denied_on_third(tmp_path, state): 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") From 5236de77bccffeb9040687d8422bd9334a4452f8 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 23 Sep 2026 03:43:35 +0000 Subject: [PATCH 12/18] =?UTF-8?q?Test:=20guard=5Fsleep=20=E3=81=AE?= =?UTF-8?q?=E6=99=82=E9=96=93=E3=83=BB=E6=97=A5=E5=8D=98=E4=BD=8D=E3=81=A8?= =?UTF-8?q?=E5=B0=8F=E6=95=B0=E7=A7=92=E6=8F=9B=E7=AE=97=E3=81=AE=E7=8F=BE?= =?UTF-8?q?=E7=8A=B6=E5=9B=BA=E5=AE=9A=20=E2=80=94=20plugins/ndf/scripts/t?= =?UTF-8?q?oken-guard.sh#guard=5Fsleep?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DENY_SLEEP は 'sleep 1m'(分)だけを固定し、時間・日の単位(h / d)と小数の 秒換算の経路が固定されていなかった。既定の上限 5 秒に対し 'sleep 0.1h'(360 秒)と 'sleep 1d'(86400 秒)が拒否されることを、waiting.md への部分一致だけで固定する 現状固定テストを追加した。対象のコードは変更していない。 Item-Id: R2-004 Round: 2 Impl-Runtime: kiro Impl-Model: default --- plugins/ndf/scripts/tests/test_token_guard.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/plugins/ndf/scripts/tests/test_token_guard.py b/plugins/ndf/scripts/tests/test_token_guard.py index 2ab995dd..475df33e 100644 --- a/plugins/ndf/scripts/tests/test_token_guard.py +++ b/plugins/ndf/scripts/tests/test_token_guard.py @@ -132,6 +132,17 @@ def test_sleep_allowed(cmd, state): assert denied(run(bash(cmd), state)) is None, cmd +@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 From 15385c2772b953a3cf73e56e013f834a8c03b5a2 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 23 Sep 2026 03:52:06 +0000 Subject: [PATCH 13/18] =?UTF-8?q?Test:=20here-string=20=E3=81=AE=20sleep?= =?UTF-8?q?=20=E5=88=A4=E5=AE=9A=E3=82=92=E7=8F=BE=E7=8A=B6=E5=9B=BA?= =?UTF-8?q?=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit here-string の内容をヒアドキュメント本文と誤認せず、拒否しない既存経路を固定する。 Item-Id: R2-005 Round: 2 Impl-Runtime: codex Impl-Model: default --- plugins/ndf/scripts/tests/test_token_guard.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/plugins/ndf/scripts/tests/test_token_guard.py b/plugins/ndf/scripts/tests/test_token_guard.py index 475df33e..420e33f4 100644 --- a/plugins/ndf/scripts/tests/test_token_guard.py +++ b/plugins/ndf/scripts/tests/test_token_guard.py @@ -132,6 +132,11 @@ 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'(分)だけを固定していたが、時間・日の単位と From 2dcef96a78f8c3b62d64650154fedee832786987 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 23 Sep 2026 04:04:13 +0000 Subject: [PATCH 14/18] =?UTF-8?q?Fix:=20#844=20=E3=83=A9=E3=82=A6=E3=83=B3?= =?UTF-8?q?=E3=83=89=201=20=E3=81=AE=E6=8C=87=E6=91=98=E3=82=92=E5=8F=8D?= =?UTF-8?q?=E6=98=A0=EF=BC=88sleep=20=E5=88=A4=E5=AE=9A=E3=81=AE=E4=BB=A3?= =?UTF-8?q?=E5=85=A5=E8=AA=9E=E3=83=BB=E8=83=8C=E6=99=AF=E5=AE=9F=E8=A1=8C?= =?UTF-8?q?=E3=83=BB=E5=BC=95=E6=95=B0=E4=BB=98=E3=81=8D=E3=82=AA=E3=83=97?= =?UTF-8?q?=E3=82=B7=E3=83=A7=E3=83=B3=E3=83=BB=E3=83=AB=E3=83=BC=E3=83=97?= =?UTF-8?q?=E5=86=85=E3=81=AE=E5=8B=95=E7=9A=84=E7=A7=92=E6=95=B0=E3=80=81?= =?UTF-8?q?=E7=89=88=E6=95=B0=E3=82=92=E8=AA=B2=E9=A1=8C=E7=95=AA=E5=8F=B7?= =?UTF-8?q?=E3=81=A8=E8=AA=AD=E3=81=BE=E3=81=AA=E3=81=84=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01LdEztjNvLXF6EasAvTjAfk --- plugins/ndf/scripts/lib/token_guard_sleep.py | 25 +++++++++++++------ plugins/ndf/scripts/tests/test_token_guard.py | 14 +++++++++++ plugins/ndf/scripts/token-guard.sh | 3 ++- .../references/waiting.md | 4 +-- 4 files changed, 35 insertions(+), 11 deletions(-) diff --git a/plugins/ndf/scripts/lib/token_guard_sleep.py b/plugins/ndf/scripts/lib/token_guard_sleep.py index 23bb07e3..ee715f35 100644 --- a/plugins/ndf/scripts/lib/token_guard_sleep.py +++ b/plugins/ndf/scripts/lib/token_guard_sleep.py @@ -3,10 +3,11 @@ 標準入力にコマンドの文字列を受け、第 1 引数に秒数の上限を受ける。拒否するなら終了コード 1、 通すなら 0 で終わる。**読めないコマンドは通す**(hook の失敗でツールを止めない)。 -拒否するのは、コマンドの位置にある `sleep <数>` が次のどちらかに当たるときだけである。 +拒否するのは、コマンドの位置にある `sleep <引数>` が次のどちらかに当たるときだけである。 +`&` で終わる(バックグラウンドで動く)`sleep` は前景を待たせないため見ない。 -- `while` / `until` のループの本体(`do` と対応する `done` の間)にある -- 秒数が上限を超える +- `while` / `until` のループの本体(`do` と対応する `done` の間)にある(秒数が変数でも止める) +- 秒数が数で、上限を超える コメント・引用の中・ヒアドキュメントの本文は見ない。`bash -c` / `sh -c` / `zsh -c` / `eval` の 実行される引数は、取り出して同じ規則で見る。 @@ -21,6 +22,9 @@ # コマンドの位置を作る語。この後ろの語はコマンドとして読む OPENERS = {"do", "then", "else", "elif", "if", "while", "until", "{", "!", "time"} UNIT = {"": 1, "s": 1, "m": 60, "h": 3600, "d": 86400} +# 引数を取る shell のオプション。引数を読み飛ばして `-c` を探す +SHELL_OPTS_WITH_ARG = {"-o", "+o", "-O", "+O"} +ASSIGN = re.compile(r"[A-Za-z_][A-Za-z0-9_]*(\[[^]]*\])?\+?=") HEREDOC = re.compile(r"<<(-?)\s*(['\"]?)([A-Za-z_][A-Za-z0-9_]*)\2") @@ -80,16 +84,21 @@ def should_deny(text: str, limit: float, in_loop: bool = False, depth: int = 0) stack[-1] = "body" if stack[-1] == "cond" else "forbody" elif tok == "done" and stack: stack.pop() - elif tok == "sleep" and i + 1 < len(toks): + elif tok == "sleep" and i + 1 < len(toks) and not is_separator(toks[i + 1]): + background = i + 2 < len(toks) and toks[i + 2] == "&" sec = seconds(toks[i + 1]) - if sec is not None and (looping or sec > limit): + if not background and (looping or (sec is not None and sec > limit)): return True - cmd_pos = tok in OPENERS + # 先頭の代入語(`X=1 sleep 30`)の後ろもコマンドの位置のまま + cmd_pos = tok in OPENERS or bool(ASSIGN.match(tok)) # 実行される引数を取り出して同じ規則で見る if tok in SHELLS: j = i + 1 - while j < len(toks) and toks[j].startswith("-") and not is_separator(toks[j]): - if "c" in toks[j].lstrip("-") and not toks[j].startswith("--"): + while j < len(toks) and toks[j][:1] in "-+" and not is_separator(toks[j]): + if toks[j] in SHELL_OPTS_WITH_ARG: + j += 2 + continue + if toks[j].startswith("-") and "c" in toks[j].lstrip("-") and not toks[j].startswith("--"): if j + 1 < len(toks) and should_deny(toks[j + 1], limit, looping, depth + 1): return True break diff --git a/plugins/ndf/scripts/tests/test_token_guard.py b/plugins/ndf/scripts/tests/test_token_guard.py index 420e33f4..138b95fa 100644 --- a/plugins/ndf/scripts/tests/test_token_guard.py +++ b/plugins/ndf/scripts/tests/test_token_guard.py @@ -96,6 +96,10 @@ def bash(cmd, **extra): '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", ] ALLOW_SLEEP = [ @@ -115,6 +119,8 @@ def bash(cmd, **extra): "sleep $X", "ls -la", "while read l; do echo $l; done < f", + "sleep 30 & echo done", + "echo X=1 sleep 30", ] @@ -500,6 +506,14 @@ def test_context_agent_once_then_pass(tmp_path, state): assert denied(run(agent(tp, desc="実装: #829"), state)) +@pytest.mark.parametrize("desc", ["設計: v10.16.1 のリリース作業", "実装: リリース 2.0.3"]) +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 の課題番号が # を付けない裸の番号でも、案内は diff --git a/plugins/ndf/scripts/token-guard.sh b/plugins/ndf/scripts/token-guard.sh index 48a4eaf7..3420eb8a 100755 --- a/plugins/ndf/scripts/token-guard.sh +++ b/plugins/ndf/scripts/token-guard.sh @@ -160,7 +160,8 @@ guard_context() { fi [ "$total" -gt "$limit" ] || exit 0 write_json "$mark" "$(jq -cn --arg k "$key" '{key:$k}')" - issues=$(printf '%s\n' "$words" | grep -oE '(^|[^0-9A-Za-z_/])#?[0-9]+\b' \ + # 版数・小数(v10.16.1 / 2.0.3)は課題番号ではないので先に取り除く + issues=$(printf '%s\n' "$words" | sed -E '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)" diff --git a/plugins/ndf/skills/development-workflow/references/waiting.md b/plugins/ndf/skills/development-workflow/references/waiting.md index bd831159..6432f446 100644 --- a/plugins/ndf/skills/development-workflow/references/waiting.md +++ b/plugins/ndf/skills/development-workflow/references/waiting.md @@ -64,11 +64,11 @@ | 判定 | 止める条件 | 止め方 | 上限を変える | | --- | --- | --- | --- | -| sleep | 前景の Bash で、コマンドの位置の `sleep <数>` が `while` / `until` のループの本体にあるか、秒数が上限を超える。コメント・引用・ヒアドキュメントの本文は見ず、`bash -c` / `sh -c` / `eval` の中身は見る | `NDF_SLEEP_GUARD=0` | `NDF_SLEEP_MAX_SEC`(既定 5) | +| sleep | 前景の Bash で、コマンドの位置(先頭の代入語 `X=1` の後ろを含む)の `sleep` が `while` / `until` のループの本体にある(秒数が変数でも止める)か、秒数が上限を超える。コメント・引用・ヒアドキュメントの本文は見ず、`bash -c` / `sh -c` / `eval` の中身は見る | `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`、`for` のループの中の上限以下の `sleep`、`&` で終わるバックグラウンドの `sleep` - **判定が失敗したときは止めない**(入力が読めない・`jq` や `python3` が無い・控えを書けない) - 同じ hook が、文脈が上限を超えた conductor の工程の起動も止める([context-window.md](context-window.md) の「上限を超えたら hook が止める」) From fba6e05e0a1d51b71e344ab4da3b142b20869c21 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 23 Sep 2026 04:11:26 +0000 Subject: [PATCH 15/18] =?UTF-8?q?Fix:=20#844=20=E6=9C=80=E7=B5=82=E3=82=B9?= =?UTF-8?q?=E3=82=A4=E3=83=BC=E3=83=97=E3=81=AE=E6=8C=87=E6=91=98=E3=82=92?= =?UTF-8?q?=E5=8F=8D=E6=98=A0=EF=BC=88sleep=20=E5=88=A4=E5=AE=9A=E3=81=AE?= =?UTF-8?q?=E8=83=8C=E6=99=AF=E5=AE=9F=E8=A1=8C=E3=83=BB=E5=86=8D=E5=B8=B0?= =?UTF-8?q?=E3=81=AE=E4=BD=8D=E7=BD=AE=E3=83=BB=E7=B5=90=E5=90=88=E5=BD=A2?= =?UTF-8?q?=E3=81=AE=E3=82=AA=E3=83=97=E3=82=B7=E3=83=A7=E3=83=B3=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 背景実行は sleep の引数の後ろから次のコマンド境界までの `&` で判定する(`sleep 30 >/tmp/x &` を通す)。`2>&1` / `>&` / `&>` の `&` は背景と読まない - bash / sh / zsh / dash -c と eval の中身を見るのはコマンドの位置の語だけにする(`echo bash -c ...` を止めない)。`timeout 590` / `nohup` / `env` などの前置きの後ろもコマンドの位置とする - 末尾が o / O の結合形のオプション(`-euo pipefail`)の引数を読み飛ばして `-c` を探す - waiting.md の説明を判定に合わせる Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01LdEztjNvLXF6EasAvTjAfk --- plugins/ndf/scripts/lib/token_guard_sleep.py | 43 ++++++++++++++----- plugins/ndf/scripts/tests/test_token_guard.py | 12 ++++++ .../references/waiting.md | 4 +- 3 files changed, 47 insertions(+), 12 deletions(-) diff --git a/plugins/ndf/scripts/lib/token_guard_sleep.py b/plugins/ndf/scripts/lib/token_guard_sleep.py index ee715f35..b97653de 100644 --- a/plugins/ndf/scripts/lib/token_guard_sleep.py +++ b/plugins/ndf/scripts/lib/token_guard_sleep.py @@ -4,13 +4,16 @@ 通すなら 0 で終わる。**読めないコマンドは通す**(hook の失敗でツールを止めない)。 拒否するのは、コマンドの位置にある `sleep <引数>` が次のどちらかに当たるときだけである。 -`&` で終わる(バックグラウンドで動く)`sleep` は前景を待たせないため見ない。 +`&` で終わる(バックグラウンドで動く)`sleep` は前景を待たせないため見ない。`&` の判定は +sleep の引数の後ろから次のコマンド境界までを見る(`sleep 30 >/tmp/x &` も背景)。リダイレクトの +`>&` / `2>&1` / `&>` の `&` は背景と読まない。 - `while` / `until` のループの本体(`do` と対応する `done` の間)にある(秒数が変数でも止める) - 秒数が数で、上限を超える -コメント・引用の中・ヒアドキュメントの本文は見ない。`bash -c` / `sh -c` / `zsh -c` / `eval` の -実行される引数は、取り出して同じ規則で見る。 +コメント・引用の中・ヒアドキュメントの本文は見ない。コマンドの位置にある `bash -c` / `sh -c` / +`zsh -c` / `dash -c` / `eval` の実行される引数は、取り出して同じ規則で見る(`echo bash -c ...` の +ような引数の中の語は見ない)。`timeout 590` / `nohup` / `env` などの前置きの後ろもコマンドの位置とする。 """ from __future__ import annotations @@ -22,8 +25,10 @@ # コマンドの位置を作る語。この後ろの語はコマンドとして読む OPENERS = {"do", "then", "else", "elif", "if", "while", "until", "{", "!", "time"} UNIT = {"": 1, "s": 1, "m": 60, "h": 3600, "d": 86400} -# 引数を取る shell のオプション。引数を読み飛ばして `-c` を探す -SHELL_OPTS_WITH_ARG = {"-o", "+o", "-O", "+O"} +# 後ろの語をコマンドとして実行する前置き。オプションと数の引数(`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_]*(\[[^]]*\])?\+?=") HEREDOC = re.compile(r"<<(-?)\s*(['\"]?)([A-Za-z_][A-Za-z0-9_]*)\2") @@ -60,6 +65,19 @@ 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) -> bool: + """toks[j] から次のコマンド境界までに、背景実行の `&` があるか。""" + while j < len(toks): + if is_separator(toks[j]): + if toks[j] != "&": + return False + redirect = toks[j - 1] in (">", "<") or (j + 1 < len(toks) and toks[j + 1] == ">") + if not redirect: + return True + j += 1 + return False + + def should_deny(text: str, limit: float, in_loop: bool = False, depth: int = 0) -> bool: if depth > 5: return False @@ -70,6 +88,7 @@ def should_deny(text: str, limit: float, in_loop: bool = False, depth: int = 0) i = 0 while i < len(toks): tok = toks[i] + at_cmd = cmd_pos looping = in_loop or "body" in stack if is_separator(tok): cmd_pos = True @@ -85,17 +104,21 @@ def should_deny(text: str, limit: float, in_loop: bool = False, depth: int = 0) elif tok == "done" and stack: stack.pop() elif tok == "sleep" and i + 1 < len(toks) and not is_separator(toks[i + 1]): - background = i + 2 < len(toks) and toks[i + 2] == "&" sec = seconds(toks[i + 1]) - if not background and (looping or (sec is not None and sec > limit)): + if (looping or (sec is not None and sec > limit)) and not is_background(toks, i + 2): 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 tok in SHELLS: + 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 toks[j] in SHELL_OPTS_WITH_ARG: + 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("--"): @@ -103,7 +126,7 @@ def should_deny(text: str, limit: float, in_loop: bool = False, depth: int = 0) return True break j += 1 - elif tok == "eval": + elif at_cmd and tok == "eval": j, words = i + 1, [] while j < len(toks) and not is_separator(toks[j]): words.append(toks[j]) diff --git a/plugins/ndf/scripts/tests/test_token_guard.py b/plugins/ndf/scripts/tests/test_token_guard.py index 138b95fa..b50760b3 100644 --- a/plugins/ndf/scripts/tests/test_token_guard.py +++ b/plugins/ndf/scripts/tests/test_token_guard.py @@ -100,6 +100,13 @@ def bash(cmd, **extra): "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'", ] ALLOW_SLEEP = [ @@ -121,6 +128,11 @@ def bash(cmd, **extra): "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", ] diff --git a/plugins/ndf/skills/development-workflow/references/waiting.md b/plugins/ndf/skills/development-workflow/references/waiting.md index 6432f446..d1fcbde7 100644 --- a/plugins/ndf/skills/development-workflow/references/waiting.md +++ b/plugins/ndf/skills/development-workflow/references/waiting.md @@ -64,11 +64,11 @@ | 判定 | 止める条件 | 止め方 | 上限を変える | | --- | --- | --- | --- | -| sleep | 前景の Bash で、コマンドの位置(先頭の代入語 `X=1` の後ろを含む)の `sleep` が `while` / `until` のループの本体にある(秒数が変数でも止める)か、秒数が上限を超える。コメント・引用・ヒアドキュメントの本文は見ず、`bash -c` / `sh -c` / `eval` の中身は見る | `NDF_SLEEP_GUARD=0` | `NDF_SLEEP_MAX_SEC`(既定 5) | +| 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`、`for` のループの中の上限以下の `sleep`、同じコマンドの末尾が `&` のバックグラウンドの `sleep`(`sleep 30 >/tmp/x &` のようにリダイレクトを挟んでもよい。`2>&1` / `&>` の `&` は背景と読まない) - **判定が失敗したときは止めない**(入力が読めない・`jq` や `python3` が無い・控えを書けない) - 同じ hook が、文脈が上限を超えた conductor の工程の起動も止める([context-window.md](context-window.md) の「上限を超えたら hook が止める」) From 9ec21b3bf1941fef79314be6b947470a6c0149b8 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 23 Sep 2026 04:20:22 +0000 Subject: [PATCH 16/18] =?UTF-8?q?Fix:=20#844=20sleep=20=E5=88=A4=E5=AE=9A?= =?UTF-8?q?=E3=81=A7=E5=9B=B2=E3=81=BF=E3=81=AE=E8=A4=87=E5=90=88=E3=82=B3?= =?UTF-8?q?=E3=83=9E=E3=83=B3=E3=83=89=E3=81=AE=E8=83=8C=E6=99=AF=E5=AE=9F?= =?UTF-8?q?=E8=A1=8C=E3=82=92=E9=80=9A=E3=81=97=E3=80=81=E8=AA=B2=E9=A1=8C?= =?UTF-8?q?=E7=95=AA=E5=8F=B7=E6=8A=BD=E5=87=BA=E3=81=A7=E6=97=A5=E4=BB=98?= =?UTF-8?q?=E3=82=92=E9=99=A4=E3=81=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - (sleep 30) & / { sleep 30; } & / while ...; do sleep 1; done & / sleep 30 && echo x & を 背景として通す。前景のまま待つ形は引き続き止める - 課題番号の抽出で、ハイフン区切りの日付(2026-09-23)も版数と同じく取り除く - waiting.md の止めないものの説明を判定に合わせる Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01LdEztjNvLXF6EasAvTjAfk --- plugins/ndf/scripts/lib/token_guard_sleep.py | 69 ++++++++++++++++--- plugins/ndf/scripts/tests/test_token_guard.py | 24 ++++++- plugins/ndf/scripts/token-guard.sh | 4 +- .../references/waiting.md | 2 +- 4 files changed, 84 insertions(+), 15 deletions(-) diff --git a/plugins/ndf/scripts/lib/token_guard_sleep.py b/plugins/ndf/scripts/lib/token_guard_sleep.py index b97653de..8acef2ed 100644 --- a/plugins/ndf/scripts/lib/token_guard_sleep.py +++ b/plugins/ndf/scripts/lib/token_guard_sleep.py @@ -5,8 +5,10 @@ 拒否するのは、コマンドの位置にある `sleep <引数>` が次のどちらかに当たるときだけである。 `&` で終わる(バックグラウンドで動く)`sleep` は前景を待たせないため見ない。`&` の判定は -sleep の引数の後ろから次のコマンド境界までを見る(`sleep 30 >/tmp/x &` も背景)。リダイレクトの -`>&` / `2>&1` / `&>` の `&` は背景と読まない。 +sleep の引数の後ろから、sleep を含むリスト(`&&` / `||` / `|` でつながる範囲)の終わりまでを見る +(`sleep 30 >/tmp/x &` も背景)。sleep を囲む `( )` / `{ }` / ループ / `if` があれば、その閉じの +後ろの `&` まで見る(`(sleep 30) &`・`while ...; do sleep 1; done &` も背景)。リダイレクトの +`>&` / `2>&1` / `&>` の `&` は背景と読まない。字句による近似で、`case` の囲みは数えない。 - `while` / `until` のループの本体(`do` と対応する `done` の間)にある(秒数が変数でも止める) - 秒数が数で、上限を超える @@ -30,6 +32,9 @@ # 引数を取る 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") @@ -65,15 +70,51 @@ 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) -> bool: - """toks[j] から次のコマンド境界までに、背景実行の `&` があるか。""" +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): - if is_separator(toks[j]): - if toks[j] != "&": - return False - redirect = toks[j - 1] in (">", "<") or (j + 1 < len(toks) and toks[j + 1] == ">") - if not redirect: - return True + 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 @@ -84,6 +125,7 @@ def should_deny(text: str, limit: float, in_loop: bool = False, depth: int = 0) 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): @@ -91,10 +133,15 @@ def should_deny(text: str, limit: float, in_loop: bool = False, depth: int = 0) 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"): @@ -105,7 +152,7 @@ def should_deny(text: str, limit: float, in_loop: bool = False, depth: int = 0) 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): + 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): diff --git a/plugins/ndf/scripts/tests/test_token_guard.py b/plugins/ndf/scripts/tests/test_token_guard.py index b50760b3..ddcf595d 100644 --- a/plugins/ndf/scripts/tests/test_token_guard.py +++ b/plugins/ndf/scripts/tests/test_token_guard.py @@ -107,6 +107,13 @@ def bash(cmd, **extra): "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) &", ] ALLOW_SLEEP = [ @@ -133,6 +140,14 @@ def bash(cmd, **extra): "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 &", ] @@ -518,7 +533,7 @@ def test_context_agent_once_then_pass(tmp_path, state): assert denied(run(agent(tp, desc="実装: #829"), state)) -@pytest.mark.parametrize("desc", ["設計: v10.16.1 のリリース作業", "実装: リリース 2.0.3"]) +@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) @@ -537,6 +552,13 @@ def test_context_agent_bare_issue_number_is_normalized(tmp_path, state, desc): 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 + + 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 diff --git a/plugins/ndf/scripts/token-guard.sh b/plugins/ndf/scripts/token-guard.sh index 3420eb8a..b6507b92 100755 --- a/plugins/ndf/scripts/token-guard.sh +++ b/plugins/ndf/scripts/token-guard.sh @@ -160,8 +160,8 @@ guard_context() { fi [ "$total" -gt "$limit" ] || exit 0 write_json "$mark" "$(jq -cn --arg k "$key" '{key:$k}')" - # 版数・小数(v10.16.1 / 2.0.3)は課題番号ではないので先に取り除く - issues=$(printf '%s\n' "$words" | sed -E 's/[0-9]+(\.[0-9]+)+//g' | grep -oE '(^|[^0-9A-Za-z_/])#?[0-9]+\b' \ + # 版数・小数・日付(v10.16.1 / 2.0.3 / 2026-09-23)は課題番号ではないので先に取り除く + issues=$(printf '%s\n' "$words" | sed -E '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)" diff --git a/plugins/ndf/skills/development-workflow/references/waiting.md b/plugins/ndf/skills/development-workflow/references/waiting.md index d1fcbde7..cda4e277 100644 --- a/plugins/ndf/skills/development-workflow/references/waiting.md +++ b/plugins/ndf/skills/development-workflow/references/waiting.md @@ -68,7 +68,7 @@ | 連続 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 &` のようにリダイレクトを挟んでもよい。`2>&1` / `&>` の `&` は背景と読まない) + 外の上限以下の `sleep`、`for` のループの中の上限以下の `sleep`、末尾の `&` でバックグラウンドになる `sleep`(`sleep 30 >/tmp/x &` のようにリダイレクトを挟んでもよい。`sleep 30 && echo x &` のようなリストや、`(sleep 30) &`・`{ sleep 30; } &`・`while ...; do sleep 1; done &` のように sleep を囲む複合コマンドの全体が背景になる形も含む。`2>&1` / `&>` の `&` は背景と読まない) - **判定が失敗したときは止めない**(入力が読めない・`jq` や `python3` が無い・控えを書けない) - 同じ hook が、文脈が上限を超えた conductor の工程の起動も止める([context-window.md](context-window.md) の「上限を超えたら hook が止める」) From 01bb5d5cbc657d9ac3597ca5c871586c7421ad02 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 23 Sep 2026 04:28:54 +0000 Subject: [PATCH 17/18] =?UTF-8?q?Fix:=20#844=20bash=20-c=20/=20eval=20?= =?UTF-8?q?=E3=81=AE=E5=A4=96=E5=81=B4=E3=81=8C=E8=83=8C=E6=99=AF=E3=81=AA?= =?UTF-8?q?=E3=82=89=E4=B8=AD=E8=BA=AB=E3=82=92=E8=A6=8B=E3=81=9A=E3=80=81?= =?UTF-8?q?=E8=AA=B2=E9=A1=8C=E7=95=AA=E5=8F=B7=E6=8A=BD=E5=87=BA=E3=81=A7?= =?UTF-8?q?=E7=AF=84=E5=9B=B2=E3=82=92=E6=AE=8B=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - token_guard_sleep.py: bash -c / eval の中身を再帰検査する前に、既存の is_background で外側の & を判定する - token-guard.sh: 日付(YYYY-MM-DD)だけを先に除き、版数は [0-9]+(\.[0-9]+)+ に戻す。#829-830 / 829-830 は #829 #830 として案内する - test_token_guard.py: 背景の bash -c / eval と、版数・日付・範囲を含む description の抽出結果を固定する - waiting.md: 止めないものに bash -c / eval の外側が背景になる形を加える Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01LdEztjNvLXF6EasAvTjAfk --- plugins/ndf/scripts/lib/token_guard_sleep.py | 8 ++++--- plugins/ndf/scripts/tests/test_token_guard.py | 22 +++++++++++++++++++ plugins/ndf/scripts/token-guard.sh | 5 +++-- .../references/waiting.md | 2 +- 4 files changed, 31 insertions(+), 6 deletions(-) diff --git a/plugins/ndf/scripts/lib/token_guard_sleep.py b/plugins/ndf/scripts/lib/token_guard_sleep.py index 8acef2ed..cd4536b5 100644 --- a/plugins/ndf/scripts/lib/token_guard_sleep.py +++ b/plugins/ndf/scripts/lib/token_guard_sleep.py @@ -15,7 +15,7 @@ コメント・引用の中・ヒアドキュメントの本文は見ない。コマンドの位置にある `bash -c` / `sh -c` / `zsh -c` / `dash -c` / `eval` の実行される引数は、取り出して同じ規則で見る(`echo bash -c ...` の -ような引数の中の語は見ない)。`timeout 590` / `nohup` / `env` などの前置きの後ろもコマンドの位置とする。 +ような引数の中の語は見ない)。外側が `&` で背景になる形(`bash -c 'sleep 30' &`)は中身を見ない。`timeout 590` / `nohup` / `env` などの前置きの後ろもコマンドの位置とする。 """ from __future__ import annotations @@ -169,7 +169,9 @@ def should_deny(text: str, limit: float, in_loop: bool = False, depth: int = 0) j += 2 continue if toks[j].startswith("-") and "c" in toks[j].lstrip("-") and not toks[j].startswith("--"): - if j + 1 < len(toks) and should_deny(toks[j + 1], limit, looping, depth + 1): + # `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 @@ -178,7 +180,7 @@ def should_deny(text: str, limit: float, in_loop: bool = False, depth: int = 0) while j < len(toks) and not is_separator(toks[j]): words.append(toks[j]) j += 1 - if should_deny(" ".join(words), limit, looping, depth + 1): + if not is_background(toks, j, groups) and should_deny(" ".join(words), limit, looping, depth + 1): return True i += 1 return False diff --git a/plugins/ndf/scripts/tests/test_token_guard.py b/plugins/ndf/scripts/tests/test_token_guard.py index ddcf595d..57f1a907 100644 --- a/plugins/ndf/scripts/tests/test_token_guard.py +++ b/plugins/ndf/scripts/tests/test_token_guard.py @@ -114,6 +114,9 @@ def bash(cmd, **extra): "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 = [ @@ -148,6 +151,11 @@ def bash(cmd, **extra): "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') &", ] @@ -559,6 +567,20 @@ def test_context_date_is_not_issue_number(tmp_path, 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 diff --git a/plugins/ndf/scripts/token-guard.sh b/plugins/ndf/scripts/token-guard.sh index b6507b92..f9e597d5 100755 --- a/plugins/ndf/scripts/token-guard.sh +++ b/plugins/ndf/scripts/token-guard.sh @@ -160,8 +160,9 @@ guard_context() { fi [ "$total" -gt "$limit" ] || exit 0 write_json "$mark" "$(jq -cn --arg k "$key" '{key:$k}')" - # 版数・小数・日付(v10.16.1 / 2.0.3 / 2026-09-23)は課題番号ではないので先に取り除く - issues=$(printf '%s\n' "$words" | sed -E 's/[0-9]+([.-][0-9]+)+//g' | grep -oE '(^|[^0-9A-Za-z_/])#?[0-9]+\b' \ + # 日付・版数・小数(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)" diff --git a/plugins/ndf/skills/development-workflow/references/waiting.md b/plugins/ndf/skills/development-workflow/references/waiting.md index cda4e277..a42c5922 100644 --- a/plugins/ndf/skills/development-workflow/references/waiting.md +++ b/plugins/ndf/skills/development-workflow/references/waiting.md @@ -68,7 +68,7 @@ | 連続 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 を囲む複合コマンドの全体が背景になる形も含む。`2>&1` / `&>` の `&` は背景と読まない) + 外の上限以下の `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 が止める」) From 65f2a54da4a2beb97453b48ce51df296dc848b4b Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 23 Sep 2026 04:45:23 +0000 Subject: [PATCH 18/18] =?UTF-8?q?Docs:=20#829=20#830=20=E3=81=AE=E8=A6=81?= =?UTF-8?q?=E6=B1=82=E3=83=BB=E8=A8=AD=E8=A8=88=E3=83=BB=E6=B1=BA=E5=AE=9A?= =?UTF-8?q?=E3=83=BB=E8=A8=88=E7=94=BB=E3=82=92=E7=A2=BA=E5=AE=9A=E4=BB=95?= =?UTF-8?q?=E6=A7=98=E3=81=B8=E3=81=BE=E3=81=A8=E3=82=81=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 要求・設計・決定の記録・実装計画の 4 本を、現行の実装と一致する確定仕様 docs/specifications/ndf-token-waits-and-context-cut.md へ書き直した。元の 4 本は issues/old/milestone-26-token-waits/ へ退避した。 - 設計の「未確認」の決着(サブエージェントは agent_id で見分ける・PreToolUse の時点で その呼び出しの assistant 行は未記録・通知が 2 回届くこと)を実装のとおりに書いた - sleep の判定の背景の扱い(& で背景になる形・dash -c)など、実装レビューで決まった規則を反映した - token-guard-stages.txt の「正」の参照先を確定仕様へ向け直した Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01LdEztjNvLXF6EasAvTjAfk --- docs/specifications/README.md | 1 + .../ndf-token-waits-and-context-cut.md | 373 ++++++++++++++++++ issues/old/README.md | 1 + .../issue-829-830-design-decisions.md | 0 .../issue-829-830-design.md | 0 .../issue-829-830-implementation-plan.md | 0 .../issue-829-830-requirements.md | 0 .../ndf/scripts/lib/token-guard-stages.txt | 4 +- 8 files changed, 377 insertions(+), 2 deletions(-) create mode 100644 docs/specifications/ndf-token-waits-and-context-cut.md rename issues/{ => old/milestone-26-token-waits}/issue-829-830-design-decisions.md (100%) rename issues/{ => old/milestone-26-token-waits}/issue-829-830-design.md (100%) rename issues/{ => old/milestone-26-token-waits}/issue-829-830-implementation-plan.md (100%) rename issues/{ => old/milestone-26-token-waits}/issue-829-830-requirements.md (100%) 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/issue-829-830-implementation-plan.md b/issues/old/milestone-26-token-waits/issue-829-830-implementation-plan.md similarity index 100% rename from issues/issue-829-830-implementation-plan.md rename to issues/old/milestone-26-token-waits/issue-829-830-implementation-plan.md 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/scripts/lib/token-guard-stages.txt b/plugins/ndf/scripts/lib/token-guard-stages.txt index 716a5123..7666d9ff 100644 --- a/plugins/ndf/scripts/lib/token-guard-stages.txt +++ b/plugins/ndf/scripts/lib/token-guard-stages.txt @@ -1,6 +1,6 @@ # 文脈量の hook(token-guard.sh)が見る工程 Skill の一覧(#830)。1 行 1 名。 -# 正は issues/issue-829-830-design.md の「工程 Skill の一覧」(context-window.md の 4 つの切れ目の直後の工程と入口)。 -# worktree など切れ目の内側の工程は載せない(決定 6)。 +# 正は docs/specifications/ndf-token-waits-and-context-cut.md の「工程 Skill の一覧」(context-window.md の 4 つの切れ目の直後の工程と入口)。 +# worktree など切れ目の内側の工程は載せない(理由は同じ仕様書の「文脈量の判定」)。 implementation-plan document-drafting cross-refactoring