@tyhld/conductor 0.3.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +301 -19
  2. package/dist/cli.js +143 -18
  3. package/dist/ear-routing.js +57 -0
  4. package/dist/ear.js +57 -0
  5. package/dist/env-file-perm.js +67 -0
  6. package/dist/nudge.js +172 -0
  7. package/dist/realtime-parse.js +116 -0
  8. package/dist/realtime.js +251 -0
  9. package/dist/relay-runner.js +65 -0
  10. package/dist/relay.js +911 -134
  11. package/dist/websocket-transport.js +66 -0
  12. package/launchd/ear-install.sh +96 -0
  13. package/package.json +30 -1
  14. package/sales-template/README.md +185 -25
  15. package/sales-template/install.sh +890 -0
  16. package/sales-template/launchd/install.sh +151 -0
  17. package/sales-template/settings.json +26 -97
  18. package/sales-template/setup.sh +754 -117
  19. package/sales-template/systemd/README.md +28 -4
  20. package/sales-template/systemd/install.sh +54 -5
  21. package/sales-template/systemd/paste-cache-prune-install.sh +75 -0
  22. package/sales-template/systemd/tyhld-paste-cache-prune.service +25 -0
  23. package/sales-template/systemd/tyhld-paste-cache-prune.timer +19 -0
  24. package/sales-template/uninstall.sh +245 -0
  25. package/scripts/hooks/README.md +246 -0
  26. package/scripts/hooks/cc2_guard.py +145 -0
  27. package/scripts/hooks/codex-hooks.sample.json +58 -0
  28. package/scripts/hooks/hook_datalink.py +440 -0
  29. package/scripts/hooks/install-codex-hooks.sh +127 -0
  30. package/scripts/hooks/notification_hook.py +167 -0
  31. package/scripts/hooks/permission_request_hook.py +207 -0
  32. package/scripts/hooks/policy.py +759 -0
  33. package/scripts/hooks/settings.sample.json +142 -0
  34. package/scripts/hooks/stop_hook.py +275 -0
  35. package/scripts/hooks/summary_ja.py +155 -0
  36. package/scripts/hooks/test_hook_datalink.py +282 -0
  37. package/scripts/hooks/test_policy.py +1241 -0
  38. package/skills/conductor-craftsman/SKILL.md +40 -0
  39. package/systemd/conductor-ear.service +63 -0
  40. package/systemd/conductor@.service +62 -0
  41. package/systemd/ear-install.sh +131 -0
  42. package/systemd/guard-sync-install.sh +94 -0
  43. package/systemd/tyhld-guard-sync.service +28 -0
  44. package/systemd/tyhld-guard-sync.timer +25 -0
  45. package/sales-template/cc2_guard.py +0 -395
  46. package/sales-template/systemd/conductor@.service +0 -47
@@ -10,13 +10,21 @@
10
10
  # bash setup.sh my-app /home/user/work/my-app # ディレクトリを明示指定
11
11
  #
12
12
  # やること(順番に・冪等・失敗時は止めて報告):
13
- # ① 前提チェック(node>=18 / tmux / git / python3>=3.8 / systemd / sudo)
13
+ # ① 前提チェック(node>=18 / tmux / git / python3>=3.8 / systemd / sudo / ★Claude Code
14
14
  # ② conductor 本体インストール(npm i -g @tyhld/conductor)
15
15
  # ③ node symlink(/usr/local/bin/node → 実体・sudo)
16
- # ④ .conductor.env 作成(対話入力・chmod 600)
17
- # ⑤ .claude/ 配置(cc2_guard.py + settings.json + hooks 設定)
18
- # systemd install(enable + linger)
19
- # 職人起動の案内表示
16
+ # ④ .conductor.env 作成(★合言葉は伏せ字で入力/★その場で1回つながるか確かめる/chmod 600)
17
+ # ⑤ 番人一式の配置(★登録するイベントは見本 settings.sample.json が正・直書きしない)
18
+ # ・番人一式を ~/.tyhld/hooks/ へ/フックは ~/.claude/settings.json に1本
19
+ # ・現場の .claude/settings.json には権限ルールのみ・番人フックは書かない(ADR-013)
20
+ # ・⑤-0 で見本と配る中身を機械照合し、外れていれば止める(★足し忘れの防止・ADR-017)
21
+ # + 職人の鉄則スキル conductor-craftsman を ~/.claude/skills/ へ配置
22
+ # ⑥ systemd install(現場ごとの常駐 conductor@<現場> + このPCに1本の耳 conductor-ear/enable + linger)
23
+ # ⑦ ★自己点検(置いたものを○×で出す・確認するだけ)
24
+ # ⑧ 職人起動の案内+★管制の画面での確かめ方
25
+ #
26
+ # ★ADR-017: 「静かに間違った成功」で終わらせない。職人が無い/合言葉が違う/アドレスが違う
27
+ # ときは、その場で止めて日本語で直し方を出す。
20
28
  #
21
29
  set -euo pipefail
22
30
 
@@ -39,17 +47,114 @@ fi
39
47
  SITE="$1"
40
48
  PROJECT_DIR="${2:-$HOME/projects/$SITE}"
41
49
 
50
+ # ─── 最低版(★この値は install.sh と同じにすること) ─────────────────────────
51
+ # 【なぜ要るか(実測 2026-09-04)】公開 npm には 0.3.0 しか出ておらず、お客さまは
52
+ # それを掴んでいた。0.3.0 の中身は古い鍵名 CONDUCTOR_SHARED_SECRET を読むので、
53
+ # いまの接続情報(CONDUCTOR_TOKEN)では「未設定です」で落ちる(migakia で再現)。
54
+ # =入っている版が古ければ、設置を進める前に気づいて直す。
55
+ # ★値は sales-template/install.sh の MIN_CONDUCTOR_VERSION と同じにすること。
56
+ # 食い違うと「入口は通したのに設置係が止める」が起きる。テストで同一を固定してある。
57
+ MIN_CONDUCTOR_VERSION="0.5.0"
58
+
42
59
  # ─── ヘルパ ───────────────────────────────────────────────────────────────────
43
60
  fail() { echo; echo "[setup] エラー: $1" >&2; exit 1; }
44
61
  warn() { echo "[setup] warn: $1" >&2; }
62
+
63
+ # version_lt <a> <b> — a が b より古いか。
64
+ # ★`sort -V` に頼らない(Mac の sort には無い版がある=ADR-019 で Mac も対象)。
65
+ # 数字3つ(major.minor.patch)だけを見る。読めない字は 0 とみなす(安全側=古い扱い)。
66
+ version_lt() {
67
+ local a="${1%%-*}" b="${2%%-*}" i x y
68
+ local -a A B
69
+ IFS='.' read -r -a A <<< "$a"
70
+ IFS='.' read -r -a B <<< "$b"
71
+ for i in 0 1 2; do
72
+ x="${A[i]:-0}"; y="${B[i]:-0}"
73
+ [[ "$x" =~ ^[0-9]+$ ]] || x=0
74
+ [[ "$y" =~ ^[0-9]+$ ]] || y=0
75
+ if (( x < y )); then return 0; fi
76
+ if (( x > y )); then return 1; fi
77
+ done
78
+ return 1
79
+ }
80
+
81
+ # いま入っている conductor の版(取れなければ空)。
82
+ conductor_version() {
83
+ local v
84
+ v="$(conductor --version 2>/dev/null | tr -d '[:space:]')"
85
+ if [[ "$v" =~ ^[0-9]+\.[0-9]+\.[0-9]+ ]]; then
86
+ printf '%s' "${BASH_REMATCH[0]}"
87
+ fi
88
+ }
45
89
  step() { echo; echo "══════════════════════════════════════════════════════════"; echo "[setup] $1"; echo "══════════════════════════════════════════════════════════"; }
46
90
 
