@tyhld/conductor 0.12.108 → 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/headless.js +79 -3
- package/dist/relay.js +10 -1
- package/package.json +1 -1
- package/scripts/codex/codex-notify.py +155 -45
- package/scripts/hooks/stop_hook.py +95 -0
- package/scripts/hooks/test_stop_codex_route.py +331 -0
package/dist/headless.js
CHANGED
|
@@ -566,6 +566,12 @@ export function maskSecrets(text) {
|
|
|
566
566
|
*
|
|
567
567
|
* ★本文を渡せなかったことが先。職人が何も受け取っていないという事実は、
|
|
568
568
|
* ほかのどの終わり方よりも強い(やり直しても同じ結果になる)。
|
|
569
|
+
*
|
|
570
|
+
* ★並びは変えない(便 a6e01bbc)。本文の未渡し → 時間切れ → 止められた → 認証の問題 の
|
|
571
|
+
* 順番はそのまま。足したのは「止められた」と「認証の問題」の間の1つだけで、
|
|
572
|
+
* これは【完了の知らせの後に残った処理を片づけただけ】の便(=ふつうに終わった便)。
|
|
573
|
+
* 終了コードは片づけの SIGTERM の物なので 0 ではない=ここで拾わないと 'error' になり、
|
|
574
|
+
* 「異常終了 → 自動再開」と同じ見え方になってしまう。
|
|
569
575
|
*/
|
|
570
576
|
export function jobEndKindOf(j) {
|
|
571
577
|
if (j.bodyHandoff === 'failed')
|
|
@@ -574,10 +580,40 @@ export function jobEndKindOf(j) {
|
|
|
574
580
|
return 'timeout';
|
|
575
581
|
if (j.stopped)
|
|
576
582
|
return 'stopped';
|
|
583
|
+
if (j.reapedAfterFinish === true)
|
|
584
|
+
return 'completed';
|
|
577
585
|
if (j.authError)
|
|
578
586
|
return 'auth_error';
|
|
579
587
|
return j.exitCode === 0 ? 'completed' : 'error';
|
|
580
588
|
}
|
|
589
|
+
/**
|
|
590
|
+
* 管制が返した終端の綴りが「職人の完了の知らせで終わった」ものか(★純粋関数・便 a6e01bbc)。
|
|
591
|
+
*
|
|
592
|
+
* ★`done` だけ。取りやめ(cancelled / canceled)・失敗(failed)・放棄(abandoned)は
|
|
593
|
+
* 完了の知らせが無いまま終端になった便なので、今までどおりの終わり方(stopped)にする。
|
|
594
|
+
* ★綴りの判定は小文字・前後の空白を無視する(isTerminalCommandStatus と同じ読み方)。
|
|
595
|
+
*/
|
|
596
|
+
export function isArtisanFinishedStatus(status) {
|
|
597
|
+
if (status === null)
|
|
598
|
+
return false;
|
|
599
|
+
return status.trim().toLowerCase() === 'done';
|
|
600
|
+
}
|
|
601
|
+
/**
|
|
602
|
+
* 片づけた便に添える一言(★純粋関数・便 a6e01bbc)。
|
|
603
|
+
*
|
|
604
|
+
* ★事実のまま書く。終了コード・合図は【片づけの結果】だと分かる形でここに添える
|
|
605
|
+
* (終わりの事実の `exitCode` / `exitSignal` には入れない=管制が「ふつうに終わりました
|
|
606
|
+
* (終了コード 143)」と、異常に見える形で並べてしまうため)。
|
|
607
|
+
*/
|
|
608
|
+
export function reapedAfterFinishDetail(exitCode, exitSignal) {
|
|
609
|
+
const parts = [];
|
|
610
|
+
if (exitCode !== null)
|
|
611
|
+
parts.push(`終了コード=${exitCode}`);
|
|
612
|
+
if (exitSignal !== null)
|
|
613
|
+
parts.push(`合図=${exitSignal}`);
|
|
614
|
+
const tail = parts.length > 0 ? `(片づけの結果: ${parts.join(', ')})` : '';
|
|
615
|
+
return `完了の知らせの後に、残った処理を片づけました${tail}`;
|
|
616
|
+
}
|
|
581
617
|
/**
|
|
582
618
|
* 本文を渡せなかった便として、走っている処理を止めるか(★純粋関数・便 915694ac)。
|
|
583
619
|
*
|
|
@@ -1154,6 +1190,7 @@ resumeNote) {
|
|
|
1154
1190
|
exitCode: null,
|
|
1155
1191
|
exitSignal: null,
|
|
1156
1192
|
stopped: false,
|
|
1193
|
+
reapedAfterFinish: false,
|
|
1157
1194
|
timedOut: false,
|
|
1158
1195
|
eventBuffer: [],
|
|
1159
1196
|
eventsSent: 0,
|
|
@@ -1382,6 +1419,7 @@ async function checkRunningJob(cfg, state) {
|
|
|
1382
1419
|
bodyHandoff: job.bodyHandoff,
|
|
1383
1420
|
timedOut: job.timedOut,
|
|
1384
1421
|
stopped: job.stopped,
|
|
1422
|
+
reapedAfterFinish: job.reapedAfterFinish,
|
|
1385
1423
|
authError: isAuthError(job),
|
|
1386
1424
|
exitCode: job.exitCode,
|
|
1387
1425
|
});
|
|
@@ -1390,13 +1428,18 @@ async function checkRunningJob(cfg, state) {
|
|
|
1390
1428
|
// ★本文の言葉は1文字も読まない(2026-09-23 の確定のまま。下書きの印は関門の側だけ)。
|
|
1391
1429
|
const delivery = reportDelivery(job.commandId);
|
|
1392
1430
|
const residue = takeFinishGateResidue(job.commandId);
|
|
1431
|
+
// ★完了の知らせの後に残った処理を片づけただけの便(便 a6e01bbc)。
|
|
1432
|
+
// 終了コード・合図は【片づけの SIGTERM の物】なので、便の終わり方を表す欄には入れない
|
|
1433
|
+
// (入れると管制が「ふつうに終わりました(終了コード 143)」と、異常に見える形で出す)。
|
|
1434
|
+
// ★捨てるのではなく、人向けの一言へ「片づけの結果」と分かる形で載せる。
|
|
1435
|
+
const reaped = job.reapedAfterFinish;
|
|
1393
1436
|
await deliverJobEndFacts(cfg, {
|
|
1394
1437
|
commandId: job.commandId,
|
|
1395
1438
|
site: cfg.site,
|
|
1396
1439
|
finishedAt: new Date(job.finishedAt ?? Date.now()).toISOString(),
|
|
1397
1440
|
endKind,
|
|
1398
|
-
exitCode: job.exitCode,
|
|
1399
|
-
exitSignal: job.exitSignal,
|
|
1441
|
+
exitCode: reaped ? null : job.exitCode,
|
|
1442
|
+
exitSignal: reaped ? null : job.exitSignal,
|
|
1400
1443
|
reportDelivered: delivery.delivered,
|
|
1401
1444
|
// ★画面なし方式は中継が自分で立ち上げた処理なので、終わりを直接つかんでいる。
|
|
1402
1445
|
// つかめていないときは false を送る(1-3 の「つかめていない」と同じ形)。
|
|
@@ -1407,6 +1450,7 @@ async function checkRunningJob(cfg, state) {
|
|
|
1407
1450
|
// そのどちらも無く、報告の形が整っていないときは、その理由を載せる(便 461be339)。
|
|
1408
1451
|
detail: job.bodyHandoffError
|
|
1409
1452
|
?? residue?.text
|
|
1453
|
+
?? (reaped ? reapedAfterFinishDetail(job.exitCode, job.exitSignal) : null)
|
|
1410
1454
|
?? delivery.note
|
|
1411
1455
|
?? `attempt ${job.attempt}/${HEADLESS_MAX_ATTEMPTS}`,
|
|
1412
1456
|
// ★上限に達して「途中で終わった」便だけに付ける(管制が機械で見分けられる形)。
|
|
@@ -1445,6 +1489,20 @@ async function checkRunningJob(cfg, state) {
|
|
|
1445
1489
|
log(`site=${cfg.site} id=${job.commandId} stop_requested で停止`);
|
|
1446
1490
|
return;
|
|
1447
1491
|
}
|
|
1492
|
+
if (job.reapedAfterFinish) {
|
|
1493
|
+
// ★職人が完了の知らせを出し、管制で便が終端(done)になったので、残っていた処理を
|
|
1494
|
+
// 片づけただけ(便 a6e01bbc)。=ふつうに終わった便。
|
|
1495
|
+
// ・「止めました」の合図は送らない(持ち主の画面に「止められて終わりました」と出る元)。
|
|
1496
|
+
// ・管制へ状態は書かない(既に終端。failed を done へ戻さないのと同じ考え)。
|
|
1497
|
+
// ・★ここで必ず畳む=下の「異常終了 → 自動再開」へ入らない
|
|
1498
|
+
// (片づけの終了コードは 0 ではないので、落ちると再開の道へ入ってしまう)。
|
|
1499
|
+
const detail = reapedAfterFinishDetail(job.exitCode, job.exitSignal);
|
|
1500
|
+
noteLastJob('done', detail);
|
|
1501
|
+
state.job = null;
|
|
1502
|
+
rs.lastHeartbeatAt = null;
|
|
1503
|
+
log(`site=${cfg.site} id=${job.commandId} 畳みます(${detail})`);
|
|
1504
|
+
return;
|
|
1505
|
+
}
|
|
1448
1506
|
if (isAuthError(job)) {
|
|
1449
1507
|
// 認証エラー → 再試行しない
|
|
1450
1508
|
const detail = job.authErrorDetected
|
|
@@ -1573,12 +1631,30 @@ async function checkRunningJob(cfg, state) {
|
|
|
1573
1631
|
if (now - job.lastStopCheckAt >= HEADLESS_STOP_CHECK_MS) {
|
|
1574
1632
|
job.lastStopCheckAt = now;
|
|
1575
1633
|
const status = await fetchCommandStatus(cfg, job.commandId);
|
|
1576
|
-
|
|
1634
|
+
// ★人・管制が止めた便(便 a6e01bbc で下の終端と分けた)。終わり方は今までどおり stopped。
|
|
1635
|
+
if (status === 'stop_requested') {
|
|
1577
1636
|
log(`site=${cfg.site} id=${job.commandId} ${status} → SIGTERM`);
|
|
1578
1637
|
job.stopped = true;
|
|
1579
1638
|
await stopJob(job);
|
|
1580
1639
|
return;
|
|
1581
1640
|
}
|
|
1641
|
+
if (isTerminalCommandStatus(status)) {
|
|
1642
|
+
// ★管制で終端になった便。残っている処理は、どちらにしても片づける(SIGTERM は変えない)。
|
|
1643
|
+
// ただし【なぜ終端になったか】で終わり方を分ける(便 a6e01bbc)。
|
|
1644
|
+
// ・done … 職人が完了の知らせを出した → ふつうに終わった便。片づけただけ。
|
|
1645
|
+
// ・それ以外(取りやめ・失敗・放棄)… 完了の知らせが無い → 今までどおり stopped。
|
|
1646
|
+
if (isArtisanFinishedStatus(status)) {
|
|
1647
|
+
job.reapedAfterFinish = true;
|
|
1648
|
+
log(`site=${cfg.site} id=${job.commandId} ${status}(完了の知らせ済み) → ` +
|
|
1649
|
+
'残った処理を片づけます(SIGTERM・止められたとは扱いません)');
|
|
1650
|
+
}
|
|
1651
|
+
else {
|
|
1652
|
+
job.stopped = true;
|
|
1653
|
+
log(`site=${cfg.site} id=${job.commandId} ${status} → SIGTERM`);
|
|
1654
|
+
}
|
|
1655
|
+
await stopJob(job);
|
|
1656
|
+
return;
|
|
1657
|
+
}
|
|
1582
1658
|
}
|
|
1583
1659
|
// タイムアウト — 中央の上限(3時間)で強制停止する。
|
|
1584
1660
|
// ★RELAY_PENDING_TIMEOUT_MS(15分)は tmux の pending 解除用。画面なし方式では
|
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
|
-
|
|
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
|
@@ -59,14 +59,35 @@
|
|
|
59
59
|
④ それも分からなければ作業フォルダの名前(今までどおり)。
|
|
60
60
|
★どれでも決められない知らせは【捨てない】。理由つきで控え(UNRESOLVED_FILE)へ残す。
|
|
61
61
|
|
|
62
|
-
|
|
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
|
-
|
|
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
|
|
308
|
-
"""
|
|
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':
|
|
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
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
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(
|
|
420
|
-
|
|
421
|
-
|
|
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
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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('すべて期待どおり')
|