@tyhld/conductor 0.12.109 → 0.12.110

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
@@ -1705,7 +1705,16 @@ export function pickCodexNoticeFor(site, job, env = process.env) {
1705
1705
  for (const n of all) {
1706
1706
  const v = judgeCodexNotice(n, job);
1707
1707
  if (v.accept) {
1708
- notice = n; // 2件以上あれば新しい方を使う(古い順に見ている)
1708
+ // ★その便の物が2件以上あれば【新しい方】を使う(古い順に見ている)。
1709
+ // 同じターンで notify と Stop の両方が書くと、この形になる(便 ab910bc6)。
1710
+ // 使わなかった方も【理由つきで控えへ】残す(=黙って落とさない。完了は1回・本文は1つ)。
1711
+ if (notice !== null) {
1712
+ skipped.push({
1713
+ notice,
1714
+ why: '同じ便の知らせが複数届いた(notify と Stop の両方など)ので、新しい方を本文に使った',
1715
+ });
1716
+ }
1717
+ notice = n;
1709
1718
  }
1710
1719
  else {
1711
1720
  skipped.push({ notice: n, why: v.why });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tyhld/conductor",
3
- "version": "0.12.109",
3
+ "version": "0.12.110",
4
4
  "description": "采配くん管制の見守りアプリ(conductor-agent)。各PCで常駐し、中央(devlog-tracker)へ定期的に生存報告(heartbeat)を送る常駐CLI。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -59,14 +59,35 @@
59
59
  ④ それも分からなければ作業フォルダの名前(今までどおり)。
60
60
  ★どれでも決められない知らせは【捨てない】。理由つきで控え(UNRESOLVED_FILE)へ残す。
61
61
 
62
- 【★呼ばれた事を1行だけ残す(2026-10-06・便 36236d41)】
62
+ 【★この受け口は「2つの道」の共通の部品でもある(2026-10-06・便 ab910bc6)】
63
+ Codex には終わりの知らせの登録が【2つ独立して】在る(ADR-022 の実測)。
64
+ ・notify(`~/.codex/config.toml` の `notify`)… この受け口を引数1個で呼ぶ
65
+ ・Stop (`~/.codex/hooks.json` の `Stop`) … `stop_hook.py` を標準入力で呼ぶ。
66
+ ★渡ってくる物に `last_assistant_message`(notify と同じ「最後の返事」)が入る。
67
+ 登録先が別のファイルなので、片方が黙っても、もう片方で本文を拾える(実物: codex-probe の
68
+ Codex は 10/5 15:01 から notify を出していない=報告の本文が管制に載らない)。
69
+ そこで `stop_hook.py` が、この受け口と【同じ1行】を【同じ受け皿】へ書けるようにした。
70
+ ★決まりを2か所に書かない: 現場の決め方(resolve_site)・内部のターンの見分け(is_internal_turn)・
71
+ 1行の形(notice_line)・控えへの回し方(keep_unresolved)・呼ばれた記録(record_call)は、
72
+ どちらの道も【この1つのファイルの同じ関数】を通る(入口は handle_turn の1つだけ)。
73
+ ★`stop_hook.py` はこのファイルを【同じフォルダから読み込む】。配り方を変えなくてよい:
74
+ update-guard.sh ① が stop_hook.py を、その中の ①-e2(install-codex-hooks.sh)が
75
+ この受け口を、どちらも同じ固定パス($GUARD_HOME)へ置くため(1時間ごとの同期でも走る)。
76
+ ★読み込めない機体では、Stop の道は【何もしない】(=番人の今までの動きだけが残る)。
77
+
78
+ 【★呼ばれた事を1行だけ残す(2026-10-06・便 36236d41/便 ab910bc6 で2つ足した)】
63
79
  調査 f567f8d8 で分かったいちばん痛い所は、「受け口が呼ばれたが黙って終わった」と
64
80
  「受け口が一度も呼ばれていない」を、実物で分けられないことだった(受け口は自分の動いた
65
81
  記録を1行も残していなかった)。だから Codex が知らせを出していないのか、受け口が
66
82
  捨てているのかを、人が確かめる材料が無かった。
67
83
  そこで、呼ばれるたびに受け皿の置き場の中の1つのファイル(CALLS_FILE)へ1行だけ足す。
68
- ・時刻/type/どう扱ったか(CALL_HOW_*)/現場名/便の印が在ったか/本文の字数
69
- ★本文・指示文・作業フォルダより下の中身は【1文字も書かない】(字数と有無だけ)。
84
+ ・時刻/type/どう扱ったか(CALL_HOW_*)/現場名/便の印の頭8文字/本文の字数/
85
+ ★どの道から来たか(route=notify/stop・便 ab910bc6)
86
+ ★便の印は【頭8文字だけ】を書く(便 ab910bc6。それまでは有無だけだった)。
87
+ =`grep <便IDの頭8文字> ~/.tyhld/codex-notify/_calls.log` の1回で、
88
+ 「Codex が受け口を呼んだか」「Stop フックを呼んだか」が両方分かる。
89
+ 印は秘密ではない(配り役の記録にも管制の画面にも毎回出ている値)。印が無ければ空。
90
+ ★本文・指示文・作業フォルダより下の中身は【1文字も書かない】(字数と頭8文字だけ)。
70
91
  ★名前は `_calls.log`=配り役は受け皿(`<現場>.jsonl`)として読まないし、
71
92
  読まれない受け皿の始末(relay.ts の sweepStrayCodexNotices)の対象にもならない。
72
93
  ★行数に上限(CALLS_MAX_LINES)を付け、古い物から落とす。
@@ -119,6 +140,16 @@ CALLS_FILE = '_calls.log'
119
140
  # 記録に残す行数の上限(古い物から落とす)。
120
141
  CALLS_MAX_LINES = 500
121
142
 
143
+ # 便の印を記録に書くときの長さ(★頭だけ・全部は書かない)。
144
+ # 8文字=管制の画面・配り役の記録・報告のファイル名で人が使っている長さと同じ。
145
+ JOB_STAMP_HEAD = 8
146
+
147
+ # どの道から来たか(★この2通りだけ)。
148
+ # notify … Codex の `notify`(config.toml)がこの受け口を直接呼んだ
149
+ # stop … Codex の `Stop`(hooks.json)が stop_hook.py を呼び、そこから来た
150
+ ROUTE_NOTIFY = 'notify'
151
+ ROUTE_STOP = 'stop'
152
+
122
153
  # どう扱ったか(★この5通りだけ)。
123
154
  CALL_HOW_TRAY = 'tray' # 受け皿へ書いた
124
155
  CALL_HOW_INTERNAL = 'internal-turn' # Codex 内部のターンなので捨てた
@@ -304,8 +335,20 @@ def decide(payload, env=None):
304
335
  return True, site, report, how
305
336
 
306
337
 
307
- def notice_line(payload, site, report):
308
- """受け皿へ書く1行。"""
338
+ def job_stamp_head(job_id):
339
+ """便の印の頭8文字(★記録に書く用)。印が無ければ ''。"""
340
+ return str(job_id or '')[:JOB_STAMP_HEAD]
341
+
342
+
343
+ def notice_line(payload, site, report, job_id=None, route=ROUTE_NOTIFY):
344
+ """受け皿へ書く1行。
345
+
346
+ ★`route` は「どの道から来たか」。配り役は知らない欄を落として読むので
347
+ (relay.ts の takeCodexNotices は site / report / threadId / turnId / jobId / at だけを見る)、
348
+ この欄が在っても今までどおり読まれる・控えへも回らない(試験で固定)。
349
+ ★`job_id` を渡せば、その値を便の印として書く(Stop の道は指示本文を持っていないので、
350
+ 会話の記録から拾った印を渡す)。渡さなければ今までどおり指示本文から読む。
351
+ """
309
352
  return json.dumps(
310
353
  {
311
354
  'site': site,
@@ -313,14 +356,16 @@ def notice_line(payload, site, report):
313
356
  'threadId': payload.get('thread-id'),
314
357
  'turnId': payload.get('turn-id'),
315
358
  # ★どの便のターンの知らせかを配り役が確かめられるようにする(便 99fc49c0)。
316
- 'jobId': job_id_of(payload),
359
+ 'jobId': job_id if job_id else job_id_of(payload),
317
360
  'at': datetime.datetime.now(datetime.timezone.utc).isoformat(),
361
+ # ★どの道から来たか(便 ab910bc6)。配り役の判定には使わない(人が追う用)。
362
+ 'route': route,
318
363
  },
319
364
  ensure_ascii=False,
320
365
  )
321
366
 
322
367
 
323
- def keep_unresolved(payload, report, why, env=None):
368
+ def keep_unresolved(payload, report, why, env=None, job_id=None, route=ROUTE_NOTIFY):
324
369
  """現場を決められなかった知らせを、理由つきで控えへ残す(★捨てない)。
325
370
 
326
371
  返り: 書けた控えのパス/''。古い行から落として UNRESOLVED_MAX_LINES 行までに収める。
@@ -335,10 +380,11 @@ def keep_unresolved(payload, report, why, env=None):
335
380
  'why': why,
336
381
  'site': None,
337
382
  'cwd': payload.get('cwd'),
338
- 'jobId': job_id_of(payload),
383
+ 'jobId': job_id if job_id else job_id_of(payload),
339
384
  'threadId': payload.get('thread-id'),
340
385
  'turnId': payload.get('turn-id'),
341
386
  'report': report,
387
+ 'route': route,
342
388
  },
343
389
  ensure_ascii=False,
344
390
  )
@@ -356,20 +402,23 @@ def keep_unresolved(payload, report, why, env=None):
356
402
  return ''
357
403
 
358
404
 
359
- def call_line(how, why, payload=None, site='', report=''):
405
+ def call_line(how, why, payload=None, site='', report='', job_id=None, route=ROUTE_NOTIFY):
360
406
  """「呼ばれた事」の1行(★本文・指示文・作業フォルダは1文字も入れない)。"""
361
407
  raw_type = ''
362
408
  if isinstance(payload, dict):
363
409
  raw_type = TYPE_OK.sub('', str(payload.get('type') or ''))[:40]
410
+ stamp = job_id if job_id else (job_id_of(payload) if isinstance(payload, dict) else None)
364
411
  return json.dumps(
365
412
  {
366
413
  'at': datetime.datetime.now(datetime.timezone.utc).isoformat(),
367
414
  'type': raw_type,
415
+ # ★どの道から来たか(notify/stop・便 ab910bc6)。
416
+ 'route': route,
368
417
  'how': how,
369
418
  'why': why,
370
419
  'site': site or None,
371
- # ★印そのものは書かない(有無だけ)。
372
- 'jobStamp': job_id_of(payload) is not None if isinstance(payload, dict) else False,
420
+ # ★印は頭8文字だけ(便 ab910bc6。それまでは有無だけだった)。無ければ ''。
421
+ 'jobStamp': job_stamp_head(stamp),
373
422
  # ★本文は書かない(字数だけ)。
374
423
  'reportChars': len(report or ''),
375
424
  },
@@ -377,7 +426,8 @@ def call_line(how, why, payload=None, site='', report=''):
377
426
  )
378
427
 
379
428
 
380
- def record_call(how, why, payload=None, site='', report='', env=None):
429
+ def record_call(how, why, payload=None, site='', report='', env=None, job_id=None,
430
+ route=ROUTE_NOTIFY):
381
431
  """呼ばれた事を記録に1行足す。返り: 書けたファイル/''。
382
432
 
383
433
  ★ここで何が起きても例外を外に出さない・終了コードを変えない(Codex を止めない)。
@@ -388,7 +438,7 @@ def record_call(how, why, payload=None, site='', report='', env=None):
388
438
  if not target_dir:
389
439
  return ''
390
440
  file = os.path.join(target_dir, CALLS_FILE)
391
- line = call_line(how, why, payload, site, report)
441
+ line = call_line(how, why, payload, site, report, job_id=job_id, route=route)
392
442
  os.makedirs(target_dir, exist_ok=True)
393
443
  kept = []
394
444
  if os.path.exists(file):
@@ -402,49 +452,109 @@ def record_call(how, why, payload=None, site='', report='', env=None):
402
452
  return ''
403
453
 
404
454
 
405
- def main(argv):
406
- raw = ' '.join(argv[1:]).strip()
407
- if not raw:
408
- record_call(CALL_HOW_BAD_ARGS, 'no-args')
409
- return 0
410
- try:
411
- payload = json.loads(raw)
412
- except Exception: # noqa: BLE001
413
- record_call(CALL_HOW_BAD_ARGS, 'not-json')
414
- return 0 # 読めない通知は黙って捨てる(Codex を止めない)
415
- keep, site, report, reason = decide(payload)
455
+ def handle_turn(payload, route=ROUTE_NOTIFY, job_id=None, env=None):
456
+ """知らせ1件を、受け皿/控え/記録へ振り分ける(★notify と Stop の共通の入口)。
457
+
458
+ 返り: (どう扱ったか CALL_HOW_*, 理由, 現場名)。★例外を外に出さない所は record_call だけで、
459
+ ここは呼ぶ側(main/stop_hook)が今までどおり握る(どちらも職人を止めない)。
460
+
461
+ ★`route` と `job_id` 以外は、notify の道と1行も違わない処理を通る(決まりを2か所に書かない)。
462
+ """
463
+ keep, site, report, reason = decide(payload, env)
416
464
  if not keep:
417
465
  # ★現場を決められなかった知らせだけは、捨てずに控えへ残す(便 684ac785)。
418
466
  if reason.startswith('unresolved-site:'):
419
- wrote = keep_unresolved(payload, report, reason[len('unresolved-site:'):])
420
- record_call(
421
- CALL_HOW_UNRESOLVED if wrote else CALL_HOW_WRITE_FAILED,
422
- reason if wrote else 'unresolved-unwritable',
423
- payload,
424
- report=report,
467
+ wrote = keep_unresolved(
468
+ payload, report, reason[len('unresolved-site:'):], env,
469
+ job_id=job_id, route=route,
425
470
  )
426
- return 0
427
- record_call(
428
- CALL_HOW_INTERNAL if reason == 'internal-turn' else CALL_HOW_BAD_ARGS,
429
- reason,
430
- payload,
431
- report=report,
432
- )
433
- return 0
434
- target_dir = notify_dir()
471
+ how = CALL_HOW_UNRESOLVED if wrote else CALL_HOW_WRITE_FAILED
472
+ why = reason if wrote else 'unresolved-unwritable'
473
+ record_call(how, why, payload, report=report, env=env, job_id=job_id, route=route)
474
+ return how, why, ''
475
+ how = CALL_HOW_INTERNAL if reason == 'internal-turn' else CALL_HOW_BAD_ARGS
476
+ record_call(how, reason, payload, report=report, env=env, job_id=job_id, route=route)
477
+ return how, reason, ''
478
+ target_dir = notify_dir(env)
435
479
  if not target_dir:
436
- record_call(CALL_HOW_WRITE_FAILED, 'no-notify-dir', payload, site, report)
437
- return 0
480
+ record_call(CALL_HOW_WRITE_FAILED, 'no-notify-dir', payload, site, report,
481
+ env=env, job_id=job_id, route=route)
482
+ return CALL_HOW_WRITE_FAILED, 'no-notify-dir', site
438
483
  try:
439
484
  os.makedirs(target_dir, exist_ok=True)
440
485
  with open(os.path.join(target_dir, f'{site}.jsonl'), 'a', encoding='utf-8') as f:
441
- f.write(notice_line(payload, site, report) + '\n')
486
+ f.write(notice_line(payload, site, report, job_id=job_id, route=route) + '\n')
442
487
  except Exception: # noqa: BLE001
443
488
  # ★知らせの本体を書けなかった事も記録に残す(「呼ばれていない」と区別できるようにする)。
444
- record_call(CALL_HOW_WRITE_FAILED, 'tray-write-error', payload, site, report)
445
- return 0
489
+ record_call(CALL_HOW_WRITE_FAILED, 'tray-write-error', payload, site, report,
490
+ env=env, job_id=job_id, route=route)
491
+ return CALL_HOW_WRITE_FAILED, 'tray-write-error', site
446
492
  # ★記録は本体を書いた【後】に足す(記録が書けなくても本体は残る)。
447
- record_call(CALL_HOW_TRAY, reason, payload, site, report)
493
+ record_call(CALL_HOW_TRAY, reason, payload, site, report,
494
+ env=env, job_id=job_id, route=route)
495
+ return CALL_HOW_TRAY, reason, site
496
+
497
+
498
+ # ─────────────────────────────────────────────────────────────────────────────
499
+ # ★第2の道(Codex の `Stop` フック)— 便 ab910bc6
500
+ # stop_hook.py はこの2つだけを使う(判断も1行の形も、上の notify の道と同じ関数を通る)。
501
+ # ─────────────────────────────────────────────────────────────────────────────
502
+
503
+ # Codex の `Stop` だけが渡してくる項目(ADR-022 の実測表)。
504
+ # Claude Code の `Stop` に入るのは last_assistant_message / transcript_path / session_id /
505
+ # cwd / stop_hook_active / permission_mode の6つで、turn_id は【入らない】(ADR-024 の実測)。
506
+ # =この項目が在ることは「Codex から呼ばれた」の印になる。
507
+ CODEX_STOP_ONLY_KEY = 'turn_id'
508
+
509
+
510
+ def called_by_codex(data, env=None):
511
+ """この `Stop` は Codex から呼ばれたか。★Claude のときは必ず False(1バイトも変えない)。
512
+
513
+ 上から順に見る。
514
+ ① 職人の種類の印(CONDUCTOR_AGENT_KIND)が codex … Codex
515
+ ② 印が在って codex 以外(claude など) … ★Codex ではない(絶対に書かない)
516
+ ③ 印が無い機体 … Codex だけが渡す項目(turn_id)が在れば Codex
517
+ """
518
+ env = os.environ if env is None else env
519
+ kind = (env.get('CONDUCTOR_AGENT_KIND') or '').strip().lower()
520
+ if kind == 'codex':
521
+ return True
522
+ if kind:
523
+ return False
524
+ if not isinstance(data, dict):
525
+ return False
526
+ return str(data.get(CODEX_STOP_ONLY_KEY) or '').strip() != ''
527
+
528
+
529
+ def stop_payload(data):
530
+ """`Stop` フックに渡る形(下線の項目名)を、notify と同じ形(ハイフンの項目名)へ移す。
531
+
532
+ ★中身は1つも作らない(在る物を移すだけ)。指示本文(input-messages)は `Stop` には
533
+ 入らないので入れない=現場は登録簿か git の頂上で決まり(resolve_site の ②③④)、
534
+ 便の印は呼ぶ側が会話の記録から拾って渡す(job_id)。
535
+ """
536
+ data = data if isinstance(data, dict) else {}
537
+ last = data.get('last_assistant_message')
538
+ return {
539
+ 'type': 'agent-turn-complete',
540
+ 'cwd': data.get('cwd'),
541
+ 'last-assistant-message': last if isinstance(last, str) else '',
542
+ 'thread-id': data.get('session_id'),
543
+ 'turn-id': data.get('turn_id'),
544
+ }
545
+
546
+
547
+ def main(argv):
548
+ raw = ' '.join(argv[1:]).strip()
549
+ if not raw:
550
+ record_call(CALL_HOW_BAD_ARGS, 'no-args')
551
+ return 0
552
+ try:
553
+ payload = json.loads(raw)
554
+ except Exception: # noqa: BLE001
555
+ record_call(CALL_HOW_BAD_ARGS, 'not-json')
556
+ return 0 # 読めない通知は黙って捨てる(Codex を止めない)
557
+ handle_turn(payload, route=ROUTE_NOTIFY)
448
558
  return 0
449
559
 
450
560
 
@@ -15,6 +15,21 @@
15
15
  見るのは PR・報告ファイル・背景に回した待ちの3つだけで、職人の言葉は読まない。
16
16
  判定と回数の上限は finish_gate.py にある(そこの説明文に実地の3件と理由を書いた)。
17
17
 
18
+ ③ ★報告の「第2の道」(便 ab910bc6 で足した・★Codex から呼ばれた時だけ)…
19
+ Codex の終わりの知らせには【2つ独立した登録】が在る(ADR-022 の実測)。
20
+ ・notify(`~/.codex/config.toml`)… 受け口 codex-notify.py を呼ぶ
21
+ ・Stop (`~/.codex/hooks.json`) … このフックを呼ぶ。渡ってくる物に
22
+ `last_assistant_message`(notify と同じ「最後の返事」)が入る
23
+ 実物: codex-probe の Codex は 10/5 15:01 から notify を出していない=報告の本文が
24
+ 管制に載らない(便 36236d41 の調べ)。登録先が別ファイルなので、片方が黙っても
25
+ もう片方で本文を拾える。そこで、このフックから【受け口と同じ1行】を【同じ受け皿】へ書く。
26
+ ★決まりを2か所に書かない: 現場の決め方・内部のターンの見分け・1行の形・控えへの回し方・
27
+ 呼ばれた記録は、すべて受け口(codex-notify.py)の関数をそのまま呼ぶ。
28
+ ★Claude の現場では1バイトも変わらない(called_by_codex が False=何もしない)。
29
+ ★①②の仕事(確認カード・終わりの関門)は、順番も結果も終了コードも変えない。
30
+ 受け皿へ書けなくても、今までどおり①②を終える(例外は握って捨てる)。
31
+ ★関門が差し戻す手番では書かない(手番がまだ終わっていないので、完了の材料にしない)。
32
+
18
33
  【★カードを出す条件は2つ(両方満たしたときだけ出す)】
19
34
  (1) 紐づく指示がまだ終わっていないこと
20
35
  ・待ち時間では判定しない(60秒待つ等は「遅い返事」と区別できない)。
@@ -40,6 +55,7 @@
40
55
 
41
56
  【接続先が未設定の環境】何もせず終了する(このフックを入れても壊れない)。
42
57
  """
58
+ import importlib.util
43
59
  import json
44
60
  import os
45
61
  import re
@@ -353,6 +369,79 @@ def run(data, cfg=None, datalink=hook_datalink):
353
369
  return request_id
354
370
 
355
371
 
372
+ # ─────────────────────────────────────────────────────────────────────────────
373
+ # ★報告の「第2の道」(Codex から呼ばれた時だけ・便 ab910bc6)
374
+ #
375
+ # 受け口(scripts/codex/codex-notify.py)を【そのまま読み込んで】使う。新しい部品を作らない。
376
+ # ・決まりが1か所に留まる(現場の決め方・内部のターンの見分け・1行の形・控え・記録)
377
+ # ・★配り方を1行も変えなくてよい … update-guard.sh ① がこのフックを、その中の ①-e2
378
+ # (install-codex-hooks.sh)が受け口を、どちらも同じ固定パス($GUARD_HOME)へ置く。
379
+ # =稼働機では2つが同じフォルダに並ぶので、ここから名前で読める(1時間ごとの同期で届く)。
380
+ # ・リポジトリの中では隣(../codex/)に在るので、そちらも見る(試験と手元の確認用)。
381
+ # ・★読めない機体では【何もしない】(番人の今までの動きだけが残る=退行しない)。
382
+ # ─────────────────────────────────────────────────────────────────────────────
383
+ CODEX_NOTIFY_BASENAME = 'codex-notify.py'
384
+
385
+
386
+ def codex_notify_paths(env=None):
387
+ """受け口を探す場所(上から順)。
388
+
389
+ ★名指し(CONDUCTOR_CODEX_NOTIFY_PY)が在れば【それだけ】を見る(試験・特別な置き方)。
390
+ 名指しが外れているのに別の場所へ回ると、どの受け口を使ったか分からなくなる。
391
+ """
392
+ env = os.environ if env is None else env
393
+ here = os.path.dirname(os.path.abspath(__file__))
394
+ explicit = (env.get('CONDUCTOR_CODEX_NOTIFY_PY') or '').strip()
395
+ if explicit:
396
+ return [explicit]
397
+ return [
398
+ os.path.join(here, CODEX_NOTIFY_BASENAME), # 稼働機($GUARD_HOME に並ぶ)
399
+ os.path.join(here, os.pardir, 'codex', CODEX_NOTIFY_BASENAME), # リポジトリの中
400
+ ]
401
+
402
+
403
+ def load_codex_notify(env=None):
404
+ """受け口を読み込む。読めなければ None(★第2の道は黙って何もしない)。"""
405
+ for path in codex_notify_paths(env):
406
+ if not os.path.isfile(path):
407
+ continue
408
+ try:
409
+ spec = importlib.util.spec_from_file_location('conductor_codex_notify', path)
410
+ if spec is None or spec.loader is None:
411
+ continue
412
+ mod = importlib.util.module_from_spec(spec)
413
+ spec.loader.exec_module(mod)
414
+ except Exception: # noqa: BLE001
415
+ continue
416
+ if hasattr(mod, 'handle_turn') and hasattr(mod, 'called_by_codex'):
417
+ return mod
418
+ return None
419
+
420
+
421
+ def write_codex_turn_notice(data, env=None):
422
+ """Codex から呼ばれた手番の終わりを、受け口と同じ1行で受け皿へ書く。
423
+
424
+ 返り: (どう扱ったか, 理由, 現場名)/None(Codex ではない・受け口が読めない・失敗した)。
425
+ ★ここで何が起きても例外を外に出さない・このフックの他の仕事と終了コードを変えない。
426
+ """
427
+ env = os.environ if env is None else env
428
+ try:
429
+ if not isinstance(data, dict):
430
+ return None
431
+ notify = load_codex_notify(env)
432
+ if notify is None or not notify.called_by_codex(data, env):
433
+ return None
434
+ return notify.handle_turn(
435
+ notify.stop_payload(data),
436
+ route=notify.ROUTE_STOP,
437
+ # ★便の印は、このフックが前から持っている拾い方(会話の記録の最後の印)で渡す。
438
+ job_id=find_command_id(data.get('transcript_path')),
439
+ env=env,
440
+ )
441
+ except Exception: # noqa: BLE001
442
+ return None
443
+
444
+
356
445
  def main() -> int:
357
446
  try:
358
447
  raw = sys.stdin.read()
@@ -365,6 +454,9 @@ def main() -> int:
365
454
  message = data.get('last_assistant_message')
366
455
  asks_human = isinstance(message, str) and needs_human_answer(message)
367
456
  if asks_human:
457
+ # ③ ★Codex から呼ばれた時だけ、受け口と同じ1行を受け皿へ書く(notify と同じ間合い)。
458
+ # ★失敗しても下の確認カードは今までどおり出す(返り値を見ない)。
459
+ write_codex_turn_notice(data)
368
460
  try:
369
461
  run(data)
370
462
  except Exception: # noqa: BLE001
@@ -378,9 +470,12 @@ def main() -> int:
378
470
  except Exception: # noqa: BLE001
379
471
  reason = None
380
472
  if reason:
473
+ # ★差し戻す手番では受け皿へ書かない(手番はまだ終わっていない=完了の材料にしない)。
381
474
  print(json.dumps({'decision': 'block', 'reason': reason}, ensure_ascii=False))
382
475
  return 0
383
476
 
477
+ # ③ ★Codex から呼ばれた時だけ、受け口と同じ1行を受け皿へ書く。
478
+ write_codex_turn_notice(data)
384
479
  try:
385
480
  run(data)
386
481
  except Exception: # noqa: BLE001
@@ -0,0 +1,331 @@
1
+ #!/usr/bin/env python3
2
+ """報告の「第2の道」(Codex の `Stop` フック)の検査(便 ab910bc6)。
3
+
4
+ 実行: python3 scripts/hooks/test_stop_codex_route.py
5
+ 1件でも期待と違えば exit 1。
6
+
7
+ 【何を良くしたか】Codex の終わりの知らせには【2つ独立した登録】が在る(ADR-022 の実測)。
8
+ ・notify(`~/.codex/config.toml`)… 受け口 codex-notify.py を呼ぶ
9
+ ・Stop (`~/.codex/hooks.json`) … stop_hook.py を呼ぶ。渡ってくる物に
10
+ `last_assistant_message`(notify と同じ「最後の返事」)が入る
11
+ 実物(便 36236d41 の調べ): codex-probe の Codex は 2026-10-05 15:01 から notify を
12
+ 出していない=報告の本文が管制に載らない。登録先が別ファイルなので、片方が黙っても
13
+ もう片方で本文を拾える。そこで Stop から【受け口と同じ1行】を【同じ受け皿】へ書く。
14
+
15
+ 【ここで固定すること】
16
+ (A) 道の見分け … 実測の Codex の Stop は書く/★実測の Claude の Stop は1行も書かない
17
+ (B) Codex からの Stop … 受け皿に受け口と同じ形の1行(便の印・現場・本文・道)が入る。
18
+ 欄の集合が notify の1行と【同じ】である(決まりを2か所に書いていない証拠)
19
+ (C) 内部のターンでは書かない/現場を決められない時は控えへ
20
+ (D) ★受け皿へ書けなくても、いまの仕事(確認カード・終わりの関門)の結果と終了コードが変わらない
21
+ (E) 呼ばれた記録 … 道(stop)と便の印の頭8文字が入る/印が無ければ空/本文は入らない
22
+ (F) ★関門が差し戻す手番では書かない(手番はまだ終わっていない)
23
+ (G) ★受け口が読めない機体では何もしない(番人の今までの動きだけが残る)
24
+
25
+ ★空振り確認(使い捨ての複製の中だけ):
26
+ ・stop_hook.main から write_codex_turn_notice の呼び出しを外す → (B)(E) が落ちる
27
+ ・called_by_codex を常に True にする → (A) の「Claude では書かない」が落ちる
28
+ ・main の差し戻しの前に書くように動かす → (F) が落ちる
29
+
30
+ ★本物の受け皿(~/.tyhld/codex-notify)・本物のホームには触らない(使い捨ての置き場だけ)。
31
+ """
32
+ import json
33
+ import os
34
+ import subprocess
35
+ import sys
36
+ import tempfile
37
+
38
+ HERE = os.path.dirname(os.path.abspath(__file__))
39
+ sys.path.insert(0, HERE)
40
+
41
+ import stop_hook # noqa: E402
42
+
43
+ STOP_HOOK = os.path.join(HERE, 'stop_hook.py')
44
+ NOTIFY_PY = os.path.join(HERE, os.pardir, 'codex', 'codex-notify.py')
45
+
46
+ failures = []
47
+
48
+
49
+ def check(label, got, want):
50
+ ok = got == want
51
+ if not ok:
52
+ failures.append(f'{label}: got={got!r} want={want!r}')
53
+ print(f'{"OK " if ok else "NG "} {label:66} {str(got)[:46]}')
54
+
55
+
56
+ def truthy(label, got):
57
+ ok = bool(got)
58
+ if not ok:
59
+ failures.append(f'{label}: got={got!r} want=真')
60
+ print(f'{"OK " if ok else "NG "} {label:66} {str(got)[:46]}')
61
+
62
+
63
+ def first(rows):
64
+ """先頭の1行(★無ければ空の物。空振り確認で落ちるときも転ばず最後まで数える)。"""
65
+ return rows[0] if rows else {}
66
+
67
+
68
+ JOB = 'ab910bc6-1eb6-469b-8f3c-83742edcc1e5' # ★配達された便ID(1文字も変えない)
69
+ SITE = 'codex-probe-test-ab910bc6'
70
+
71
+
72
+ class Box:
73
+ """使い捨ての置き場(ホーム・受け皿・現場のフォルダ・会話の記録)。"""
74
+
75
+ def __init__(self, with_stamp=True, site=SITE):
76
+ self.root = tempfile.mkdtemp(prefix='stop-codex-route-')
77
+ self.home = os.path.join(self.root, 'home')
78
+ self.tray = os.path.join(self.root, 'tray')
79
+ self.work = os.path.join(self.home, 'projects', site)
80
+ for d in (self.home, self.tray, self.work, os.path.join(self.work, '.git')):
81
+ os.makedirs(d, exist_ok=True)
82
+ self.transcript = os.path.join(self.root, 'transcript.jsonl')
83
+ lines = ['{"text": "ごあいさつ"}']
84
+ if with_stamp:
85
+ lines.append(json.dumps({'text': f'お願いします\n\nCONDUCTOR_JOB:{JOB}'}))
86
+ with open(self.transcript, 'w', encoding='utf-8') as f:
87
+ f.write('\n'.join(lines) + '\n')
88
+
89
+ def env(self, extra=None):
90
+ env = {
91
+ 'HOME': self.home,
92
+ 'PATH': os.environ.get('PATH', '/usr/bin:/bin'),
93
+ 'CONDUCTOR_CODEX_NOTIFY_DIR': self.tray,
94
+ }
95
+ env.update(extra or {})
96
+ return env
97
+
98
+ def tray_lines(self, site=SITE):
99
+ return self._read(os.path.join(self.tray, f'{site}.jsonl'))
100
+
101
+ def unresolved(self):
102
+ return self._read(os.path.join(self.tray, '_unresolved.skipped.jsonl'))
103
+
104
+ def calls(self):
105
+ return self._read(os.path.join(self.tray, '_calls.log'))
106
+
107
+ def calls_raw(self):
108
+ p = os.path.join(self.tray, '_calls.log')
109
+ if not os.path.isfile(p):
110
+ return ''
111
+ with open(p, encoding='utf-8') as f:
112
+ return f.read()
113
+
114
+ @staticmethod
115
+ def _read(path):
116
+ if not os.path.isfile(path):
117
+ return []
118
+ with open(path, encoding='utf-8') as f:
119
+ return [json.loads(l) for l in f.read().split('\n') if l.strip()]
120
+
121
+
122
+ def codex_stop(box, message='出来ました。報告は置きました。', cwd=None):
123
+ """Codex の `Stop` に渡る形(★ADR-022 の実測の項目名そのまま)。"""
124
+ return {
125
+ 'cwd': box.work if cwd is None else cwd,
126
+ 'hook_event_name': 'Stop',
127
+ 'last_assistant_message': message,
128
+ 'model': 'gpt-5-codex',
129
+ 'permission_mode': 'on-request',
130
+ 'session_id': 'se-abc',
131
+ 'stop_hook_active': False,
132
+ 'transcript_path': box.transcript,
133
+ 'turn_id': 'tu-123',
134
+ }
135
+
136
+
137
+ def claude_stop(box, message='出来ました。報告は置きました。'):
138
+ """Claude Code の `Stop` に渡る形(★ADR-024 §1 の実測の6項目だけ・turn_id は入らない)。"""
139
+ return {
140
+ 'cwd': box.work,
141
+ 'last_assistant_message': message,
142
+ 'transcript_path': box.transcript,
143
+ 'session_id': 'se-abc',
144
+ 'stop_hook_active': False,
145
+ 'permission_mode': 'default',
146
+ }
147
+
148
+
149
+ def run_hook(box, data, extra_env=None):
150
+ """フックを【実物と同じ呼び方】で1回走らせる。返り: (終了コード, 画面へ出た物)。"""
151
+ res = subprocess.run(
152
+ [sys.executable, STOP_HOOK],
153
+ input=json.dumps(data),
154
+ capture_output=True,
155
+ text=True,
156
+ env=box.env(extra_env),
157
+ check=False,
158
+ )
159
+ return res.returncode, res.stdout
160
+
161
+
162
+ # ─────────────────────────────────────────────────────────────────────────────
163
+ print('=== (A) 道の見分け(★Claude のときは必ず書かない) ===')
164
+ # ─────────────────────────────────────────────────────────────────────────────
165
+ box = Box()
166
+ check('実測の Codex の Stop → Codex と見分ける',
167
+ stop_hook.write_codex_turn_notice(codex_stop(box), box.env()) is not None, True)
168
+ check(' 受け皿に1行入った', len(box.tray_lines()), 1)
169
+
170
+ box_c = Box()
171
+ check('★実測の Claude の Stop → 何もしない',
172
+ stop_hook.write_codex_turn_notice(claude_stop(box_c), box_c.env()), None)
173
+ check('★Claude は受け皿に1行も書かない', box_c.tray_lines(), [])
174
+ check('★Claude は呼ばれた記録にも書かない', box_c.calls(), [])
175
+
176
+ box_k = Box()
177
+ no_turn = {k: v for k, v in codex_stop(box_k).items() if k != 'turn_id'}
178
+ check('職人の種類の印が codex なら、項目が無くても書く',
179
+ stop_hook.write_codex_turn_notice(
180
+ no_turn, box_k.env({'CONDUCTOR_AGENT_KIND': 'codex'}))[0], 'tray')
181
+
182
+ box_k2 = Box()
183
+ check('★職人の種類の印が claude なら、項目が在っても書かない',
184
+ stop_hook.write_codex_turn_notice(
185
+ codex_stop(box_k2), box_k2.env({'CONDUCTOR_AGENT_KIND': 'claude'})), None)
186
+ check('★その時は受け皿も空', box_k2.tray_lines(), [])
187
+
188
+ # ─────────────────────────────────────────────────────────────────────────────
189
+ print('=== (B) 受け皿の1行は、受け口が書く物と同じ形 ===')
190
+ # ─────────────────────────────────────────────────────────────────────────────
191
+ box = Box()
192
+ code, out = run_hook(box, codex_stop(box))
193
+ check('終了コードは 0', code, 0)
194
+ check('画面へ何も出さない', out, '')
195
+ lines = box.tray_lines()
196
+ check('受け皿に1行だけ', len(lines), 1)
197
+ got = first(lines)
198
+ check('現場名(登録簿も配達文も無いので git の頂上で決まる)', got.get('site'), SITE)
199
+ check('本文(最後の返事)がそのまま入る', got.get('report'), '出来ました。報告は置きました。')
200
+ check('便の印は全部(配り役が便と突き合わせる値)', got.get('jobId'), JOB)
201
+ check('★どの道から来たか', got.get('route'), 'stop')
202
+ check('ターンの印', got.get('turnId'), 'tu-123')
203
+ truthy('書いた時刻が入る', isinstance(got.get('at'), str) and got['at'])
204
+
205
+ # ★欄の集合が notify の1行と同じ=1行の形を2か所に書いていない。
206
+ notify_payload = {
207
+ 'type': 'agent-turn-complete',
208
+ 'thread-id': 'th-1',
209
+ 'turn-id': 'tu-1',
210
+ 'cwd': box.work,
211
+ 'input-messages': [f'やってください\n\nCONDUCTOR_JOB:{JOB}\n最終行: DONE:{SITE} とだけ出力'],
212
+ 'last-assistant-message': 'できました。',
213
+ }
214
+ box_n = Box()
215
+ subprocess.run([sys.executable, NOTIFY_PY, json.dumps(notify_payload)],
216
+ env=box_n.env(), check=True, capture_output=True)
217
+ check('★欄の集合が notify の1行と同じ',
218
+ sorted(first(box_n.tray_lines()).keys()), sorted(got.keys()))
219
+ check('notify の道の印は notify', first(box_n.tray_lines()).get('route'), 'notify')
220
+
221
+ # ─────────────────────────────────────────────────────────────────────────────
222
+ print('=== (C) 内部のターンでは書かない/現場を決められない時は控えへ ===')
223
+ # ─────────────────────────────────────────────────────────────────────────────
224
+ box = Box()
225
+ run_hook(box, codex_stop(box, message='{"summary":"まとめ","next_action":null}'))
226
+ check('内部のターンは受け皿へ入れない', box.tray_lines(), [])
227
+ check('記録には1行残る(捨てた事が分かる)', [l['how'] for l in box.calls()], ['internal-turn'])
228
+
229
+ box = Box()
230
+ # 配達文の DONE: も登録簿も git の頂上も無く、作業フォルダの名前も現場名に使えない。
231
+ run_hook(box, codex_stop(box, cwd=''))
232
+ check('現場を決められない知らせは控えへ(捨てない)', len(box.unresolved()), 1)
233
+ check('控えの1行にも道が入る', first(box.unresolved()).get('route'), 'stop')
234
+ check('控えの1行に便の印が入る', first(box.unresolved()).get('jobId'), JOB)
235
+ check('記録は unresolved-site', [l['how'] for l in box.calls()], ['unresolved-site'])
236
+
237
+ # ─────────────────────────────────────────────────────────────────────────────
238
+ print('=== (D) ★受け皿へ書けなくても、いまの仕事と終了コードは変わらない ===')
239
+ # ─────────────────────────────────────────────────────────────────────────────
240
+ box = Box()
241
+ os.makedirs(os.path.join(box.tray, f'{SITE}.jsonl')) # 受け皿の名前でフォルダ=書けない
242
+ code, out = run_hook(box, codex_stop(box))
243
+ check('終了コードは 0(Codex を止めない)', code, 0)
244
+ check('画面へ何も出さない', out, '')
245
+ check('書けなかった事は記録に残る', [l['how'] for l in box.calls()], ['write-failed'])
246
+
247
+ box = Box()
248
+ ask = codex_stop(box, message='どちらにしますか?')
249
+ check('問いかけの手番は、前からの確認カードの経路だけを通る(止めない・出力なし)',
250
+ run_hook(box, ask), (0, ''))
251
+ check('★問いかけの手番でも受け皿には書く(notify と同じ間合い)', len(box.tray_lines()), 1)
252
+
253
+ # ★受け皿が丸ごと書けなくても、関門の判定(いまの仕事)は1つも変わらない。
254
+ box = Box()
255
+ state = os.path.join(box.root, 'state')
256
+ gate_env = box.env({'XDG_STATE_HOME': state, 'CONDUCTOR_RUNNER_MODE': 'headless',
257
+ 'CONDUCTOR_SITE': SITE, 'CONDUCTOR_WORK_DIR': box.work,
258
+ 'CONDUCTOR_CODEX_NOTIFY_DIR': os.path.join(box.tray, 'ない', '置き場')})
259
+ before = stop_hook.finish_gate_block(codex_stop(box), gate_env)
260
+ truthy('報告が無い手番は、受け皿が書けなくても今までどおり差し戻す', before)
261
+
262
+ # ─────────────────────────────────────────────────────────────────────────────
263
+ print('=== (E) 呼ばれた記録(道・便の印の頭8文字・本文は入らない) ===')
264
+ # ─────────────────────────────────────────────────────────────────────────────
265
+ box = Box()
266
+ secretish = 'ここに本文が入ってはいけない-ABCDEF'
267
+ run_hook(box, codex_stop(box, message=secretish))
268
+ rec = first(box.calls())
269
+ check('道が入る', rec.get('route'), 'stop')
270
+ check('★便の印は頭8文字だけ', rec.get('jobStamp'), JOB[:8])
271
+ check('現場名が入る', rec.get('site'), SITE)
272
+ check('本文は字数だけ', rec.get('reportChars'), len(secretish))
273
+ check('★本文は1文字も入らない', secretish in box.calls_raw(), False)
274
+ check('★会話の記録の置き場も入らない', box.transcript in box.calls_raw(), False)
275
+
276
+ box = Box(with_stamp=False)
277
+ run_hook(box, codex_stop(box))
278
+ check('★便の印が無ければ空', first(box.calls()).get('jobStamp'), '')
279
+ check('印が無くても受け皿には入る(配り役が時刻で判断する)', len(box.tray_lines()), 1)
280
+ check('受け皿の便の印は空(作り話をしない)', first(box.tray_lines()).get('jobId'), None)
281
+
282
+ box = Box()
283
+ os.makedirs(os.path.join(box.tray, '_calls.log')) # 記録の名前でフォルダ=記録だけ書けない
284
+ code, _ = run_hook(box, codex_stop(box))
285
+ check('記録が書けなくても終了コードは 0', code, 0)
286
+ check('★記録が書けなくても知らせの本体は書かれる', len(box.tray_lines()), 1)
287
+
288
+ # ─────────────────────────────────────────────────────────────────────────────
289
+ print('=== (F) ★関門が差し戻す手番では書かない ===')
290
+ # ─────────────────────────────────────────────────────────────────────────────
291
+ box = Box()
292
+ state = os.path.join(box.root, 'state')
293
+ code, out = run_hook(box, codex_stop(box), {
294
+ 'XDG_STATE_HOME': state,
295
+ 'CONDUCTOR_RUNNER_MODE': 'headless',
296
+ 'CONDUCTOR_SITE': SITE,
297
+ 'CONDUCTOR_WORK_DIR': box.work,
298
+ })
299
+ truthy('報告が無いので差し戻す', out.strip().startswith('{'))
300
+ check('差し戻しの形は変わらない', json.loads(out)['decision'], 'block')
301
+ check('終了コードは 0', code, 0)
302
+ check('★差し戻した手番では受け皿へ書かない', box.tray_lines(), [])
303
+ check('★呼ばれた記録にも足さない', box.calls(), [])
304
+
305
+ # ─────────────────────────────────────────────────────────────────────────────
306
+ print('=== (G) ★受け口が読めない機体では何もしない ===')
307
+ # ─────────────────────────────────────────────────────────────────────────────
308
+ box = Box()
309
+ missing = os.path.join(box.root, 'ない', 'codex-notify.py')
310
+ check('読めなければ何もしない',
311
+ stop_hook.write_codex_turn_notice(
312
+ codex_stop(box), box.env({'CONDUCTOR_CODEX_NOTIFY_PY': missing})) is None,
313
+ True)
314
+ check('★受け皿も空のまま', box.tray_lines(), [])
315
+ check('名指しが在れば、それだけを見る',
316
+ stop_hook.codex_notify_paths(box.env({'CONDUCTOR_CODEX_NOTIFY_PY': missing})), [missing])
317
+ check('名指しが無ければ、稼働機の場所とリポジトリの隣の2つ',
318
+ len(stop_hook.codex_notify_paths({})), 2)
319
+ truthy('★稼働機の場所(番人と同じフォルダ)を見ている',
320
+ os.path.join(HERE, 'codex-notify.py') in stop_hook.codex_notify_paths({}))
321
+ truthy('★リポジトリの隣(scripts/codex/)も見ている',
322
+ any(os.path.normpath(p) == os.path.normpath(NOTIFY_PY)
323
+ for p in stop_hook.codex_notify_paths({})))
324
+
325
+ print('\n' + '=' * 72)
326
+ if failures:
327
+ print(f'NG {len(failures)} 件')
328
+ for f in failures:
329
+ print(' ' + f)
330
+ sys.exit(1)
331
+ print('すべて期待どおり')