@tyhld/conductor 0.11.7 → 0.11.9

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.7",
3
+ "version": "0.11.9",
4
4
  "description": "采配くん管制の見守りアプリ(conductor-agent)。各PCで常駐し、中央(devlog-tracker)へ定期的に生存報告(heartbeat)を送る常駐CLI。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -44,3 +44,24 @@ approval_policy = "on-request"
44
44
  # 作業フォルダの中だけ書けるようにします。
45
45
  # ★danger-full-access は絶対に使わないでください(守りが丸ごと外れます)。
46
46
  sandbox_mode = "workspace-write"
47
+
48
+ # ── 通信を開ける(★えふさん決定 2026-09-06) ──────────────────────────────
49
+ # 【なぜ要るか=実測 2026-09-06・本番現場 meilink】
50
+ # これが無いと、箱の中から外へ一切出られません。実機で curl が終了コード6(名前解決失敗)
51
+ # になり、npm install / npx vercel / gh / 公開サイトの確認 が全部できませんでした。
52
+ # =Codex の職人は「ローカルの編集とコミット」しかできない状態でした。
53
+ # ★開けるのは【通信だけ】です。箱(作業フォルダの外へは書けない)はそのまま残ります。
54
+ # danger-full-access は使いません(あれは箱ごと外す設定)。
55
+ [sandbox_workspace_write]
56
+ network_access = true
57
+
58
+ # ── 管制の MCP(Claude と同じ接続先・同じ道具名) ─────────────────────────
59
+ # 便の末尾に付く「完了時は conductor_complete を呼んでください」を Codex でも通すための登録です。
60
+ # ・道具の名前は Claude と同じ conductor_complete(管制側の実物で確認済み)。
61
+ # ・★合言葉はここに書きません(平文を置かない)。この受け口は Supabase の JWT を求めるので、
62
+ # 人が1回だけ `codex mcp login ai-kana` でログインして持たせます(=人の手番)。
63
+ # ・長生きする JWT を用意する運用にするなら、環境変数の【名前だけ】を書く形にもできます:
64
+ # codex mcp add ai-kana --url <URL> --bearer-token-env-var <環境変数名>
65
+ # (値は config.toml に載りません)
66
+ [mcp_servers.ai-kana]
67
+ url = "https://ai-kana.ty-hld.com/api/mcp/mcp"
@@ -507,30 +507,49 @@ def record_autorun_local(tool_name, gate=None, sent=True, reason='', env=None):
507
507
  def record_autorun(cfg, payload):
508
508
  """「聞かずに通した(自走)」1件を管制へ【履歴として】送る。返り: True/False。
509
509
 
510
- 【なぜ2手(登録→即クローズ)なのか=いまの管制で“カードを出さずに”残せる唯一の形】
511
- 管制の受け口は今のところ「人に聞くカード」しか受け取らない:
512
- ・kind は 'tool' か 'message' の2つだけ(未知の種別は 400 で弾かれる)
513
- ・登録した行は必ず status='waiting' から始まり、寿命は必ず +2分の余白が付く
514
- (nextLeaseExpiry。waitMs=0 でも 2 分は「人が押せるカード」として画面に出る)
515
- そこで、登録したその場で DELETE を送り status='abandoned' にする。管制の一覧は
516
- status='waiting' の行だけを出すので【カードとしては出ない】が、行は消えないので
517
- 「いつ・どの現場で・何を聞かずに通したか」は後から必ず読める=要求どおりの履歴になる。
518
-
519
- 【★次の便への申し送り(管制側・DDL不要)】
520
- HookRequest.kind は enum ではなく text 列(「種別が増えても DDL 不要」と明記されている)。
521
- 管制側で kind='auto' を1つ受け付け、その行は一覧に出さない扱いにすれば、この DELETE は
522
- 要らなくなり、一覧にも「自走の履歴」として素直に出せる。★DBの作り替えは要らない。
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回投げても増えるのは負荷だけ。
523
522
 
524
523
  ★何が起きても職人は止めない(例外は握る)。送れなかった事実は _request が1行残す。
525
524
  """