91
+ # ─── OS の見分けと、OS ごとに違う道具(★ADR-019・案C) ───────────────────────
92
+ # 【考え方】共通部分(前提チェック・番人の配置・鍵・自己点検)は【1本のまま】。
93
+ # OS で違うのは「常駐の仕組み」だけなので、そこだけ systemd 用 / launchd 用に分ける。
94
+ # ★2本のインストーラを作らない。作ると「直すたび2か所」になり、片方が古くなる事故が起きる
95
+ # (この製品で ADR-011/013 と繰り返し起きた型)。
96
+ case "$(uname -s)" in
97
+ Darwin) OS_KIND=mac ;;
98
+ *) OS_KIND=linux ;;
99
+ esac
100
+ # 常駐の呼び名(案内文で使う)。Mac は launchd、Linux(WSL含む) は systemd。
101
+ if [[ "$OS_KIND" == "mac" ]]; then
102
+ SVC_KIND="launchd"
103
+ PKG_HINT="brew install"
104
+ else
105
+ SVC_KIND="systemd"
106
+ PKG_HINT="sudo apt install"
107
+ fi
108
+
109
+ # ★bash 3.2(Mac 標準)でも動く小文字化。`${変数,,}` は bash 4 以降でしか使えない。
110
+ lower() { printf '%s' "$1" | tr '[:upper:]' '[:lower:]'; }
111
+
112
+ # ★BSD でも GNU でも同じに動く「実体のパス」。`readlink -f` は BSD に無い版がある。
113
+ # python3 は①で必須にしているので、それに任せるのがいちばん確実。
114
+ resolve_path() {
115
+ python3 -c 'import os,sys; print(os.path.realpath(sys.argv[1]))' "$1" 2>/dev/null || printf '%s' "$1"
116
+ }
117
+
118
+ # ★BSD でも GNU でも同じに動く置換。`sed -i` は BSD だと引数が要る(`sed -i ''`)ので使わない。
119
+ sed_replace() { # sed_replace <置換式> <ファイル>
120
+ local expr="$1" file="$2" tmp="$2.setuptmp"
121
+ sed "$expr" "$file" > "$tmp" && mv -f "$tmp" "$file"
122
+ }
123
+
124
+ # ★常駐の生死を OS ごとに確かめる(自己点検⑦で使う)。
125
+ service_is_running() { # service_is_running <systemd のユニット名> <launchd のラベル>
126
+ if [[ "$OS_KIND" == "mac" ]]; then
127
+ launchctl list 2>/dev/null | grep -q "[[:space:]]$2\$"
128
+ else
129
+ systemctl --user is-active --quiet "$1"
130
+ fi
131
+ }
132
+
133
+ # ★お客さまへ見せる「困ったときの1行」も OS で違う。案内文の中で使う。
134
+ if [[ "$OS_KIND" == "mac" ]]; then
135
+ SVC_LABEL="com.tyhld.conductor.${SITE}"
136
+ SVC_STATUS_CMD="launchctl list | grep ${SVC_LABEL}"
137
+ SVC_LOG_CMD="tail -f ~/Library/Logs/tyhld/conductor-${SITE}.log"
138
+ SVC_RESTART_CMD="launchctl kickstart -k gui/\$(id -u)/${SVC_LABEL}"
139
+ SVC_STOP_CMD="launchctl bootout gui/\$(id -u)/${SVC_LABEL}"
140
+ else
141
+ SVC_STATUS_CMD="systemctl --user status conductor@${SITE}"
142
+ SVC_LOG_CMD="journalctl --user -u conductor@${SITE} -f"
143
+ SVC_RESTART_CMD="systemctl --user restart conductor@${SITE}"
144
+ SVC_STOP_CMD="systemctl --user disable --now conductor@${SITE}"
145
+ fi
146
+ SVC_RESTART_HINT="${SVC_RESTART_CMD} を実行してください"
147
+
47
148
  # ─── ① 前提チェック ──────────────────────────────────────────────────────────
48
149
  step "① 前提チェック"
49
150
 
50
151
  errors=()
51
152
 
52
- # node >= 18
153
+ # node >= 18(実行要件。package.json の engines.node と一致させる)
154
+ # 顧客が実行するのは tsc でコンパイル済みの dist/ の JS なので、Node 18 で動く。
155
+ # Node 22 が要るのは開発時の `npm test` だけ(--experimental-strip-types で .ts を直接実行するため)。
156
+ # 開発要件はこのチェックではなく .nvmrc と CI の node-version で表明する。
157
+ # ここを 22 に上げると、実際には動く Node 18/20 の顧客をインストーラが弾いてしまう。
53
158
  if command -v node >/dev/null 2>&1; then
54
159
  NODE_VER="$(node --version 2>/dev/null || echo 'v0')"
55
160
  NODE_MAJOR="${NODE_VER#v}"
@@ -60,21 +165,21 @@ if command -v node >/dev/null 2>&1; then
60
165
  echo "[setup] node: $NODE_VER OK"
61
166
  fi
62
167
  else
63
- errors+=("node が見つかりません。Node.js 18 以上をインストールしてください(fnm / nvm / volta / apt)。")
168
+ errors+=("node が見つかりません。Node.js 18 以上をインストールしてください(nvm / fnm / volta、または $PKG_HINT node)。")
64
169
  fi
65
170
 
66
171
  # tmux
67
172
  if command -v tmux >/dev/null 2>&1; then
68
173
  echo "[setup] tmux: $(tmux -V 2>/dev/null || echo 'OK')"
69
174
  else
70
- errors+=("tmux が見つかりません。sudo apt install tmux でインストールしてください。")
175
+ errors+=("tmux が見つかりません。$PKG_HINT tmux でインストールしてください。")
71
176
  fi
72
177
 
73
178
  # git
74
179
  if command -v git >/dev/null 2>&1; then
75
180
  echo "[setup] git: $(git --version 2>/dev/null)"
76
181
  else
77
- errors+=("git が見つかりません。sudo apt install git でインストールしてください。")
182
+ errors+=("git が見つかりません。$PKG_HINT git でインストールしてください。")
78
183
  fi
79
184
 
80
185
  # python3 >= 3.8
@@ -88,27 +193,58 @@ if command -v python3 >/dev/null 2>&1; then
88
193
  echo "[setup] python3: $PY_VER OK"
89
194
  fi
90
195
  else
91
- errors+=("python3 が見つかりません。sudo apt install python3 でインストールしてください。")
196
+ errors+=("python3 が見つかりません。$PKG_HINT python3 でインストールしてください。")
92
197
  fi
93
198
 
94
- # systemd
95
- if command -v systemctl >/dev/null 2>&1; then
96
- if systemctl --user show-environment >/dev/null 2>&1; then
97
- echo "[setup] systemd: ユーザーセッション OK"
199
+ # 常駐の仕組み(★OS で見るものが違う。Mac に systemd は無い=ADR-019)
200
+ if [[ "$OS_KIND" == "mac" ]]; then
201
+ if command -v launchctl >/dev/null 2>&1; then
202
+ echo "[setup] launchd: OK(Mac の常駐の仕組み)"
98
203
  else
99
- errors+=("systemd ユーザーセッションに接続できません。WSL の場合: /etc/wsl.conf に [boot] systemd=true を設定し wsl --shutdown で再起動してください。")
204
+ errors+=("launchctl が見つかりません。macOS が壊れている可能性があります。")
100
205
  fi
101
206
  else
102
- errors+=("systemctl が見つかりません。systemd が有効か確認してください。")
207
+ if command -v systemctl >/dev/null 2>&1; then
208
+ if systemctl --user show-environment >/dev/null 2>&1; then
209
+ echo "[setup] systemd: ユーザーセッション OK"
210
+ else
211
+ errors+=("systemd ユーザーセッションに接続できません。WSL の場合: /etc/wsl.conf に [boot] systemd=true を設定し wsl --shutdown で再起動してください。")
212
+ fi
213
+ else
214
+ errors+=("systemctl が見つかりません。systemd が有効か確認してください。")
215
+ fi
103
216
  fi
104
217
 
105
- # sudo
218
+ # sudo(★Mac では node のリンク作成をしないので必須にしない)
106
219
  if command -v sudo >/dev/null 2>&1; then
107
220
  echo "[setup] sudo: OK"
221
+ elif [[ "$OS_KIND" == "mac" ]]; then
222
+ echo "[setup] sudo: 見つかりませんが Mac では使いません(③は行いません)"
108
223
  else
109
224
  errors+=("sudo が見つかりません。node symlink の作成(③)で必要です。")
110
225
  fi
111
226
 
