@tyhld/conductor 0.11.9 → 0.11.11

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tyhld/conductor",
3
- "version": "0.11.9",
3
+ "version": "0.11.11",
4
4
  "description": "采配くん管制の見守りアプリ(conductor-agent)。各PCで常駐し、中央(devlog-tracker)へ定期的に生存報告(heartbeat)を送る常駐CLI。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -483,73 +483,15 @@ def autorun_enabled(env=None):
483
483
  return not _AUTORUN_OFF_RE.match(str(raw).strip())
484
484
 
485
485
 
486
- # 自走の履歴を管制へ送るときの上限時間(秒)。★短くする。
487
- # これは「あとで人が読む記録」であって、職人を待たせてよい理由が1つも無い。
488
- # 管制が遅い・落ちているときに職人の手が止まらないよう、通常の 35 秒ではなくここで切る。
489
- AUTORUN_SEND_TIMEOUT_SEC = 4
490
-
491
-
492
- def record_autorun_local(tool_name, gate=None, sent=True, reason='', env=None):
486
+ def record_autorun_local(tool_name, gate=None, env=None):
493
487
  """「聞かずに通した(自走)」1回を、この機の記録に1行残す。
494
488
 
495
- 形: `<ISO時刻> Autorun <tool> -> allow[ gate=<種別>][ 管制へ送れず(<理由>)]`
496
- ★管制へ送れなくても【必ず】ここには残す(=黙って通したことにならない)。
497
- ★秘密もコマンド全文も残さない: ツール名・関門種別・送れたか、だけ。
489
+ 形: `<ISO時刻> Autorun <tool> -> allow[ gate=<種別>]`
490
+ ★管制へは何も送らない(えふさん決定 2026-09-07・この記録は不要)。残すのはこの1行だけで、
491
+ 「黙って通したことにならない」状態は保つ(後から何を通したか数えられる)。
492
+ ★秘密もコマンド全文も残さない: ツール名と関門種別だけ。
498
493
  """
499
494
  safe_tool = str(tool_name or 'unknown').split()[0][:40]
500
495
  suffix = f' gate={str(gate)[:16]}' if gate else ''
501
- if not sent:
502
- suffix += f' 管制へ送れず({str(reason)[:40]})'
503
496
  line = f'{time.strftime("%Y-%m-%dT%H:%M:%S%z")} Autorun {safe_tool} -> allow{suffix}\n'
504
497
  return _append_line(decision_log_path(env), line)
505
-
506
-
507
- def record_autorun(cfg, payload):
508
- """「聞かずに通した(自走)」1件を管制へ【履歴として】送る。返り: True/False。
509
-
510
- 【1手で済むようになった(管制 PR#276・2026-09-06)】
511
- 管制が kind='auto' を受け付け、その行は status='recorded' で生まれる=
512
- 「確認待ち」を1度も通らないので、カードとして人の前に出ない。
513
- ★以前は「登録してすぐ閉じる(POST→DELETE)」でカードを消していた。管制が
514
- 『人に聞くカード』しか受け取らなかった頃の逃げ方で、登録から DELETE までの
515
- 一瞬だけカードが出得た。もう要らないので【丸ごと消した】。
516
-
517
- 【古い管制への互換(★止まらないための逃げ道)】
518
- kind='auto' を知らない版は 400(こちらの送信内容の不備)で弾く。そのときだけ、
519
- 昔の形(kind='message' + 登録してすぐ閉じる)で送り直す=記録は必ず残る。
520
- ★400 以外(不通・401・5xx)では送り直さない。相手が受け取れない理由が違うので、
521
- 同じものを2回投げても増えるのは負荷だけ。
522
-
523
- ★何が起きても職人は止めない(例外は握る)。送れなかった事実は _request が1行残す。
524
- """
525
- try:
526
- reg = _request(cfg, 'POST', '/api/conductor/hook-requests', payload,
527
- timeout=AUTORUN_SEND_TIMEOUT_SEC)
528
- if reg and reg.get('id'):
529
- return True
530
- if last_failure_kind() != FAIL_CLIENT:
531
- return False # 不通・認証・管制側異常 … 送り直しても同じ。次の機会に任せる。
532
- return _record_autorun_legacy(cfg, payload)
533
- except Exception: # noqa: BLE001 記録のために職人を止めない
534
- return False
535
-
536
-
537
- def _record_autorun_legacy(cfg, payload):
538
- """★古い管制(kind='auto' を知らない版)への送り直し。返り: True/False。
539
-
540
- 昔の形=「人に聞くカード」として登録し、その場で閉じる(status='abandoned')。
541
- 一覧は『確認待ち』の行しか出さないのでカードにはならず、行は残るので後から読める。
542
- ★この道は互換のためだけに残している。管制が新しければ1手目で終わる(ここへ来ない)。
543
- """
544
- legacy = dict(payload)
545
- legacy['kind'] = 'message'
546
- # 古い管制の kind='message' は question が必須。人が読む1行をそのまま入れる。
547
- legacy['question'] = payload.get('summary') or '(自走で通しました)'
548
- legacy['waitMs'] = 0
549
- reg = _request(cfg, 'POST', '/api/conductor/hook-requests', legacy,
550
- timeout=AUTORUN_SEND_TIMEOUT_SEC)
551
- if not reg or not reg.get('id'):
552
- return False
553
- _request(cfg, 'DELETE', f"/api/conductor/hook-requests/{reg['id']}",
554
- timeout=AUTORUN_SEND_TIMEOUT_SEC)
555
- return True
File without changes
@@ -24,8 +24,9 @@
24
24
  【★自走モード(既定・えふさん決定 2026-09-06「すべてを自走できるようにして」)】