526
525
  try:
527
526
  reg = _request(cfg, 'POST', '/api/conductor/hook-requests', payload,
528
527
  timeout=AUTORUN_SEND_TIMEOUT_SEC)
529
- if not reg or not reg.get('id'):
530
- return False
531
- # ★登録した瞬間に閉じる=人に「押してください」のカードを出さない。
532
- _request(cfg, 'DELETE', f"/api/conductor/hook-requests/{reg['id']}",
533
- timeout=AUTORUN_SEND_TIMEOUT_SEC)
534
- return True
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)
535
533
  except Exception: # noqa: BLE001 記録のために職人を止めない
536
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
@@ -16,7 +16,10 @@
16
16
  # 書き換えて ~/.codex/hooks.json へ登録する
17
17
  # 3. 顧客が足した他のイベント・他のキーは触らない(見本のイベントだけ差し替える)
18
18
  # 4. 完了の受け口(codex-notify.py)を固定パス $GUARD_HOME へ置く
19
- # 5. ~/.codex/config.toml の【3つの鍵だけ】を揃える(notify / approval_policy / sandbox_mode)
19
+ # 5. ~/.codex/config.toml の【決まった場所だけ】を揃える
20
+ # ・3つの鍵: notify / approval_policy / sandbox_mode
21
+ # ・[sandbox_workspace_write] network_access = true … 箱はそのままに通信だけ開ける
22
+ # ・[mcp_servers.ai-kana] url = … … 管制のMCP(★合言葉は書かない)
20
23
  # ★他の行(信頼の記録・現場ごとの設定・顧客が足した設定)は1文字も触らない
21
24
  #
22
25
  # ★Claude 側(~/.claude/settings.json)には一切触れません。
@@ -161,13 +164,18 @@ fi
161
164
  # 現場ごとの信頼設定が入っている。まるごと上書きすると信頼が消え、番人が動かなくなる
162
165
  # (=いちばん危ない壊し方)。だから既存の行は一切動かさず、必要な3つだけを揃える。
163
166
  CODEX_CONFIG="${CODEX_CONFIG:-$HOME/.codex/config.toml}"
164
- info "設定の確認: $CODEX_CONFIGnotify / approval_policy / sandbox_mode の3つだけ)"
165
- APPLY="$APPLY" NOTIFY_CMD="$CODEX_NOTIFY_DST" python3 - "$CODEX_CONFIG" <<'PYEOF'
167
+ # 管制の MCPClaude と同じ接続先・同じ道具名 conductor_complete)。★合言葉は書かない。
168
+ CODEX_MCP_URL="${CODEX_MCP_URL:-https://ai-kana.ty-hld.com/api/mcp/mcp}"
169
+ info "設定の確認: $CODEX_CONFIG(notify / approval_policy / sandbox_mode + 通信の許可 + 管制のMCP)"
170
+ APPLY="$APPLY" NOTIFY_CMD="$CODEX_NOTIFY_DST" MCP_URL="$CODEX_MCP_URL" \
171
+ python3 - "$CODEX_CONFIG" <<'PYEOF'
166
172
  import os, re, shutil, sys, time
167
173
 
168
174
  path = sys.argv[1]
169
175
  apply_mode = os.environ.get("APPLY") == "1"
170
176
  notify_cmd = os.environ["NOTIFY_CMD"]
177
+ mcp_url = os.environ["MCP_URL"]
178
+ MCP_URL = mcp_url
171
179
 
172
180
  # ★この3つだけを揃える(値の意味は scripts/codex/config.sample.toml に書いてある)。
173
181
  # approval_policy: on-request … 承認の場面を【番人(PermissionRequest)に通す】ための設定。
