@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.
- package/README.md +301 -19
- package/dist/cli.js +143 -18
- package/dist/ear-routing.js +57 -0
- package/dist/ear.js +57 -0
- package/dist/env-file-perm.js +67 -0
- package/dist/nudge.js +172 -0
- package/dist/realtime-parse.js +116 -0
- package/dist/realtime.js +251 -0
- package/dist/relay-runner.js +65 -0
- package/dist/relay.js +911 -134
- package/dist/websocket-transport.js +66 -0
- package/launchd/ear-install.sh +96 -0
- package/package.json +30 -1
- package/sales-template/README.md +185 -25
- package/sales-template/install.sh +890 -0
- package/sales-template/launchd/install.sh +151 -0
- package/sales-template/settings.json +26 -97
- package/sales-template/setup.sh +754 -117
- package/sales-template/systemd/README.md +28 -4
- package/sales-template/systemd/install.sh +54 -5
- package/sales-template/systemd/paste-cache-prune-install.sh +75 -0
- package/sales-template/systemd/tyhld-paste-cache-prune.service +25 -0
- package/sales-template/systemd/tyhld-paste-cache-prune.timer +19 -0
- package/sales-template/uninstall.sh +245 -0
- package/scripts/hooks/README.md +246 -0
- package/scripts/hooks/cc2_guard.py +145 -0
- package/scripts/hooks/codex-hooks.sample.json +58 -0
- package/scripts/hooks/hook_datalink.py +440 -0
- package/scripts/hooks/install-codex-hooks.sh +127 -0
- package/scripts/hooks/notification_hook.py +167 -0
- package/scripts/hooks/permission_request_hook.py +207 -0
- package/scripts/hooks/policy.py +759 -0
- package/scripts/hooks/settings.sample.json +142 -0
- package/scripts/hooks/stop_hook.py +275 -0
- package/scripts/hooks/summary_ja.py +155 -0
- package/scripts/hooks/test_hook_datalink.py +282 -0
- package/scripts/hooks/test_policy.py +1241 -0
- package/skills/conductor-craftsman/SKILL.md +40 -0
- package/systemd/conductor-ear.service +63 -0
- package/systemd/conductor@.service +62 -0
- package/systemd/ear-install.sh +131 -0
- package/systemd/guard-sync-install.sh +94 -0
- package/systemd/tyhld-guard-sync.service +28 -0
- package/systemd/tyhld-guard-sync.timer +25 -0
- package/sales-template/cc2_guard.py +0 -395
- package/sales-template/systemd/conductor@.service +0 -47
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_comment": [
|
|
3
|
+
"番人(段2)を登録するための見本。★このファイルは読むだけの見本で、これ自体は効きません。",
|
|
4
|
+
"人間が ~/.claude/settings.json の hooks をこの内容へ置き換えてください(AIは書き換えません)。",
|
|
5
|
+
"パスは実機に合わせて直してください(下は conductor をチェックアウトした場所の例)。",
|
|
6
|
+
"",
|
|
7
|
+
"★パスの書き方: 特定の機体の実パス(/home/ユーザー名/... )は書きません。$HOME を使います。",
|
|
8
|
+
" フックのコマンドはシェル経由で実行されるため $HOME はそのまま展開されます。",
|
|
9
|
+
" conductor を $HOME/projects/conductor 以外へ置いた場合は、その場所に合わせて直してください。",
|
|
10
|
+
" (配布 scripts/update-guard.sh はファイル名(末尾のbasename)だけを見て固定パス",
|
|
11
|
+
" $HOME/.tyhld/hooks/ へ置き換えるので、ここのディレクトリ部分は配布結果に影響しません。)",
|
|
12
|
+
"",
|
|
13
|
+
"★1本化: 旧 ~/.tyhld/hooks/cc2_guard.py への参照は【消して】、リポジトリ内の",
|
|
14
|
+
" scripts/hooks/cc2_guard.py だけを正にします(判定の正が2つある状態を解消する)。",
|
|
15
|
+
"",
|
|
16
|
+
"★matcher: 番人は Bash 以外も見るので、PreToolUse は全ツール(matcher 省略)にします。",
|
|
17
|
+
" 旧設定は Bash|WebFetch|... と列挙していたため、新しいツールが増えると素通りしていました。",
|
|
18
|
+
"",
|
|
19
|
+
"★permissions: 関門は番人自身が持つようになったので、settings 側の ask 13件は不要です。",
|
|
20
|
+
" また settings.local.json に溜まった allow 200行も、既定が allow になったので不要です。",
|
|
21
|
+
" (消すかどうかは人間の判断。残っていても deny → ask → allow の順で評価されるため、",
|
|
22
|
+
" 番人の deny と ask は allow より先に効きます=関門と危険は破られません。)"
|
|
23
|
+
],
|
|
24
|
+
"hooks": {
|
|
25
|
+
"PreToolUse": [
|
|
26
|
+
{
|
|
27
|
+
"hooks": [
|
|
28
|
+
{
|
|
29
|
+
"type": "command",
|
|
30
|
+
"command": "python3 $HOME/projects/conductor/scripts/hooks/cc2_guard.py"
|
|
31
|
+
}
|
|
32
|
+
]
|
|
33
|
+
}
|
|
34
|
+
],
|
|
35
|
+
"PermissionRequest": [
|
|
36
|
+
{
|
|
37
|
+
"hooks": [
|
|
38
|
+
{
|
|
39
|
+
"type": "command",
|
|
40
|
+
"timeout": 600,
|
|
41
|
+
"command": "python3 $HOME/projects/conductor/scripts/hooks/permission_request_hook.py"
|
|
42
|
+
}
|
|
43
|
+
]
|
|
44
|
+
}
|
|
45
|
+
],
|
|
46
|
+
"Stop": [
|
|
47
|
+
{
|
|
48
|
+
"hooks": [
|
|
49
|
+
{
|
|
50
|
+
"type": "command",
|
|
51
|
+
"timeout": 600,
|
|
52
|
+
"command": "python3 $HOME/projects/conductor/scripts/hooks/stop_hook.py"
|
|
53
|
+
}
|
|
54
|
+
]
|
|
55
|
+
}
|
|
56
|
+
],
|
|
57
|
+
"Notification": [
|
|
58
|
+
{
|
|
59
|
+
"hooks": [
|
|
60
|
+
{
|
|
61
|
+
"type": "command",
|
|
62
|
+
"timeout": 600,
|
|
63
|
+
"command": "python3 $HOME/projects/conductor/scripts/hooks/notification_hook.py"
|
|
64
|
+
}
|
|
65
|
+
]
|
|
66
|
+
}
|
|
67
|
+
]
|
|
68
|
+
},
|
|
69
|
+
"_comment_notification": [
|
|
70
|
+
"★Notification は「端末でしか押せない問い」を管制へ出すための入口(ADR-016)。",
|
|
71
|
+
"実機 2026-09-02 14:12: サンドボックスの通信許可(Host: results-receiver.actions.",
|
|
72
|
+
"githubusercontent.com)が端末にだけ出て職人が止まり、管制には1件も出なかった。",
|
|
73
|
+
"この問いは PermissionRequest を鳴らさないが、Claude Code 2.1.246 以降は",
|
|
74
|
+
"Notification フックを鳴らす(本体同梱の変更履歴に修正の記載あり・実機は 2.1.258)。",
|
|
75
|
+
"ところが Notification はホームにも現場にも【1つも登録されていなかった】=鳴っても誰も聞いていない。",
|
|
76
|
+
"ここに登録することで、管制に『端末で押してください』のカードが出るようになる。"
|
|
77
|
+
],
|
|
78
|
+
"_comment_sandbox": [
|
|
79
|
+
"★外への通信は【全部通す】。職人は聞かれずに自走して完了まで行く(えふさん決定 2026-09-03=甲)。",
|
|
80
|
+
"",
|
|
81
|
+
"【なぜ全部通すのか】",
|
|
82
|
+
" 『Network request outside of sandbox / 1.Yes 2.Yes,don't ask again 3.No』の問いは、",
|
|
83
|
+
" ★管制の3ボタンでは押せない。Claude Code 2.1.258 本体の作りで、この問いは PermissionRequest",
|
|
84
|
+
" フックに繋がっておらず(実測 2026-09-03・便 622e5497)、答えられるのは端末のダイアログか",
|
|
85
|
+
" Anthropic 公式のリモート操作だけ。=人の目が無いと職人が止まったままになる。",
|
|
86
|
+
" 止めても止めなくても最後は人が PR の結果を見て直すので、途中で止める意味が無い。",
|
|
87
|
+
"",
|
|
88
|
+
"【全部通す書き方(★実物で確定・2026-09-03 実測)】",
|
|
89
|
+
" allowedDomains に \"*\" を1つ入れる。本体のホスト照合は zN(host, pattern) で",
|
|
90
|
+
" pattern === \"*\" なら無条件に一致 → 『Allowed by config rule』",
|
|
91
|
+
" となり、確認そのものが起きない(実測: allowedDomains=[] だと curl が 403 で塞がれ、",
|
|
92
|
+
" [\"*\"] だと 200。対話画面でも通信ダイアログは0回、permission_prompt の通知も出ない)。",
|
|
93
|
+
"",
|
|
94
|
+
"【★これで緩むのは『電話をかける』だけ。『金庫を持ち出す』は1ミリも緩まない】",
|
|
95
|
+
" ・秘密の持ち出し(curl -d @.env / cp .env / tar ~/.ssh / base64 .env など)を止めるのは",
|
|
96
|
+
" 番人 cc2_guard.py + policy.py の『対象と作用』判定で、通信の設定とは【別の仕組み】。",
|
|
97
|
+
" 番人はコマンドの文字列だけを見ており、allowedDomains を1文字も参照していない",
|
|
98
|
+
" (test/network-always-allow.test.ts が両者の独立を機械で固定している)。",
|
|
99
|
+
" ・本番DB・secret・設定ファイル・.env・hooks・.tyhld・.ssh への書き込み禁止(deny)もそのまま。",
|
|
100
|
+
" ・deniedDomains は allowedDomains より【先に】判定される(本体の順序: Denied → Allowed →",
|
|
101
|
+
" 一致なし)。だから \"*\" を入れても sales-template の本番DB直結の禁止は効いたまま(実測で確認)。",
|
|
102
|
+
"",
|
|
103
|
+
"★キー名は公式ドキュメント準拠: sandbox.network.allowedDomains(allow ではない)。",
|
|
104
|
+
"★配布: 実機の ~/.claude/settings.json はこの見本では書き換えない。scripts/update-guard.sh が",
|
|
105
|
+
" ①-e3 でホームへ \"*\" を配り(1台1か所)、④で sandbox を持つ現場にも追記する(追加のみ)。"
|
|
106
|
+
],
|
|
107
|
+
"_comment_domain_groups": [
|
|
108
|
+
"★下の _domain_groups は【消さない】。通信が全開になった今でも『職人が実際にどこへ出るのか』",
|
|
109
|
+
" の記録であり、将来もし全開をやめて絞り直すときの出発点になるため(ADR-016 の資産)。",
|
|
110
|
+
"★通信が全開なので、この組に足しても足さなくても職人は止まらない。足すのは記録のためだけ。",
|
|
111
|
+
"★同じ理由で notification_hook.py も残す。全開なら permission_prompt は出なくなるが、",
|
|
112
|
+
" 『端末でしか押せない他の問い』(idle_prompt など)は今も出るので、入口は要る。",
|
|
113
|
+
"★measured = この機体で実際に接続が観測できたもの。documented = GitHub が公式に挙げている端点。"
|
|
114
|
+
],
|
|
115
|
+
"_domain_groups": {
|
|
116
|
+
"npm": [
|
|
117
|
+
"npm.pkg.github.com",
|
|
118
|
+
"registry.npmjs.org"
|
|
119
|
+
],
|
|
120
|
+
"github": [
|
|
121
|
+
"github.com",
|
|
122
|
+
"api.github.com",
|
|
123
|
+
"codeload.github.com",
|
|
124
|
+
"objects.githubusercontent.com"
|
|
125
|
+
],
|
|
126
|
+
"github-actions": [
|
|
127
|
+
"results-receiver.actions.githubusercontent.com",
|
|
128
|
+
"pipelines.actions.githubusercontent.com"
|
|
129
|
+
],
|
|
130
|
+
"playwright": [
|
|
131
|
+
"cdn.playwright.dev",
|
|
132
|
+
"playwright.download.prss.microsoft.com"
|
|
133
|
+
]
|
|
134
|
+
},
|
|
135
|
+
"sandbox": {
|
|
136
|
+
"network": {
|
|
137
|
+
"allowedDomains": [
|
|
138
|
+
"*"
|
|
139
|
+
]
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""stop_hook.py — Stop フック(段2・職人が人へ返して手が止まった時に管制へ知らせる)。
|
|
3
|
+
|
|
4
|
+
【いつ走るか】職人が応答を返し終えた瞬間(=入力待ちに入る瞬間)。
|
|
5
|
+
|
|
6
|
+
【何をするか】
|
|
7
|
+
紐づく指示(ConductorCommand)が【まだ終わっていない】、かつ最後の発言が【人の判断を
|
|
8
|
+
求める問いかけ】のときだけ、その発言を kind='message' として管制へ送る。
|
|
9
|
+
管制側ではフックが待っている間だけカードが出る。
|
|
10
|
+
|
|
11
|
+
【★カードを出す条件は2つ(両方満たしたときだけ出す)】
|
|
12
|
+
(1) 紐づく指示がまだ終わっていないこと
|
|
13
|
+
・待ち時間では判定しない(60秒待つ等は「遅い返事」と区別できない)。
|
|
14
|
+
・指示が done/failed/cancelled/dismissed/timeout なら、それは「終わった」であって
|
|
15
|
+
「待っている」ではない=送らない。
|
|
16
|
+
・指示IDが分からない/管制に聞けない場合は【送らない】(安全側。カードを増やさない)。
|
|
17
|
+
(2) 最後の発言が【人の判断を求める問いかけ】であること(★ADR-008)
|
|
18
|
+
・以前はここを見ていなかったため、「CIの完走を待っています。」のような【ただの進捗報告】
|
|
19
|
+
でも「担当者から質問があります」のカードが出て、職人が人を待って止まっていた(実機
|
|
20
|
+
2026-08-30)。進捗報告に人の判断は要らない。
|
|
21
|
+
・見分け方は needs_human_answer()。迷う文は【出さない側】へ倒す(止まって人を待つより、
|
|
22
|
+
報告して進む方が実害が小さい。本当に必要なら職人はもう一度聞ける)。
|
|
23
|
+
|
|
24
|
+
【★ここは「危ない操作の確認」ではない】
|
|
25
|
+
実行前の3関門(main直変更/本番確認/DB実行)・秘密の持ち出し・SQL は PreToolUse
|
|
26
|
+
(cc2_guard.py → policy.py)と PermissionRequest(permission_request_hook.py)が止める。
|
|
27
|
+
このフックは【ツールを実行しない】場面のお知らせだけを扱うので、ここを絞っても関門は
|
|
28
|
+
1つも弱まらない。
|
|
29
|
+
|
|
30
|
+
【指示IDの見つけ方】
|
|
31
|
+
職人へ渡る指示文の末尾に管理用ジョブ印 `CONDUCTOR_JOB:<uuid>` が入っている。
|
|
32
|
+
Stop フックの入力に含まれる transcript_path(会話の記録)から、最後に出てきたものを拾う。
|
|
33
|
+
|
|
34
|
+
【接続先が未設定の環境】何もせず終了する(このフックを入れても壊れない)。
|
|
35
|
+
"""
|
|
36
|
+
import json
|
|
37
|
+
import os
|
|
38
|
+
import re
|
|
39
|
+
import sys
|
|
40
|
+
|
|
41
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
42
|
+
|
|
43
|
+
import hook_datalink # noqa: E402
|
|
44
|
+
import summary_ja # noqa: E402
|
|
45
|
+
|
|
46
|
+
JOB_RE = re.compile(r'CONDUCTOR_JOB:([0-9a-fA-F-]{36})')
|
|
47
|
+
|
|
48
|
+
# 指示が「もう終わっている」状態(relay の isTerminalCommandStatus + 中央の追加終端に合わせる)。
|
|
49
|
+
TERMINAL_STATUSES = frozenset({
|
|
50
|
+
'done', 'failed', 'cancelled', 'canceled', 'abandoned', 'dismissed', 'timeout',
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
# ★このフックが1件の確認を待つ上限(秒)。返事が来なければ「返事なし」として先へ進む。
|
|
54
|
+
# 共有の上限 hook_datalink.MAX_WAIT_SEC(600秒)から【30秒差し引く】。
|
|
55
|
+
# 理由: Claude Code 側のフック実行タイムアウトも 600 秒(settings.json の timeout)なので、
|
|
56
|
+
# 同じ 600 秒まで待つと、時間切れの瞬間にフックが外から打ち切られ、finally の abandon()
|
|
57
|
+
# (カードを閉じる後始末)が走らないことがある=管制に【残骸カード】が残る(ADR-009 と同じ病気)。
|
|
58
|
+
# 後始末を確実に走らせるため、待つのを先に切り上げる。
|
|
59
|
+
# ★共有の MAX_WAIT_SEC は変えない(ツール実行の許可を待つ permission_request_hook に影響が出る)。
|
|
60
|
+
STOP_WAIT_SEC = max(60, hook_datalink.MAX_WAIT_SEC - 30)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
64
|
+
# 「本当に人の判断が要る問いかけか」の見分け(★出す側ではなく出さない側へ倒す)
|
|
65
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
66
|
+
# 判定の邪魔になる部分。コード・引用・URL の中の「?」で問いかけと誤らないため先に落とす。
|
|
67
|
+
_FENCE_RE = re.compile(r'```.*?```', re.S) # 対になったコード塊
|
|
68
|
+
_FENCE_OPEN_RE = re.compile(r'```.*\Z', re.S) # 閉じ忘れたコード塊(末尾まで)
|
|
69
|
+
_INLINE_CODE_RE = re.compile(r'`[^`\n]*`')
|
|
70
|
+
_QUOTE_LINE_RE = re.compile(r'^[ \t]*>[^\n]*', re.M)
|
|
71
|
+
_URL_RE = re.compile(r'https?://\S+')
|
|
72
|
+
|
|
73
|
+
# 文の区切り。★半角ピリオドは入れない(stop_hook.py や 1.5 で文が切れてしまう)。
|
|
74
|
+
_TERMINATORS = '。.!!??\n\r'
|
|
75
|
+
|
|
76
|
+
# 文末の飾り(かっこ・引用符・強調記号)。ここまで剥がしてから文末の言い回しを見る。
|
|
77
|
+
_CLOSERS = '」』))】〕》〉”"\'’`**__〜~…・、, \t'
|
|
78
|
+
|
|
79
|
+
# 文末に来たら【問いかけ】とみなす言い回し。日本語は語順ではなく文末で問いかけが決まる。
|
|
80
|
+
# 例: 〜しますか / 〜でしょうか / 〜ませんか / 〜でしたか / どちらにしますか
|
|
81
|
+
# ★「〜します」「〜しています」「〜しました」「〜を待っています」「〜が完了しました」
|
|
82
|
+
# 「〜を設定しました」は当たらない=完了・状況の報告はここで問いかけにならない。
|
|
83
|
+
_ASK_TAIL_RE = re.compile(
|
|
84
|
+
r'(?:ます|ました|ません|ませんでした|ましょう|です|でした|でしょう|だろう|んだろう|のだろう)'
|
|
85
|
+
r'か(?:ね|な|しら)?$'
|
|
86
|
+
r'|いかがでしょう$',
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
# 「?」で終わる文のうち、【人に判断を求めている】と読める合図。★ADR-011。
|
|
90
|
+
# ここに当たらない「?」は、調べものの自問・表のセル・記事タイトル・引用のことが多い。
|
|
91
|
+
# 実機の記録7,581件を走査した結果、誤発火37件の大半がその形だった(ADR-011 に内訳)。
|
|
92
|
+
_ASK_WORD_JA_RE = re.compile(
|
|
93
|
+
r'どちら|どっち|どれ|いずれ' # 選択を求める
|
|
94
|
+
r'|よろし|構いま|許可|承認' # 許可を求める
|
|
95
|
+
r'|どうしま|どうすれ|いかが' # 判断を仰ぐ
|
|
96
|
+
r'|ご指示|お願いしま|教えてくださ|決めてくださ|選んでくださ',
|
|
97
|
+
)
|
|
98
|
+
# 英語は【人称を伴う許可・依頼】の形だけを問いかけとみなす。
|
|
99
|
+
# 「do subcontractor photos reach …?」のような三人称の調べもの(自問)は人への問いかけではない。
|
|
100
|
+
_ASK_WORD_EN_RE = re.compile(
|
|
101
|
+
r'\b(?:should i|shall i|may i|do you want|would you like|do you prefer'
|
|
102
|
+
r'|let me know|please confirm|ok(?:ay)? to|can i (?:proceed|continue|go ahead))\b',
|
|
103
|
+
re.I,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
# 表の行(Markdown のセル)。セルに置いた「?」マークで問いかけと誤らないため丸ごと外す。
|
|
107
|
+
_TABLE_ROW_RE = re.compile(r'^[ \t]*\|')
|
|
108
|
+
|
|
109
|
+
# 実質「?」だけ・記号だけの断片は問いかけにしない(「「?」で始まる…」のような引用の欠片)。
|
|
110
|
+
_MIN_QUESTION_CHARS = 4
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _strip_noise(text):
|
|
114
|
+
"""コード塊・引用行・URL を落とす(この中の「?」は職人の問いかけではない)。"""
|
|
115
|
+
t = _FENCE_RE.sub(' ', text)
|
|
116
|
+
t = _FENCE_OPEN_RE.sub(' ', t)
|
|
117
|
+
t = _INLINE_CODE_RE.sub(' ', t)
|
|
118
|
+
t = _QUOTE_LINE_RE.sub(' ', t)
|
|
119
|
+
return _URL_RE.sub(' ', t)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _sentences(text):
|
|
123
|
+
"""文に切り分ける(区切り文字は各文の末尾に残す)。"""
|
|
124
|
+
buf = []
|
|
125
|
+
for ch in text:
|
|
126
|
+
buf.append(ch)
|
|
127
|
+
if ch in _TERMINATORS:
|
|
128
|
+
s = ''.join(buf).strip()
|
|
129
|
+
if s:
|
|
130
|
+
yield s
|
|
131
|
+
buf = []
|
|
132
|
+
tail = ''.join(buf).strip()
|
|
133
|
+
if tail:
|
|
134
|
+
yield tail
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _is_asking(sentence):
|
|
138
|
+
"""1文が【人の判断を求める問いかけ】か。★ADR-011 で「疑わしきは出さない」へ寄せた。
|
|
139
|
+
|
|
140
|
+
出すのは次のどちらかだけ:
|
|
141
|
+
(1) 日本語の問いかけの語尾(〜しますか / 〜でしょうか / 〜ましょうか / 〜でしたか …)
|
|
142
|
+
(2) 「?」で終わり、かつ【人に判断を求める合図】がある(どちら/よろしい/許可/
|
|
143
|
+
Should I … のような人称を伴う依頼)
|
|
144
|
+
|
|
145
|
+
出さないのは:
|
|
146
|
+
・表の行(セルに置いた「?」マーク)
|
|
147
|
+
・「?」だけの断片(引用の欠片・記号)
|
|
148
|
+
・「?」で終わるが人に向いていない文(調べものの自問・記事タイトル・引用の写し)
|
|
149
|
+
・完了・状況の報告(「〜しました」「〜を待っています」「〜が完了しました」
|
|
150
|
+
「〜を設定しました」)… (1)(2) のどちらにも当たらないので構造的に出ない
|
|
151
|
+
"""
|
|
152
|
+
s = sentence.strip()
|
|
153
|
+
if _TABLE_ROW_RE.match(s): # 表の行はセルの寄せ集め=文ではない
|
|
154
|
+
return False
|
|
155
|
+
s = s.rstrip(_CLOSERS)
|
|
156
|
+
if not s:
|
|
157
|
+
return False
|
|
158
|
+
ends_with_mark = s[-1] in '??'
|
|
159
|
+
core = s.rstrip(_TERMINATORS + _CLOSERS + '.')
|
|
160
|
+
if len(core) < _MIN_QUESTION_CHARS:
|
|
161
|
+
return False # 「?」だけ/記号だけの断片
|
|
162
|
+
if _ASK_TAIL_RE.search(core):
|
|
163
|
+
return True # (1) 問いかけの語尾
|
|
164
|
+
if not ends_with_mark:
|
|
165
|
+
return False
|
|
166
|
+
# (2) 「?」で終わる文は、人に判断を求めていると読めるときだけ出す。
|
|
167
|
+
return bool(_ASK_WORD_JA_RE.search(core) or _ASK_WORD_EN_RE.search(core))
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def needs_human_answer(message):
|
|
171
|
+
"""職人の最後の発言に、人が答えるべき問いかけが含まれるか。
|
|
172
|
+
|
|
173
|
+
★迷ったら False(=カードを出さない)。進捗・状況の報告で職人を止めないことを優先する。
|
|
174
|
+
"""
|
|
175
|
+
if not isinstance(message, str) or not message.strip():
|
|
176
|
+
return False
|
|
177
|
+
return any(_is_asking(s) for s in _sentences(_strip_noise(message)))
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def find_command_id(transcript_path):
|
|
181
|
+
"""会話の記録から、最後に出てきた管理用ジョブ印の ID を拾う。見つからなければ None。"""
|
|
182
|
+
if not transcript_path or not os.path.isfile(transcript_path):
|
|
183
|
+
return None
|
|
184
|
+
found = None
|
|
185
|
+
try:
|
|
186
|
+
with open(transcript_path, encoding='utf-8', errors='replace') as f:
|
|
187
|
+
for line in f:
|
|
188
|
+
for m in JOB_RE.finditer(line):
|
|
189
|
+
found = m.group(1)
|
|
190
|
+
except Exception: # noqa: BLE001
|
|
191
|
+
return None
|
|
192
|
+
return found
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def command_is_open(cfg, command_id, datalink=hook_datalink):
|
|
196
|
+
"""指示がまだ終わっていないか。分からなければ False(=送らない・安全側)。"""
|
|
197
|
+
if not command_id:
|
|
198
|
+
return False
|
|
199
|
+
res = datalink._request(cfg, 'GET', f'/api/conductor/commands/{command_id}')
|
|
200
|
+
if not isinstance(res, dict):
|
|
201
|
+
return False
|
|
202
|
+
status = res.get('status')
|
|
203
|
+
if not isinstance(status, str) or not status.strip():
|
|
204
|
+
return False
|
|
205
|
+
return status.strip().lower() not in TERMINAL_STATUSES
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def build_payload(data, command_id):
|
|
209
|
+
cwd = data.get('cwd') or ''
|
|
210
|
+
site = os.environ.get('CONDUCTOR_SITE') or (cwd.rstrip('/').rsplit('/', 1)[-1] if cwd else '')
|
|
211
|
+
machine = os.environ.get('CONDUCTOR_MACHINE') or os.uname().nodename
|
|
212
|
+
message = data.get('last_assistant_message') or ''
|
|
213
|
+
return {
|
|
214
|
+
'site': site or 'unknown',
|
|
215
|
+
'machine': machine,
|
|
216
|
+
# ★sessionId は絶対に空で送らない(空だと管制が 400 で弾く)。
|
|
217
|
+
'sessionId': hook_datalink.stable_session_id(data.get('session_id'), machine, site),
|
|
218
|
+
'kind': 'message',
|
|
219
|
+
'question': message,
|
|
220
|
+
'summary': summary_ja.summarize_question(message),
|
|
221
|
+
'commandId': command_id,
|
|
222
|
+
'waitMs': STOP_WAIT_SEC * 1000,
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def run(data, cfg=None, datalink=hook_datalink):
|
|
227
|
+
"""返り値: 送った確認のID / None(送らなかった)。"""
|
|
228
|
+
message = data.get('last_assistant_message')
|
|
229
|
+
if not isinstance(message, str) or not message.strip():
|
|
230
|
+
return None
|
|
231
|
+
if not needs_human_answer(message):
|
|
232
|
+
return None # ただの進捗・状況の報告 → 人の判断は要らない=カードを作らない
|
|
233
|
+
cfg = cfg if cfg is not None else datalink.load_config()
|
|
234
|
+
if cfg is None:
|
|
235
|
+
return None
|
|
236
|
+
|
|
237
|
+
command_id = find_command_id(data.get('transcript_path'))
|
|
238
|
+
if not command_is_open(cfg, command_id, datalink=datalink):
|
|
239
|
+
return None # 終わっている/分からない → カードを作らない
|
|
240
|
+
|
|
241
|
+
reg = datalink.register(cfg, build_payload(data, command_id))
|
|
242
|
+
if not reg or not reg.get('id'):
|
|
243
|
+
return None
|
|
244
|
+
request_id = reg['id']
|
|
245
|
+
|
|
246
|
+
# 人が答えるまで(または時間切れまで)待つ。★ここで待つ間だけ管制にカードが出る。
|
|
247
|
+
# Stop フックは答えを「実行の可否」には使わない(実行するツールが無い)。
|
|
248
|
+
# 待つこと自体が「担当者はまだ返事を待っている」を管制へ伝え続ける役割を持つ。
|
|
249
|
+
# ★待っている間だけ「この止まりは管制が受け持っている」印を置く(ADR-016)。
|
|
250
|
+
# Claude Code の Notification(「入力待ちです」)で同じ止まりが2枚出るのを防ぐ。
|
|
251
|
+
datalink.mark_handoff(data.get('session_id') or '')
|
|
252
|
+
try:
|
|
253
|
+
# ★上限つき(STOP_WAIT_SEC)。返事が来なければ時間切れで先へ進む=職人を無限に止めない。
|
|
254
|
+
datalink.wait_for_decision(cfg, request_id, max_wait_sec=STOP_WAIT_SEC)
|
|
255
|
+
finally:
|
|
256
|
+
datalink.abandon(cfg, request_id) # 去るときは必ず閉じる(残骸カードを作らない)
|
|
257
|
+
datalink.clear_handoff(data.get('session_id') or '')
|
|
258
|
+
return request_id
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def main() -> int:
|
|
262
|
+
try:
|
|
263
|
+
raw = sys.stdin.read()
|
|
264
|
+
data = json.loads(raw) if raw.strip() else {}
|
|
265
|
+
except Exception: # noqa: BLE001
|
|
266
|
+
return 0
|
|
267
|
+
try:
|
|
268
|
+
run(data)
|
|
269
|
+
except Exception: # noqa: BLE001
|
|
270
|
+
pass
|
|
271
|
+
return 0 # ★Stop フックは会話を止めない(何があっても exit 0・出力なし)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
if __name__ == '__main__':
|
|
275
|
+
sys.exit(main())
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""summary_ja.py — 確認カードに出す【日本語1行】の組み立て。
|
|
3
|
+
|
|
4
|
+
【なぜ要るか】
|
|
5
|
+
確認カードは非エンジニアのお客さまがそのまま読んで判断する。英文や生コマンドを貼ると
|
|
6
|
+
「何を許可するのか」が分からないまま押すことになる。ここで必ず日本語の1行に写す。
|
|
7
|
+
生の内容(コマンド全文・ツール入力)はカードの「くわしく」に残す(消さない)。
|
|
8
|
+
|
|
9
|
+
【方針】
|
|
10
|
+
・どんな入力でも必ず日本語が返る(ここで詰まってカードが出ない、を作らない)。
|
|
11
|
+
・関門(マージ/本番確認/DB実行)は、何の関門かが一目で分かる文言にする。
|
|
12
|
+
・推測で断定しない。分からないものは「〜してよいですか」の中立形にする。
|
|
13
|
+
"""
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import sys
|
|
17
|
+
|
|
18
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
19
|
+
|
|
20
|
+
import policy # noqa: E402 … 関門に当たったセグメントを特定するため
|
|
21
|
+
|
|
22
|
+
# 動詞ごとの言い換え(先頭一致で見る)。★増やすときはここだけ触る。
|
|
23
|
+
_PHRASES = [
|
|
24
|
+
(r'^gh\s+pr\s+merge\b', '変更を本番の元(main)に取り込んでよいですか'),
|
|
25
|
+
(r'^gh\s+pr\s+create\b', '変更の提案(プルリクエスト)を作ってよいですか'),
|
|
26
|
+
(r'^gh\s+pr\s+(view|checks|list|status|diff)\b', '変更の提案の状態を見てよいですか'),
|
|
27
|
+
(r'^gh\s+run\b', '自動テストの実行結果を見てよいですか'),
|
|
28
|
+
(r'^git\s+push\b', '作業内容をGitHubへ送ってよいですか'),
|
|
29
|
+
(r'^git\s+commit\b', '作業内容を記録(コミット)してよいですか'),
|
|
30
|
+
(r'^git\s+merge\b', '別の作業内容を取り込んでよいですか'),
|
|
31
|
+
(r'^git\s+(status|log|diff|show|branch)\b', '作業の状態を確認してよいですか'),
|
|
32
|
+
(r'^vercel\b.*--prod', '本番サイトへ公開してよいですか'),
|
|
33
|
+
(r'^vercel\s+(promote|alias|rollback)\b', '本番サイトの向き先を切り替えてよいですか'),
|
|
34
|
+
(r'^(npm|pnpm|yarn)\s+publish\b', '部品を公開(配布)してよいですか'),
|
|
35
|
+
(r'^(npm|pnpm|yarn)\s+(install|ci|add)\b', '必要な部品を取り寄せてよいですか'),
|
|
36
|
+
(r'^(npm|pnpm|yarn)\s+run\s+build\b', 'プログラムを組み立て(ビルド)してよいですか'),
|
|
37
|
+
(r'^(npm|pnpm|yarn)\s+(test|run\s+test)\b', 'テストを実行してよいですか'),
|
|
38
|
+
(r'^npx\s+tsc\b', 'プログラムの型チェックを実行してよいですか'),
|
|
39
|
+
(r'^psql\b|^mysql\b', 'データベースへ直接つないでよいですか'),
|
|
40
|
+
(r'prisma\s+(migrate|db)\b', 'データベースの構造を変更してよいですか'),
|
|
41
|
+
(r'supabase\s+db\s+(push|reset)|supabase\s+migration\s+up',
|
|
42
|
+
'データベースの構造を変更してよいですか'),
|
|
43
|
+
(r'^supabase\s+functions\s+deploy\b', '本番の処理を差し替えてよいですか'),
|
|
44
|
+
(r'^rm\b', 'ファイルを削除してよいですか'),
|
|
45
|
+
(r'^(cat|head|tail|less)\b', 'ファイルの中身を見てよいですか'),
|
|
46
|
+
(r'^(ls|find|grep|rg)\b', 'ファイルを探して見てよいですか'),
|
|
47
|
+
(r'^(journalctl|systemctl)\b', '動作の記録(ログ)を確認してよいですか'),
|
|
48
|
+
(r'^(docker|make|go|cargo)\b', 'プログラムを組み立て・実行してよいですか'),
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
# 関門ごとの見出し(policy.py の gate 種別に対応)。
|
|
52
|
+
_GATE_HEADLINE = {
|
|
53
|
+
'merge': '【お客さまの確認:本番の元を直接変更】',
|
|
54
|
+
'release': '【お客さまの確認:本番へ反映】',
|
|
55
|
+
'db': '【お客さまの確認:データベースの変更】',
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _gating_segment(command: str, gate) -> str:
|
|
60
|
+
"""関門に当たったセグメントの本文を返す(無ければ空)。
|
|
61
|
+
|
|
62
|
+
★複合コマンド(`cd /x && gh pr merge 1`)で先頭語(cd)に引きずられないため、
|
|
63
|
+
見出しは【関門に当たったセグメント】から作る。judgment は policy に一致させる。
|
|
64
|
+
"""
|
|
65
|
+
try:
|
|
66
|
+
for seg in policy.split_segments(command or ''):
|
|
67
|
+
r = policy.decide_bash(seg)
|
|
68
|
+
if r.get('decision') == policy.GATE and r.get('gate') == gate:
|
|
69
|
+
return seg
|
|
70
|
+
except Exception: # noqa: BLE001 文言づくりで詰まってカードを止めない
|
|
71
|
+
return ''
|
|
72
|
+
return ''
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _pushes_to_protected(segment: str) -> bool:
|
|
76
|
+
"""`git push` の宛先が本番ブランチか(policy とまったく同じ見方をする)。
|
|
77
|
+
|
|
78
|
+
★関門1の中身が「PRのマージ」から「main への直接 push」へ変わったため、カードの文言も
|
|
79
|
+
それに合わせる。字面の正規表現で当てると `feat/main-fix` のような枝名まで拾ってしまうので、
|
|
80
|
+
policy の判定材料(PROTECTED_BRANCHES と参照名の取り出し方)をそのまま使う。
|
|
81
|
+
"""
|
|
82
|
+
try:
|
|
83
|
+
toks = policy.tokens_of(segment)
|
|
84
|
+
if policy.verb_of(toks) != 'git' or len(toks) < 2 or toks[1] != 'push':
|
|
85
|
+
return False
|
|
86
|
+
return any(t.split(':')[-1].strip() in policy.PROTECTED_BRANCHES
|
|
87
|
+
for t in toks[2:] if not t.startswith('-'))
|
|
88
|
+
except Exception: # noqa: BLE001 文言づくりで詰まってカードを止めない
|
|
89
|
+
return False
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _verb_of(segment: str) -> str:
|
|
93
|
+
"""セグメントの実行動詞(ラッパ・変数代入を飛ばす)。policy と同じ見方をする。"""
|
|
94
|
+
try:
|
|
95
|
+
return policy.verb_of(policy.tokens_of(segment))
|
|
96
|
+
except Exception: # noqa: BLE001
|
|
97
|
+
return segment.split(' ', 1)[0].split('/')[-1] if segment else ''
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def summarize_bash(command: str, gate=None) -> str:
|
|
101
|
+
"""Bash コマンドを日本語1行にする。"""
|
|
102
|
+
c = ' '.join((command or '').split())
|
|
103
|
+
# 関門のときは、当たったセグメントを見出しの素材にする(先頭語ではなく)。
|
|
104
|
+
target = c
|
|
105
|
+
if gate:
|
|
106
|
+
seg = _gating_segment(command, gate)
|
|
107
|
+
if seg:
|
|
108
|
+
target = ' '.join(seg.split())
|
|
109
|
+
# 本番ブランチへの直接 push(関門1)だけは、字面より先に専用の文言を当てる。
|
|
110
|
+
body = '本番の元(main)を直接書き換えてよいですか' if _pushes_to_protected(target) else None
|
|
111
|
+
if body is None:
|
|
112
|
+
for pat, phrase in _PHRASES:
|
|
113
|
+
if re.search(pat, target):
|
|
114
|
+
body = phrase
|
|
115
|
+
break
|
|
116
|
+
if body is None:
|
|
117
|
+
# 未知のコマンドは動詞だけ拾って中立形にする(英文をそのまま貼らない)。
|
|
118
|
+
verb = _verb_of(target) or (target.split(' ', 1)[0].split('/')[-1] if target else '')
|
|
119
|
+
body = f'パソコンの中で作業({verb})を実行してよいですか' if verb \
|
|
120
|
+
else 'パソコンの中で作業を実行してよいですか'
|
|
121
|
+
head = _GATE_HEADLINE.get(gate or '', '')
|
|
122
|
+
return f'{head}{body}'
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def summarize_tool(tool_name: str, tool_input, gate=None) -> str:
|
|
126
|
+
"""Bash 以外のツールを日本語1行にする。"""
|
|
127
|
+
if tool_name == 'Bash':
|
|
128
|
+
cmd = (tool_input or {}).get('command', '') if isinstance(tool_input, dict) else ''
|
|
129
|
+
return summarize_bash(cmd, gate)
|
|
130
|
+
head = _GATE_HEADLINE.get(gate or '', '')
|
|
131
|
+
if tool_name in ('Write', 'Edit', 'MultiEdit', 'NotebookEdit'):
|
|
132
|
+
path = (tool_input or {}).get('file_path', '') if isinstance(tool_input, dict) else ''
|
|
133
|
+
name = path.rsplit('/', 1)[-1] if path else ''
|
|
134
|
+
return f'{head}ファイル({name})を書き換えてよいですか' if name \
|
|
135
|
+
else f'{head}ファイルを書き換えてよいですか'
|
|
136
|
+
if tool_name == 'WebFetch':
|
|
137
|
+
return f'{head}インターネットのページを読んでよいですか'
|
|
138
|
+
if tool_name == 'WebSearch':
|
|
139
|
+
return f'{head}インターネットで調べてよいですか'
|
|
140
|
+
if isinstance(tool_name, str) and tool_name.startswith('mcp__'):
|
|
141
|
+
return f'{head}外部の道具を使ってよいですか'
|
|
142
|
+
return f'{head}この操作を行ってよいですか'
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def summarize_question(text: str) -> str:
|
|
146
|
+
"""職人が人へ投げた自由文を、カードの1行見出しにする。
|
|
147
|
+
|
|
148
|
+
★中身の翻訳はしない(勝手な意訳で意味が変わるのを避ける)。
|
|
149
|
+
「担当者から質問があります」+原文の先頭だけを添え、全文は詳細欄に残す。
|
|
150
|
+
"""
|
|
151
|
+
t = ' '.join((text or '').split())
|
|
152
|
+
if not t:
|
|
153
|
+
return '担当者が返事を待っています。'
|
|
154
|
+
head = t[:60] + ('…' if len(t) > 60 else '')
|
|
155
|
+
return f'担当者から質問があります:{head}'
|