@tyhld/conductor 0.11.4 → 0.11.5
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 +12 -6
- package/package.json +3 -1
- package/scripts/codex/codex-notify.py +113 -0
- package/scripts/codex/config.sample.toml +46 -0
- package/scripts/hooks/cc2_guard.py +14 -2
- package/scripts/hooks/codex-hooks.sample.json +12 -2
- package/scripts/hooks/install-codex-hooks.sh +124 -2
- package/scripts/hooks/permission_request_hook.py +11 -5
- package/scripts/hooks/test_autorun.py +17 -0
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 し、両方で
|
|
1198
|
-
*
|
|
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 || !
|
|
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 || !
|
|
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.
|
|
3
|
+
"version": "0.11.5",
|
|
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
|
|
63
|
+
""""python3 /どこか/xxx.py --旗" → "python3 <GUARD_HOME>/xxx.py --旗"。
|
|
64
|
+
|
|
65
|
+
★書き換えるのは【.py で終わる語】だけ。以前は「最後の語」を書き換えていたので、
|
|
66
|
+
引数(--codex など)を付けた瞬間に $GUARD_HOME/--codex という壊れた命令になった。
|
|
67
|
+
"""
|
|
61
68
|
parts = cmd.split()
|
|
62
|
-
|
|
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
|
-
|
|
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']
|