@@ -203,14 +211,61 @@ for line in head:
203
211
  continue # 既存の行は落として、下で揃えた値を書き直す
204
212
  kept.append(line)
205
213
 
214
+ # ── ★表([section])で管理するもの ────────────────────────────────────────────
215
+ # ここも「その表の中身だけ」を揃える。他の表(信頼の記録・現場ごとの設定)は1文字も触らない。
216
+ #
217
+ # [sandbox_workspace_write] network_access = true
218
+ # ★箱(作業フォルダの外へ書けない)はそのままに、通信だけを開ける(えふさん決定 2026-09-06)。
219
+ # 実測 2026-09-06: これが無いと箱の中から外へ出られず、curl は終了コード6(名前解決失敗)。
220
+ # meilink の日常作業(npm install / npx vercel / gh / 公開サイトの確認)が全部できなかった。
221
+ # ★danger-full-access は使わない(それは箱ごと外す設定)。
222
+ #
223
+ # [mcp_servers.ai-kana] url = …
224
+ # ★Claude と同じ接続先。道具の名前も同じ(conductor_complete)なので、便の末尾の
225
+ # 「完了時は conductor_complete を呼ぶ」がそのまま通る。
226
+ # ★合言葉はここに書かない(平文を置かない)。この受け口は Supabase の JWT を要求するので、
227
+ # 人が1回 `codex mcp login ai-kana` でログインして持たせる(=人の手番)。
228
+ # 長生きする JWT を用意する運用にするなら、codex mcp add の --bearer-token-env-var で
229
+ # 環境変数の【名前だけ】を書く形にもできる(値は config.toml に載らない)。
230
+ TABLES = {
231
+ "sandbox_workspace_write": ["network_access = true"],
232
+ "mcp_servers.ai-kana": [f'url = "{MCP_URL}"'],
233
+ }
234
+
235
+
236
+ def table_slice(all_lines, name):
237
+ """表 [name] の範囲(見出し行の位置, 次の表の直前)を返す。無ければ (None, None)。"""
238
+ header = re.compile(r"^\s*\[" + re.escape(name) + r"\]\s*$")
239
+ start = next((i for i, ln in enumerate(all_lines) if header.match(ln)), None)
240
+ if start is None:
241
+ return None, None
242
+ end = len(all_lines)
243
+ for j in range(start + 1, len(all_lines)):
244
+ if all_lines[j].lstrip().startswith("["):
245
+ end = j
246
+ break
247
+ return start, end
248
+
249
+
250
+ def table_needs_update(all_lines, name, wants):
251
+ start, end = table_slice(all_lines, name)
252
+ if start is None:
253
+ return True
254
+ body = [ln.strip() for ln in all_lines[start + 1:end] if ln.strip()]
255
+ return any(w not in body for w in wants)
256
+
257
+
258
+ tables_missing = [t for t, w in TABLES.items() if table_needs_update(tail, t, w)]
206
259
  missing = [k for k in WANT if current.get(k) != WANT[k]]
207
- if not missing:
260
+ if not missing and not tables_missing:
208
261
  print("[codex-hooks] 最新です(変更なし)")
209
262
  sys.exit(0)
210
263
  for k in missing:
211
264
  now = current.get(k)
212
265
  print(f"[codex-hooks] 要更新: {k}"
213
266
  + (f"(いま: {now})" if now else "(未設定)"))
267
+ for t in tables_missing:
268
+ print(f"[codex-hooks] 要更新: [{t}]")
214
269
  if not apply_mode:
215
270
  print("[codex-hooks] (予定)上記を揃えます(APPLY=1 で実行)")
216
271
  sys.exit(0)
@@ -219,6 +274,21 @@ banner = "# ★采配くんが管理する3行(notify / approval_policy / sand
219
274
  while kept and not kept[-1].strip():
220
275
  kept.pop()
221
276
  new_head = kept + ([""] if kept else []) + [banner] + [WANT[k] for k in ("notify", "approval_policy", "sandbox_mode")] + [""]