25
25
  上の1〜4は【聞く】ための手順で、自走モードでは通らない。自走モードでは:
26
26
  ・人には一切聞かない。その場で allow を返す(★Claude Code 自身の確認画面にも落とさない)。
27
- ・代わりに「聞かずに通した」を管制へ【履歴として】送る(★3ボタンのカードは出さない)。
28
- 送れなくても職人は止めない(送れなかった事実はこの機の記録に1行残る)。
27
+ ・★管制へは何も送らない(えふさん決定 2026-09-07)。2026-09-06 の自走化では「聞かずに
28
+ 通した」を管制へ1件ずつ送っていたが、要らないと決まったので送る仕組みごと消した。
29
+ 残すのはこの機の記録1行だけ($HOME/.conductor-guard.log の `Autorun …`)。
29
30
  ・★deny(持ち出し・秘密・番人の設定への書き込み等)は1ミリも変えない。自走でも通さない。
30
31
  切り替えは【1か所】: 環境変数 CC2_AUTORUN、または $HOME/.conductor.env の CC2_AUTORUN。
31
32
  未設定=自走。`CC2_AUTORUN=0`(0/false/no/off)で【従来どおり3ボタンで聞く】動きに戻る。
@@ -35,7 +36,6 @@
35
36
  """
36
37
  import json
37
38
  import os
38
- import re
39
39
  import sys
40
40
 
41
41
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
@@ -49,33 +49,6 @@ import summary_ja # noqa: E402
49
49
  # localSettings に恒久 allow が書かれ、関門が静かに消える経路になっていた。塞ぐため固定する。
50
50
  ALLOW_ALWAYS_DESTINATION = 'session'
51
51
 
52
- # 自走の記録として管制へ送るときの種別(管制 PR#276 が 'recorded' で受ける=カードにしない)。
53
- # ★古い管制はこれを知らず 400 で弾く。そのときは hook_datalink が昔の形で送り直す(互換)。
54
- AUTORUN_KIND = 'auto'
55
-
56
- # 履歴の1行説明の頭に付ける印(人が読んで「判断を求められていない」と分かるように)。
57
- AUTORUN_SUMMARY_PREFIX = '【自走で通しました】'
58
- # 聞くときの見出し「【お客さまの確認:本番へ反映】」を、履歴の見出しへ置き換えるための形。
59
- _ASK_HEAD_RE = re.compile(r'^【お客さまの確認:([^】]*)】')
60
- # 「…してよいですか」を「…しています」に直す(履歴なのに問いかけのままだと人が迷う)。
61
- _ASK_TAIL_RE = re.compile(r'([てで])よいですか\s*$')
62
-
63
-
64
- def autorun_summary(summary):
65
- """聞くための1行説明を、読むための1行説明(履歴)に直す。
66
-
67
- 例) 【お客さまの確認:本番へ反映】…を実行してよいですか
68
- → 【自走で通しました:本番へ反映】…を実行しています
69
- ★形が想定と違っても必ず印は付ける(読んだ人が「判断を求められていない」と分かることが第一)。
70
- """
71
- text = (summary or '').strip()
72
- head = _ASK_HEAD_RE.match(text)
73
- if head:
74
- text = f'【自走で通しました:{head.group(1)}】' + _ASK_HEAD_RE.sub('', text)
75
- else:
76
- text = AUTORUN_SUMMARY_PREFIX + text
77
- return _ASK_TAIL_RE.sub(r'\1います', text)
78
-
79
52
 
80
53
  def build_choices(gate=None):
81
54
  """管制のカードに出す選択肢。端末の確認と同じ数にする(確定仕様)。
