@tyhld/conductor 0.11.4 → 0.11.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/relay.js CHANGED
@@ -1194,8 +1194,14 @@ export function sendToTmux(site, text) {
1194
1194
  }
1195
1195
  /**
1196
1196
  * 二重チェックの「アイドル」判定。
1197
- * RELAY_SETTLE_MS あけて2回 capture し、両方で looksIdle が成立(❯入力待ち行あり+
1198
- * BUSY_MARKERS/スピナー不在)すれば idle とみなす。
1197
+ * RELAY_SETTLE_MS あけて2回 capture し、両方で looksIdleFor が成立すれば idle とみなす。
1198
+ *
1199
+ * 【★職人の種類を見る(実配備 2026-09-06 で見つかった穴の根治)】
1200
+ * ADR-021 で「種類ごとのアイドル判定(looksIdleFor)」を足したのに、ここだけが Claude 専用の
1201
+ * looksIdle を呼び続けていた。Codex の画面は入力欄の形が違うので【永久にアイドルに見えず】、
1202
+ * 実験台(codex-probe)へ流した便が「作業中の可能性ありスキップ」を繰り返して1度も届かなかった。
1203
+ * ★kind の既定は 'claude'、かつ looksIdleFor('claude') は looksIdle と同一なので、
1204
+ * Claude 系統の挙動は1バイトも変わらない(テストで固定してある)。
1199
1205
  * いずれかの capture が失敗、または looksIdle 不成立なら false(送らない側)。
1200
1206
  *
1201
1207
  * 【設計変更】以前は末尾40行の完全一致(tailOf(cap1) === tailOf(cap2))も要求していたが、
@@ -1203,13 +1209,13 @@ export function sendToTmux(site, text) {
1203
1209
  * 同一指示を毎tickスキップし続ける不具合の原因だった。
1204
1210
  * 「2回連続でアイドルに見える」ことで十分保守的なので、末尾全文一致は要求しない。
1205
1211
  */