277
+
278
+ # 表は「在れば中身を揃える/無ければ末尾に足す」。順番は TOML では意味を持たない。
279
+ for name, wants in TABLES.items():
280
+ start, end = table_slice(tail, name)
281
+ if start is None:
282
+ while tail and not tail[-1].strip():
283
+ tail.pop()
284
+ tail += ["", f"[{name}]"] + wants
285
+ continue
286
+ body = [ln for ln in tail[start + 1:end]]
287
+ keys = [w.split("=", 1)[0].strip() for w in wants]
288
+ kept_body = [ln for ln in body
289
+ if not any(re.match(r"^\s*" + re.escape(k) + r"\s*=", ln) for k in keys)]
290
+ tail[start + 1:end] = wants + kept_body
291
+
222
292
  out = "\n".join(new_head + tail).rstrip("\n") + "\n"
223
293
 
224
294
  os.makedirs(os.path.dirname(path), exist_ok=True)
@@ -240,7 +310,7 @@ except Exception as e: # noqa: BLE001
240
310
  sys.exit(0)
241
311
 
242
312
  os.replace(tmp, path) # 置換は原子的
243
- print("[codex-hooks] 揃えました(3行)")
313
+ print("[codex-hooks] 揃えました")
244
314
  PYEOF
245
315
 
246
316
  if [[ "$APPLY" == "1" ]]; then
@@ -49,6 +49,10 @@ 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
+
52
56
  # 履歴の1行説明の頭に付ける印(人が読んで「判断を求められていない」と分かるように)。
53
57
  AUTORUN_SUMMARY_PREFIX = '【自走で通しました】'
54
58
  # 聞くときの見出し「【お客さまの確認:本番へ反映】」を、履歴の見出しへ置き換えるための形。
@@ -186,18 +190,18 @@ def autorun_enabled(env=None, datalink=hook_datalink):
186
190
 
187
191
 
188
192
  def build_autorun_payload(data, gate=None):
