@tyhld/conductor 0.3.0 → 0.7.0

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.
Files changed (46) hide show
  1. package/README.md +301 -19
  2. package/dist/cli.js +143 -18
  3. package/dist/ear-routing.js +57 -0
  4. package/dist/ear.js +57 -0
  5. package/dist/env-file-perm.js +67 -0
  6. package/dist/nudge.js +172 -0
  7. package/dist/realtime-parse.js +116 -0
  8. package/dist/realtime.js +251 -0
  9. package/dist/relay-runner.js +65 -0
  10. package/dist/relay.js +911 -134
  11. package/dist/websocket-transport.js +66 -0
  12. package/launchd/ear-install.sh +96 -0
  13. package/package.json +30 -1
  14. package/sales-template/README.md +185 -25
  15. package/sales-template/install.sh +890 -0
  16. package/sales-template/launchd/install.sh +151 -0
  17. package/sales-template/settings.json +26 -97
  18. package/sales-template/setup.sh +754 -117
  19. package/sales-template/systemd/README.md +28 -4
  20. package/sales-template/systemd/install.sh +54 -5
  21. package/sales-template/systemd/paste-cache-prune-install.sh +75 -0
  22. package/sales-template/systemd/tyhld-paste-cache-prune.service +25 -0
  23. package/sales-template/systemd/tyhld-paste-cache-prune.timer +19 -0
  24. package/sales-template/uninstall.sh +245 -0
  25. package/scripts/hooks/README.md +246 -0
  26. package/scripts/hooks/cc2_guard.py +145 -0
  27. package/scripts/hooks/codex-hooks.sample.json +58 -0
  28. package/scripts/hooks/hook_datalink.py +440 -0
  29. package/scripts/hooks/install-codex-hooks.sh +127 -0
  30. package/scripts/hooks/notification_hook.py +167 -0
  31. package/scripts/hooks/permission_request_hook.py +207 -0
  32. package/scripts/hooks/policy.py +759 -0
  33. package/scripts/hooks/settings.sample.json +142 -0
  34. package/scripts/hooks/stop_hook.py +275 -0
  35. package/scripts/hooks/summary_ja.py +155 -0
  36. package/scripts/hooks/test_hook_datalink.py +282 -0
  37. package/scripts/hooks/test_policy.py +1241 -0
  38. package/skills/conductor-craftsman/SKILL.md +40 -0
  39. package/systemd/conductor-ear.service +63 -0
  40. package/systemd/conductor@.service +62 -0
  41. package/systemd/ear-install.sh +131 -0
  42. package/systemd/guard-sync-install.sh +94 -0
  43. package/systemd/tyhld-guard-sync.service +28 -0
  44. package/systemd/tyhld-guard-sync.timer +25 -0
  45. package/sales-template/cc2_guard.py +0 -395
  46. package/sales-template/systemd/conductor@.service +0 -47