1206
- export async function isIdleStable(site) {
1212
+ export async function isIdleStable(site, kind = 'claude') {
1207
1213
  const cap1 = tmuxCapture(site);
1208
- if (cap1 === null || !looksIdle(cap1))
1214
+ if (cap1 === null || !looksIdleFor(cap1, kind))
1209
1215
  return false;
1210
1216
  await sleep(RELAY_SETTLE_MS);
1211
1217
  const cap2 = tmuxCapture(site);
1212
- if (cap2 === null || !looksIdle(cap2))
1218
+ if (cap2 === null || !looksIdleFor(cap2, kind))
1213
1219
  return false;
1214
1220
  return true;
1215
1221
  }
@@ -2014,7 +2020,7 @@ export async function relayTick(cfg, state, hooks) {
2014
2020
  log(`site=${cfg.site} id=${cmd.id} tmux未起動のため送らずスキップ`);
2015
2021
  return;
2016
2022
  }
2017
- if (!(await isIdleStable(cfg.site))) {
2023
+ if (!(await isIdleStable(cfg.site, kindOf(cfg)))) {
2018
2024
  // 連続スキップ回数を可観測化(観測のみ・送信挙動は変えない)。
2019
2025
  // 別idに変わっていたら連続をリセットしてから数え直す。
2020
2026
  if (state.lastSkippedId !== cmd.id) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tyhld/conductor",
3
- "version": "0.11.4",
3
+ "version": "0.11.6",
4
4
  "description": "采配くん管制の見守りアプリ(conductor-agent)。各PCで常駐し、中央(devlog-tracker)へ定期的に生存報告(heartbeat)を送る常駐CLI。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -19,6 +19,8 @@
19
19
  "sales-template",
20
20
  "scripts/hooks",
21
21
  "!scripts/hooks/__pycache__",
22
+ "scripts/codex",
23
+ "!scripts/codex/__pycache__",
22
24
  "skills",
23
25
  "launchd",
24
26
  "systemd",
@@ -0,0 +1,113 @@
1
+ #!/usr/bin/env python3
2
+ """codex-notify.py — Codex の `notify` から呼ばれ、完了と報告本文を relay へ渡す受け口(ADR-021)。
3
+
4
+ 【なぜ画面を読まないのか】
5
+ Claude Code では完了(DONE 行)と報告本文を画面から拾っていたが、全画面表示のせいで
6
+ さかのぼれず、報告が黙って落ちた(ADR-020)。Codex には `notify` があり、
7
+ ★完了と最終メッセージが【データで】届く。画面を読む必要がない。
8
+
9
+ 【受け取る形(実測 2026-09-02・codex 0.152.1)】引数1個に JSON がそのまま入る:
10
+ {"type":"agent-turn-complete",
11
+ "thread-id":"…","turn-id":"…",
12
+ "cwd":"/home/fshim/projects/<現場>",
13
+ "client":"codex-tui",
14
+ "input-messages":["<送った指示>"],
15
+ "last-assistant-message":"<職人の最後の発言>"}
16
+
17
+ 【★ふるい分け(実測に基づく)】1回の作業で通知が2件飛ぶ。
18
+ 1件は Codex 内部の「会話に題名を付ける」ターンで、次の特徴を持つ:
19
+ ・input-messages[0] が英文の指示で始まる
20
+ (実測値: "Generate a concise, single-line task title of at most 36 characters…")
21
+ ・last-assistant-message が {"title":"…"} という JSON 文字列
22
+ この2つのどちらかに当たる通知は【捨てる】。人が出した指示の完了だけを残す。
23
+
24
+ 【やること】残す通知だけを、現場ごとの受け皿へ1行足す。
25
+ 置き場: $CONDUCTOR_CODEX_NOTIFY_DIR(既定 $HOME/.tyhld/codex-notify)/<現場>.jsonl
26
+ ★中央へは送らない。送るのは relay の仕事(完了の経路は1本に保つ)。
27
+ ★何があっても exit 0(Codex の動作を止めない)。
28
+ """
29
+ import json
30
+ import os
31
+ import sys
32
+
33
+ # 内部の「題名を付ける」ターンの目印(実測値の先頭部分)。
34
+ TITLE_TURN_PROMPT = 'Generate a concise, single-line task title'
35
+
36
+
37
+ def notify_dir(env=None):
38
+ env = os.environ if env is None else env
39
+ explicit = (env.get('CONDUCTOR_CODEX_NOTIFY_DIR') or '').strip()
40
+ if explicit:
41
+ return explicit
42
+ home = (env.get('HOME') or '').strip()
43
+ return os.path.join(home, '.tyhld', 'codex-notify') if home else ''
44
+
45
+
46
+ def site_of(cwd):
47
+ """現場名=作業フォルダの名前(relay の現場名と同じ決め方)。"""
48
+ return os.path.basename(str(cwd or '').rstrip('/')) or ''
49
+
50
+
51
+ def is_internal_turn(payload):
52
+ """Codex 内部の「題名を付ける」ターンなら True(=人の指示の完了ではない)。"""
53
+ msgs = payload.get('input-messages')
54
+ if isinstance(msgs, list) and msgs and isinstance(msgs[0], str):
55
+ if TITLE_TURN_PROMPT in msgs[0]:
56
+ return True
57
+ last = payload.get('last-assistant-message')
58
+ if isinstance(last, str):
59
+ stripped = last.strip()
60
+ if stripped.startswith('{') and '"title"' in stripped:
61
+ return True
62
+ return False
63
+
64
+
65
+ def decide(payload):
66
+ """受け取った通知をどう扱うか。返り: (残すか, 現場名, 報告本文, 理由)"""
67
+ if not isinstance(payload, dict):
68
+ return False, '', '', 'not-json-object'
69
+ if payload.get('type') != 'agent-turn-complete':
70
+ return False, '', '', 'other-type'
71
+ if is_internal_turn(payload):
72
+ return False, '', '', 'internal-title-turn'
73
+ site = site_of(payload.get('cwd'))
74
+ if not site:
75
+ return False, '', '', 'no-site'
76
+ report = payload.get('last-assistant-message')
77
+ return True, site, report if isinstance(report, str) else '', ''
78
+
79
+
80
+ def main(argv):
81
+ raw = ' '.join(argv[1:]).strip()
82
+ if not raw:
83
+ return 0
84
+ try:
85
+ payload = json.loads(raw)
86
+ except Exception: # noqa: BLE001
87
+ return 0 # 読めない通知は黙って捨てる(Codex を止めない)
88
+ keep, site, report, _reason = decide(payload)
89
+ if not keep:
90
+ return 0
91
+ target_dir = notify_dir()
92
+ if not target_dir:
93
+ return 0
94
+ try:
95
+ os.makedirs(target_dir, exist_ok=True)
96
+ line = json.dumps(
97
+ {
98
+ 'site': site,
99
+ 'report': report,
100
+ 'threadId': payload.get('thread-id'),
101
+ 'turnId': payload.get('turn-id'),
102
+ },
103
+ ensure_ascii=False,
104
+ )
105
+ with open(os.path.join(target_dir, f'{site}.jsonl'), 'a', encoding='utf-8') as f:
106
+ f.write(line + '\n')
107
+ except Exception: # noqa: BLE001
108
+ return 0
109
+ return 0
110
+
111
+
112
+ if __name__ == '__main__':
113
+ sys.exit(main(sys.argv))
@@ -0,0 +1,46 @@
1
+ # ~/.codex/config.toml の見本(★ADR-021・Codex を職人として使う現場向け)
2
+ #
3
+ # ★これは見本(読むためのもの)です。このファイル自体は効きません。
4
+ #
5
+ # ★2026-09-06 から、この3つの鍵は【自動で配られます】。人が書き写す必要はありません:
6
+ # APPLY=1 bash scripts/update-guard.sh
7
+ # (中で scripts/hooks/install-codex-hooks.sh が ~/.codex/config.toml の
8
+ # notify / approval_policy / sandbox_mode の3行【だけ】を揃えます。
9
+ # ★信頼の記録や現場ごとの設定など、他の行は1文字も触りません。)
10
+ #
11
+ # ★notify のパスは配布時に $HOME/.tyhld/hooks/codex-notify.py へ書き換えられます。
12
+ # 絶対パスでなければならず(Codex はシェルを通さない)、かつリポジトリの置き場所に
13
+ # 依存してはいけない(npm で入れた顧客機では ~/projects/conductor が無い)ためです。
14
+
15
+ # ── 完了と報告の受け取り(★ここが要) ────────────────────────────────────
16
+ # Codex はターンが終わると、この命令へ JSON を1個の引数で渡します(実測 2026-09-02):
17
+ # {"type":"agent-turn-complete","thread-id":…,"turn-id":…,
18
+ # "cwd":"/home/<ユーザー>/projects/<現場>","client":"codex-tui",
19
+ # "input-messages":[…],"last-assistant-message":"<職人の最後の発言>"}
20
+ # 受け口は「内部の題名付けターン」をふるい落として、現場ごとの受け皿へ1行書きます。
21
+ # relay がそれを読み、いままでどおりの完了経路(中央への done 通知)へ橋渡しします。
22
+ #
23
+ # ★画面を読まないので、全画面表示でも報告が落ちません(ADR-020 の壊れ方が起きない)。
24
+ notify = ["python3", "/home/<ユーザー>/.tyhld/hooks/codex-notify.py"]
25
+
26
+ # ── 安全側の既定(★変えないでください) ──────────────────────────────────
27
+ # approval_policy:
28
+ # 人に聞く側にします。★この版(0.152.1)では "untrusted" は廃止されており、
29
+ # 起動時に「no longer supported」と拒否されます(実測で確認済み)。
30
+ # 使えるのは on-request / never の2つです。
31
+ #
32
+ # ★自走はこの鍵ではなく【起動オプション --approve-for-me】で作ります(実配備 2026-09-06 の実測):
33
+ # ① on-request のまま起動 … 途中で人に確認が出る(git コミットで実際に止まった)
34
+ # ② never で起動 … 確認は出ないが .git に書けず【コミットできない】=仕事にならない
35
+ # ③ --approve-for-me で起動 … ★確認は1度も出ず、コミットもできた。承認は Codex 自身の
36
+ # 自動レビューが workspace-write のまま判断し、PermissionRequest(番人)も今までどおり
37
+ # 呼ばれる=「聞かずに通した」履歴が管制に残る。
38
+ # ★--approve-for-me は --ask-for-approval と同時に指定できません(実測でエラー)。
39
+ # そのためこの鍵は on-request のままにします。意味は「もし素の codex で起動されたときは、
40
+ # 黙って進まず人に聞く側へ倒す」=安全側の受け皿です。
41
+ approval_policy = "on-request"
42
+
43
+ # sandbox_mode:
44
+ # 作業フォルダの中だけ書けるようにします。
45
+ # ★danger-full-access は絶対に使わないでください(守りが丸ごと外れます)。
46
+ sandbox_mode = "workspace-write"
@@ -100,10 +100,17 @@ def _deny_output(reason):
100
100
  }
101
101
 
102
102
 
103
- def build_output(data, datalink=hook_datalink):
103
+ def build_output(data, datalink=hook_datalink, codex=False):
104
104
  """入力1件から出力(dict) を作る。返り値 None は「何も出力しない(委譲=defer)」。
105
105
 
106
106
  ★datalink は差し替え可能(テストで到達可否を固定するため)。
107
+
108
+ ★codex=True(Codex から呼ばれたとき・登録に --codex を付ける):
109
+ allow を【返さない】。Codex は PreToolUse の allow を受け付けず、返すと毎回
110
+ 「PreToolUse hook (failed) / unsupported permissionDecision:allow」
111
+ が画面に出る(実測 2026-09-06・codex 0.152.1)。返さなくても意味は同じ(委譲)で、
112
+ そのあと Codex 自身の承認の流れ→PermissionRequest の番人(=自走で通し、履歴を残す)へ進む。
113
+ ★止める側(deny)は1文字も変えない。Codex でも今までどおり実際に止まる(ADR-022 の実測)。
107
114
  """
108
115
  result = policy.decide_event(data)
109
116
 
@@ -114,6 +121,8 @@ def build_output(data, datalink=hook_datalink):
114
121
  pass
115
122
 
116
123
  decision = _DECISION_MAP.get(result['decision'])
124
+ if codex and decision == 'allow':
125
+ return None # ★Codex は allow を受け付けない(上の説明)。委譲は allow と同じ意味。
117
126
  if decision is not None:
118
127
  return {
119
128
  'hookSpecificOutput': {
@@ -149,13 +158,16 @@ def build_output(data, datalink=hook_datalink):
149
158
 
150
159
 
151
160
  def main() -> int:
161
+ # ★--codex … Codex から呼ばれた印(登録先 ~/.codex/hooks.json に付けてある)。
162
+ # 知らない引数は無視する(増えても番人は落とさない)。
163
+ codex = '--codex' in sys.argv[1:]
152
164
  try:
153
165
  raw = sys.stdin.read()
154
166
  data = json.loads(raw) if raw.strip() else {}
155
167
  except Exception: # noqa: BLE001
156
168
  return 0 # 入力不正 → 委譲(フェイルセーフ)
157
169
  try:
158
- out = build_output(data)
170
+ out = build_output(data, codex=codex)
159
171
  if out is not None:
160
172
  sys.stdout.write(json.dumps(out, ensure_ascii=False))
161
173
  return 0
@@ -17,6 +17,16 @@
17
17
  " ・Stop には last_assistant_message が入る(Claude と同じ名前)",
18
18
  " ・★Notification は【存在しない】→ ADR-016 の「端末でしか押せない問い」は Codex では出せない",
19
19
  "",
20
+ "★--codex を付ける理由(実測 2026-09-06・実配備で判明):",
21
+ " Codex は PreToolUse の \"permissionDecision\":\"allow\" を受け付けません。返すと画面に",
22
+ " 『PreToolUse hook (failed) / error: PreToolUse hook returned unsupported permissionDecision:allow』",
23
+ " が毎回出ます(deny は効きます=ADR-022 の実測どおり)。--codex を付けると番人は",
24
+ " allow を【返さない】(何も出さない=委譲)ようになり、この失敗が出なくなります。",
25
+ " ★止める側(deny)は1文字も変わりません。Claude 側は従来どおり allow を返します。",
26
+ " 同じ理由で PermissionRequest にも --codex を付けます。Codex は決定に添える updatedInput を",
27
+ " 受け付けず、付けると『PermissionRequest hook (failed) / unsupported updatedInput』が出て",
28
+ " こちらの答えが丸ごと捨てられます(実測 2026-09-06)。--codex を付けると添えません。",
29
+ "",
20
30
  "★人の手番: Codex は【フックを信頼する】操作を1回求めます(実測の画面)。",
21
31
  " 『Hooks need review / 3 hooks are new or changed.』→『2. Trust all and continue』",
22
32
  " 番人を入れ替えたあとも、同じ確認がもう一度出ます(内容のハッシュで見ているため)。"
@@ -27,7 +37,7 @@
27
37
  "hooks": [
28
38
  {
29
39
  "type": "command",
30
- "command": "python3 $HOME/projects/conductor/scripts/hooks/cc2_guard.py"
40
+ "command": "python3 $HOME/projects/conductor/scripts/hooks/cc2_guard.py --codex"
31
41
  }
32
42
  ]
33
43
  }
@@ -38,7 +48,7 @@
38
48
  {
39
49
  "type": "command",
40
50
  "timeout": 600,
41
- "command": "python3 $HOME/projects/conductor/scripts/hooks/permission_request_hook.py"
51
+ "command": "python3 $HOME/projects/conductor/scripts/hooks/permission_request_hook.py --codex"
42
52
  }
43
53
  ]
44
54
  }
@@ -15,6 +15,9 @@
15
15
  # 2. 見本(codex-hooks.sample.json)のイベントを、コマンドのパスだけ固定パス $GUARD_HOME へ
16
16
  # 書き換えて ~/.codex/hooks.json へ登録する
17
17
  # 3. 顧客が足した他のイベント・他のキーは触らない(見本のイベントだけ差し替える)
18
+ # 4. 完了の受け口(codex-notify.py)を固定パス $GUARD_HOME へ置く
19
+ # 5. ~/.codex/config.toml の【3つの鍵だけ】を揃える(notify / approval_policy / sandbox_mode)
20
+ # ★他の行(信頼の記録・現場ごとの設定・顧客が足した設定)は1文字も触らない
18
21
  #
19
22
  # ★Claude 側(~/.claude/settings.json)には一切触れません。
20
23
  #
@@ -57,9 +60,16 @@ apply_mode = os.environ.get("APPLY") == "1"
57
60
  guard_home = os.environ["GUARD_HOME"]
58
61
 
59
62
  def at(cmd):
60
- """"python3 /どこか/xxx.py" → "python3 <GUARD_HOME>/xxx.py"(Claude 側と同じ勤務用コピー方式)。"""
63
+ """"python3 /どこか/xxx.py --旗" → "python3 <GUARD_HOME>/xxx.py --旗"
64
+
65
+ ★書き換えるのは【.py で終わる語】だけ。以前は「最後の語」を書き換えていたので、
66
+ 引数(--codex など)を付けた瞬間に $GUARD_HOME/--codex という壊れた命令になった。
67
+ """
61
68
  parts = cmd.split()
62
- parts[-1] = os.path.join(guard_home, os.path.basename(parts[-1]))
69
+ for i, tok in enumerate(parts):
70
+ if tok.endswith(".py"):
71
+ parts[i] = os.path.join(guard_home, os.path.basename(tok))
72
+ break
63
73
  return " ".join(parts)
64
74
 
65
75
  try:
@@ -121,6 +131,118 @@ os.replace(tmp, dst_path) # 置換は原子的=途中で壊れたファ
121
131
  print("[codex-hooks] 登録しました")
122
132
  PYEOF
123
133
 
134
+ # --- 3) 完了の受け口(codex-notify.py)を固定パスへ置く ------------------------
135
+ # ★なぜ固定パスへ置くのか: config.toml の notify は【絶対パス】でしか書けない(Codex は
136
+ # シェルを通さない)。リポジトリの場所に依存した書き方をすると、npm で配った顧客機や
137
+ # リポジトリを移した機体で「通知だけ静かに届かない」状態になる。番人と同じ「1台1箇所」
138
+ # ($GUARD_HOME)へ置けば、どの入れ方でも同じ1つのパスを指せる。
139
+ CODEX_NOTIFY_SRC="${CODEX_NOTIFY_SRC:-$SCRIPT_DIR/../codex/codex-notify.py}"
140
+ CODEX_NOTIFY_DST="$GUARD_HOME/codex-notify.py"
141
+ if [[ -f "$CODEX_NOTIFY_SRC" ]]; then
142
+ if [[ "$APPLY" == "1" ]]; then
143
+ # 壊れた Python を固定パスへ置かない(番人一式と同じ作法)。
144
+ if python3 -m py_compile "$CODEX_NOTIFY_SRC"; then
145
+ mkdir -p "$GUARD_HOME"
146
+ cp -f "$CODEX_NOTIFY_SRC" "$CODEX_NOTIFY_DST"
147
+ chmod +x "$CODEX_NOTIFY_DST"
148
+ info "完了の受け口を設置: $CODEX_NOTIFY_DST"
149
+ else
150
+ warn "完了の受け口の構文確認に失敗しました(設置をスキップ): $CODEX_NOTIFY_SRC"
151
+ fi
152
+ else
153
+ info "(予定)完了の受け口を設置: $CODEX_NOTIFY_DST"
154
+ fi
155
+ else
156
+ warn "完了の受け口が見つかりません: $CODEX_NOTIFY_SRC(スキップ)"
157
+ fi
158
+
159
+ # --- 4) ~/.codex/config.toml の3つの鍵だけを揃える ----------------------------
160
+ # ★なぜ「3つだけ」なのか: config.toml には Codex 自身が書く【フックの信頼の記録】や
161
+ # 現場ごとの信頼設定が入っている。まるごと上書きすると信頼が消え、番人が動かなくなる
162
+ # (=いちばん危ない壊し方)。だから既存の行は一切動かさず、必要な3つだけを揃える。
163
+ CODEX_CONFIG="${CODEX_CONFIG:-$HOME/.codex/config.toml}"
164
+ info "設定の確認: $CODEX_CONFIG(notify / approval_policy / sandbox_mode の3つだけ)"
165
+ APPLY="$APPLY" NOTIFY_CMD="$CODEX_NOTIFY_DST" python3 - "$CODEX_CONFIG" <<'PYEOF'
166
+ import os, re, shutil, sys, time
167
+
168
+ path = sys.argv[1]
169
+ apply_mode = os.environ.get("APPLY") == "1"
170
+ notify_cmd = os.environ["NOTIFY_CMD"]
171
+
172
+ # ★この3つだけを揃える(値の意味は scripts/codex/config.sample.toml に書いてある)。
173
+ # approval_policy: on-request … 承認の場面を【番人(PermissionRequest)に通す】ための設定。
174
+ # never にすると番人が呼ばれず、自走の履歴も残らない(=何をしたか誰も分からなくなる)。
175
+ # sandbox_mode: workspace-write … 作業フォルダの外へは書けない。danger-full-access は使わない。
176
+ WANT = {
177
+ "notify": f'notify = ["python3", "{notify_cmd}"]',
178
+ "approval_policy": 'approval_policy = "on-request"',
179
+ "sandbox_mode": 'sandbox_mode = "workspace-write"',
180
+ }
181
+
182
+ text = ""
183
+ if os.path.exists(path):
184
+ with open(path, encoding="utf-8") as f:
185
+ text = f.read()
186
+ lines = text.splitlines()
187
+
188
+ # TOML の決まり: 表([section])より前が「いちばん外側の設定」。3つの鍵はここに置く。
189
+ first_table = len(lines)
190
+ for i, line in enumerate(lines):
191
+ if line.lstrip().startswith("["):
192
+ first_table = i
193
+ break
194
+
195
+ head, tail = lines[:first_table], lines[first_table:]
196
+ key_re = {k: re.compile(r"^\s*" + re.escape(k) + r"\s*=") for k in WANT}
197
+
198
+ kept, current = [], {}
199
+ for line in head:
200
+ hit = next((k for k, rx in key_re.items() if rx.match(line)), None)
201
+ if hit:
202
+ current[hit] = line.strip()
203
+ continue # 既存の行は落として、下で揃えた値を書き直す
204
+ kept.append(line)
205
+
206
+ missing = [k for k in WANT if current.get(k) != WANT[k]]
207
+ if not missing:
208
+ print("[codex-hooks] 最新です(変更なし)")
209
+ sys.exit(0)
210
+ for k in missing:
211
+ now = current.get(k)
212
+ print(f"[codex-hooks] 要更新: {k}"
213
+ + (f"(いま: {now})" if now else "(未設定)"))
214
+ if not apply_mode:
215
+ print("[codex-hooks] (予定)上記を揃えます(APPLY=1 で実行)")
216
+ sys.exit(0)
217
+
218
+ banner = "# ★采配くんが管理する3行(notify / approval_policy / sandbox_mode)。他の行は触りません。"
219
+ while kept and not kept[-1].strip():
220
+ kept.pop()
221
+ new_head = kept + ([""] if kept else []) + [banner] + [WANT[k] for k in ("notify", "approval_policy", "sandbox_mode")] + [""]
222
+ out = "\n".join(new_head + tail).rstrip("\n") + "\n"
223
+
224
+ os.makedirs(os.path.dirname(path), exist_ok=True)
225
+ if os.path.exists(path):
226
+ shutil.copy2(path, path + ".bak." + time.strftime("%Y%m%d-%H%M%S"))
227
+ tmp = path + ".tmp"
228
+ with open(tmp, "w", encoding="utf-8") as f:
229
+ f.write(out)
230
+
231
+ # ★書いたものが TOML として読めることを確かめてから置き換える。
232
+ # 壊れた config.toml を置くと Codex が起動しない=現場が丸ごと止まる。
233
+ try:
234
+ import tomllib
235
+ with open(tmp, "rb") as f:
236
+ tomllib.load(f)
237
+ except Exception as e: # noqa: BLE001
238
+ os.unlink(tmp)
239
+ print(f"[codex-hooks] 注意: 設定の組み立てに失敗したため書き換えていません({e})", file=sys.stderr)
240
+ sys.exit(0)
241
+
242
+ os.replace(tmp, path) # 置換は原子的
243
+ print("[codex-hooks] 揃えました(3行)")
244
+ PYEOF
245
+
124
246
  if [[ "$APPLY" == "1" ]]; then
125
247
  info "★人の手番: Codex を起動すると『Hooks need review』が1回出ます。"
126
248
  info " 『2. Trust all and continue』を選んでください(選ぶまで番人は動きません)。"
@@ -201,7 +201,7 @@ def build_autorun_payload(data, gate=None):
201
201
  return payload
202
202
 
203
203
 
204
- def decide_autorun(data, verdict, gate, cfg=None, datalink=hook_datalink):
204
+ def decide_autorun(data, verdict, gate, cfg=None, datalink=hook_datalink, codex=False):
205
205
  """自走モードの判定。★人には一切聞かず、その場で allow を返す。
206
206
 
207
207
  ★deny は自走でも通さない(policy の判定は1文字も変えていない)。ここへ来る前に
@@ -227,24 +227,28 @@ def decide_autorun(data, verdict, gate, cfg=None, datalink=hook_datalink):
227
227
 
228
228
  decision = {'behavior': 'allow'}
229
229
  tin = data.get('tool_input')
230
- if isinstance(tin, dict) and tin:
230
+ # ★Codex updatedInput を受け付けない(実測 2026-09-06・codex 0.152.1)。付けて返すと
231
+ # 毎回「PermissionRequest hook (failed) / unsupported updatedInput」が画面に出て、
232
+ # こちらの答えが丸ごと捨てられる。中身を書き換えているわけではないので、付けなくてよい。
233
+ if not codex and isinstance(tin, dict) and tin:
231
234
  decision['updatedInput'] = tin
232
235
  # ★updatedPermissions は付けない=各現場の .claude/settings.json を1文字も汚さない
233
236
  # (allow 行を書くと関門が恒久的に消える。自走はセッション内の判断だけで完結させる)。
234
237
  return {'hookSpecificOutput': {'hookEventName': 'PermissionRequest', 'decision': decision}}
235
238
 
236
239
 
237
- def decide(data, cfg=None, datalink=hook_datalink, autorun=None):
240
+ def decide(data, cfg=None, datalink=hook_datalink, autorun=None, codex=False):
238
241
  """1件分の判定。返り値 None は「何も出力しない(従来の確認ダイアログへ委譲)」。
239
242
 
240
243
  ★既定は自走(decide_autorun)。CC2_AUTORUN=0 のときだけ、下の【従来どおり聞く】経路を通る。
244
+ ★codex=True(登録に --codex を付けたとき)は、Codex が受け付けない項目を外して返す。
241
245
  """
242
246
  verdict = policy.decide_event(data)
243
247
  gate = verdict.get('gate')
244
248
  if autorun is None:
245
249
  autorun = autorun_enabled(datalink=datalink)
246
250
  if autorun:
247
- return decide_autorun(data, verdict, gate, cfg, datalink)
251
+ return decide_autorun(data, verdict, gate, cfg, datalink, codex)
248
252
  return decide_by_asking(data, verdict, gate, cfg, datalink)
249
253
 
250
254
 
@@ -302,8 +306,10 @@ def main() -> int:
302
306
  data = json.loads(raw) if raw.strip() else {}
303
307
  except Exception: # noqa: BLE001
304
308
  return 0
309
+ # ★--codex … Codex から呼ばれた印(登録先 ~/.codex/hooks.json に付けてある)。
310
+ codex = '--codex' in sys.argv[1:]
305
311
  try:
306
- out = decide(data)
312
+ out = decide(data, codex=codex)
307
313
  if out is not None:
308
314
  sys.stdout.write(json.dumps(out, ensure_ascii=False))
309
315
  return 0
@@ -203,6 +203,23 @@ for label, ev in [
203
203
  check(f'{label}: deny のまま', behavior(out), 'deny')
204
204
  check(f'{label}: 履歴も送らない', len(fake.sent), 0)
205
205
 
206
+ print('\n── (e2) ★Codex には Codex が受け付ける形だけを返す ' + '─' * 16)
207
+ # 実測(2026-09-06・codex 0.152.1): Codex は決定に添える updatedInput を受け付けず、
208
+ # 付けると「PermissionRequest hook (failed) / unsupported updatedInput」が出て
209
+ # こちらの答えが【丸ごと捨てられる】。付けなくても意味は同じ(中身は書き換えていない)。
210
+ out_codex = prh.decide(event(), datalink=FakeDatalink(), codex=True)
211
+ check('Codex: allow は返す', behavior(out_codex), 'allow')
212
+ check('Codex: updatedInput を添えない',
213
+ 'updatedInput' in out_codex['hookSpecificOutput']['decision'], False)
214
+ fake_cx = FakeDatalink()
215
+ prh.decide(event(), datalink=fake_cx, codex=True)
216
+ check('Codex でも自走の履歴は送る', len(fake_cx.sent), 1)
217
+ check('Codex でも deny は変わらない',
218
+ behavior(prh.decide(event('cat .env'), datalink=FakeDatalink(), codex=True)), 'deny')
219
+ out_claude = prh.decide(event(), datalink=FakeDatalink())
220
+ check_true('Claude 側は従来どおり updatedInput を添える',
221
+ 'updatedInput' in out_claude['hookSpecificOutput']['decision'])
222
+
206
223
  print('\n── (e) 各現場の設定ファイルを汚さない ' + '─' * 30)
207
224
  out = prh.decide(event(), datalink=FakeDatalink())
208
225
  d = out['hookSpecificOutput']['decision']