@tyhld/conductor 0.3.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 ADDED
@@ -0,0 +1,339 @@
1
+ # @tyhld/conductor(見守りアプリ / conductor-agent)
2
+
3
+ 采配くん管制の「見守りアプリ」です。
4
+ 各PCで動かしておくと、**自分がいま作業中であること(生存報告 / heartbeat)** を、
5
+ 30秒ごとに中央サーバー(devlog-tracker)へ自動で送り続けます。
6
+
7
+ 中央の管制画面では、どのPCがどの現場(リポジトリ)で、どのブランチを、
8
+ 作業中(working)か待機中(idle)かが一覧で見えるようになります。
9
+
10
+ > 仕組みはシンプルです。あなたのPCから中央へ「生きてます」という小さな合図を
11
+ > 定期的に送るだけ。難しい設定は要りません。
12
+
13
+ ---
14
+
15
+ ## これは何をするもの?
16
+
17
+ - `conductor start` を実行すると、その場で常駐します。
18
+ - 起動した瞬間に1回、そのあとは **30秒ごと**(変更可)に中央へ報告します。
19
+ - 報告する内容: PC名 / 現場名(リポ名) / ブランチ名 / 状態(working/idle) / 直近の作業メモ。
20
+ - 止めたいときは **Ctrl+C** を押すだけです。
21
+
22
+ 現場名(site)とブランチ名は、コマンドを実行したフォルダの git 情報から**自動で判定**します。
23
+ git のフォルダでなければ、現在のフォルダ名を現場名として使います。
24
+
25
+ ---
26
+
27
+ ## 必要なもの
28
+
29
+ - Node.js **18以上**(`node -v` で確認できます)
30
+
31
+ ---
32
+
33
+ ## 導入(他PC向け / git clone 方式)
34
+
35
+ メンバーの各PCで conductor を導入・起動する手順です。**3人規模では git clone での配布で十分**なので、現段階では GitHub Packages への publish は行いません(人数が増えたら publish 配布を再検討します)。
36
+
37
+ ### 前提
38
+
39
+ - **Node.js 18以上**(`node -v` で確認)
40
+ - **git**
41
+ - 対象リポジトリへのアクセス権(GitHub `tyhld` org)
42
+
43
+ ### 手順
44
+
45
+ ```bash
46
+ # 1) clone(URL は conductor リポジトリのもの)
47
+ git clone https://github.com/tyhld/conductor.git
48
+ cd conductor
49
+
50
+ # 2) 依存インストール
51
+ npm install
52
+
53
+ # 3) ビルド(tsc で dist/ を生成)
54
+ npm run build
55
+ ```
56
+
57
+ ### 各自で設定する環境変数
58
+
59
+ | 環境変数 | 値 | 備考 |
60
+ |----------|----|------|
61
+ | `CONDUCTOR_URL` | `https://<your-conductor-host>`(中央) | 接続先 |
62
+ | `CONDUCTOR_SHARED_SECRET` | **Bitwarden から取得** | 共有シークレット。値はここに書きません |
63
+ | `CONDUCTOR_MACHINE` | 任意 | PC識別名。未設定なら `os.hostname()` |
64
+
65
+ > 🔑 **鍵(`CONDUCTOR_SHARED_SECRET`)は Bitwarden の共有項目から取得してください。**
66
+ > コード・リポジトリ・この README には値を一切書きません。
67
+
68
+ 設定して起動する例:
69
+
70
+ ```bash
71
+ export CONDUCTOR_URL=https://<your-conductor-host>
72
+ read -s -p 'shared secret: ' CONDUCTOR_SHARED_SECRET && export CONDUCTOR_SHARED_SECRET
73
+ node dist/cli.js start # もしくは scripts/conductor-start.sh(後述)
74
+ ```
75
+
76
+ > 補足: `npm install -g` でのグローバル導入や GitHub Packages での publish 配布は現段階では行いません。clone したリポジトリ内で `node dist/cli.js start`(または補助スクリプト)を使う運用です。
77
+
78
+ ---
79
+
80
+ ## 使い方
81
+
82
+ 最低限、次の2つの環境変数を渡して `conductor start` を実行します。
83
+
84
+ ```bash
85
+ CONDUCTOR_URL=https://<your-conductor-host> \
86
+ CONDUCTOR_SHARED_SECRET=(共有シークレット) \
87
+ conductor start
88
+ ```
89
+
90
+ 実行すると、こんな1行ログが定期的に流れます。
91
+
92
+ ```
93
+ [conductor] 開始: machine=my-pc site=kenzokun status=working interval=30s url=https://...
94
+ [conductor] 10:00:00 site=kenzokun status=working -> HTTP 200 OK
95
+ [conductor] 10:00:30 site=kenzokun status=working -> HTTP 200 OK
96
+ ```
97
+
98
+ 止めるときは **Ctrl+C**。
99
+
100
+ ### よく使うオプション
101
+
102
+ ```bash
103
+ # 現場名を手動で指定したいとき
104
+ conductor start -s kenzokun
105
+
106
+ # 待機中として報告したいとき
107
+ conductor start -t idle
108
+
109
+ # 直近の作業メモを添えたいとき
110
+ conductor start -a "ログイン画面を実装中"
111
+ ```
112
+
113
+ | 引数 | 意味 | 既定値 |
114
+ |------|------|--------|
115
+ | `-s <site>` | 現場名(リポ名) | git リポ名 → 無ければカレントフォルダ名 |
116
+ | `-t <idle\|working>` | 状態 | `working` |
117
+ | `-a <lastAction>` | 直近の作業メモ(自由文) | `heartbeat` |
118
+
119
+ ---
120
+
121
+ ## 環境変数一覧
122
+
123
+ | 環境変数 | 必須 | 意味 | 既定値 |
124
+ |----------|------|------|--------|
125
+ | `CONDUCTOR_URL` | ✅ | 中央(devlog-tracker)のベースURL。例: `https://<your-conductor-host>` | (なし) |
126
+ | `CONDUCTOR_SHARED_SECRET` | ✅ | 共有シークレット。中央API側の同名 env と一致させます | (なし) |
127
+ | `CONDUCTOR_MACHINE` | – | PCの識別名 | `os.hostname()`(PCのホスト名) |
128
+ | `CONDUCTOR_INTERVAL_SEC` | – | heartbeat送信間隔(秒) | `30` |
129
+ | `CONDUCTOR_RELAY_ENABLED` | – | `true` で relay(指示の取得→アイドル時のみ tmux 送信→完了検出)を有効化 | `false` |
130
+ | `CONDUCTOR_RELAY_INTERVAL_SEC` | – | relay のポーリング間隔(秒) | `15` |
131
+
132
+ > **relay(ADR-029 段階3 / 既定 off)**:`CONDUCTOR_RELAY_ENABLED=true` のときだけ、
133
+ > 中央から自分宛(machine+site)の指示を取得し、**送信先のtmuxセッション(名前 = 現場名 site)が
134
+ > 完全にアイドルのときだけ** 指示を流し込みます。少しでも作業中っぽければ送らず次回に再挑戦する
135
+ > 保守的な判定(「誤爆より送り遅れ」)です。off の間は従来どおり heartbeat のみで挙動は変わりません。
136
+
137
+ ---
138
+
139
+ ## よくある質問
140
+
141
+ **Q. オフライン(offline)は送らないの?**
142
+ A. 送りません。中央側は「最後の生存報告からの経過時間」でオフラインを判定する設計です。
143
+ そのため Ctrl+C で止めると、しばらく後に中央画面側で自動的にオフライン表示になります。
144
+
145
+ **Q. 鍵(シークレット)はどこに書くの?**
146
+ A. **コードやリポジトリには絶対に書きません。** 実行時に環境変数で渡してください。
147
+
148
+ ---
149
+
150
+ ## 補助スクリプト: CC2のワンタッチ起動(scripts/cc2.sh)★推奨
151
+
152
+ CC2(人が起動する Claude Code)の起動手順を1コマンドに畳んで、**起動ミスを根治**するためのスクリプトです。
153
+ 「箱(tmux)が未起動なのに気づかず指示だけ待機」「cd 忘れ / `unset` 忘れ / 現場名のタイプミス / 二重起動」を構造的に防ぎます。
154
+
155
+ ```bash
156
+ bash scripts/cc2.sh <現場名> # 例: bash scripts/cc2.sh kenzokun
157
+ ```
158
+
159
+ `~/.bashrc` に次の alias を足すと、`cc2 kenzokun` だけで起動できます(**.bashrc の自動編集はしません。手動で追記してください**):
160
+
161
+ ```bash
162
+ alias cc2='bash ~/projects/conductor/scripts/cc2.sh'
163
+ ```
164
+
165
+ やること:
166
+
167
+ - 引数なし → usage と**登録済み現場一覧**(`sites.sh`)を表示して終了。
168
+ - 未知の現場名 → **似た候補を提示(タイプミス検出)**してから一覧を表示。
169
+ - **二重起動防止**: 同名 tmux セッションがあれば attach するだけ(並列禁止)。同じディレクトリで動く別名セッションがあれば軽く警告。
170
+ - 新規セッションは作業ディレクトリで作成 → `unset ANTHROPIC_API_KEY` → `claude` を起動 → attach。**cd 忘れ・unset 忘れを構造的に不可能**にします。
171
+
172
+ > 現場名→ディレクトリの対応表は `scripts/sites.sh` を**単一の真実**として再利用します(対応表を二重定義しない)。
173
+ > **新しい現場は `sites.sh` を直すだけで `cc2.sh` も自動対応**します。破壊的操作(tmux kill 等)はしません。
174
+
175
+ ---
176
+
177
+ ## 補助スクリプト: CC2の起動(scripts/cc2-start.sh)
178
+
179
+ 人が CC2(Claude Code)を起動するときの「tmux new → cd → `unset ANTHROPIC_API_KEY` → `claude`」を1コマンドにまとめた補助です(常駐/報告役とは無関係)。`cc2.sh` の前身で、タイプミス検出・同ディレクトリ警告は付いていません(基本動作は同じ)。
180
+
181
+ ```bash
182
+ bash scripts/cc2-start.sh <現場名> # 例: bash scripts/cc2-start.sh kenzokun
183
+ ```
184
+
185
+ 引数なし・未知の現場名で実行すると、有効な現場名一覧を表示します。同名の tmux セッションが既にあればそこへ接続し、無ければ作成して CC2 を起動します。
186
+
187
+ > 現場名→ディレクトリの対応表は `scripts/sites.sh` に集約し、`cc2-start.sh` と `conductor-start.sh` の両方が source して共有します(重複を持たない)。
188
+
189
+ ---
190
+
191
+ ## 補助スクリプト: 報告役の常駐起動(scripts/conductor-start.sh)
192
+
193
+ 報告役(heartbeat、必要なら relay)を1コマンドで起動する補助です。`cc2-start.sh` と同じ作法・同じ対応表を使います。
194
+
195
+ ```bash
196
+ bash scripts/conductor-start.sh <現場名> [--relay]
197
+
198
+ # 例
199
+ bash scripts/conductor-start.sh kenzokun # heartbeat のみ
200
+ bash scripts/conductor-start.sh kenzokun --relay # heartbeat + relay
201
+ ```
202
+
203
+ - 第1引数=現場名(`sites.sh` の対応表から作業ディレクトリを解決)。
204
+ - `--relay` 指定時のみ relay(`CONDUCTOR_RELAY_ENABLED=true`)を有効化します。
205
+ - 内部では現場のディレクトリへ `cd` してから `node dist/cli.js start -s <現場> -t working -a "<現場>稼働"` を実行します(ブランチ名を現場の git から判定するため)。
206
+ - `CONDUCTOR_URL` / `CONDUCTOR_SHARED_SECRET` は**呼び出し元のシェルに設定済み前提**で、スクリプトに鍵は書きません。未設定なら案内を出して終了します。
207
+ - 未知の現場名・引数なしのときは有効な現場名一覧を表示します。
208
+
209
+ > これは **人がログイン後に1回叩く半自動** です。PC起動時の完全自動は次の「自動常駐」を使います。
210
+
211
+ ---
212
+
213
+ ## relay を手動で起動する(テスト・手動運用 / scripts/relay-start.sh)
214
+
215
+ relay(指示を CC2 に流す役)を **1コマンドで手動起動**する補助です。これまで毎回打っていた `source ~/.conductor.env` → 各種 `export` → `node dist/cli.js start ...` を1コマンドにまとめ、source した鍵が子プロセス(node)に渡らない「未設定エラー」事故も `set -a` で根治しています。
216
+
217
+ ```bash
218
+ bash scripts/relay-start.sh <現場名> [作業ラベル]
219
+
220
+ # 例
221
+ bash scripts/relay-start.sh relaytest
222
+ bash scripts/relay-start.sh relaytest "スタートアップ調査"
223
+
224
+ # relay 間隔を短くしてテスト(既定 15秒)
225
+ CONDUCTOR_RELAY_INTERVAL_SEC=5 bash scripts/relay-start.sh relaytest
226
+ ```
227
+
228
+ - 鍵(`CONDUCTOR_URL` / `CONDUCTOR_SHARED_SECRET`)は `~/.conductor.env` から**自動読込**します。スクリプトには鍵を書かず、画面にも値を出しません。鍵ファイルが無ければ案内して終了します。
229
+ - `CONDUCTOR_RELAY_ENABLED=true` を付けて relay を有効化します。間隔は `CONDUCTOR_RELAY_INTERVAL_SEC`(既定 15秒)で上書き可。
230
+ - どのディレクトリから実行しても OK(スクリプト位置からリポルートを解決)。`dist/cli.js` が無ければ `npm run build` を案内して終了します。
231
+ - 作業ラベル(第2引数)の既定は `relay手動起動` です。
232
+
233
+ ---
234
+
235
+ ## PC起動時の自動常駐(第1段 / scripts/autostart.sh)
236
+
237
+ リストに並べた現場を、それぞれ **heartbeat のみ** でバックグラウンド常駐させる、人手を介さない起動口です。WSL 上で `bash scripts/autostart.sh` を叩くと、リストの現場をまとめて常駐させます。
238
+
239
+ ### 1) 鍵ファイル `$HOME/.conductor.env` を用意
240
+
241
+ 鍵はスクリプトに書きません。各PCのホームに `$HOME/.conductor.env` を作り、そこから読み込みます。
242
+
243
+ ```bash
244
+ cat > "$HOME/.conductor.env" <<'EOF'
245
+ CONDUCTOR_URL=https://<your-conductor-host>
246
+ CONDUCTOR_SHARED_SECRET=(Bitwarden から取得した値を貼る)
247
+ EOF
248
+ chmod 600 "$HOME/.conductor.env" # 自分だけ読める権限に
249
+ ```
250
+
251
+ > 🔑 `CONDUCTOR_SHARED_SECRET` の値は **Bitwarden の共有項目から取得**してください。
252
+ > `$HOME/.conductor.env` は **git 管理外**(リポジトリには入れません)。このファイルが無い/鍵が空のときは案内を出して終了します。
253
+
254
+ ### 2) 自動起動する現場リスト
255
+
256
+ 「このPCで自動起動する現場」を1行1現場で並べます(`#` 始まりと空行は無視)。優先順位:
257
+
258
+ 1. `bash scripts/autostart.sh <リストファイル>` の第1引数
259
+ 2. `$HOME/.conductor.autostart.list`(各PC個別・git管理外)
260
+ 3. `scripts/autostart.list`(同梱サンプル)
261
+
262
+ 各PCでは `$HOME/.conductor.autostart.list` を置く(または同梱の `scripts/autostart.list` を編集する)運用です。
263
+
264
+ ### 3) 起動と停止
265
+
266
+ ```bash
267
+ bash scripts/autostart.sh # リストの現場を heartbeat のみで常駐起動
268
+ bash scripts/autostart-stop.sh # 自動常駐(node dist/cli.js start)をまとめて停止
269
+ ```
270
+
271
+ - 各現場のログは `$HOME/.conductor-logs/<現場>.log` に追記されます。
272
+ - 既に同じ現場の自動常駐が動いていれば二重起動しません。未知の現場名は warn を出してスキップします(全体は止めません)。
273
+ - relay は有効化しません(heartbeat のみ)。relay が要るときは `conductor-start.sh --relay` を使ってください。
274
+
275
+ > 停止スクリプトは `dist/cli.js start` を含む node を対象にするため、`conductor-start.sh` や手動起動の報告役も一緒に止まります。
276
+
277
+ ### 補足
278
+
279
+ - **Windows タスクスケジューラからの呼び出しは人間が別途設定**します(このリポジトリでは設定しません)。
280
+ - 次段では、自動起動する現場の **②管制画面からのトグル化**(ファイル編集なしで ON/OFF)を予定しています。
281
+
282
+ ---
283
+
284
+ ## PC起動時の自動常駐(第2段 / systemd ユーザーサービス)
285
+
286
+ 第1段(`autostart.sh`)を **systemd ユーザーサービス**に置き換えたものです。各現場を `conductor@<現場>.service` として登録し、**`Restart=always` で「落ちても自動復活」** します。これが常用、旧 `autostart.sh` は **手動予備として温存**します。
287
+
288
+ ### 前提
289
+
290
+ - WSL で **systemd が有効**(`/etc/wsl.conf` の `[boot] systemd=true`、`[user] default=fshim`)。`systemctl --user` が使えること。
291
+ - 鍵ファイル `$HOME/.conductor.env`(`chmod 600` / git管理外)が用意済み。中身は第1段と同じ(`CONDUCTOR_URL` / `CONDUCTOR_SHARED_SECRET`)。**鍵はリポジトリに入れません。**
292
+ - conductor リポで `npm run build` 済み(`dist/cli.js` が存在)。
293
+
294
+ ### 導入
295
+
296
+ ```bash
297
+ bash scripts/systemd-install.sh # リストの現場を登録+起動(enable --now)
298
+ ```
299
+
300
+ - ユニット `systemd/conductor@.service` を `~/.config/systemd/user/` にコピーし、`daemon-reload`。
301
+ - リストファイル(第1段と同じ優先順: 第1引数 → `$HOME/.conductor.autostart.list` → `scripts/autostart.list`)の各現場を `systemctl --user enable --now conductor@<現場>`。
302
+ - `loginctl enable-linger $USER` を実行(ログオフ中・PC起動直後でもユーザーサービスが動くように)。通常 sudo は不要です。
303
+ - `$HOME/.conductor.env` が無ければ警告して中断します。
304
+ - relay は有効化しません(heartbeat のみ)。
305
+
306
+ > **WorkingDirectory**: 報告役はカレントの git からブランチを判定するため、現場ディレクトリで起動します。既定は `%h/projects/<現場>`(現状は「現場名 = ディレクトリ名」で一致)。将来この2つが食い違う現場が出た場合は、install スクリプトが `sites.sh` の対応表を見て drop-in(`conductor@<現場>.service.d/override.conf`)で `WorkingDirectory` を上書きします。テンプレート本体は触りません。
307
+
308
+ ### 状態確認
309
+
310
+ ```bash
311
+ systemctl --user status 'conductor@*' # 全現場のサービス状態
312
+ systemctl --user list-units 'conductor@*' # 一覧
313
+ journalctl --user -u 'conductor@kenzokun' -f # 特定現場のログを追尾
314
+ ```
315
+
316
+ ### 停止 / 無効化
317
+
318
+ ```bash
319
+ bash scripts/systemd-uninstall.sh # 各現場を disable --now +ユニット削除+daemon-reload
320
+ ```
321
+
322
+ - linger は外しません(他用途の可能性があるため報告のみ)。完全に戻すときは手動で `loginctl disable-linger "$USER"`。
323
+
324
+ ### 補足
325
+
326
+ - **落ちても `Restart=always`(`RestartSec=5`)で自動復活**します。第1段の `nohup &` と違い、クラッシュ・OOM 後も systemd が起こし直します。
327
+ - 旧 `scripts/autostart.sh` は **手動予備として温存**(常用は systemd)。
328
+ - **Windows タスクスケジューラは「WSL を起こす」役割に変更**します(常駐の起動は systemd が担当)。例: ログオン時に `wsl.exe -d Ubuntu -u fshim true` 等で WSL を立ち上げるだけ。この変更は**人間が別途設定**します。
329
+ - 次段では、自動起動する現場の **②管制画面からのトグル化**(ファイル編集なしで ON/OFF)を予定しています。
330
+
331
+ ---
332
+
333
+ ## 開発
334
+
335
+ ```bash
336
+ npm install
337
+ npm run build # tsc で dist/ を生成
338
+ npm start # = node dist/cli.js start
339
+ ```
package/dist/cli.js ADDED
@@ -0,0 +1,262 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * @tyhld/conductor — 采配くん管制の「見守りアプリ(conductor-agent)」。
4
+ *
5
+ * 各PC(現場)で常駐し、中央 devlog-tracker の報告API
6
+ * POST {CONDUCTOR_URL}/api/conductor/report
7
+ * へ、起動直後に1回 + 以後 CONDUCTOR_INTERVAL_SEC 秒ごとに生存報告(heartbeat)を送る。
8
+ *
9
+ * 認可は共有シークレットの Bearer のみ(API 側 env CONDUCTOR_SHARED_SECRET と一致させる)。
10
+ * 鍵はコードに書かず、必ず環境変数で渡す。
11
+ */
12
+ import { execFileSync } from 'node:child_process';
13
+ import { readFileSync } from 'node:fs';
14
+ import { fileURLToPath } from 'node:url';
15
+ import os from 'node:os';
16
+ import { createRelayState, relayTick, tmuxHasSession } from './relay.js';
17
+ /** git コマンドを安全に実行し、失敗したら null を返す(git でない/未インストール等)。 */
18
+ function git(args) {
19
+ try {
20
+ const out = execFileSync('git', args, {
21
+ stdio: ['ignore', 'pipe', 'ignore'],
22
+ encoding: 'utf8',
23
+ });
24
+ const trimmed = out.trim();
25
+ return trimmed.length > 0 ? trimmed : null;
26
+ }
27
+ catch {
28
+ return null;
29
+ }
30
+ }
31
+ /** 現在のブランチ名(取れなければ null)。 */
32
+ function detectBranch() {
33
+ return git(['rev-parse', '--abbrev-ref', 'HEAD']);
34
+ }
35
+ /** 現場名(site): git リポのトップ階層フォルダ名。git でなければカレントフォルダ名。 */
36
+ function detectSite() {
37
+ const top = git(['rev-parse', '--show-toplevel']);
38
+ const dir = top ?? process.cwd();
39
+ // 末尾のセパレータを除いた最後の要素を取る(Windows の \ にも一応対応)。
40
+ const parts = dir.replace(/[\\/]+$/, '').split(/[\\/]/);
41
+ return parts[parts.length - 1] || dir;
42
+ }
43
+ /**
44
+ * 引数を簡易パース。`conductor start [-s site] [-t status] [-a lastAction]`。
45
+ * 余計な依存を足さないため自前で処理する。
46
+ */
47
+ function parseArgs(argv) {
48
+ const [command, ...rest] = argv;
49
+ const result = {
50
+ command,
51
+ };
52
+ for (let i = 0; i < rest.length; i++) {
53
+ const arg = rest[i];
54
+ const next = rest[i + 1];
55
+ switch (arg) {
56
+ case '-s':
57
+ case '--site':
58
+ result.site = next;
59
+ i++;
60
+ break;
61
+ case '-t':
62
+ case '--status':
63
+ result.status = next;
64
+ i++;
65
+ break;
66
+ case '-a':
67
+ case '--action':
68
+ result.lastAction = next;
69
+ i++;
70
+ break;
71
+ default:
72
+ // 未知の引数は無視(将来の拡張余地)。
73
+ break;
74
+ }
75
+ }
76
+ return result;
77
+ }
78
+ /** わかりやすいエラーを出して終了。 */
79
+ function fail(message) {
80
+ console.error(`[conductor] エラー: ${message}`);
81
+ process.exit(1);
82
+ }
83
+ /** 環境変数+引数から設定を組み立てる。必須が無ければここで終了する。 */
84
+ function loadConfig(args) {
85
+ const url = (process.env.CONDUCTOR_URL ?? '').trim();
86
+ const secret = (process.env.CONDUCTOR_SHARED_SECRET ?? '').trim();
87
+ if (!url) {
88
+ fail('環境変数 CONDUCTOR_URL が未設定です(例: CONDUCTOR_URL=https://<your-conductor-host>)');
89
+ }
90
+ if (!secret) {
91
+ fail('環境変数 CONDUCTOR_SHARED_SECRET が未設定です(中央APIと同じ共有シークレットを渡してください)');
92
+ }
93
+ const machine = (process.env.CONDUCTOR_MACHINE ?? '').trim() || os.hostname();
94
+ const status = (args.status ?? 'working').trim();
95
+ if (status !== 'idle' && status !== 'working') {
96
+ fail(`status は idle か working を指定してください(指定値: "${status}")`);
97
+ }
98
+ const site = (args.site ?? '').trim() || detectSite();
99
+ const lastAction = (args.lastAction ?? '').trim() || 'heartbeat';
100
+ let intervalSec = 30;
101
+ const rawInterval = (process.env.CONDUCTOR_INTERVAL_SEC ?? '').trim();
102
+ if (rawInterval) {
103
+ const parsed = Number(rawInterval);
104
+ if (!Number.isFinite(parsed) || parsed <= 0) {
105
+ fail(`CONDUCTOR_INTERVAL_SEC は正の数で指定してください(指定値: "${rawInterval}")`);
106
+ }
107
+ intervalSec = parsed;
108
+ }
109
+ // relay 設定(既定 off。off の間は既存の heartbeat 挙動を一切変えない)。
110
+ const relayEnabled = /^(1|true|yes|on)$/i.test((process.env.CONDUCTOR_RELAY_ENABLED ?? '').trim());
111
+ let relayIntervalSec = 15;
112
+ const rawRelayInterval = (process.env.CONDUCTOR_RELAY_INTERVAL_SEC ?? '').trim();
113
+ if (rawRelayInterval) {
114
+ const parsed = Number(rawRelayInterval);
115
+ if (!Number.isFinite(parsed) || parsed <= 0) {
116
+ fail(`CONDUCTOR_RELAY_INTERVAL_SEC は正の数で指定してください(指定値: "${rawRelayInterval}")`);
117
+ }
118
+ relayIntervalSec = parsed;
119
+ }
120
+ return {
121
+ url: url.replace(/\/+$/, ''),
122
+ secret,
123
+ machine,
124
+ site,
125
+ status,
126
+ lastAction,
127
+ intervalSec,
128
+ relayEnabled,
129
+ relayIntervalSec,
130
+ };
131
+ }
132
+ /** 1回分の報告を送る。送信結果(HTTPステータス)をログ表示する。 */
133
+ async function sendReport(cfg) {
134
+ const body = {
135
+ machine: cfg.machine,
136
+ site: cfg.site,
137
+ branch: detectBranch(),
138
+ status: cfg.status,
139
+ lastAction: cfg.lastAction,
140
+ artisanReady: tmuxHasSession(cfg.site),
141
+ };
142
+ const time = new Date().toLocaleTimeString();
143
+ try {
144
+ const res = await fetch(`${cfg.url}/api/conductor/report`, {
145
+ method: 'POST',
146
+ headers: {
147
+ Authorization: `Bearer ${cfg.secret}`,
148
+ 'Content-Type': 'application/json',
149
+ },
150
+ body: JSON.stringify(body),
151
+ });
152
+ const ok = res.ok ? 'OK' : 'NG';
153
+ console.log(`[conductor] ${time} site=${cfg.site} status=${cfg.status} -> HTTP ${res.status} ${ok}`);
154
+ if (!res.ok) {
155
+ const text = await res.text().catch(() => '');
156
+ if (text)
157
+ console.log(`[conductor] 応答: ${text.slice(0, 200)}`);
158
+ }
159
+ }
160
+ catch (err) {
161
+ const msg = err instanceof Error ? err.message : String(err);
162
+ console.log(`[conductor] ${time} site=${cfg.site} status=${cfg.status} -> 送信失敗: ${msg}`);
163
+ }
164
+ }
165
+ /** `conductor start`: 起動直後に1回 + 以後 intervalSec ごとに報告し続ける。 */
166
+ async function start(args) {
167
+ const cfg = loadConfig(args);
168
+ console.log(`[conductor] 開始: machine=${cfg.machine} site=${cfg.site} status=${cfg.status} ` +
169
+ `interval=${cfg.intervalSec}s url=${cfg.url}`);
170
+ console.log('[conductor] 停止するには Ctrl+C を押してください。');
171
+ // 起動直後に1回。
172
+ await sendReport(cfg);
173
+ const timer = setInterval(() => {
174
+ void sendReport(cfg);
175
+ }, cfg.intervalSec * 1000);
176
+ // ADR-029 relay。CONDUCTOR_RELAY_ENABLED=true のときだけ別インターバルで回す。
177
+ // heartbeat とは独立。off の間は何も起きない(既存挙動を壊さない)。
178
+ let relayTimer = null;
179
+ if (cfg.relayEnabled) {
180
+ console.log(`[relay] 有効: site=${cfg.site} interval=${cfg.relayIntervalSec}s ` +
181
+ `(tmuxセッション "${cfg.site}" がアイドルのときだけ送信します)`);
182
+ const relayState = createRelayState();
183
+ relayTimer = setInterval(() => {
184
+ void relayTick(cfg, relayState);
185
+ }, cfg.relayIntervalSec * 1000);
186
+ }
187
+ // Ctrl+C で綺麗に停止。
188
+ // ※ 中央APIは status を idle|working のみ受理し offline は受け付けない
189
+ // (オフライン判定は heartbeat の鮮度から画面側で行う設計)。
190
+ // そのため終了時は余計な送信をせず、タイマーを止めて静かに終わる。
191
+ const stop = () => {
192
+ clearInterval(timer);
193
+ if (relayTimer)
194
+ clearInterval(relayTimer);
195
+ console.log('\n[conductor] 停止しました。');
196
+ process.exit(0);
197
+ };
198
+ process.on('SIGINT', stop);
199
+ process.on('SIGTERM', stop);
200
+ }
201
+ /** package.json の version を返す(読めなければ 'unknown')。dist/cli.js からは ../package.json。 */
202
+ function readVersion() {
203
+ try {
204
+ const pkgPath = fileURLToPath(new URL('../package.json', import.meta.url));
205
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
206
+ return pkg.version ?? 'unknown';
207
+ }
208
+ catch {
209
+ return 'unknown';
210
+ }
211
+ }
212
+ function printVersion() {
213
+ console.log(readVersion());
214
+ }
215
+ function printUsage() {
216
+ console.log([
217
+ '@tyhld/conductor — 生存報告(heartbeat)常駐CLI',
218
+ '',
219
+ '使い方:',
220
+ ' conductor start [-s <site>] [-t <idle|working>] [-a <lastAction>]',
221
+ '',
222
+ '必須の環境変数:',
223
+ ' CONDUCTOR_URL 中央(devlog-tracker)のベースURL',
224
+ ' CONDUCTOR_SHARED_SECRET 共有シークレット(中央APIと一致させる)',
225
+ '',
226
+ '任意の環境変数:',
227
+ ' CONDUCTOR_MACHINE PC識別名(既定: os.hostname())',
228
+ ' CONDUCTOR_INTERVAL_SEC heartbeat送信間隔・秒(既定: 30)',
229
+ '',
230
+ 'relay(ADR-029 段階3 / 既定off。tmuxセッション名=site の現場が対象):',
231
+ ' CONDUCTOR_RELAY_ENABLED true で relay 有効(既定: false)',
232
+ ' CONDUCTOR_RELAY_INTERVAL_SEC relayポーリング間隔・秒(既定: 15)',
233
+ '',
234
+ '例:',
235
+ ' CONDUCTOR_URL=https://<your-conductor-host> \\',
236
+ ' CONDUCTOR_SHARED_SECRET=*** conductor start',
237
+ ].join('\n'));
238
+ }
239
+ async function main() {
240
+ const args = parseArgs(process.argv.slice(2));
241
+ switch (args.command) {
242
+ case 'start':
243
+ await start(args);
244
+ break;
245
+ case 'version':
246
+ case '-v':
247
+ case '--version':
248
+ printVersion();
249
+ break;
250
+ case undefined:
251
+ case 'help':
252
+ case '-h':
253
+ case '--help':
254
+ printUsage();
255
+ break;
256
+ default:
257
+ console.error(`[conductor] 不明なコマンド: ${args.command}`);
258
+ printUsage();
259
+ process.exit(2);
260
+ }
261
+ }
262
+ void main();