@@ -189,22 +162,6 @@ def autorun_enabled(env=None, datalink=hook_datalink):
189
162
  return False
190
163
 
191
164
 
192
- def build_autorun_payload(data, gate=None):
193
- """管制へ【履歴として】送る値。★種別は 'auto'(人に聞くカードではない・管制 PR#276)。
194
-
195
- ★主役は summary(人がそのまま読む1行)。管制はこれを「自走で通した」の一覧に出す。
196
- 例) 【自走で通しました:本番へ反映】部品を公開(配布)しています
197
- ★toolName / toolInput は「実行された中身」として今までどおり添える(あとで追える)。
198
- ★聞くための持ち物(待ち時間・選択肢)は付けない。誰も待っていないし、押させないため。
199
- """
200
- payload = build_payload(data, gate)
201
- payload['kind'] = AUTORUN_KIND
202
- payload['summary'] = autorun_summary(payload.get('summary'))
203
- payload.pop('waitMs', None)
204
- payload.pop('choices', None)
205
- return payload
206
-
207
-
208
165
  def decide_autorun(data, verdict, gate, cfg=None, datalink=hook_datalink, codex=False):
209
166
  """自走モードの判定。★人には一切聞かず、その場で allow を返す。
210
167
 
@@ -215,18 +172,14 @@ def decide_autorun(data, verdict, gate, cfg=None, datalink=hook_datalink, codex=
215
172
  if verdict.get('decision') == policy.DENY:
216
173
  return _deny(f'安全のため実行しません({verdict.get("reason", "危険な操作")})。')
217
174
 
218
- # 管制へ履歴を送る。★送れても送れなくても、この下の allow は変わらない(職人は止まらない)。
219
- sent, why = False, 'not-configured'
175
+ # ★管制へ履歴は送らない(えふさん決定 2026-09-07・この記録は不要)。
176
+ # 2026-09-06 の自走化(便 7abe05e2)で「聞かずに通した」を管制へ1件ずつ送っていたが、
177
+ # 要らないと決まったので【送る仕組みごと消した】。設定で切り替える形にはしない
178
+ # (切り替えを残すと「いま送っているのか」を毎回確かめないと分からなくなる)。
179
+ # ★この機の記録には今までどおり1行だけ残す(何を通したかを後から数えられる状態は保つ)。
220
180
  try:
221
- cfg = cfg if cfg is not None else datalink.load_config()
222
- if cfg is not None:
223
- sent = bool(datalink.record_autorun(cfg, build_autorun_payload(data, gate)))
224
- why = '' if sent else 'send-failed'
181
+ datalink.record_autorun_local(data.get('tool_name'), gate)
225
182
  except Exception: # noqa: BLE001 記録のために職人を止めない
226
- sent, why = False, 'error'
227
- try:
228
- datalink.record_autorun_local(data.get('tool_name'), gate, sent, why)
229
- except Exception: # noqa: BLE001
230
183
  pass
231
184
 
232
185
  decision = {'behavior': 'allow'}
@@ -61,8 +61,9 @@ def behavior(out):
61
61
  class FakeDatalink:
62
62
  """管制の代わり。呼ばれた道筋を全部覚える。"""
63
63
 
64
- def __init__(self, cfg=CFG, send_ok=True, autorun=True):
65
- self.cfg, self.send_ok, self.autorun = cfg, send_ok, autorun
64
+ def __init__(self, cfg=CFG, autorun=True):
65
+ self.cfg, self.autorun = cfg, autorun
66
+ # sent は「管制へ送ってしまった件数」。★0 のままであることを固定するために残す。
66
67
  self.sent, self.local, self.asked, self.marks = [], [], 0, 0
67
68
 
68
69
  # ── 自走で使うもの ──
@@ -72,12 +73,8 @@ class FakeDatalink:
72
73
  def load_config(self):
73
74
  return self.cfg
74
75
 
75
- def record_autorun(self, cfg, payload):
76
- self.sent.append(payload)
77
- return self.send_ok
78
-
79
- def record_autorun_local(self, tool, gate=None, sent=True, reason=''):
80
- self.local.append((tool, gate, sent, reason))
76
+ def record_autorun_local(self, tool, gate=None):
77
+ self.local.append((tool, gate))
81
78
 
82
79
  # ── 従来(聞く)経路で使うもの。★自走では1回も呼ばれてはいけない ──
83
80
  def register(self, cfg, payload):
@@ -119,119 +116,43 @@ for label, cmd in [
119
116
  check(f'{label}: 人には聞かない(管制へ質問0回)', fake.asked, 0)
120
117
  check(f'{label}: 端末の確認画面に落とさない(Noneでない)', out is not None, True)
121
118
 
122
- print('\n── (b) 管制には「聞かずに通した」が履歴として残る ' + '─' * 22)
119
+ print('\n── (b) ★管制へは何も送らない(えふさん決定 2026-09-07・この記録は不要) ' + '─' * 4)
120
+ # 2026-09-06 の自走化では「聞かずに通した」を管制へ1件ずつ送っていたが、要らないと決まった。
121
+ # ★設定での切り替えにせず【送る仕組みごと消した】ので、ここでは「送る道が無いこと」を固定する。
123
122
  fake = FakeDatalink()
124
123
  prh.decide(event(), datalink=fake)
125
- check('履歴を1件だけ送る', len(fake.sent), 1)
126
- sent = fake.sent[0]
127
- check_true('現場が入っている', sent.get('site') == 'demo')
128
- check_true('PCが入っている', bool(sent.get('machine')))
129
- check_true('ツール名が入っている', sent.get('toolName') == 'Bash')
130
- check_true('実行される中身が入っている', 'vercel' in str(sent.get('toolInput')))
131
- check_true('1行説明が「聞く文」ではなく「履歴の文」になっている',
132
- sent['summary'].startswith('【自走で通しました'))
133
- check_true('1行説明に「お客さまの確認」が残っていない(人に判断を求めない)',
134
- 'お客さまの確認' not in sent['summary'])
135
- check_true('1行説明が問いかけで終わらない', not sent['summary'].endswith('よいですか'))
136
- check('見出しの種別は残す(何を通したかが読める)',
137
- prh.autorun_summary('【お客さまの確認:本番へ反映】部品を公開(配布)してよいですか'),
138
- '【自走で通しました:本番へ反映】部品を公開(配布)しています')
139
- check('見出しが無い文にも印を付ける',
140
- prh.autorun_summary('ファイルを削除してよいですか'), '【自走で通しました】ファイルを削除しています')
141
- check('想定外の形でも印だけは必ず付く', prh.autorun_summary('謎の文'), '【自走で通しました】謎の文')
142
- check('★種別は auto(人に聞くカードではない・管制 PR#276)', sent.get('kind'), 'auto')
143
- check('待つための持ち物は付けない(waitMs)', 'waitMs' in sent, False)
144
- check('押させる選択肢は付けない(choices)', 'choices' in sent, False)
145
-
146
- print('\n── (b2) ★送り方は1手だけ(登録して終わり・即閉じは消した) ' + '─' * 12)
147
- # 管制が kind='auto' を status='recorded' で受けるので、カードにならない=閉じる必要が無い。
148
- # 以前の「登録→すぐ閉じる」は、その一瞬だけカードが出得た逃げ方だった。丸ごと消してある。
149
- calls = []
150
-
151
-
152
- def fake_request(cfg, method, path, body=None, timeout=None):
153
- calls.append((method, path.split('?')[0], timeout, dict(body or {})))
154
- return {'id': 'req-9'} if method == 'POST' else {'ok': True}
155
-
156
-
157
- def with_fake_request(handler, run):
158
- """_request を差し替えて run() を回す(元へ必ず戻す)。"""
159
- orig = dl._request
160
- dl._request = handler
161
- try:
162
- return run()
163
- finally:
164
- dl._request = orig
165
-
166
-
167
- calls.clear()
168
- check('送れた', with_fake_request(fake_request,
169
- lambda: dl.record_autorun(CFG, {'site': 'demo', 'kind': 'auto', 'summary': 'x'})), True)
170
- check('★1手だけ(登録して終わり)', len(calls), 1)
171
- check('その1手は登録', calls[0][0], 'POST')
172
- check('★閉じる手はもう無い', [c[0] for c in calls].count('DELETE'), 0)
173
- check_true('職人を待たせない短い上限', all(c[2] == dl.AUTORUN_SEND_TIMEOUT_SEC for c in calls))
174
-
175
- print('\n── (b3) ★古い管制(auto を知らない版)には昔の形で送り直す ' + '─' * 10)
176
-
177
-
178
- def refuse_auto(cfg, method, path, body=None, timeout=None):
179
- """kind='auto' を 400 で弾く古い管制のふり。message なら受ける。"""
180
- calls.append((method, path.split('?')[0], timeout, dict(body or {})))
181
- if method == 'POST' and (body or {}).get('kind') == 'auto':
182
- dl._last_failure = dl.FAIL_CLIENT # 400=こちらの送信内容の不備
183
- return None
184
- dl._last_failure = None
185
- return {'id': 'old-1'} if method == 'POST' else {'ok': True}
186
-
187
-
188
- calls.clear()
189
- check('古い管制でも記録できた', with_fake_request(refuse_auto,
190
- lambda: dl.record_autorun(CFG, {'site': 'demo', 'kind': 'auto', 'summary': '【自走で通しました】x'})),
191
- True)
192
- kinds = [c[3].get('kind') for c in calls if c[0] == 'POST']
193
- check('1手目は新しい形(auto)', kinds[0], 'auto')
194
- check('★弾かれたら昔の形(message)で送り直す', kinds[1], 'message')
195
- check('昔の形には question を入れる(無いと古い管制も400)',
196
- calls[1][3].get('question'), '【自走で通しました】x')
197
- check('★昔の形のときだけ閉じる(カードにしない)', [c[0] for c in calls].count('DELETE'), 1)
198
-
199
-
200
- def unreachable(cfg, method, path, body=None, timeout=None):
201
- """管制へ届かない(不通・401・5xx)。★送り直さない=同じものを2回投げない。"""
202
- calls.append((method, path.split('?')[0], timeout, dict(body or {})))
203
- dl._last_failure = dl.FAIL_UNREACHABLE
204
- return None
205
-
206
-
207
- calls.clear()
208
- check('届かないときは False', with_fake_request(unreachable,
209
- lambda: dl.record_autorun(CFG, {'site': 'demo', 'kind': 'auto', 'summary': 'x'})), False)
210
- check('★届かないときは送り直さない(1回だけ)', len(calls), 1)
211
-
212
- print('\n── (c) 管制へ送れなくても職人は止まらない ' + '─' * 28)
213
- for label, fake in [
214
- ('送信に失敗', FakeDatalink(send_ok=False)),
215
- ('管制が未設定', FakeDatalink(cfg=None)),
216
- ]:
217
- out = prh.decide(event(), datalink=fake)
218
- check(f'{label}: それでも allow', behavior(out), 'allow')
219
- check(f'{label}: この機の記録に1行残る', len(fake.local), 1)
220
- check(f'{label}: 送れなかったと分かる', fake.local[0][2], False)
124
+ check('★管制へ1件も送らない', len(fake.sent), 0)
125
+ check('★聞きにも行かない(従来の3ボタンの道も通らない)', fake.asked, 0)
126
+ check('この機の記録には1行残る(黙って通したことにならない)', len(fake.local), 1)
127
+ check('記録はツール名と関門種別だけ', fake.local[0][:2], ('Bash', 'release'))
221
128
 
222
- fake = FakeDatalink()
223
- prh.decide(event(), datalink=fake)
224
- check('送れたときも記録に1行残る', len(fake.local), 1)
225
- check('送れたと分かる', fake.local[0][2], True)
129
+ # ★送るための部品が【1つも残っていない】こと。残っていると、いつか誰かが呼び戻せてしまう。
130
+ for gone in ('record_autorun', '_record_autorun_legacy', 'AUTORUN_SEND_TIMEOUT_SEC'):
131
+ check(f'連絡線に {gone} が残っていない', hasattr(dl, gone), False)
132
+ for gone in ('build_autorun_payload', 'autorun_summary', 'AUTORUN_KIND', 'AUTORUN_SUMMARY_PREFIX'):
133
+ check(f'番人に {gone} が残っていない', hasattr(prh, gone), False)
134
+
135
+ # ★自走の判定そのものに、管制へ送る呼び出しが1つも書かれていないこと(実物の行を見る)。
136
+ import inspect # noqa: E402
137
+ _src = inspect.getsource(prh.decide_autorun)
138
+ for word in ('record_autorun(', 'register(', 'hook-requests'):
139
+ check(f'自走の判定に {word} が無い', word in _src, False)
140
+
141
+ print('\n── (c) 記録できなくても職人は止まらない ' + '─' * 28)
226
142
 
227
143
 
228
144
  class BoomDatalink(FakeDatalink):
229
- def record_autorun(self, cfg, payload):
230
- raise RuntimeError('管制が爆発した')
145
+ def record_autorun_local(self, tool, gate=None):
146
+ raise RuntimeError('記録が爆発した')
231
147
 
232
148
 
233
149
  out = prh.decide(event(), datalink=BoomDatalink())
234
- check('記録が例外でも allow は返る', behavior(out), 'allow')
150
+ check('記録が例外でも allow は返る(職人は止まらない)', behavior(out), 'allow')
151
+
152
+ fake = FakeDatalink(cfg=None)
153
+ out = prh.decide(event(), datalink=fake)
154
+ check('管制が未設定でも allow', behavior(out), 'allow')
155
+ check('管制が未設定でも管制を叩かない', len(fake.sent) + fake.asked, 0)
235
156
 
236
157
  print('\n── (d) ★deny は1ミリも変わらない(自走でも通さない) ' + '─' * 18)
237
158
  for label, ev in [
@@ -259,9 +180,8 @@ check('Codex: updatedInput を添えない',
259
180
  'updatedInput' in out_codex['hookSpecificOutput']['decision'], False)
260
181
  fake_cx = FakeDatalink()
261
182
  prh.decide(event(), datalink=fake_cx, codex=True)
262
- check('Codex でも自走の履歴は送る', len(fake_cx.sent), 1)
263
- check('★Codex でも同じ1か所・同じ形(kind=auto)', fake_cx.sent[0].get('kind'), 'auto')
264
- check_true('★Codex でも人が読む1行が入る', fake_cx.sent[0]['summary'].startswith('【自走で通しました'))
183
+ check('Codex でも管制へは何も送らない', len(fake_cx.sent), 0)
184
+ check('★Codex でもこの機の記録には1行残る', len(fake_cx.local), 1)
265
185
  check('Codex でも deny は変わらない',
266
186
  behavior(prh.decide(event('cat .env'), datalink=FakeDatalink(), codex=True)), 'deny')
267
187
  out_claude = prh.decide(event(), datalink=FakeDatalink())