@@ -0,0 +1,440 @@
1
+ #!/usr/bin/env python3
2
+ """hook_datalink.py — フックと管制の連絡線(クライアント側)。
3
+
4
+ 【役割】
5
+ フックが「これを実行してよいか」を管制へ【データで】送り、人の答えを受け取って戻る。
6
+ 画面の読み取り(tmux スクレイプ)に依存しない経路。
7
+
8
+ 【★製品固有の前提を持ち込まないこと】
9
+ このファイルは将来そのまま共通部品として切り出す。したがってここに入れてよいのは
10
+ ・やり取りの形(送る値/受け取る値)
11
+ ・待ち方(短く待つ×繰り返し)
12
+ ・接続先と鍵の読み取り(環境変数のみ)
13
+ だけ。判定(何を止めるか)は policy.py、文言の組み立ては summary_ja.py が持つ。
14
+
15
+ 【接続先】環境変数から読む。無ければ何もしない(フックは黙って委譲する)。
16
+ CONDUCTOR_URL … 管制のURL(例 https://example.vercel.app)
17
+ CONDUCTOR_TOKEN … Bearer トークン(中央 TY Auth 発行のテナントトークン)
18
+ どちらも未設定なら $HOME/.conductor.env(KEY=VALUE 形式)から補う。
19
+
20
+ 【★通らなかった理由を必ず1行残す】
21
+ 通信は「フックを絶対に落とさない」ため例外を握るが、握った事実を残さないと後から原因が
22
+ 分からない。実地でこれが起きた: 受け口が middleware に登録されておらず POST が 307 で
23
+ サインイン画面へ折り返されていたのに、フック側には何の痕跡も無く、特定に丸1日かかった
24
+ (調査 a35b74f0)。よって _request が失敗したときだけ CONDUCTOR_HOOK_LOG(既定
25
+ $HOME/.conductor-hook.log)へ1行追記する。
26
+
27
+ 残すのは【メソッド・パス・状態・応答の短い抜粋】だけ。
28
+ ★秘密は絶対に載せない: Authorization ヘッダ・トークン・接続先ホスト・URLのクエリは
29
+ いずれも記録しない(パスは "?" の手前で切る)。
30
+
31
+ 【依存】標準ライブラリのみ(urllib)。外部パッケージを増やさない。
32
+ """
33
+ import json
34
+ import os
35
+ import time
36
+ import urllib.error
37
+ import urllib.request
38
+
39
+ # 1回の問い合わせでサーバが待つ上限(秒)。管制側の HOOK_POLL_MAX_MS と揃える。
40
+ POLL_WAIT_MS = 25_000
41
+ # フックが1件の確認を待てる上限(秒)。フックの既定タイムアウト 600 秒に合わせる。
42
+ MAX_WAIT_SEC = 600
43
+ # 通信1回のタイムアウト(秒)。サーバの待ち時間+余裕。
44
+ HTTP_TIMEOUT_SEC = 35
45
+
46
+ # 診断ログの出力先を差し替える環境変数(未設定なら $HOME/.conductor-hook.log)。
47
+ LOG_PATH_ENV = 'CONDUCTOR_HOOK_LOG'
48
+ # 既定のファイル名。接続設定 $HOME/.conductor.env と同じ場所に置く。
49
+ DEFAULT_LOG_NAME = '.conductor-hook.log'
50
+ # 番人が呼ばれた事実を残す「起動記録」の出力先(診断ログとは別ファイルにする)。
51
+ # ★診断ログ(.conductor-hook.log)は「通信失敗時だけ」書く失敗ログで、番人(PreToolUse)が
52
+ # 呼ばれたかは分からない。それが原因特定を遅らせた(調査 09c70615)。用途が違うので分ける。
53
+ DECISION_LOG_PATH_ENV = 'CONDUCTOR_GUARD_LOG'
54
+ DEFAULT_DECISION_LOG_NAME = '.conductor-guard.log'
55
+ # 応答本文をこの文字数だけ残す(原因が読める最小限。長い HTML を丸ごと書かない)。
56
+ BODY_EXCERPT_CHARS = 120
57
+ # ログがこの大きさを超えたら作り直す(放置しても際限なく育たないようにする)。
58
+ LOG_MAX_BYTES = 1_000_000
59
+ # 起動記録の出力先が決まらないと record_decision は黙って何もしない。
60
+
61
+ # ── 送信失敗の「種別」───────────────────────────────────────────────────────
62
+ # ★呼び出し側が【こちらの送信内容の不備】と【相手側/連絡線の問題】を区別するために読む。
63
+ # 実地でこれが混同された: sessionId 空で 400 が返っていたのに、呼び出し側は「管制に到達
64
+ # できない」と読み替えてフェイルクローズしていた(報告 CC1・本ADR)。両者は原因も直し方も
65
+ # 人へ出す文言も違うので、種別を分けて持てるようにする。
66
+ FAIL_CLIENT = 'client' # 4xx のうち送信内容の不備(400/402/405-499…)。管制には届いている。
67
+ FAIL_AUTH = 'auth' # 401/403 … 認証・認可(トークン/権限=相手側・連絡線の設定)。
68
+ FAIL_UNREACHABLE = 'unreachable' # 到達不能・タイムアウト・折返し(3xx)・受け口なし(404)・管制側異常(5xx)。
69
+
70
+ # 直近の _request 失敗の種別(成功すると None に戻す)。フックは1回1プロセスなので単一で足りる。
71
+ _last_failure = None
72
+
73
+
74
+ def last_failure_kind():
75
+ """直近の送信失敗の種別(FAIL_*)を返す。無ければ None。★register 等の直後に読む。"""
76
+ return _last_failure
77
+
78
+
79
+ def _classify_status(status):
80
+ """HTTP 状態から失敗の種別を決める。★400番台でも 401/403 と 404 は相手側扱い。"""
81
+ if status in (401, 403):
82
+ return FAIL_AUTH
83
+ if status == 404:
84
+ return FAIL_UNREACHABLE # 受け口が無い=連絡線/配備の問題であって送信内容の不備ではない
85
+ if 400 <= status < 500:
86
+ return FAIL_CLIENT # 400/422/409/405… こちらの送信内容の不備
87
+ return FAIL_UNREACHABLE # 3xx(折返し) / 5xx(管制側異常) / その他
88
+
89
+
90
+ def _load_env_file(path):
91
+ """KEY=VALUE 形式のファイルを辞書で返す(読めなければ空)。"""
92
+ out = {}
93
+ try:
94
+ with open(path, encoding='utf-8') as f:
95
+ for line in f:
96
+ line = line.strip()
97
+ if not line or line.startswith('#') or '=' not in line:
98
+ continue
99
+ k, v = line.split('=', 1)
100
+ out[k.strip()] = v.strip().strip('"').strip("'")
101
+ except Exception: # noqa: BLE001
102
+ return {}
103
+ return out
104
+
105
+
106
+ def load_config(env=None):
107
+ """接続先と鍵を集める。揃わなければ None(=連絡線を使わない)。"""
108
+ env = os.environ if env is None else env
109
+ url = env.get('CONDUCTOR_URL', '').strip()
110
+ token = env.get('CONDUCTOR_TOKEN', '').strip()
111
+ if not url or not token:
112
+ home = env.get('HOME', '')
113
+ if home:
114
+ fromfile = _load_env_file(os.path.join(home, '.conductor.env'))
115
+ url = url or fromfile.get('CONDUCTOR_URL', '').strip()
116
+ token = token or fromfile.get('CONDUCTOR_TOKEN', '').strip()
117
+ if not url or not token:
118
+ return None
119
+ return {'url': url.rstrip('/'), 'token': token}
120
+
121
+
122
+ def stable_session_id(session_id, machine='', site='', env=None):
123
+ """★空でない sessionId を必ず返す(空文字は絶対に返さない)。
124
+
125
+ 管制は sessionId が空だと 400 {"error":"sessionId required"} で登録を弾く。実際の
126
+ session_id が取れないときは、機械名・現場・番人プロセスの親ID から復元可能な値を組む。
127
+ 形: `local-<機械名>-<現場>-p<親PID>`
128
+ ★親PID(os.getppid)を使うのは、同じセッション中の何度もの番人呼び出しで【同じ値】に
129
+ なるため(=管制側で1セッションとして束ねられる)。起動時刻をそのまま入れると呼び出し
130
+ ごとに値が変わり、1セッションが多数に分裂してしまう。安定を優先して親PIDを採る。
131
+ ★秘密は含めない(機械名・現場・PIDのみ)。"""
132
+ sid = (session_id or '').strip()
133
+ if sid:
134
+ return sid
135
+ machine = (machine or '').strip() or 'unknown-machine'
136
+ site = (site or '').strip() or 'unknown-site'
137
+ try:
138
+ ppid = os.getppid()
139
+ except Exception: # noqa: BLE001 取れなくても空文字は返さない
140
+ ppid = 0
141
+ return f'local-{machine}-{site}-p{ppid}'
142
+
143
+
144
+ def log_path(env=None):
145
+ """診断ログ(通信失敗の記録)の出力先。決められなければ None(=記録しない)。"""
146
+ env = os.environ if env is None else env
147
+ explicit = (env.get(LOG_PATH_ENV) or '').strip()
148
+ if explicit:
149
+ return explicit
150
+ home = (env.get('HOME') or '').strip()
151
+ return os.path.join(home, DEFAULT_LOG_NAME) if home else None
152
+
153
+
154
+ def decision_log_path(env=None):
155
+ """起動記録(番人が呼ばれた事実)の出力先。決められなければ None(=記録しない)。"""
156
+ env = os.environ if env is None else env
157
+ explicit = (env.get(DECISION_LOG_PATH_ENV) or '').strip()
158
+ if explicit:
159
+ return explicit
160
+ home = (env.get('HOME') or '').strip()
161
+ return os.path.join(home, DEFAULT_DECISION_LOG_NAME) if home else None
162
+
163
+
164
+ def _append_line(target, line):
165
+ """1行を追記する。上限を超えていたら作り直す(際限なく育てない)。
166
+ ★書けない環境(読み取り専用HOME等)でもフックは止めない(例外を握って None)。"""
167
+ if not target:
168
+ return None
169
+ try:
170
+ mode = 'a'
171
+ try:
172
+ if os.path.getsize(target) > LOG_MAX_BYTES:
173
+ mode = 'w' # 履歴より「今」が読めることを優先して作り直す
174
+ except OSError:
175
+ pass
176
+ with open(target, mode, encoding='utf-8') as f:
177
+ f.write(line)
178
+ except Exception: # noqa: BLE001
179
+ return None
180
+ return line
181
+
182
+
183
+ def _excerpt(text):
184
+ """応答本文の先頭を1行に畳んで短く切る(改行・制御文字を潰す)。"""
185
+ if not text:
186
+ return ''
187
+ flat = ' '.join(str(text).split())
188
+ return flat[:BODY_EXCERPT_CHARS]
189
+
190
+
191
+ def record_failure(method, path, status=None, body='', error='', env=None):
192
+ """通らなかった1回を1行だけ残す。★記録自体が失敗してもフックは止めない。
193
+
194
+ 形: `<ISO時刻> <METHOD> <パス> -> <状態> body="<抜粋>"`
195
+ 状態は HTTP のステータス(307 / 401 / 404 / 500 …)か、通信できなかったときの
196
+ 例外名(timeout / URLError …)。★秘密は載せない: ホスト・クエリ・トークンは書かない。
197
+ """
198
+ # クエリには待ち時間やID以外が将来入り得る。原因特定にも不要なので必ず落とす。
199
+ safe_path = str(path).split('?', 1)[0]
200
+ state = f'HTTP {status}' if status is not None else (error or 'error')
201
+ line = (
202
+ f'{time.strftime("%Y-%m-%dT%H:%M:%S%z")} {method} {safe_path} '
203
+ f'-> {state} body="{_excerpt(body)}"\n'
204
+ )
205
+ return _append_line(log_path(env), line)
206
+
207
+
208
+ def record_decision(tool_name, decision, gate=None, env=None):
209
+ """番人(PreToolUse)が呼ばれた1回を1行だけ残す(起動記録)。
210
+
211
+ 形: `<ISO時刻> PreToolUse <tool> -> <decision>[ gate=<種別>]`
212
+ ★秘密もコマンド全文も残さない: ツール名・判定・関門種別だけ(=最小限)。
213
+ 「いつ・何のツールに・どう判定したか」が後から読めれば、壊れた時期の特定に足りる。
214
+ ★上限つき追記(_append_line)でローテーションするので肥大しない。
215
+ """
216
+ safe_tool = str(tool_name or 'unknown').split()[0][:40]
217
+ safe_dec = str(decision or '?')[:16]
218
+ suffix = f' gate={str(gate)[:16]}' if gate else ''
219
+ line = f'{time.strftime("%Y-%m-%dT%H:%M:%S%z")} PreToolUse {safe_tool} -> {safe_dec}{suffix}\n'
220
+ return _append_line(decision_log_path(env), line)
221
+
222
+
223
+ def record_notification(notification_type, forwarded, reason='', env=None):
224
+ """Claude Code 自身の通知(Notification)を受けた1回を1行だけ残す(★ADR-016 の見張り)。
225
+
226
+ 形: `<ISO時刻> Notification <種別> -> forwarded|skipped(<理由>)`
227
+ 【なぜ要るか】端末にだけ出て管制に出ない問いは、これまで「起きたことすら分からなかった」。
228
+ 2026-09-01(Playwright)と 2026-09-02(GitHub Actions)で同じ型が2回起きたのに、
229
+ 2回目も端末を見ていた人しか気づけなかった。種別だけでも残しておけば、
230
+ 新しい型が出たとき「いつから・どの種別が来ていたか」を後から数えられる。
231
+ ★秘密も本文も残さない: 通知の種別と、送ったか送らなかったか(と短い理由)だけ。
232
+ """
233
+ safe_type = str(notification_type or 'unknown').split()[0][:40]
234
+ verdict = 'forwarded' if forwarded else 'skipped'
235
+ suffix = f'({str(reason)[:32]})' if (reason and not forwarded) else ''
236
+ line = (f'{time.strftime("%Y-%m-%dT%H:%M:%S%z")} Notification {safe_type} '
237
+ f'-> {verdict}{suffix}\n')
238
+ return _append_line(decision_log_path(env), line)
239
+
240
+
241
+ # ─── 「その問いはもう管制に出してある」印(★二重カードを作らないため) ───────────────
242
+ # PermissionRequest / Stop は自分でカードを出して人の答えを待つ。その最中に Claude Code の
243
+ # Notification(「許可待ちです」「入力待ちです」)が鳴ると、同じ止まりで2枚出てしまう
244
+ # (ADR-013 で潰したのと同じ病気)。待っている間だけ印を置き、Notification 側は印が新しければ
245
+ # 何もしない。★印はプロセス間の合図なので一時領域に置く(設定ファイルには一切触れない)。
246
+ HANDOFF_TTL_SEC = 15 * 60
247
+
248
+
249
+ def handoff_marker_path(session_id='', env=None):
250
+ """『いま管制がこの現場の問いを受け持っている』印の置き場(一時領域)。"""
251
+ env = os.environ if env is None else env
252
+ base = (env.get('TMPDIR') or '/tmp').rstrip('/')
253
+ safe = ''.join(c for c in str(session_id or 'default') if c.isalnum() or c in '-_')[:64]
254
+ return os.path.join(base, f'.tyhld-hook-handoff-{safe or "default"}')
255
+
256
+
257
+ def mark_handoff(session_id='', env=None):
258
+ """印を置く/新しくする。失敗しても黙って進む(フックは止めない)。"""
259
+ path = handoff_marker_path(session_id, env)
260
+ try:
261
+ with open(path, 'w', encoding='utf-8') as f:
262
+ f.write(str(int(time.time())))
263
+ except Exception: # noqa: BLE001
264
+ return None
265
+ return path
266
+
267
+
268
+ def clear_handoff(session_id='', env=None):
269
+ """印を消す(カードを閉じたとき)。"""
270
+ try:
271
+ os.unlink(handoff_marker_path(session_id, env))
272
+ except Exception: # noqa: BLE001
273
+ pass
274
+
275
+
276
+ def handoff_active(session_id='', env=None, now=None):
277
+ """印が新しければ True(=別のフックが既に管制へ出している)。
278
+
279
+ ★古い印は無効にする。フックが強制終了して印が残っても、次の停止が
280
+ 永久に「出さない」側へ倒れない(=黙って見えなくならない)。
281
+ """
282
+ path = handoff_marker_path(session_id, env)
283
+ try:
284
+ age = (time.time() if now is None else now) - os.path.getmtime(path)
285
+ except Exception: # noqa: BLE001
286
+ return False
287
+ return 0 <= age < HANDOFF_TTL_SEC
288
+
289
+
290
+ def _request(cfg, method, path, body=None, timeout=HTTP_TIMEOUT_SEC):
291
+ """管制へ1回だけ問い合わせる。失敗は None(例外を投げない=フックを止めない)。
292
+
293
+ ★失敗したときは record_failure で理由を1行残す(握るが、握った事実は残す)。
294
+ ★同時に last_failure_kind()(FAIL_*)を更新し、呼び出し側が「送信内容の不備(4xx)」と
295
+ 「到達不能・認証・タイムアウト」を取り違えないようにする。成功時は None に戻す。
296
+ """
297
+ global _last_failure
298
+ _last_failure = None
299
+ data = json.dumps(body).encode() if body is not None else None
300
+ req = urllib.request.Request(
301
+ f"{cfg['url']}{path}",
302
+ data=data,
303
+ method=method,
304
+ headers={
305
+ 'Authorization': f"Bearer {cfg['token']}",
306
+ 'Content-Type': 'application/json',
307
+ },
308
+ )
309
+ try:
310
+ with urllib.request.urlopen(req, timeout=timeout) as res:
311
+ status = getattr(res, 'status', None)
312
+ raw = res.read().decode('utf-8', 'replace')
313
+ except urllib.error.HTTPError as e: # 4xx / 5xx
314
+ raw = ''
315
+ try:
316
+ raw = e.read().decode('utf-8', 'replace')
317
+ except Exception: # noqa: BLE001
318
+ pass
319
+ _last_failure = _classify_status(e.code)
320
+ record_failure(method, path, status=e.code, body=raw)
321
+ return None
322
+ except Exception as e: # noqa: BLE001 通信不能・タイムアウト・不正URL 等
323
+ _last_failure = FAIL_UNREACHABLE
324
+ record_failure(method, path, error=type(e).__name__)
325
+ return None
326
+
327
+ # ── 3xx はルート本体に届いていない(リダイレクト)=明確な異常として扱う ──
328
+ # urllib は POST の 307 を例外にせず「成功応答」として返すため、ここで弾かないと
329
+ # 本文 "Redirecting..." を JSON として読もうとして「本文がJSONでない」に化ける。
330
+ # それが実地で原因特定を丸一日遅らせた(調査 a35b74f0)。
331
+ if status is not None and status >= 300:
332
+ _last_failure = _classify_status(status)
333
+ record_failure(method, path, status=status, body=raw)
334
+ return None
335
+
336
+ if not raw.strip():
337
+ return {}
338
+ try:
339
+ return json.loads(raw)
340
+ except Exception: # noqa: BLE001 2xx なのに JSON でない=受け口の想定違い
341
+ # 2xx なのに JSON でない=受け口の想定違い(相手側)。送信内容の不備ではない。
342
+ _last_failure = FAIL_UNREACHABLE
343
+ record_failure(method, path, status=status, body=raw)
344
+ return None
345
+
346
+
347
+ class _NoRedirect(urllib.request.HTTPRedirectHandler):
348
+ """リダイレクトを追わない。サインインへの折返し(3xx)を『不通』として検出するため。
349
+ 追従すると signin ページの 200(HTML) を掴んで『到達OK』に化ける(本日の307と同型の罠)。"""
350
+
351
+ def redirect_request(self, *a, **k): # noqa: D401
352
+ return None
353
+
354
+
355
+ # 到達確認に使う、実在しない正規UUID形式のID。到達+認証OKなら 404 が返る(実測済み)。
356
+ _PING_ID = '00000000-0000-4000-8000-000000000000'
357
+
358
+
359
+ def ping(cfg, timeout=8):
360
+ """管制に到達でき、認証も通るかを軽く確かめる。★カードを作らない副作用ゼロの確認。
361
+
362
+ 返り: True=到達OK(確認カードを出せる見込み) / False=不通・認証不可・折返し。
363
+ 仕組み: 実在しないIDへ認証つきで GET し、HTTP状態で判断する(実測に基づく)。
364
+ 404 … 到達して認証も通り「そのIDが無い」だけ → True
365
+ 2xx … 到達(通常は起きないが到達とみなす) → True
366
+ 3xx … サインインへの折返し(受け口に届いていない) → False
367
+ 401/403 … 認証拒否(confirm を登録できない) → False
368
+ 5xx・通信不能・タイムアウト → False(安全側=止める材料)
369
+ ★どんな例外でも False を返す(=関門はフェイルクローズへ倒れる)。
370
+ """
371
+ if not isinstance(cfg, dict) or not cfg.get('url') or not cfg.get('token'):
372
+ return False
373
+ # ★到達確認が「確認できない」で倒れたときも1行残す。以前ここは全部を握り潰して
374
+ # 何も残さなかったため、関門が黙って deny された理由が後から読めなかった(本ADR)。
375
+ # 404 は「到達+認証OKで、そのIDが無いだけ」=正常なので記録しない。
376
+ ping_path = f'/api/conductor/hook-requests/{_PING_ID}'
377
+ try:
378
+ opener = urllib.request.build_opener(_NoRedirect)
379
+ req = urllib.request.Request(
380
+ f"{cfg['url']}{ping_path}",
381
+ method='GET',
382
+ headers={'Authorization': f"Bearer {cfg['token']}"},
383
+ )
384
+ try:
385
+ with opener.open(req, timeout=timeout) as res:
386
+ status = getattr(res, 'status', 0) or 0
387
+ if 200 <= status < 300:
388
+ return True
389
+ record_failure('GET', ping_path, status=status) # 折返し等が成功応答で返る場合
390
+ return False
391
+ except urllib.error.HTTPError as e:
392
+ if e.code == 404 or 200 <= e.code < 300:
393
+ return True
394
+ record_failure('GET', ping_path, status=e.code) # 3xx(折返し)/401・403/5xx を記録
395
+ return False # 3xx(折返し) / 401・403(認証不可) / 5xx はいずれも「確認できない」
396
+ except Exception as e: # noqa: BLE001 通信不能・タイムアウト・不正URL 等
397
+ record_failure('GET', ping_path, error=type(e).__name__)
398
+ return False
399
+ except Exception: # noqa: BLE001
400
+ return False
401
+
402
+
403
+ def register(cfg, payload):
404
+ """確認を1件登録する。返り: {'id':…, 'status':…, 'decision':…} / None。"""
405
+ return _request(cfg, 'POST', '/api/conductor/hook-requests', payload)
406
+
407
+
408
+ def poll_once(cfg, request_id, wait_ms=POLL_WAIT_MS):
409
+ """答えを短く待つ。返り: {'status','decision','outcome'} / None。"""
410
+ return _request(
411
+ cfg, 'GET', f'/api/conductor/hook-requests/{request_id}?waitMs={int(wait_ms)}'
412
+ )
413
+
414
+
415
+ def abandon(cfg, request_id):
416
+ """フックが去ることを知らせる(カードを即座に閉じる)。"""
417
+ return _request(cfg, 'DELETE', f'/api/conductor/hook-requests/{request_id}', timeout=10)
418
+
419
+
420
+ def wait_for_decision(cfg, request_id, max_wait_sec=MAX_WAIT_SEC, sleep=time.sleep):
421
+ """短く待つ×繰り返しで人の答えを待つ。
422
+
423
+ 返り: 'allow' | 'allow_always' | 'deny' | None(時間切れ・通信不能)
424
+ ★答えないことは許可ではない。時間切れは None を返し、呼び出し側は【許可しない】。
425
+ """
426
+ deadline = time.time() + max_wait_sec
427
+ while time.time() < deadline:
428
+ remain_ms = max(1000, int((deadline - time.time()) * 1000))
429
+ res = poll_once(cfg, request_id, min(POLL_WAIT_MS, remain_ms))
430
+ if res is None:
431
+ # 通信不能。少し待って再試行(管制の一時的な不調でフックを殺さない)。
432
+ sleep(3)
433
+ continue
434
+ outcome = res.get('outcome')
435
+ if outcome == 'decided':
436
+ return res.get('decision')
437
+ if outcome == 'gone':
438
+ return None
439
+ # pending … そのまま次の周回へ(サーバ側で待っているので追加のsleepは不要)
440
+ return None
@@ -0,0 +1,127 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # install-codex-hooks.sh — Codex の職人へ番人(安全装置)を登録する。★ADR-022
4
+ #
5
+ # ★これは「1つの実装・2人の呼び手」です。新規設置(sales-template/setup.sh)と
6
+ # 稼働機の更新(scripts/update-guard.sh)の両方がこのスクリプトを呼びます。
7
+ # 同じ処理を2か所に書くと片方が古くなる事故が起きる(ADR-011/013/019 で繰り返した型)。
8
+ #
9
+ # 使い方:
10
+ # bash scripts/hooks/install-codex-hooks.sh # 予定を表示するだけ(既定)
11
+ # APPLY=1 bash scripts/hooks/install-codex-hooks.sh # 実際に登録する
12
+ #
13
+ # やること(冪等:何度実行しても同じ結果):
14
+ # 1. Codex を使う機体かを見る。使わない機体では何もしない(~/.codex も作らない)
15
+ # 2. 見本(codex-hooks.sample.json)のイベントを、コマンドのパスだけ固定パス $GUARD_HOME へ
16
+ # 書き換えて ~/.codex/hooks.json へ登録する
17
+ # 3. 顧客が足した他のイベント・他のキーは触らない(見本のイベントだけ差し替える)
18
+ #
19
+ # ★Claude 側(~/.claude/settings.json)には一切触れません。
20
+ #
21
+ set -euo pipefail
22
+
23
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
24
+ GUARD_HOME="${GUARD_HOME:-$HOME/.tyhld/hooks}"
25
+ CODEX_SAMPLE="${CODEX_SAMPLE:-$SCRIPT_DIR/codex-hooks.sample.json}"
26
+ CODEX_HOOKS="${CODEX_HOOKS:-$HOME/.codex/hooks.json}"
27
+ APPLY="${APPLY:-0}"
28
+
29
+ info() { echo "[codex-hooks] $*"; }
30
+ warn() { echo "[codex-hooks] 注意: $*" >&2; }
31
+
32
+ # --- 1) Codex を使う機体か ---------------------------------------------------
33
+ # 使わない機体に ~/.codex を作らない(余計なものを置かない)。
34
+ # ・codex コマンドがある/~/.codex が既にある/CONDUCTOR_AGENT_KIND=codex のいずれか。
35
+ uses_codex=0
36
+ command -v codex >/dev/null 2>&1 && uses_codex=1
37
+ [[ -d "$HOME/.codex" ]] && uses_codex=1
38
+ [[ "$(printf '%s' "${CONDUCTOR_AGENT_KIND:-}" | tr '[:upper:]' '[:lower:]')" == "codex" ]] && uses_codex=1
39
+
40
+ if [[ "$uses_codex" != "1" ]]; then
41
+ info "この機体は Codex を使っていません(登録しません)"
42
+ exit 0
43
+ fi
44
+
45
+ if [[ ! -f "$CODEX_SAMPLE" ]]; then
46
+ warn "Codex 用の見本が見つかりません: $CODEX_SAMPLE(登録をスキップ)"
47
+ exit 0
48
+ fi
49
+
50
+ # --- 2) 見本どおりに登録する -------------------------------------------------
51
+ info "登録先: $CODEX_HOOKS"
52
+ APPLY="$APPLY" GUARD_HOME="$GUARD_HOME" python3 - "$CODEX_HOOKS" "$CODEX_SAMPLE" <<'PYEOF'
53
+ import json, os, shutil, sys, time
54
+
55
+ dst_path, sample_path = sys.argv[1], sys.argv[2]
56
+ apply_mode = os.environ.get("APPLY") == "1"
57
+ guard_home = os.environ["GUARD_HOME"]
58
+
59
+ def at(cmd):
60
+ """"python3 /どこか/xxx.py" → "python3 <GUARD_HOME>/xxx.py"(Claude 側と同じ勤務用コピー方式)。"""
61
+ parts = cmd.split()
62
+ parts[-1] = os.path.join(guard_home, os.path.basename(parts[-1]))
63
+ return " ".join(parts)
64
+
65
+ try:
66
+ sample_hooks = json.load(open(sample_path, encoding="utf-8")).get("hooks") or {}
67
+ except Exception as e: # noqa: BLE001
68
+ print(f"[codex-hooks] 注意: 見本が読めません: {e}", file=sys.stderr)
69
+ sys.exit(0)
70
+ if not sample_hooks:
71
+ print("[codex-hooks] 注意: 見本に hooks がありません(登録しません)", file=sys.stderr)
72
+ sys.exit(0)
73
+
74
+ desired = {}
75
+ for ev, groups in sample_hooks.items():
76
+ new_groups = []
77
+ for g in (groups or []):
78
+ ng = dict(g)
79
+ ng["hooks"] = [
80
+ {**h, "command": at(h["command"])}
81
+ if isinstance(h, dict) and isinstance(h.get("command"), str) and h["command"].strip() else h
82
+ for h in (g.get("hooks") or [])
83
+ ]
84
+ new_groups.append(ng)
85
+ desired[ev] = new_groups
86
+
87
+ # 既存を読む(無ければ空から作る。壊れていたら触らない)。
88
+ cur = {}
89
+ if os.path.exists(dst_path):
90
+ try:
91
+ cur = json.load(open(dst_path, encoding="utf-8"))
92
+ except Exception as e: # noqa: BLE001
93
+ print(f"[codex-hooks] 注意: JSON として読めないので触りません: {dst_path} ({e})", file=sys.stderr)
94
+ sys.exit(0)
95
+ if not isinstance(cur, dict):
96
+ cur = {}
97
+
98
+ hooks = cur.get("hooks")
99
+ merged = dict(hooks) if isinstance(hooks, dict) else {}
100
+ for ev in desired: # ★見本のイベントだけ差し替える(他は温存)
101
+ merged[ev] = desired[ev]
102
+
103
+ if cur.get("hooks") == merged:
104
+ print("[codex-hooks] 最新です(変更なし)")
105
+ sys.exit(0)
106
+
107
+ print("[codex-hooks] 登録するイベント: " + " / ".join(sorted(desired)))
108
+ if not apply_mode:
109
+ print("[codex-hooks] (予定)上記を登録します(APPLY=1 で実行)")
110
+ sys.exit(0)
111
+
112
+ cur["hooks"] = merged
113
+ os.makedirs(os.path.dirname(dst_path), exist_ok=True)
114
+ if os.path.exists(dst_path):
115
+ shutil.copy2(dst_path, dst_path + ".bak." + time.strftime("%Y%m%d-%H%M%S"))
116
+ tmp = dst_path + ".tmp"
117
+ with open(tmp, "w", encoding="utf-8") as f:
118
+ json.dump(cur, f, indent=2, ensure_ascii=False)
119
+ f.write("\n")
120
+ os.replace(tmp, dst_path) # 置換は原子的=途中で壊れたファイルを残さない
121
+ print("[codex-hooks] 登録しました")
122
+ PYEOF
123
+
124
+ if [[ "$APPLY" == "1" ]]; then
125
+ info "★人の手番: Codex を起動すると『Hooks need review』が1回出ます。"
126
+ info " 『2. Trust all and continue』を選んでください(選ぶまで番人は動きません)。"
127
+ fi