227
+ # ★Claude Code(職人そのもの)
228
+ # 【なぜここで見るか】采配くんは「職人(Claude Code)へ指示を配る」仕組みなので、職人が
229
+ # 居なければ設置しても1つも動かない。ところが以前はチェックにも入れていなかったため、
230
+ # ⑦の案内どおり `claude` と打って初めて「コマンドが見つかりません」になり、
231
+ # お客さまには何が悪いのか分からなかった(調査 3f2337e6・2026-09-02)。
232
+ # 【★入れないと決めた理由】setup.sh は Claude Code を【入れない】。理由は3つ:
233
+ # 1. 入れてもログイン(ブラウザでのやり取り)が必ず人の手番として残る=手数は減らない。
234
+ # 2. お客さまのグローバル環境(npm -g)へ、采配くんの範囲を超えて手を入れることになる。
235
+ # 版を固定して運用しているお客さまの環境を壊しうる。
236
+ # 3. 「入れてあげる」より「無いことをはっきり伝えて止まる」ほうが、原因が分かる。
237
+ # =ここでは【確認して、無ければ入れ方を日本語で案内して中断する】に徹する。
238
+ if command -v claude >/dev/null 2>&1; then
239
+ echo "[setup] Claude Code: $(claude --version 2>/dev/null || echo 'OK')"
240
+ else
241
+ errors+=("Claude Code(職人)が見つかりません。采配くんは職人へ指示を配る仕組みなので、
242
+ 先に職人を入れてログインしてください。次の2つを順に実行します:
243
+ npm i -g @anthropic-ai/claude-code
244
+ claude ← 画面の案内にそってログイン(1回だけ)
245
+ ログインが終わったら、この setup.sh をもう一度実行してください。")
246
+ fi
247
+
112
248
  # プロジェクトディレクトリ
113
249
  if [[ -d "$PROJECT_DIR" ]]; then
114
250
  echo "[setup] プロジェクトディレクトリ: $PROJECT_DIR OK"
@@ -144,83 +280,385 @@ if ! command -v conductor >/dev/null 2>&1; then
144
280
  fi
145
281
  echo "[setup] conductor: $(conductor --version 2>/dev/null || command -v conductor)"
146
282
 
147
- # ─── node symlink ──────────────────────────────────────────────────────────
148
- step "③ node symlink(systemd PATH 対応)"
149
-
150
- NODE_PATH="$(readlink -f "$(command -v node)")"
151
- SYMLINK_PATH="/usr/local/bin/node"
152
-
153
- need_symlink=true
154
- if [[ -f "$SYMLINK_PATH" || -L "$SYMLINK_PATH" ]]; then
155
- existing="$(readlink -f "$SYMLINK_PATH" 2>/dev/null || echo '')"
156
- if [[ "$existing" == "$NODE_PATH" ]]; then
157
- echo "[setup] $SYMLINK_PATH は既に正しいリンクです → スキップ"
158
- need_symlink=false
159
- fi
283
+ # --- ★版が古すぎないかを確かめる(古い版は動かない・実測 2026-09-04) --------
284
+ # 上で `npm update -g` は済んでいる。それでもまだ古いなら、取りに行った先に新しい版が
285
+ # 無い(=配布が止まっている)ということなので、ここで止めて人へ渡す。
286
+ # ★このまま進めると「設置は成功したのに指示が1つも届かない」で終わる。
287
+ # お客さまがいちばん困る形なので、必ずここで気づかせる(ADR-017 と同じ考え方)。
288
+ CONDUCTOR_VER="$(conductor_version)"
289
+ if [[ -z "$CONDUCTOR_VER" ]]; then
290
+ warn "conductor の版を読み取れませんでした(続行します)。"
291
+ elif version_lt "$CONDUCTOR_VER" "$MIN_CONDUCTOR_VERSION"; then
292
+ fail "采配くん本体の版が古すぎます(いま ${CONDUCTOR_VER} ${MIN_CONDUCTOR_VERSION} 以上が必要です)。
293
+ 更新を試しましたが、新しい版を取ってこられませんでした。
294
+ ★古い版のままでは、設置できても指示が1つも届きません(鍵の名前が違うため)。
295
+ 次の1行をお試しのうえ、それでも直らないときはサポートへご連絡ください:
296
+ npm install -g @tyhld/conductor@latest"
297
+ else
298
+ echo "[setup] conductor の版: OK(${CONDUCTOR_VER} ≧ ${MIN_CONDUCTOR_VERSION})"
160
299
  fi
161
300
 
162
- if $need_symlink; then
163
- echo "[setup] $SYMLINK_PATH → $NODE_PATH のシンボリックリンクを作成します(sudo)"
164
- if [[ -w "$(dirname "$SYMLINK_PATH")" ]]; then
165
- ln -sf "$NODE_PATH" "$SYMLINK_PATH"
166
- else
167
- sudo ln -sf "$NODE_PATH" "$SYMLINK_PATH"
301
+ # ─── ③ node symlink ──────────────────────────────────────────────────────────
302
+ step " node の場所を常駐から見えるようにする"
303
+
304
+ # 【なぜ要るか】常駐(systemd / launchd)はログインシェルの PATH を引き継がない。
305
+ # そのため node の場所を「決め打ちで見える形」にしておく必要がある。
306
+ # ★OS で解き方が違う(ADR-019):
307
+ # Linux(WSL) … 従来どおり /usr/local/bin/node へリンクを作る(sudo)
308
+ # Mac … ★リンクを作らない。launchd の plist に node の絶対パスを直接書くので不要。
309
+ # sudo も要らず、Apple Silicon(/opt/homebrew/bin)でもそのまま動く。
310
+ NODE_PATH="$(resolve_path "$(command -v node)")"
311
+ echo "[setup] node の実体: $NODE_PATH"
312
+
313
+ if [[ "$OS_KIND" == "mac" ]]; then
314
+ echo "[setup] Mac のため、リンクは作りません(常駐には上の絶対パスをそのまま書きます)"
315
+ else
316
+ SYMLINK_PATH="/usr/local/bin/node"
317
+ need_symlink=true
318
+ if [[ -f "$SYMLINK_PATH" || -L "$SYMLINK_PATH" ]]; then
319
+ existing="$(resolve_path "$SYMLINK_PATH")"
320
+ if [[ "$existing" == "$NODE_PATH" ]]; then
321
+ echo "[setup] $SYMLINK_PATH は既に正しいリンクです → スキップ"
322
+ need_symlink=false
323
+ fi
324
+ fi
325
+ if $need_symlink; then
326
+ echo "[setup] $SYMLINK_PATH → $NODE_PATH のシンボリックリンクを作成します(sudo)"
327
+ if [[ -w "$(dirname "$SYMLINK_PATH")" ]]; then
328
+ ln -sf "$NODE_PATH" "$SYMLINK_PATH"
329
+ else
330
+ sudo ln -sf "$NODE_PATH" "$SYMLINK_PATH"
331
+ fi
332
+ echo "[setup] リンク作成完了"
168
333
  fi
169
- echo "[setup] symlink 作成完了"
170
334
  fi
171
335
 
172
336
  # ─── ④ .conductor.env 作成 ───────────────────────────────────────────────────
173
337
  step "④ .conductor.env(管制サーバ接続情報)"
174
338
 
339
+ # ★合言葉(トークン)が管制に通るかを、その場で1回だけ確かめる。
340
+ # 【なぜ要るか】以前は入力後に何も試さなかったため、打ち間違えても最後まで「成功」で終わり、
341
+ # お客さまは後日「指示が届かない」で初めて気づいた(調査 3f2337e6・2026-09-02)。
342
+ # 【やり方】番人(hook_datalink.ping)とまったく同じ確かめ方を使う=実在しないIDへ
343
+ # 認証つきで1回 GET するだけ。★カードは作らない・何も書き換えない(副作用ゼロ)。
344
+ # 返す言葉: ok / bad-token / bad-url / unreachable
345
+ check_console_connection() {
346
+ local url="$1" token="$2"
347
+ CONDUCTOR_URL="$url" CONDUCTOR_TOKEN="$token" python3 <<'PYEOF' 2>/dev/null || echo unreachable
348
+ import os, urllib.error, urllib.request
349
+
350
+ PING_ID = '00000000-0000-4000-8000-000000000000'
351
+
352
+
353
+ class _NoRedirect(urllib.request.HTTPRedirectHandler):
354
+ """折返し(サインイン画面へのリダイレクト)を成功と読み違えない。"""
355
+ def redirect_request(self, *a, **k):
356
+ return None
357
+
358
+
359
+ url = (os.environ.get('CONDUCTOR_URL') or '').rstrip('/')
360
+ token = os.environ.get('CONDUCTOR_TOKEN') or ''
361
+ req = urllib.request.Request(
362
+ f'{url}/api/conductor/hook-requests/{PING_ID}',
363
+ method='GET', headers={'Authorization': f'Bearer {token}'})
364
+ try:
365
+ with urllib.request.build_opener(_NoRedirect).open(req, timeout=10) as res:
366
+ code = getattr(res, 'status', 0) or 0
367
+ print('ok' if 200 <= code < 300 else 'bad-url')
368
+ except urllib.error.HTTPError as e:
369
+ # 404 = 届いて認証も通り「そのIDが無い」だけ=正常。401/403 = 合言葉が違う。
370
+ if e.code == 404 or 200 <= e.code < 300:
371
+ print('ok')
372
+ elif e.code in (401, 403):
373
+ print('bad-token')
374
+ elif 300 <= e.code < 400:
375
+ print('bad-url')
376
+ else:
377
+ print('unreachable') # 5xx は管制側の一時的な不調とみなす
378
+ except Exception:
379
+ print('unreachable') # 名前が引けない・つながらない・時間切れ
380
+ PYEOF
381
+ }
382
+
175
383
  ENV_FILE="$HOME/.conductor.env"
176
384
  if [[ -f "$ENV_FILE" ]]; then
177
- echo "[setup] $ENV_FILE は既に存在します → スキップ"
178
- echo "[setup] 内容を変更する場合は手動で編集してください。"
385
+ echo "[setup] $ENV_FILE は既に存在します → スキップ(入力は求めません)"
386
+ echo "[setup] ★合言葉(トークン)を入れ直したいときは、次の1行でこのファイルを消してから"
387
+ echo "[setup] もう一度この setup.sh を実行してください:"
388
+ echo "[setup] rm ~/.conductor.env"
179
389
  else
180
390
  echo "[setup] 管制サーバへの接続情報を入力してください。"
391
+ echo "[setup] ★この2つは弊社からお渡しした値です。手元の案内をご覧ください。"
181
392
  echo
182
393
 
183
- read -r -p " CONDUCTOR_URL(例: https://your-conductor.vercel.app): " input_url
184
- [[ -z "$input_url" ]] && fail "CONDUCTOR_URL が空です。"
394
+ read -r -p " ① 管制のアドレス(CONDUCTOR_URL・例: https://…): " input_url
395
+ [[ -z "$input_url" ]] && fail "管制のアドレスが空です。"
185
396
 
186
- read -r -p " CONDUCTOR_SHARED_SECRET: " input_secret
187
- [[ -z "$input_secret" ]] && fail "CONDUCTOR_SHARED_SECRET が空です。"
397
+ # ★合言葉は画面に出さない(-s)。画面共有・録画に残さないため。
398
+ # 何も出ないと固まったと思われるので、受け取ったことを必ず1行返す。
399
+ read -r -s -p " ② 合言葉(CONDUCTOR_TOKEN・入力しても画面には出ません): " input_secret
400
+ echo
401
+ [[ -z "$input_secret" ]] && fail "合言葉が空です。"
402
+ echo "[setup] 受け取りました(${#input_secret} 文字)。画面には表示していません。"
403
+
404
+ # ★その場で1回だけ確かめる(副作用ゼロ)。
405
+ echo "[setup] 管制につながるか確認しています..."
406
+ case "$(check_console_connection "$input_url" "$input_secret")" in
407
+ ok)
408
+ echo "[setup] ✓ つながりました(アドレスと合言葉は正しいです)"
409
+ ;;
410
+ bad-token)
411
+ fail "合言葉(トークン)が違うようです(管制に拒否されました)。
412
+ お手元の案内をもう一度ご確認のうえ、この setup.sh を実行し直してください。
413
+ ★ここで止めるのは、このまま進めても指示が1つも届かないためです。"
414
+ ;;
415
+ bad-url)
416
+ fail "管制のアドレスが違うようです(受け口に届いていません)。
417
+ https:// から始まる正しいアドレスか、末尾に余計な文字が入っていないかをご確認のうえ、
418
+ この setup.sh を実行し直してください。"
419
+ ;;
420
+ *)
421
+ # ★一時的なネット不調と区別する。設置自体は続ける(後から直せる)。
422
+ warn "いま管制につながりませんでした(ネットワークが一時的に不調な可能性があります)。
423
+ ★入力した値は保存し、設置は続けます。設置後に指示が届かないときは、
424
+  インターネット接続を確認してから次の1行で確かめ直してください:
425
+   ${SVC_RESTART_CMD}"
426
+ ;;
427
+ esac
188
428
 