189
- """管制へ【履歴として】送る値。人に聞くカードではないことが一目で分かる形にする。
193
+ """管制へ【履歴として】送る値。★種別は 'auto'(人に聞くカードではない・管制 PR#276)。
190
194
 
191
- ★中身は聞くときと同じ(現場・PC・ツール名・実行される内容・1行説明)=あとで
192
- 「何が通ったか」を人が読める。違うのは次の3つだけ:
193
- ・1行説明の頭に【自走で通しました】と入れる(読んだ人が判断を求められない)
194
- ・待つ気が無いので waitMs=0
195
- ・選択肢は「押す必要はありません」の1つだけ(万一カードが残っても人を迷わせない)
195
+ ★主役は summary(人がそのまま読む1行)。管制はこれを「自走で通した」の一覧に出す。
196
+ 例) 【自走で通しました:本番へ反映】部品を公開(配布)しています
197
+ ★toolName / toolInput は「実行された中身」として今までどおり添える(あとで追える)。
198
+ ★聞くための持ち物(待ち時間・選択肢)は付けない。誰も待っていないし、押させないため。
196
199
  """
197
200
  payload = build_payload(data, gate)
201
+ payload['kind'] = AUTORUN_KIND
198
202
  payload['summary'] = autorun_summary(payload.get('summary'))
199
- payload['waitMs'] = 0
200
- payload['choices'] = [{'value': 'allow', 'label': '(自走で通しました・押す必要はありません)'}]
203
+ payload.pop('waitMs', None)
204
+ payload.pop('choices', None)
201
205
  return payload
202
206
 
203
207
 
@@ -6,7 +6,8 @@
6
6
 
7
7
  【何を守っているか】
8
8
  (a) 許可の質問が【1つも人に届かない】=その場で allow が返る(端末の確認画面にも落ちない)
9
- (b) 管制には「聞かずに通した」が【履歴として】残る=登録したその場で閉じる(カードは出さない)
9
+ (b) 管制には「聞かずに通した」が【履歴として】残る=kind='auto' で1手だけ送る(カードは出さない)
10
+ ★古い管制(auto を知らない版)には昔の形で送り直す(互換)
10
11
  (c) 管制へ送れなくても職人は止まらない(allow は変わらない)+この機の記録に1行残る
11
12
  (d) ★deny は1ミリも変わらない=自走でも通さない
12
13
  (e) 各現場の .claude/settings.json を汚さない(updatedPermissions を付けない)
@@ -138,31 +139,76 @@ check('見出しの種別は残す(何を通したかが読める)',
138
139
  check('見出しが無い文にも印を付ける',
139
140
  prh.autorun_summary('ファイルを削除してよいですか'), '【自走で通しました】ファイルを削除しています')
140
141
  check('想定外の形でも印だけは必ず付く', prh.autorun_summary('謎の文'), '【自走で通しました】謎の文')
141
- check('待つ気は無い(waitMs=0)', sent.get('waitMs'), 0)
142
- check('押させる選択肢は出さない(1つだけ)', len(sent.get('choices') or []), 1)
143
- check_true('その選択肢は「押す必要はありません」', '押す必要はありません' in sent['choices'][0]['label'])
142
+ check('★種別は auto(人に聞くカードではない・管制 PR#276)', sent.get('kind'), 'auto')
143
+ check('待つための持ち物は付けない(waitMs)', 'waitMs' in sent, False)
144
+ check('押させる選択肢は付けない(choices)', 'choices' in sent, False)
144
145
 
145
- print('\n── (b2) 送り方は「登録したその場で閉じる」=カードにしない ' + '─' * 14)
146
+ print('\n── (b2) ★送り方は1手だけ(登録して終わり・即閉じは消した) ' + '─' * 12)
147
+ # 管制が kind='auto' を status='recorded' で受けるので、カードにならない=閉じる必要が無い。
148
+ # 以前の「登録→すぐ閉じる」は、その一瞬だけカードが出得た逃げ方だった。丸ごと消してある。
146
149
  calls = []
147
150
 
148
151
 
149
152
  def fake_request(cfg, method, path, body=None, timeout=None):
150
- calls.append((method, path.split('?')[0], timeout))
153
+ calls.append((method, path.split('?')[0], timeout, dict(body or {})))
151
154
  return {'id': 'req-9'} if method == 'POST' else {'ok': True}
152
155
 
153
156
 
154
- orig_request = dl._request
155
- try:
156
- dl._request = fake_request
157
- check('送れた', dl.record_autorun(CFG, {'site': 'demo'}), True)
158
- finally:
159
- dl._request = orig_request
160
- check('2手だけ(登録→閉じる)', len(calls), 2)
161
- check('1手目は登録', calls[0][0], 'POST')
162
- check('2手目は閉じる(カードを出したままにしない)', calls[1][0], 'DELETE')
163
- check('閉じる先は登録した行', calls[1][1], '/api/conductor/hook-requests/req-9')
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)
164
173
  check_true('職人を待たせない短い上限', all(c[2] == dl.AUTORUN_SEND_TIMEOUT_SEC for c in calls))
165
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
+
166
212
  print('\n── (c) 管制へ送れなくても職人は止まらない ' + '─' * 28)
167
213
  for label, fake in [
168
214
  ('送信に失敗', FakeDatalink(send_ok=False)),
@@ -214,6 +260,8 @@ check('Codex: updatedInput を添えない',
214
260
  fake_cx = FakeDatalink()
215
261
  prh.decide(event(), datalink=fake_cx, codex=True)
216
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('【自走で通しました'))
217
265
  check('Codex でも deny は変わらない',
218
266
  behavior(prh.decide(event('cat .env'), datalink=FakeDatalink(), codex=True)), 'deny')
219
267
  out_claude = prh.decide(event(), datalink=FakeDatalink())