189
429
  cat > "$ENV_FILE" <<ENVEOF
190
430
  CONDUCTOR_URL=${input_url}
191
- CONDUCTOR_SHARED_SECRET=${input_secret}
431
+ CONDUCTOR_TOKEN=${input_secret}
192
432
  ENVEOF
193
433
  chmod 600 "$ENV_FILE"
194
- echo "[setup] $ENV_FILE を作成しました(chmod 600)"
434
+ echo "[setup] $ENV_FILE を作成しました(本人だけが読める権限にしました)"
195
435
  fi
196
436
 
197
- # ─── ⑤ .claude/ 配置(PERM ルール + Hook) ──────────────────────────────────
198
- step "⑤ .claude/ 配置(権限ルール + Hook)"
437
+ # ─── ⑤ 番人一式の配置(ホーム1本方式・登録するイベントは見本が決める) ──────
438
+ step "⑤ 番人一式の配置(安全装置)"
199
439
 
200
440
  CLAUDE_DIR="$PROJECT_DIR/.claude"
201
- HOOKS_DIR="$CLAUDE_DIR/hooks"
202
- mkdir -p "$HOOKS_DIR"
203
-
204
- # --- cc2_guard.py ---
205
- GUARD_SRC="$SCRIPT_DIR/cc2_guard.py"
206
- GUARD_DST="$HOOKS_DIR/cc2_guard.py"
207
- if [[ -f "$GUARD_SRC" ]]; then
208
- cp -f "$GUARD_SRC" "$GUARD_DST"
209
- chmod +x "$GUARD_DST"
210
- echo "[setup] 配置: $GUARD_DST"
441
+ mkdir -p "$CLAUDE_DIR"
442
+
443
+ # ★番人は「1台に1箇所」だけ置く(現場ごとのコピーを作らない)。
444
+ # 以前は現場ごとに .claude/hooks/ へコピーしていたため、現場が増えるほど番人が増殖し、
445
+ # 更新のたびに全現場を回る必要があった(=取り残しが起きる)。集約すれば更新は1箇所で済む。
446
+ # ★番人フックは【ホームの ~/.claude/settings.json に1本だけ】書く。各現場の
447
+ # PROJECT_DIR/.claude/settings.json には番人フックを書かない(=二重番人を作らない・ADR-013)。
448
+ # ★どのイベントを登録するかは【見本 scripts/hooks/settings.sample.json だけ】が決める
449
+ # (ADR-005 と同じ考え方)。以前はここにイベント名を直書きしていたため、ADR-016
450
+ # Notification を足しても【新規に設置したお客さまの機体には入らなかった】。
451
+ # 下の ⑤-0 で見本と配る中身を機械照合し、外れていれば設置を止める=足し忘れが起こせない。
452
+ GUARD_HOME="$HOME/.tyhld/hooks"
453
+ GUARD_SAMPLE="$SCRIPT_DIR/../scripts/hooks/settings.sample.json"
454
+
455
+ # --- 番人一式(正本1本)を固定パスへ配置 ---
456
+ # ★配布元は「正本1本」だけ。以前は sales-template/ に複製を置いていたため、正本が更新されても
457
+ # 顧客へ届かず乖離した。複製は廃止し、ここでは常に正本を配布する=乖離が構造的に起きない。
458
+ #
459
+ # 正本の在り処は「setup.sh から見て ../scripts/hooks」。npm 配布物でもリポジトリでも
460
+ # 同じ相対位置になる(package.json の files に scripts/hooks を含めてある)。
461
+ GUARD_SRC_DIR="$SCRIPT_DIR/../scripts/hooks"
462
+
463
+ # ★配る番人一式(段2の3フック+その依存)。scripts/update-guard.sh と同じ集合にする。
464
+ # 1本でも欠けると差し替えた現場の番人が読めず止まる(勤務用コピーの考え方=ADR-002)。
465
+ # ★この一覧は scripts/update-guard.sh の INSTALL_FILES と【同じ集合】にすること。
466
+ # 食い違うと「稼働機では動くのに新規設置だけ壊れる」が起きる。テストで同一を固定してある。
467
+ GUARD_FILES=(
468
+ cc2_guard.py # PreToolUse … 危険を止める/関門を人へ渡す
469
+ permission_request_hook.py # PermissionRequest … 確認を管制へ送り答えを待つ
470
+ stop_hook.py # Stop … 手が止まったことを管制へ知らせる
471
+ notification_hook.py # Notification … 端末でしか押せない問いを管制へ知らせる(ADR-016)
472
+ policy.py # cc2_guard / permission_request_hook が使う判定本体
473
+ summary_ja.py # permission_request_hook / stop_hook が使う日本語の文言
474
+ hook_datalink.py # 各フックが使う管制との連絡線
475
+ )
476
+ GUARD_ENTRYPOINTS=(cc2_guard.py permission_request_hook.py stop_hook.py notification_hook.py)
477
+ GUARD_CONFIGS=(safe_verbs.json network_allow.json) # 任意・あれば同居させる
478
+
479
+ # --- ⑤-0 見本と「配る中身」がズレていないか機械照合する(★足し忘れの再発防止) ---
480
+ # 見本が呼ぶ入口スクリプトが (a) 正本にあるか (b) 上の GUARD_FILES に入っているか を確かめる。
481
+ # 1つでも外れていれば「登録はされるのに実体が無い」=番人が動かない状態になるので、ここで止める。
482
+ # ★見本が無い環境では止めない(rc=7)。従来どおりの3フックで進む=壊さない。
483
+ echo "[setup] ⑤-0 見本と配る中身の照合"
484
+ drift_out="$(GUARD_FILES="${GUARD_FILES[*]}" python3 - "$GUARD_SAMPLE" "$GUARD_SRC_DIR" <<'PYEOF'
485
+ import json, os, sys
486
+
487
+ sample_path, src_dir = sys.argv[1], sys.argv[2]
488
+ install = set(os.environ.get("GUARD_FILES", "").split())
489
+
490
+ try:
491
+ hooks = (json.load(open(sample_path, encoding="utf-8")).get("hooks") or {})
492
+ except FileNotFoundError:
493
+ print("SKIP 見本がありません(従来どおりの内容で設置します)")
494
+ sys.exit(7)
495
+ except Exception as e: # noqa: BLE001
496
+ print("FAIL 見本が JSON として読めません: %s" % e)
497
+ sys.exit(1)
498
+ if not hooks:
499
+ print("FAIL 見本に hooks がありません(配る対象が決められない)")
500
+ sys.exit(1)
501
+
502
+ refs = {os.path.basename(h["command"].split()[-1])
503
+ for groups in hooks.values() for g in (groups or [])
504
+ for h in (g.get("hooks") or []) if h.get("command")}
505
+ missing_src = sorted(b for b in refs if not os.path.isfile(os.path.join(src_dir, b)))
506
+ not_installed = sorted(b for b in refs if b not in install)
507
+ if missing_src:
508
+ print("FAIL 見本が呼ぶスクリプトが正本にありません: %s" % ", ".join(missing_src))
509
+ sys.exit(1)
510
+ if not_installed:
511
+ print("FAIL 見本が呼ぶスクリプトが設置一覧(GUARD_FILES)にありません: %s"
512
+ % ", ".join(not_installed))
513
+ sys.exit(1)
514
+ print("OK 見本の %d イベント(%s)を登録します" % (len(hooks), ", ".join(sorted(hooks))))
515
+ PYEOF
516
+ )" && drift_rc=0 || drift_rc=$?
517
+ echo "[setup] $drift_out"
518
+ if [[ "${drift_rc:-1}" == "1" ]]; then
519
+ fail "見本と配る中身が食い違っています(上記)。配布物が壊れている可能性があります。
520
+ お手数ですが、この画面のままサポートへご連絡ください。"
521
+ fi
522
+ SAMPLE_OK=0
523
+ [[ "${drift_rc:-1}" == "0" ]] && SAMPLE_OK=1
524
+
525
+ # ★番人は安全境界そのもの。見つからないまま黙って進むと「守りが無い状態」で稼働してしまう。
526
+ # 従来は warn+スキップだったが、それは最も危険な失敗の仕方なので中断する。
527
+ missing_guard=()
528
+ for f in "${GUARD_FILES[@]}"; do
529
+ [[ -f "$GUARD_SRC_DIR/$f" ]] || missing_guard+=("$f")
530
+ done
531
+ if [[ ${#missing_guard[@]} -gt 0 ]]; then
532
+ fail "番人一式が見つかりません: ${missing_guard[*]}(探索先: $GUARD_SRC_DIR)
533
+ 安全装置が揃わない状態では設置できません。配布物が壊れている可能性があります。
534
+ お手数ですが、この画面のままサポートへご連絡ください。"
535
+ fi
536
+
537
+ # ★壊れた Python を固定パスへ置くと全現場が同時に止まる。置く前に構文を確認する。
538
+ if ! python3 -m py_compile "${GUARD_FILES[@]/#/$GUARD_SRC_DIR/}"; then
539
+ fail "番人一式の構文確認に失敗しました。固定パスは変更していません。"
540
+ fi
541
+
542
+ mkdir -p "$GUARD_HOME"
543
+ for f in "${GUARD_FILES[@]}"; do
544
+ cp -f "$GUARD_SRC_DIR/$f" "$GUARD_HOME/$f"
545
+ done
546
+ for e in "${GUARD_ENTRYPOINTS[@]}"; do
547
+ chmod +x "$GUARD_HOME/$e"
548
+ done
549
+ echo "[setup] 番人一式(${#GUARD_FILES[@]}本)を配置: $GUARD_HOME(この1台の全現場が共有します)"
550
+
551
+ # --- 番人の設定ファイル(任意・同居必須)---
552
+ # 番人は「自分と同じフォルダ」の safe_verbs.json / network_allow.json を読む(_SCRIPT_DIR 基準)。
553
+ # 無い場合は番人が既定値で動く(フェイルセーフ)ので、存在するものだけ配置する。
554
+ for guard_conf in "${GUARD_CONFIGS[@]}"; do
555
+ if [[ -f "$GUARD_SRC_DIR/$guard_conf" ]]; then
556
+ cp -f "$GUARD_SRC_DIR/$guard_conf" "$GUARD_HOME/$guard_conf"
557
+ echo "[setup] 配置: $GUARD_HOME/$guard_conf"
558
+ fi
559
+ done
560
+
561
+ # --- 番人フックを【ホームの ~/.claude/settings.json】へ1本だけ登録 ---
562
+ # ★1台1箇所。ここに書けば、この機の全現場(全 tmux セッション)へ同時に効く。
563
+ # 現場ごとの settings.json には番人フックを書かない=二重番人・取り残しを構造的に防ぐ(ADR-013)。
564
+ # ★登録するイベントは【見本 settings.sample.json が正】。ここに名前を直書きしない。
565
+ # 直書きしていたせいで、ADR-016 で Notification を足しても新規設置には入らなかった。
566
+ # 見本が無い環境だけ、従来どおりの3イベントへ落ちる(後退させない)。
567
+ # ★matcher は付けない(PreToolUse は全ツール対象)。番人は Bash 以外も見るため、列挙すると
568
+ # 新しいツールが増えたとき素通りする(見本の方針に一致)。
569
+ # ★command には ~ を使わず絶対パスを書く。フックがシェル経由で実行される保証が無く、
570
+ # ~ が展開されないと番人が起動せず「守りが黙って外れる」最悪の失敗になるため。
571
+ # ★既存の ~/.claude/settings.json の他キー(permissions 等)は壊さず hooks だけ差し替える。
572
+ HOME_CLAUDE_DIR="$HOME/.claude"
573
+ HOME_SETTINGS="$HOME_CLAUDE_DIR/settings.json"
574
+ mkdir -p "$HOME_CLAUDE_DIR"
575
+ registered_events="$(GUARD_HOME="$GUARD_HOME" HOME_SETTINGS="$HOME_SETTINGS" \
576
+ SAMPLE_OK="$SAMPLE_OK" python3 - "$GUARD_SAMPLE" <<'PYEOF'
577
+ import json, os, shutil, sys, time
578
+
579
+ p = os.environ['HOME_SETTINGS']
580
+ gh = os.environ['GUARD_HOME']
581
+ sample_ok = os.environ.get('SAMPLE_OK') == '1'
582
+
583
+
584
+ def at(name):
585
+ return 'python3 ' + os.path.join(gh, name)
586
+
587
+
588
+ def from_sample(path):
589
+ """見本の hooks を、コマンドのパスだけ固定パスへ置き換えた形で返す。"""
590
+ hooks = json.load(open(path, encoding='utf-8')).get('hooks') or {}
591
+ out = {}
592
+ for ev, groups in hooks.items():
593
+ new = []
594
+ for g in (groups or []):
595
+ ng = dict(g)
596
+ ng['hooks'] = [
597
+ {**h, 'command': at(os.path.basename(h['command'].split()[-1]))}
598
+ if isinstance(h, dict) and h.get('command') else h
599
+ for h in (g.get('hooks') or [])
600
+ ]
601
+ new.append(ng)
602
+ out[ev] = new
603
+ return out
604
+
605
+
606
+ if sample_ok:
607
+ desired = from_sample(sys.argv[1])
608
+ else:
609
+ # 見本が無い環境(配布物が古い等)。従来どおりの3イベントで設置する=後退させない。
610
+ desired = {
611
+ 'PreToolUse': [{'hooks': [{'type': 'command', 'command': at('cc2_guard.py')}]}],
612
+ 'PermissionRequest': [{'hooks': [
613
+ {'type': 'command', 'timeout': 600, 'command': at('permission_request_hook.py')}]}],
614
+ 'Stop': [{'hooks': [
615
+ {'type': 'command', 'timeout': 600, 'command': at('stop_hook.py')}]}],
616
+ }
617
+
618
+ d = {}
619
+ if os.path.exists(p):
620
+ with open(p, encoding='utf-8') as f:
621
+ d = json.load(f) # 壊れていれば例外→インストーラが警告して手動対応を促す
622
+ if not isinstance(d, dict):
623
+ d = {}
624
+
625
+ d['hooks'] = desired
626
+
627
+ if os.path.exists(p):
628
+ shutil.copy2(p, p + '.bak.' + time.strftime('%Y%m%d-%H%M%S'))
629
+ tmp = p + '.tmp'
630
+ with open(tmp, 'w', encoding='utf-8') as f:
631
+ json.dump(d, f, indent=2, ensure_ascii=False)
632
+ f.write('\n')
633
+ os.replace(tmp, p) # 置換は原子的=途中で壊れた settings.json を残さない
634
+ print(' / '.join(sorted(desired)))
635
+ PYEOF
636
+ )" || fail "番人フックの登録に失敗しました: $HOME_SETTINGS
637
+ 既存ファイルが JSON として壊れている可能性があります。手動で確認してください。"
638
+ echo "[setup] 番人フック($registered_events)を $HOME_SETTINGS に登録しました"
639
+
640
+ # --- Codex の職人にも番人を登録する(★ADR-022・Codex を使う機体だけ) -------
641
+ # ★同じ処理を2か所に書かない: 稼働機の更新(update-guard.sh ①-e2)と同じスクリプトを呼ぶ。
642
+ # Codex を使っていない機体では中で何もしない(~/.codex を勝手に作らない)。
643
+ CODEX_HOOK_INSTALL="$SCRIPT_DIR/../scripts/hooks/install-codex-hooks.sh"
644
+ if [[ -f "$CODEX_HOOK_INSTALL" ]]; then
645
+ APPLY=1 GUARD_HOME="$GUARD_HOME" bash "$CODEX_HOOK_INSTALL" \
646
+ || warn "Codex への番人登録に失敗しました(Claude 側の設置は完了しています)"
211
647
  else
212
- warn "cc2_guard.py が見つかりません: $GUARD_SRC(スキップ)"
648
+ warn "Codex 用の登録スクリプトが見つかりません: $CODEX_HOOK_INSTALL(スキップ)"
213
649
  fi
214
650
 
215
- # --- settings.json ---
651
+ # --- 現場の権限ルール settings.json(番人フックは書かない) ---
652
+ # ★ここに置くのは「その現場固有の権限ルール(deny・Supabase 等の sandbox)」だけ。
653
+ # 番人フックは上でホームに1本化済み。現場側には hooks を書かない(二重番人を作らない)。
216
654
  SETTINGS_SRC="$SCRIPT_DIR/settings.json"
217
655
  SETTINGS_DST="$CLAUDE_DIR/settings.json"
218
656
  if [[ -f "$SETTINGS_SRC" ]]; then
219
657
  if [[ -f "$SETTINGS_DST" ]]; then
220
658
  echo "[setup] $SETTINGS_DST は既に存在します。上書きしますか?"
221
659
  read -r -p " 上書き (y/N): " overwrite
222
- if [[ "${overwrite,,}" != "y" ]]; then
223
- echo "[setup] settings.json をスキップしました。"
660
+ if [[ "$(lower "$overwrite")" != "y" ]]; then
661
+ echo "[setup] 現場の settings.json をスキップしました。"
224
662
  else
225
663
  cp -f "$SETTINGS_SRC" "$SETTINGS_DST"
226
664
  echo "[setup] 配置: $SETTINGS_DST(上書き)"
@@ -233,7 +671,7 @@ if [[ -f "$SETTINGS_SRC" ]]; then
233
671
  # <PROJECT_DIR> をプロジェクトの絶対パスに置換
234
672
  if [[ -f "$SETTINGS_DST" ]] && grep -q '<PROJECT_DIR>' "$SETTINGS_DST"; then
235
673
  ABS_PROJECT_DIR="$(cd "$PROJECT_DIR" && pwd)"
236
- sed -i "s|<PROJECT_DIR>|${ABS_PROJECT_DIR}|g" "$SETTINGS_DST"
674
+ sed_replace "s|<PROJECT_DIR>|${ABS_PROJECT_DIR}|g" "$SETTINGS_DST"
237
675
  echo "[setup] <PROJECT_DIR> → $ABS_PROJECT_DIR に置換"
238
676
  fi
239
677
 
@@ -241,10 +679,10 @@ if [[ -f "$SETTINGS_SRC" ]]; then
241
679
  if [[ -f "$SETTINGS_DST" ]] && grep -q '<SUPABASE_PROJECT_ID>' "$SETTINGS_DST"; then
242
680
  echo
243
681
  read -r -p "[setup] Supabase を使いますか? (y/N): " use_supabase
244
- if [[ "${use_supabase,,}" == "y" ]]; then
682
+ if [[ "$(lower "$use_supabase")" == "y" ]]; then
245
683
  read -r -p " Supabase Project Reference ID: " supabase_id
246
684
  if [[ -n "$supabase_id" ]]; then
247
- sed -i "s|<SUPABASE_PROJECT_ID>|${supabase_id}|g" "$SETTINGS_DST"
685
+ sed_replace "s|<SUPABASE_PROJECT_ID>|${supabase_id}|g" "$SETTINGS_DST"
248
686
  echo "[setup] <SUPABASE_PROJECT_ID> → $supabase_id に置換"
249
687
  else
250
688
  warn "Supabase ID が空のため、sandbox 設定にプレースホルダが残ります。"
@@ -272,79 +710,278 @@ else
272
710
  warn "settings.json が見つかりません: $SETTINGS_SRC(スキップ)"
273
711
  fi
274
712
 
275
- # --- hooks 設定を settings.json に注入 ---
276
- if [[ -f "$SETTINGS_DST" ]]; then
277
- # hooks キーが既に存在するか確認
278
- if ! python3 -c "
279
- import json
280
- with open('$SETTINGS_DST') as f:
281
- d = json.load(f)
282
- print('has_hooks' if 'hooks' in d else 'no_hooks')
283
- " 2>/dev/null | grep -q 'has_hooks'; then
284
- # hooks セクションを追加
285
- python3 -c "
286
- import json
287
- with open('$SETTINGS_DST') as f:
288
- d = json.load(f)
289
- d['hooks'] = {
290
- 'PreToolUse': [
291
- {
292
- 'matcher': 'Bash',
293
- 'hooks': [
294
- {
295
- 'type': 'command',
296
- 'command': 'python3 .claude/hooks/cc2_guard.py'
297
- }
298
- ]
299
- }
300
- ]
301
- }
302
- with open('$SETTINGS_DST', 'w') as f:
303
- json.dump(d, f, indent=2, ensure_ascii=False)
304
- f.write('\n')
305
- " 2>/dev/null && echo "[setup] hooks 設定を settings.json に注入しました" \
306
- || warn "hooks 設定の注入に失敗しました。手動で追加してください。"
307
- else
308
- echo "[setup] hooks 設定は既に存在します → スキップ"
309
- fi
713
+ # --- 職人の鉄則スキル conductor-craftsman を配置 ------------------------------
714
+ # ★番人(安全装置)が「やらせない」側なら、こちらは職人自身が「そう動く」側。
715
+ # 便(CONDUCTOR_JOB 印の付いた作業指示)を受けた職人が最初に読む作業規律を、
716
+ # 番人と同じく「1台に1箇所」=ホームの ~/.claude/skills/ に置く。現場ごとに
717
+ # コピーを作らない(増殖させない・更新は1箇所で済む)。
718
+ # ★配布元は正本1本(リポジトリの skills/)。番人と同じく setup.sh からの相対位置で探す。
719
+ # ★番人と違い、これは安全境界ではなく作業規律なので、見つからないときは中断せず警告して進む
720
+ # (スキルが無くても番人は効いており、安全に穴は空かない)。
721
+ CRAFTSMAN_SKILL_NAME="conductor-craftsman"
722
+ CRAFTSMAN_SKILL_SRC="$SCRIPT_DIR/../skills/$CRAFTSMAN_SKILL_NAME"
723
+ CRAFTSMAN_SKILL_DST="$HOME/.claude/skills/$CRAFTSMAN_SKILL_NAME"
724
+
725
+ if [[ -f "$CRAFTSMAN_SKILL_SRC/SKILL.md" ]]; then
726
+ # 冪等: 毎回 mkdir -p + cp -f で正本の内容に揃える(再実行しても壊れない・差分だけ上書き)。
727
+ mkdir -p "$CRAFTSMAN_SKILL_DST"
728
+ cp -f "$CRAFTSMAN_SKILL_SRC/SKILL.md" "$CRAFTSMAN_SKILL_DST/SKILL.md"
729
+ echo "[setup] 職人の鉄則スキルを配置: $CRAFTSMAN_SKILL_DST(この1台の全現場が共有します)"
730
+ else
731
+ warn "職人の鉄則スキルが見つかりません: $CRAFTSMAN_SKILL_SRC/SKILL.md(スキップ)
732
+ 番人は設置済みのため安全装置は効いていますが、職人の作業規律は配られていません。"
310
733
  fi
311
734
 
312
735
  # ─── ⑥ systemd install ──────────────────────────────────────────────────────
313
736
  step "⑥ systemd 常駐サービスの設定"
314
737
 
315
- SYSTEMD_INSTALL="$SCRIPT_DIR/systemd/install.sh"
316
- if [[ -f "$SYSTEMD_INSTALL" ]]; then
317
- bash "$SYSTEMD_INSTALL" "$SITE" "$PROJECT_DIR"
738
+ # --- 古いユニットの掃除(best-effort・冪等) ---------------------------------
739
+ # アップグレード時、旧バージョンが入れたユニットは cp -f の上書きでは消えない。
740
+ # 特に `@` を含まない単発ユニット(旧 conductor-orchestrator.service)は誰も消さず、
741
+ # enabled のまま常駐し続ける(2026-07-09 実地。10日間 heartbeat を送り続けた)。
742
+ #
743
+ # ★掃除してよいのは「このインストーラが過去に置いたが、現行版ではもう置かないユニット」だけ。
744
+ # 現行の systemd/install.sh が置くのは conductor@.service(テンプレート本体)と
745
+ # conductor@<現場>.service の2種のみ。したがって掃除対象は、台帳に載っている
746
+ # `@` 無しの単発ユニット(conductor-*.service)に尽きる。
747
+ #
748
+ # ★conductor@* には一切触れない(テンプレート本体・各現場とも)。
749
+ # 台帳にはインストール済みの「全現場」が載る。今回の現場以外を掃除対象にすると、
750
+ # 顧客が2つ目の現場を追加するために setup.sh を再実行しただけで、1つ目の現場が
751
+ # disable --now されて消える。conductor@<現場> は常に現役の可能性がある。
752
+ #
753
+ # ★台帳が無い/空なら何もしない。setup.sh は無人で走りうるため、確認プロンプトも
754
+ # --dry-run も無いこの場所で、顧客自作の conductor-*.service を巻き込む可能性のある
755
+ # 走査を実行してはならない。台帳導入前の環境の掃除は uninstall.sh(--dry-run 付き)に委ねる。
756
+ #
757
+ # 冪等性の条件:
758
+ # - 対象が無くてもエラーで落ちない(|| true で握りつぶす)
759
+ # - disable --now → ファイル削除 → 最後に一度だけ daemon-reload の順序を守る
760
+ # - set -e 下でも掃除の失敗が本体インストールを中断しない(best-effort)
761
+ # - 2回連続実行すると2回目は no-op(1回目で台帳から消えるため)
762
+ #
763
+ # ★掃除の例外(現行版が入れる「@ 無し」のユニット)— ADR-066 便2e
764
+ # 耳 conductor-ear.service は「PCに1本」の現役ユニットで、`@` を含まないため、
765
+ # 何もしなければ上の掃除規則(台帳に載っている conductor-*.service)に丸ごと当たり、
766
+ # セットアップを再実行するたびに自分で入れた耳を自分で消してしまう。
767
+ # ★この例外は【台帳へ載せる処理より先】に入っていなければならない。順序を逆にすると、
768
+ # 一度入った耳が次回セットアップで撤去される(便2c の申し送りで警告した罠)。
769
+ # 耳を台帳へ載せるのは systemd/ear-install.sh で、下の cleanup_stale_units 呼び出しより
770
+ # 後に実行される。この順序は test/setup-ear-order.test.ts が固定している。
771
+ CURRENT_MACHINE_UNITS=(
772
+ conductor-ear.service # ADR-066 の耳(このPCに1本・現役)。掃除してはいけない。
773
+ )
774
+
775
+ cleanup_stale_units() {
776
+ local ledger_dir="${XDG_CONFIG_HOME:-$HOME/.config}/conductor"
777
+ local ledger_file="$ledger_dir/installed-units.txt"
778
+ local unit_dir="$HOME/.config/systemd/user"
779
+ local stale=() u raw line keep
780
+ local cleaned=0
781
+
782
+ # 台帳が無い=台帳導入前の環境。ここでは何も掃除しない(安全側・上記★参照)。
783
+ if [[ ! -f "$ledger_file" ]]; then
784
+ echo "[setup] 台帳が無いため古いユニットの掃除はスキップします。"
785
+ echo "[setup] 旧環境の撤去は 'bash uninstall.sh --dry-run' で対象を確認のうえ実施してください。"
786
+ return 0
787
+ fi
788
+
789
+ # 台帳に載っている `@` 無しの単発ユニットだけを掃除対象にする。
790
+ while IFS= read -r raw || [[ -n "$raw" ]]; do
791
+ line="${raw#"${raw%%[![:space:]]*}"}"
792
+ line="${line%"${line##*[![:space:]]}"}"
793
+ [[ -z "$line" || "${line:0:1}" == "#" ]] && continue
794
+ # conductor@... は現役の可能性があるので一切触らない(テンプレート本体・各現場とも)。
795
+ [[ "$line" == conductor@* ]] && continue
796
+ # ★現行版が入れる「@ 無し」の現役ユニット(耳など)は掃除しない。
797
+ for keep in "${CURRENT_MACHINE_UNITS[@]}"; do
798
+ [[ "$line" == "$keep" ]] && continue 2
799
+ done
800
+ # 掃除対象は「現行版がもう置かない」単発ユニットのみ。それ以外の綴りは無視する。
801
+ [[ "$line" == conductor-*.service ]] || continue
802
+ stale+=("$line")
803
+ done < "$ledger_file"
804
+
805
+ if [[ ${#stale[@]} -eq 0 ]]; then
806
+ return 0
807
+ fi
808
+
809
+ for u in "${stale[@]}"; do
810
+ systemctl --user disable --now "$u" >/dev/null 2>&1 || true
811
+ if [[ -f "$unit_dir/$u" ]]; then
812
+ rm -f "$unit_dir/$u" || true
813
+ fi
814
+ # 撤去したものは台帳からも消す(台帳に幽霊を残さない)。
815
+ grep -vxF "$u" "$ledger_file" > "$ledger_file.tmp" 2>/dev/null || true
816
+ mv -f "$ledger_file.tmp" "$ledger_file" 2>/dev/null || rm -f "$ledger_file.tmp"
817
+ echo "[setup] 古いユニットを撤去: $u"
818
+ cleaned=1
819
+ done
820
+
821
+ if [[ $cleaned -eq 1 ]]; then
822
+ systemctl --user daemon-reload || true
823
+ echo "[setup] daemon-reload 実行(掃除の反映)"
824
+ fi
825
+ return 0
826
+ }
827
+ # best-effort: 掃除が失敗しても本体インストールは続行する(warn は上で定義済み)。
828
+ # ★古いユニットの掃除は systemd の話。Mac には掃除対象が存在しないので行わない(ADR-019)。
829
+ if [[ "$OS_KIND" == "mac" ]]; then
830
+ echo "[setup] 古いユニットの掃除は Mac では不要のため行いません"
831
+ else
832
+ cleanup_stale_units || warn "古いユニットの掃除に失敗しました(インストールは続行します)。"
833
+ fi
834
+
835
+ # ★常駐の設置だけが OS で分かれる(ADR-019・案C)。ここより上も下も1本のまま。
836
+ if [[ "$OS_KIND" == "mac" ]]; then
837
+ SVC_INSTALL="$SCRIPT_DIR/launchd/install.sh"
838
+ else
839
+ SVC_INSTALL="$SCRIPT_DIR/systemd/install.sh"
840
+ fi
841
+ if [[ -f "$SVC_INSTALL" ]]; then
842
+ bash "$SVC_INSTALL" "$SITE" "$PROJECT_DIR"
843
+ else
844
+ warn "常駐の設置スクリプトが見つかりません: $SVC_INSTALL(スキップ)"
845
+ echo "[setup] 手動で設定する場合は sales-template/${SVC_KIND}/ を参照してください。"
846
+ fi
847
+
848
+ # --- 耳(指示の着信合図の受け取り役/このPCに1本)の設置 ---------------------
849
+ # ADR-066: 指示が積まれた合図を受け取るのは PC につき1本の conductor-ear だけ(現場ごとではない)。
850
+ # 耳が受けた合図は、その現場の relay へ「今すぐ1周して」と伝えるだけ。指示は relay が
851
+ # 従来どおり取得APIから取る。=つなぎっぱなしの本数がソフト数ではなく PC 数で決まる。
852
+ # ★導入処理の正本は配布物の systemd/ear-install.sh(自社機の導入処理と共用・写しを持たない)。
853
+ # ★best-effort: 耳が入らなくてもセットアップは成功扱いにする。
854
+ # 耳は加速装置であって依存先ではなく、各現場の relay は15秒ポーリングで従来どおり完全に動く。
855
+ # ★この呼び出しは必ず cleanup_stale_units より後に置くこと。耳を台帳へ載せるのはこの中で、
856
+ # 掃除の例外(CURRENT_MACHINE_UNITS)は上で先に効かせてある(test/setup-ear-order.test.ts が固定)。
857
+ if [[ "$OS_KIND" == "mac" ]]; then
858
+ EAR_INSTALL="$SCRIPT_DIR/../launchd/ear-install.sh"
859
+ else
860
+ EAR_INSTALL="$SCRIPT_DIR/../systemd/ear-install.sh"
861
+ fi
862
+ if [[ -f "$EAR_INSTALL" ]]; then
863
+ bash "$EAR_INSTALL" || warn "耳の設置に失敗しました(relay は15秒ごとの確認で従来どおり動きます)。"
864
+ else
865
+ warn "耳の導入処理が見つかりません: $EAR_INSTALL(耳の設置はスキップ)
866
+ relay は15秒ごとの確認で従来どおり動きます。"
867
+ fi
868
+
869
+ # --- 貼り付け一時ファイルの毎日自動掃除タイマー(機体1回・現場ごとではない) ---
870
+ # ~/.claude/paste-cache に貼り付け由来の平文が溜まるのを、放置でも溜まらない形にする。
871
+ # best-effort: 失敗しても本体セットアップは完了扱いにする。
872
+ PRUNE_INSTALL="$SCRIPT_DIR/systemd/paste-cache-prune-install.sh"
873
+ if [[ "$OS_KIND" == "mac" ]]; then
874
+ # ★Mac 版の毎日掃除タイマーはまだ用意していない(残る穴として ADR-019 に明記)。
875
+ warn "貼り付け一時ファイルの毎日自動掃除は、Mac ではまだ用意していません(手動で消せます:
876
+ find ~/.claude/paste-cache -type f -mtime +1 -delete)"
877
+ elif [[ -f "$PRUNE_INSTALL" ]]; then
878
+ bash "$PRUNE_INSTALL" || warn "貼り付け一時ファイルの自動掃除タイマー設置に失敗しました(セットアップは続行)。"
318
879
  else
319
- warn "systemd/install.sh が見つかりません: $SYSTEMD_INSTALL(スキップ)"
320
- echo "[setup] 手動で systemd を設定する場合は sales-template/systemd/README.md を参照してください。"
880
+ warn "paste-cache-prune-install.sh が見つかりません: $PRUNE_INSTALL(自動掃除タイマーはスキップ)"
321
881
  fi
322
882
 
323
883
  # ─── ⑦ 職人起動の案内 ────────────────────────────────────────────────────────
324
- step "完了! 職人(Claude Code)の起動方法"
884
+ # ─── 自己点検(★お客さまが読んで分かる形で出す) ──────────────────────────
885
+ # 【なぜ要るか】以前の確認手段は systemctl / journalctl / 番人の手動テストの3つだけで、
886
+ # どれも黒い画面の読み方が要る=60代のお客さまには判断できなかった(調査 3f2337e6)。
887
+ # そこで、インストーラ自身が「置いたものが在るか」を1つずつ見て、○×で出す。
888
+ # ★ここは【確認するだけ】。何も作らない・変えない(何度実行しても同じ)。
889
+ step "⑦ 自己点検(ここまで出れば大丈夫です)"
890
+
891
+ ok_count=0
892
+ ng_lines=()
893
+ mark() { # mark <条件の結果> <項目名> <直し方>
894
+ if [[ "$1" == "ok" ]]; then
895
+ echo " ✓ $2"
896
+ ok_count=$((ok_count + 1))
897
+ else
898
+ echo " ✗ $2"
899
+ ng_lines+=("$2 … $3")
900
+ fi
901
+ }
902
+
903
+ RETRY="setup.sh をもう一度実行してください"
904
+ if [[ -x "$GUARD_HOME/cc2_guard.py" ]]; then
905
+ mark ok "安全装置(番人)が置かれている" ""
906
+ else
907
+ mark ng "安全装置(番人)が置かれている" "$RETRY"
908
+ fi
909
+ if [[ -s "$HOME_SETTINGS" ]]; then
910
+ mark ok "番人の登録ができている" ""
911
+ else
912
+ mark ng "番人の登録ができている" "$RETRY"
913
+ fi
914
+ if [[ -f "$ENV_FILE" ]]; then
915
+ mark ok "管制への接続情報がある" ""
916
+ else
917
+ mark ng "管制への接続情報がある" "$RETRY"
918
+ fi
919
+ if service_is_running "conductor@${SITE}" "com.tyhld.conductor.${SITE}"; then
920
+ mark ok "この現場(${SITE})の常駐が動いている" ""
921
+ else
922
+ mark ng "この現場(${SITE})の常駐が動いている" "$SVC_RESTART_HINT"
923
+ fi
924
+ if service_is_running conductor-ear com.tyhld.conductor.ear; then
925
+ mark ok "指示の受け取り役が動いている" ""
926
+ else
927
+ # 耳は加速装置。無くても15秒ごとの確認で届くので、✗にはするが不安を煽らない。
928
+ mark ng "指示の受け取り役が動いている" \
929
+ "無くても指示は届きます(少し遅くなるだけ)。気になるときはご連絡ください"
930
+ fi
931
+ if command -v claude >/dev/null 2>&1; then
932
+ mark ok "職人(Claude Code)が使える" ""
933
+ else
934
+ mark ng "職人(Claude Code)が使える" "npm i -g @anthropic-ai/claude-code を実行してください"
935
+ fi
936
+
937
+ echo
938
+ if [[ ${#ng_lines[@]} -eq 0 ]]; then
939
+ echo " ★ぜんぶ ✓ です(${ok_count}項目)。この画面のとおりなら設置は成功しています。"
940
+ else
941
+ echo " ★ ✗ が ${#ng_lines[@]} 件あります。次のようにしてください:"
942
+ for l in "${ng_lines[@]}"; do
943
+ echo " - $l"
944
+ done
945
+ echo " 直らないときは、この画面をそのままサポートへお送りください。"
946
+ fi
947
+
948
+ step "⑧ 完了! 職人(Claude Code)の起動方法"
325
949
 
326
950
  cat <<GUIDE
327
951
 
328
952
  采配くんのセットアップが完了しました。
329
953
 
330
- 次のステップ: Claude Code(職人)を起動してください。
954
+ 次にやること: Claude Code(職人)を起動してください。下の3行をそのまま貼ります。
331
955
 
332
- # 1. tmux セッションを作成
956
+ # 1. 作業部屋(tmux)を用意する
333
957
  tmux new-session -d -s "$SITE" -c "$PROJECT_DIR"
334
958
 
335
- # 2. ANTHROPIC_API_KEY を無効化して Claude Code を起動
959
+ # 2. 職人を起動する
336
960
  tmux send-keys -t "$SITE" "unset ANTHROPIC_API_KEY && claude" C-m
337
961
 
338
- # 3. セッションに接続
962
+ # 3. 職人の画面を開く
339
963
  tmux attach -t "$SITE"
340
964
 
341
- Claude Code が起動したら、管制サーバから指示を送ることができます。
342
- relay が15秒ごとに指示をポーリングし、Claude Code がアイドル状態のときに自動で流し込みます。
343
-
344
- --- 管理コマンド ---
345
- 状態確認: systemctl --user status conductor@${SITE}
346
- ログ確認: journalctl --user -u conductor@${SITE} -f
347
- 再起動: systemctl --user restart conductor@${SITE}
348
- 停止: systemctl --user disable --now conductor@${SITE}
965
+ ──────────────────────────────────────────────────────────
966
+ ★いちばん確実な「動いています」の確かめ方
967
+ ──────────────────────────────────────────────────────────
968
+
969
+ 管制の画面(采配くん)をブラウザで開いてください。
970
+ そこに「${SITE}」が出ていれば、つながって動いています。★これが正の確認方法です。
971
+
972
+ 出てこないときは、次の順に試してください。
973
+ 1. 職人の画面(上の3行)を開いたままにする
974
+ 2. 1〜2分待ってから、管制の画面を開き直す
975
+ 3. それでも出ないときは: ${SVC_RESTART_CMD}
976
+ 4. それでも出ないときは、この画面をそのままサポートへお送りください
977
+
978
+ ──────────────────────────────────────────────────────────
979
+ 困ったとき(黒い画面のコマンド・補助)
980
+ ──────────────────────────────────────────────────────────
981
+ 状態を見る: ${SVC_STATUS_CMD}
982
+ 動きの記録: ${SVC_LOG_CMD}
983
+ 入れ直す: ${SVC_RESTART_CMD}
984
+ 止める: ${SVC_STOP_CMD}
985
+ 合言葉を入れ直す: rm ~/.conductor.env → そのあと setup.sh をもう一度実行
349
986
 
350
987
  GUIDE