@tyhld/conductor 0.12.0 → 0.12.1

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.12.0",
3
+ "version": "0.12.1",
4
4
  "description": "采配くん管制の見守りアプリ(conductor-agent)。各PCで常駐し、中央(devlog-tracker)へ定期的に生存報告(heartbeat)を送る常駐CLI。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -71,10 +71,16 @@ NODE_MAJOR_WANTED=24
71
71
  # 0.12.0 でその穴を塞いだが、下限が 0.11.0 のままだと 0.11.x が入っている機は
72
72
  # 「入っています」で飛ばされ、★穴の開いた番人のまま回り続ける。だから下限を 0.12.0 にする。
73
73
  # =この行こそが「直しをお客さま機へ届ける唯一の道」なので、下げないこと。
74
+ # 【0.12.1 へ上げた理由(えふさん確定 2026-09-16・便 927da60e)】
75
+ # 0.12.0 までの番人は、利用上限からの自動再開の知らせ3種を「知らない種類=出す」に
76
+ # 落としていた。もう自動で続いているのに【押せない確認カードが9分出たまま】になり
77
+ # (2026-09-16 11:01 に3現場で発生)、逆に本当に止まる2種(端末で Enter が要る/
78
+ # 続きの指示が要る)も同じ文面で出るので、何をすればよいか分からなかった。
79
+ # 0.12.1 で3種を分けた。下限を上げないと 0.12.0 の機に届かない。
74
80
  # ★食い違うと「入口は通したのに設置係が止める」が起きる。テストで同一を固定してある。
75
81
  # ★公開 npm の latest より新しい値を書かないこと(書くと全員が設置できなくなる)。
76
82
  # package.json の版を超えていないことをテストで固定してある。
77
- MIN_CONDUCTOR_VERSION="0.12.0"
83
+ MIN_CONDUCTOR_VERSION="0.12.1"
78
84
 
79
85
  # nvm の版は固定する(毎回ちがう版が降ってくると再現しない)。
80
86
  NVM_VERSION='v0.40.1'
@@ -102,7 +102,7 @@ PROJECT_DIR="${2:-$HOME/projects/$SITE}"
102
102
  # 食い違うと「入口は通したのに設置係が止める」が起きる。テストで同一を固定してある。
103
103
  # ★公開 npm の latest より新しい値を書かないこと(書くと全員が設置できなくなる)。
104
104
  # package.json の版を超えていないことをテストで固定してある。
105
- MIN_CONDUCTOR_VERSION="0.12.0"
105
+ MIN_CONDUCTOR_VERSION="0.12.1"
106
106
 
107
107
  # ─── ヘルパ ───────────────────────────────────────────────────────────────────
108
108
  fail() { echo; echo "[setup] エラー: $1" >&2; exit 1; }
@@ -221,21 +221,74 @@ def record_decision(tool_name, decision, gate=None, env=None):
221
221
  return _append_line(decision_log_path(env), line)
222
222
 
223
223
 
224
- def record_notification(notification_type, forwarded, reason='', env=None):
224
+ # 通知の本文をどれだけ残すか(★短く切る。理由は下の record_notification を参照)。
225
+ NOTIFICATION_DETAIL_CHARS = 120
226
+
227
+ # 秘密らしき字面を伏せるための型。★「読めなくする」ためではなく「写さない」ため。
228
+ # ① 名前つきの代入・項目(TOKEN= / secret: / api_key= …)
229
+ _SECRETISH_ASSIGN_RE = re.compile(
230
+ r'\b([A-Za-z0-9_]*(?:TOKEN|SECRET|PASSWORD|PASSWD|APIKEY|API_KEY|PRIVATE_KEY|CREDENTIAL)'
231
+ r'[A-Za-z0-9_]*)\s*[=:]\s*\S+',
232
+ re.IGNORECASE,
233
+ )
234
+ # ② 認可ヘッダの値(Bearer xxx / Basic xxx)
235
+ _BEARER_RE = re.compile(r'\b(bearer|basic)\s+\S+', re.IGNORECASE)
236
+ # ③ 見るからに乱数の長い塊(英数が混ざった32文字以上のひと続き)。
237
+ # ★英単語・パス・種別の名前(quota_auto_resume_disabled 等)は数字を含まないので当たらない。
238
+ # ★`/` と `.` を字の仲間に入れない=パスやホスト名は区切りで千切れて短くなり、当たらない。
239
+ _LONG_TOKEN_RE = re.compile(
240
+ r'\b(?=[A-Za-z0-9+_-]*\d)(?=[A-Za-z0-9+_-]*[A-Za-z])[A-Za-z0-9+_-]{32,}={0,2}'
241
+ )
242
+
243
+
244
+ def mask_secretish(text):
245
+ """秘密らしき字面を `***` に置き換える(★記録へ写す前に必ず通す)。
246
+
247
+ ★完全な見分けはできない。ここは「よくある形を写さない」ための最後の一拭きで、
248
+ 本当の守りは『そもそも短くしか残さない』こと(NOTIFICATION_DETAIL_CHARS)。
249
+ """
250
+ if not text:
251
+ return ''
252
+ s = _SECRETISH_ASSIGN_RE.sub(lambda m: f'{m.group(1)}=***', str(text))
253
+ s = _BEARER_RE.sub(lambda m: f'{m.group(1)} ***', s)
254
+ return _LONG_TOKEN_RE.sub('***', s)
255
+
256
+
257
+ def record_notification(notification_type, forwarded, reason='', env=None, detail=''):
225
258
  """Claude Code 自身の通知(Notification)を受けた1回を1行だけ残す(★ADR-016 の見張り)。
226
259
 
227
- 形: `<ISO時刻> Notification <種別> -> forwarded|skipped(<理由>)`
260
+ 形: `<ISO時刻> Notification <種別> -> forwarded|skipped(<理由>) "<本文の先頭>"`
228
261
  【なぜ要るか】端末にだけ出て管制に出ない問いは、これまで「起きたことすら分からなかった」。
229
262
  2026-09-01(Playwright)と 2026-09-02(GitHub Actions)で同じ型が2回起きたのに、
230
263
  2回目も端末を見ていた人しか気づけなかった。種別だけでも残しておけば、
231
264
  新しい型が出たとき「いつから・どの種別が来ていたか」を後から数えられる。
232
- ★秘密も本文も残さない: 通知の種別と、送ったか送らなかったか(と短い理由)だけ。
265
+
266
+ 【★本文の先頭も残すようにした理由(便 927da60e)】
267
+ 2026-09-16 11:01 に利用上限の通知が3現場へ来たとき、記録に残っていたのは【種別だけ】で、
268
+ 実際に何と書いてあったのかを後から一度も確かめられなかった(調査便 68a6f75e で行き止まり)。
269
+ 種別が同じでも本文で意味が変わる型(quota_auto_resume_disabled は理由が6通りある)が
270
+ あるので、種別だけでは足りない。
271
+
272
+ 【★秘密が混ざり得る場合の扱い(この便で決めたこと)】
273
+ 通知の本文は Claude Code が作る画面の文だが、止まった操作のコマンド行やホスト名が
274
+ 混ざることがある(例: サンドボックスの通信許可)。そこにお客さまの鍵が載る道が
275
+ ゼロだとは言い切れない。よって次の2段で扱う。
276
+ (1) 写す前に mask_secretish を通す(名前つきの代入・Bearer・長い乱数の塊を `***` に)
277
+ (2) ★そのうえで先頭 NOTIFICATION_DETAIL_CHARS 文字だけに切る
278
+ ★見分けは完全ではない。だから「短くしか残さない」ことを本体の守りにしている。
279
+ ★それでも残したくない環境は、環境変数 CONDUCTOR_NOTIFY_LOG_DETAIL=0 で本文を落とせる。
233
280
  """
281
+ env = os.environ if env is None else env
234
282
  safe_type = str(notification_type or 'unknown').split()[0][:40]
235
283
  verdict = 'forwarded' if forwarded else 'skipped'
236
284
  suffix = f'({str(reason)[:32]})' if (reason and not forwarded) else ''
285
+ tail = ''
286
+ if (env.get('CONDUCTOR_NOTIFY_LOG_DETAIL') or '1').strip() != '0':
287
+ flat = ' '.join(mask_secretish(detail).split())[:NOTIFICATION_DETAIL_CHARS]
288
+ if flat:
289
+ tail = f' "{flat}"'
237
290
  line = (f'{time.strftime("%Y-%m-%dT%H:%M:%S%z")} Notification {safe_type} '
238
- f'-> {verdict}{suffix}\n')
291
+ f'-> {verdict}{suffix}{tail}\n')
239
292
  return _append_line(decision_log_path(env), line)
240
293
 
241
294
 
@@ -22,9 +22,18 @@
22
22
 
23
23
  【この入口の役目】
24
24
  1. 受けた通知を必ず1行記録する(見張り。送った/送らなかったを後から数えられる)
25
- 2. 「人が端末で操作しないと進まない」種類だけを、管制へカードとして出す
26
- 3. カードには★答えを付けない。この問いは端末でしか押せないので、
27
- 「端末で押してください」と分かる形で出し、押されるまで出したままにする
25
+ ★本文の先頭も残す(伏せ字と長さの上限つき・便 927da60e)。種別だけでは、同じ種別で
26
+ 意味が変わる型を後から確かめられなかった。
27
+ 2. 「人が操作しないと進まない」種類だけを、管制へカードとして出す
28
+ 3. カードには★答えを付けない。人は別の場所(端末か、管制からの便)で動かすので、
29
+ ★「何が止まっているか」と「何をすれば再開するか」を書いて出す(便 927da60e)
30
+
31
+ 【★利用上限からの自動再開は3種類に分ける(えふさん確定 2026-09-16③)】
32
+ 自動再開した(fired) → カードを出さない・履歴だけ
33
+ 端末での操作待ち(stale) → 要操作カード(端末で Enter)
34
+ 自動再開が切れている(disabled) → 要操作カード(続きの指示を送る)
35
+ ★以前は3つとも「知らない種別=出す」に落ち、もう動いているのに押せないカードが
36
+ 9分出たままになっていた(2026-09-16 11:01 に3現場で発生)。
28
37
 
29
38
  【★二重に出さない】
30
39
  PermissionRequest / Stop は自分でカードを出して待つ。その最中の通知は送らない
@@ -43,28 +52,93 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
43
52
 
44
53
  import hook_datalink # noqa: E402
45
54
 
46
- # 人が【端末で】操作しないと先へ進まない通知の種別。ここだけを管制へ出す。
55
+ # ─────────────────────────────────────────────────────────────────────────────
56
+ # 利用上限からの自動再開の知らせ(★3種類・えふさん確定 2026-09-16③)
57
+ #
58
+ # 実測(便 68a6f75e・Claude Code 2.1.273 の中の文字列 + 2026-09-16 11:01 の3現場):
59
+ # fired … 見出し「Usage limit reset」本文「continuing automatically」
60
+ # =もう自動で続いた。3現場とも【誰も答えずに】4〜8秒で職人が再開した。
61
+ # stale … 本文「press enter to continue」
62
+ # =端末で Enter を1回押すまで止まる。
63
+ # disabled … 本文「this task will not resume on its own」「send a prompt to continue」
64
+ # =自動で続く仕組みが切れている。人が続きの指示を送るまで止まる。
65
+ #
66
+ # ★名前が似ているだけで扱いは別。3つまとめて「お知らせ」にすると、止まったまま誰も
67
+ # 気づけない(stale と disabled)。3つまとめて「カード」にすると、もう動いているのに
68
+ # 押せないカードが9分出たままになる(fired・11:01 に実際に起きた)。
69
+ # ─────────────────────────────────────────────────────────────────────────────
70
+ QUOTA_RESUME_FIRED = 'quota_auto_resume_fired'
71
+ QUOTA_RESUME_STALE = 'quota_auto_resume_stale'
72
+ QUOTA_RESUME_DISABLED = 'quota_auto_resume_disabled'
73
+
74
+ # 人が【操作しないと先へ進まない】通知の種別。ここだけを管制へ出す。
47
75
  # permission_prompt … 許可待ち(サンドボックスの通信許可もここに乗る)
48
76
  # worker_permission_prompt … 手伝いの職人が許可待ち
49
77
  # idle_prompt … 入力待ちのまま止まっている
78
+ # quota_auto_resume_stale … 利用上限から戻ったが自動で続かなかった(端末で Enter)
79
+ # quota_auto_resume_disabled … 自動で続く仕組みが切れている(続きの指示を送る)
50
80
  HUMAN_BLOCKING_TYPES = frozenset({
51
81
  'permission_prompt',
52
82
  'worker_permission_prompt',
53
83
  'idle_prompt',
84
+ QUOTA_RESUME_STALE,
85
+ QUOTA_RESUME_DISABLED,
54
86
  })
55
87
 
56
- # 進行の知らせで、人が何かする必要のないもの(=出さない)。
88
+ # 進行の知らせで、人が何かする必要のないもの(=出さない・記録だけ)。
57
89
  # ★「知らない種別は出す」側に倒す。新しい種別が増えたとき黙って見えなくなるより、
58
90
  # 1枚多く出るほうが害が小さい(見えない停止こそが今回の事故)。
91
+ # ★ここに入れてよいのは「実測で、誰も答えなくても先へ進むと確かめた種別」だけ。
59
92
  INFORMATIONAL_TYPES = frozenset({
60
93
  'agent_completed',
61
94
  'auth_success',
62
95
  'computer_use_exit',
63
- 'elicitation_complete',
64
- 'elicitation_response',
65
96
  'push_notification',
97
+ QUOTA_RESUME_FIRED,
66
98
  })
67
99
 
100
+ # ★本体(Claude Code 2.1.273)が実際に出す種別の一覧(実測・便 68a6f75e)。
101
+ # 上の2つの表に、本体に無い名前を書かないための突き合わせ用。
102
+ # 【なぜ要るか】以前この表には elicitation_complete / elicitation_response という
103
+ # 【本体に存在しない名前】が入っていた。届かない名前なので実害は出ていなかったが、
104
+ # 表を読んだ人が「elicitation は出さない設定だ」と誤って理解する元になる。
105
+ # 実在するのは elicitation_dialog / elicitation_url_dialog で、どちらも入力を求める
106
+ # 画面なので「知らない種別=出す」で正しくカードになる。
107
+ KNOWN_NOTIFICATION_TYPES = frozenset({
108
+ 'permission_prompt',
109
+ 'idle_prompt',
110
+ 'auth_success',
111
+ 'elicitation_dialog',
112
+ 'agent_needs_input',
113
+ 'agent_completed',
114
+ 'elicitation_url_dialog',
115
+ 'worker_permission_prompt',
116
+ 'push_notification',
117
+ 'computer_use_enter',
118
+ 'computer_use_exit',
119
+ QUOTA_RESUME_FIRED,
120
+ QUOTA_RESUME_STALE,
121
+ QUOTA_RESUME_DISABLED,
122
+ 'model_refusal_fallback',
123
+ })
124
+
125
+ # 要操作カードの書き分け(★種別ごとに「何が止まっているか」「何をすれば再開するか」)。
126
+ # ここに無い種別は、従来どおりの共通の文面(端末でしか押せない問い)を使う。
127
+ ACTION_GUIDE = {
128
+ QUOTA_RESUME_STALE: {
129
+ 'summary': '利用上限から戻りましたが、自動では続いていません',
130
+ 'stopped': '利用上限から戻りましたが、職人が自動で続かずに止まっています。',
131
+ 'howto': 'その現場の職人の端末(黒い画面)で Enter を1回押すと続きます。',
132
+ 'console': '★このカードは管制からは押せません(端末での操作が要ります)。',
133
+ },
134
+ QUOTA_RESUME_DISABLED: {
135
+ 'summary': '自動で再開しない設定のため、続きの指示を待っています',
136
+ 'stopped': '自動で続く仕組みが切れているため、利用上限から戻っても職人は自分では再開しません。',
137
+ 'howto': 'その現場へ続きの指示を送ると動き出します(管制から便を出すか、端末で指示を打つ)。',
138
+ 'console': '★このカードは管制から便を出せば解けます(端末で打っても構いません)。',
139
+ },
140
+ }
141
+
68
142
  # カードを出したままにする上限(秒)。押されれば人は端末で先へ進む。
69
143
  # ★フックの打ち切り(600秒)より先に切り上げ、後始末(カードを閉じる)を必ず走らせる。
70
144
  NOTIFY_WAIT_SEC = int(os.environ.get('CC2_NOTIFY_WAIT_SEC') or 540)
@@ -83,21 +157,43 @@ def should_forward(notification_type):
83
157
  return True, ''
84
158
 
85
159
 
160
+ def notice_detail(data):
161
+ """通知の本文(見出し+本文)を1つに畳んだ字面。記録にもカードにも同じものを使う。"""
162
+ message = (data.get('message') or '').strip()
163
+ title = (data.get('title') or '').strip()
164
+ return ' / '.join(x for x in (title, message) if x)
165
+
166
+
86
167
  def build_payload(data):
87
- """管制へ出すカードの中身。★答えの選択肢は付けない(端末でしか押せないため)。"""
168
+ """管制へ出すカードの中身。★答えの選択肢は付けない(人が別の場所で操作するため)。
169
+
170
+ ★種別ごとの書き分け(ACTION_GUIDE にある種別)では、
171
+ 「いま止まっているもの」「再開のしかた」を日本語で書く。
172
+ ★それ以外の種別の文面は1文字も変えない(従来どおり)。
173
+ """
88
174
  cwd = data.get('cwd') or ''
89
175
  site = os.environ.get('CONDUCTOR_SITE') or (cwd.rstrip('/').rsplit('/', 1)[-1] if cwd else '')
90
176
  machine = os.environ.get('CONDUCTOR_MACHINE') or os.uname().nodename
91
- message = (data.get('message') or '').strip()
92
- title = (data.get('title') or '').strip()
93
177
  kind = (data.get('notification_type') or '').strip() or 'unknown'
94
- detail = ' / '.join(x for x in (title, message) if x)[:400]
95
- lines = [
96
- '★この確認は管制からは押せません。職人の端末(黒い画面)で選んでください。',
97
- f'現場: {site or "不明"}/種別: {kind}',
98
- ]
178
+ detail = notice_detail(data)[:400]
179
+ guide = ACTION_GUIDE.get(kind)
180
+ if guide:
181
+ where = site or '不明'
182
+ lines = [
183
+ f'いま止まっているもの: 現場「{where}」の職人が、{guide["stopped"]}',
184
+ f'再開のしかた: {guide["howto"]}',
185
+ guide['console'],
186
+ f'現場: {where}/種別: {kind}',
187
+ ]
188
+ summary = f'{guide["summary"]}({site or "現場不明"})'
189
+ else:
190
+ lines = [
191
+ '★この確認は管制からは押せません。職人の端末(黒い画面)で選んでください。',
192
+ f'現場: {site or "不明"}/種別: {kind}',
193
+ ]
194
+ summary = f'端末で操作が要ります({site or "現場不明"})'
99
195
  if detail:
100
- lines.append(detail)
196
+ lines.append(f'知らせの中身: {detail}' if guide else detail)
101
197
  body = '\n'.join(lines)
102
198
  return {
103
199
  'site': site or 'unknown',
@@ -106,7 +202,7 @@ def build_payload(data):
106
202
  'sessionId': hook_datalink.stable_session_id(data.get('session_id'), machine, site),
107
203
  'kind': 'message',
108
204
  'question': body,
109
- 'summary': f'端末で操作が要ります({site or "現場不明"})',
205
+ 'summary': summary,
110
206
  'waitMs': NOTIFY_WAIT_SEC * 1000,
111
207
  }
112
208
 
@@ -125,7 +221,10 @@ def run(data, cfg=None, datalink=hook_datalink):
125
221
  if forward and cfg is None:
126
222
  forward, reason = False, 'no-console'
127
223
 
128
- datalink.record_notification(kind, forward, reason) # ★出しても出さなくても必ず残す
224
+ # ★出しても出さなくても必ず残す。本文の先頭も一緒に残す(伏せ字と長さの上限は
225
+ # record_notification が掛ける)。種別だけでは、同じ種別で意味が変わる型
226
+ # (quota_auto_resume_disabled は理由が6通りある)を後から確かめられない。
227
+ datalink.record_notification(kind, forward, reason, detail=notice_detail(data))
129
228
  if not forward:
130
229
  return None
131
230
 
@@ -146,7 +245,8 @@ def run(data, cfg=None, datalink=hook_datalink):
146
245
  # ★時間切れ=「まだ端末で押されていない可能性が高い」。見えなくして終わりにせず、
147
246
  # 後から数えられるように記録に残す(ADR-016 の見張り)。
148
247
  if answered is None:
149
- datalink.record_notification(kind, False, 'timeout-still-stuck')
248
+ datalink.record_notification(kind, False, 'timeout-still-stuck',
249
+ detail=notice_detail(data))
150
250
  return request_id
151
251
 
152
252
 
@@ -19,6 +19,7 @@ import tempfile
19
19
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
20
20
 
21
21
  import cc2_guard # noqa: E402
22
+ import hook_datalink # noqa: E402
22
23
  import notification_hook # noqa: E402
23
24
  import permission_request_hook # noqa: E402
24
25
  import policy # noqa: E402
@@ -543,6 +544,7 @@ class FakeDatalink:
543
544
  self.handoff = [] # 「管制が受け持っている」印の置き/消しの記録(ADR-016)
544
545
  self.handoff_is_active = False
545
546
  self.notifications = [] # 見張りの記録(種別, 出したか, 理由)
547
+ self.notice_details = [] # 見張りに渡した本文(便 927da60e)
546
548
 
547
549
  def load_config(self):
548
550
  return {'url': 'http://x', 'token': 't'}
@@ -558,8 +560,9 @@ class FakeDatalink:
558
560
  def handoff_active(self, session_id=''):
559
561
  return self.handoff_is_active
560
562
 
561
- def record_notification(self, notification_type, forwarded, reason=''):
563
+ def record_notification(self, notification_type, forwarded, reason='', detail=''):
562
564
  self.notifications.append((notification_type, forwarded, reason))
565
+ self.notice_details.append(detail)
563
566
  return 'line'
564
567
 
565
568
  def last_failure_kind(self):
@@ -1025,8 +1028,7 @@ check('入口として実行権を付ける対象に入っている',
1025
1028
  # ② 出す/出さないの仕分け。★知らない種別は「出す」側(見えない停止を作らない)。
1026
1029
  for _t in ('permission_prompt', 'worker_permission_prompt', 'idle_prompt'):
1027
1030
  check(f'人が端末で押す種別は出す: {_t}', notification_hook.should_forward(_t)[0], True)
1028
- for _t in ('agent_completed', 'auth_success', 'push_notification',
1029
- 'elicitation_complete', 'elicitation_response', 'computer_use_exit'):
1031
+ for _t in ('agent_completed', 'auth_success', 'push_notification', 'computer_use_exit'):
1030
1032
  check(f'知らせるだけの種別は出さない: {_t}', notification_hook.should_forward(_t)[0], False)
1031
1033
  check('知らない種別は出す(安全側)', notification_hook.should_forward('brand_new_kind')[0], True)
1032
1034
  check('種別が空なら出さない', notification_hook.should_forward('')[0], False)
@@ -1274,6 +1276,166 @@ for cmd in REDIRECT_DENY:
1274
1276
  for cmd in ['echo x > .env', 'y=$(echo x > .env)', 'echo x > "/home/fshim/.ssh/config"']:
1275
1277
  check(f'deny {cmd[:58]}', policy.decide_bash(cmd)['decision'], policy.DENY)
1276
1278
 
1279
+ # ─────────────────────────────────────────────────────────────────────────────
1280
+ # (h) ★利用上限からの自動再開は3種類に分ける(便 927da60e・えふさん確定 2026-09-16③)
1281
+ #
1282
+ # 自動再開した(fired) → カードを出さない・履歴だけ
1283
+ # 端末での操作待ち(stale) → 要操作カード(端末で Enter)
1284
+ # 自動再開が切れている(disabled) → 要操作カード(続きの指示を送る)
1285
+ # ─────────────────────────────────────────────────────────────────────────────
1286
+ _NH = notification_hook
1287
+
1288
+ # (h-1) 仕分け
1289
+ check('自動再開した(fired)はカードを出さない', _NH.should_forward(_NH.QUOTA_RESUME_FIRED),
1290
+ (False, 'informational'))
1291
+ check('端末での操作待ち(stale)はカードを出す', _NH.should_forward(_NH.QUOTA_RESUME_STALE)[0], True)
1292
+ check('自動再開が切れている(disabled)はカードを出す',
1293
+ _NH.should_forward(_NH.QUOTA_RESUME_DISABLED)[0], True)
1294
+
1295
+ # (h-2) 3種類の置き場が正しい表に入っている
1296
+ check('fired はお知らせの表に入っている', _NH.QUOTA_RESUME_FIRED in _NH.INFORMATIONAL_TYPES, True)
1297
+ check('fired は要操作の表に入っていない', _NH.QUOTA_RESUME_FIRED in _NH.HUMAN_BLOCKING_TYPES, False)
1298
+ check('stale は要操作の表に入っている', _NH.QUOTA_RESUME_STALE in _NH.HUMAN_BLOCKING_TYPES, True)
1299
+ check('disabled は要操作の表に入っている', _NH.QUOTA_RESUME_DISABLED in _NH.HUMAN_BLOCKING_TYPES, True)
1300
+ check('stale はお知らせの表に入っていない', _NH.QUOTA_RESUME_STALE in _NH.INFORMATIONAL_TYPES, False)
1301
+ check('disabled はお知らせの表に入っていない',
1302
+ _NH.QUOTA_RESUME_DISABLED in _NH.INFORMATIONAL_TYPES, False)
1303
+
1304
+ # (h-3) ★表に「本体に実在しない名前」が入っていない(外した2つの再発防止)
1305
+ check('お知らせの表は実在する種別だけ',
1306
+ sorted(_NH.INFORMATIONAL_TYPES - _NH.KNOWN_NOTIFICATION_TYPES), [])
1307
+ check('要操作の表は実在する種別だけ',
1308
+ sorted(_NH.HUMAN_BLOCKING_TYPES - _NH.KNOWN_NOTIFICATION_TYPES), [])
1309
+ check('外した名前はもう表に無い(elicitation_complete)',
1310
+ 'elicitation_complete' in _NH.INFORMATIONAL_TYPES, False)
1311
+ check('外した名前はもう表に無い(elicitation_response)',
1312
+ 'elicitation_response' in _NH.INFORMATIONAL_TYPES, False)
1313
+ # ★外しても動きは変わらない: 実在する elicitation は今までどおり「知らない種別=出す」でカードになる。
1314
+ for _t in ('elicitation_dialog', 'elicitation_url_dialog'):
1315
+ check(f'実在する elicitation は今までどおり出す: {_t}', _NH.should_forward(_t)[0], True)
1316
+
1317
+ # (h-4) 要操作カードの文面(★何が止まっているか/何をすれば再開するか)
1318
+ _QEV = {'hook_event_name': 'Notification', 'notification_type': _NH.QUOTA_RESUME_STALE,
1319
+ 'title': 'Usage limit has reset', 'message': 'press enter to continue',
1320
+ 'cwd': '/home/fshim/projects/kenzokun', 'session_id': 'sess-q'}
1321
+ _qp = _NH.build_payload(_QEV)
1322
+ check('stale: 何が止まっているかが書いてある', 'いま止まっているもの' in _qp['question'], True)
1323
+ check('stale: 現場名が本文に入る', '「kenzokun」' in _qp['question'], True)
1324
+ check('stale: 再開のしかたが書いてある', '再開のしかた' in _qp['question'], True)
1325
+ check('stale: 端末で Enter と書いてある', 'Enter' in _qp['question'], True)
1326
+ check('stale: 通知の本文の先頭も添える', 'press enter to continue' in _qp['question'], True)
1327
+ check('stale: 要点が日本語で分かる', '自動では続いていません' in _qp['summary'], True)
1328
+ check('stale: 答えの選択肢は付けない', 'choices' in _qp, False)
1329
+
1330
+ _DEV = dict(_QEV, notification_type=_NH.QUOTA_RESUME_DISABLED,
1331
+ title='Automatic continue was turned off',
1332
+ message='this task will not resume on its own')
1333
+ _dp = _NH.build_payload(_DEV)
1334
+ check('disabled: 何が止まっているかが書いてある', 'いま止まっているもの' in _dp['question'], True)
1335
+ check('disabled: 続きの指示を送ると書いてある', '続きの指示' in _dp['question'], True)
1336
+ check('disabled: 通知の本文の先頭も添える',
1337
+ 'this task will not resume on its own' in _dp['question'], True)
1338
+ check('disabled: 要点が日本語で分かる', '続きの指示を待っています' in _dp['summary'], True)
1339
+ # ★disabled は管制から便を出せば解ける=「管制からは押せません」とは書かない。
1340
+ check('disabled: 「管制からは押せません」とは書かない',
1341
+ '管制からは押せません' in _dp['question'], False)
1342
+ check('stale: こちらは端末でしか押せないと書く', '管制からは押せません' in _qp['question'], True)
1343
+
1344
+ # (h-5) ★他の種別の文面は1文字も変えない
1345
+ _pp = _NH.build_payload({'hook_event_name': 'Notification', 'notification_type': 'permission_prompt',
1346
+ 'message': 'Claude needs your permission to use Bash',
1347
+ 'cwd': '/home/fshim/projects/devlog-tracker', 'session_id': 's'})
1348
+ check('他の種別: 共通の文面のまま',
1349
+ _pp['question'].startswith('★この確認は管制からは押せません。職人の端末(黒い画面)で選んでください。'), True)
1350
+ check('他の種別: 要点も従来どおり', _pp['summary'], '端末で操作が要ります(devlog-tracker)')
1351
+ check('他の種別: 「いま止まっているもの」は付けない', 'いま止まっているもの' in _pp['question'], False)
1352
+
1353
+ # (h-6) 実際の出し分け(管制へ出す/出さない)と記録
1354
+ fd = FakeDatalink(None)
1355
+ check('fired は管制へ出さない', _NH.run(dict(_QEV, notification_type=_NH.QUOTA_RESUME_FIRED,
1356
+ title='Usage limit reset',
1357
+ message='continuing automatically'), datalink=fd), None)
1358
+ check('fired も記録には残す', fd.notifications[0][1:], (False, 'informational'))
1359
+ check('fired の記録に本文の先頭が入る',
1360
+ 'continuing automatically' in fd.notice_details[0], True)
1361
+
1362
+ fd = FakeDatalink(None)
1363
+ check('stale は管制へ出す', _NH.run(dict(_QEV), cfg={'url': 'http://x', 'token': 't'},
1364
+ datalink=fd), 'req-1')
1365
+ check('stale の記録に本文の先頭が入る', 'press enter to continue' in fd.notice_details[0], True)
1366
+
1367
+ fd = FakeDatalink(None)
1368
+ check('disabled は管制へ出す', _NH.run(dict(_DEV), cfg={'url': 'http://x', 'token': 't'},
1369
+ datalink=fd), 'req-1')
1370
+
1371
+ # (h-7) 記録の本文=伏せ字と長さの上限
1372
+ check('記録: 名前つきの代入は伏せる',
1373
+ 'sk-abcdefghij' in hook_datalink.mask_secretish('API_KEY=sk-abcdefghij'), False)
1374
+ check('記録: 名前つきの代入は名前だけ残す',
1375
+ hook_datalink.mask_secretish('API_KEY=sk-abcdefghij'), 'API_KEY=***')
1376
+ check('記録: 認可ヘッダの値は伏せる',
1377
+ hook_datalink.mask_secretish('Authorization: Bearer abc.def.ghi'),
1378
+ 'Authorization: Bearer ***')
1379
+ check('記録: 長い乱数の塊は伏せる',
1380
+ hook_datalink.mask_secretish('x ' + ('a1B2c3D4' * 5) + ' y'), 'x *** y')
1381
+ check('記録: 普通の英文は変えない',
1382
+ hook_datalink.mask_secretish('Usage limit reset / continuing automatically'),
1383
+ 'Usage limit reset / continuing automatically')
1384
+ check('記録: 種別の名前は伏せない(数字を含まないので当たらない)',
1385
+ hook_datalink.mask_secretish('quota_auto_resume_disabled fired'),
1386
+ 'quota_auto_resume_disabled fired')
1387
+ check('記録: パスは伏せない', hook_datalink.mask_secretish('/home/fshim/projects/conductor/src'),
1388
+ '/home/fshim/projects/conductor/src')
1389
+ _log_dir = tempfile.mkdtemp(prefix='conductor-notify-log-')
1390
+ try:
1391
+ _log = os.path.join(_log_dir, 'guard.log')
1392
+ _env = {'CONDUCTOR_GUARD_LOG': _log}
1393
+ hook_datalink.record_notification('quota_auto_resume_stale', True, '',
1394
+ env=_env, detail='Usage limit has reset / ' + ('あ' * 300))
1395
+ _line = open(_log, encoding='utf-8').read()
1396
+ check('記録: 本文の先頭が1行に入る', 'Usage limit has reset' in _line, True)
1397
+ check('記録: 長さの上限で切る(1行が短いまま)', len(_line) < 260, True)
1398
+ hook_datalink.record_notification('quota_auto_resume_fired', False, 'informational',
1399
+ env=dict(_env, CONDUCTOR_NOTIFY_LOG_DETAIL='0'),
1400
+ detail='continuing automatically')
1401
+ _line2 = open(_log, encoding='utf-8').read().splitlines()[-1]
1402
+ check('記録: 本文を残さない設定なら残さない', 'continuing automatically' in _line2, False)
1403
+ check('記録: その場合も種別と結果は残る',
1404
+ 'quota_auto_resume_fired -> skipped(informational)' in _line2, True)
1405
+ finally:
1406
+ shutil.rmtree(_log_dir, ignore_errors=True)
1407
+
1408
+ # (h-8) ★空振り確認=仕分けを元に戻すと落ちる
1409
+ _orig_info = _NH.INFORMATIONAL_TYPES
1410
+ _orig_block = _NH.HUMAN_BLOCKING_TYPES
1411
+ try:
1412
+ # ① fired を「出す側」へ戻すと、カードを出さない約束が崩れる。
1413
+ _NH.INFORMATIONAL_TYPES = _orig_info - {_NH.QUOTA_RESUME_FIRED}
1414
+ check('★空振り: fired を出す側に戻すと、出さない約束が崩れる',
1415
+ _NH.should_forward(_NH.QUOTA_RESUME_FIRED)[0], True)
1416
+ _NH.INFORMATIONAL_TYPES = _orig_info
1417
+ # ② stale と disabled を「お知らせ」に入れると、要操作カードが出なくなる。
1418
+ _NH.INFORMATIONAL_TYPES = _orig_info | {_NH.QUOTA_RESUME_STALE, _NH.QUOTA_RESUME_DISABLED}
1419
+ check('★空振り: stale をお知らせに入れるとカードが消える',
1420
+ _NH.should_forward(_NH.QUOTA_RESUME_STALE)[0], False)
1421
+ check('★空振り: disabled をお知らせに入れるとカードが消える',
1422
+ _NH.should_forward(_NH.QUOTA_RESUME_DISABLED)[0], False)
1423
+ # ③ 書き分けの表を空にすると、要操作カードの文面が共通の文面へ戻る。
1424
+ _orig_guide = _NH.ACTION_GUIDE
1425
+ _NH.ACTION_GUIDE = {}
1426
+ check('★空振り: 書き分けの表を空にすると「いま止まっているもの」が消える',
1427
+ 'いま止まっているもの' in _NH.build_payload(_QEV)['question'], False)
1428
+ _NH.ACTION_GUIDE = _orig_guide
1429
+ finally:
1430
+ _NH.INFORMATIONAL_TYPES = _orig_info
1431
+ _NH.HUMAN_BLOCKING_TYPES = _orig_block
1432
+
1433
+ # (h-9) 戻したら元どおり動く(上の細工が残っていないこと)
1434
+ check('★空振り: 戻すと fired は再び出さない', _NH.should_forward(_NH.QUOTA_RESUME_FIRED)[0], False)
1435
+ check('★空振り: 戻すと stale は再び出す', _NH.should_forward(_NH.QUOTA_RESUME_STALE)[0], True)
1436
+ check('★空振り: 戻すと書き分けの文面も再び出る',
1437
+ 'いま止まっているもの' in _NH.build_payload(_QEV)['question'], True)
1438
+
1277
1439
  # ─────────────────────────────────────────────────────────────────────────────
1278
1440
  # (g) ★秘密ファイルの【読み取り】が、経路を問わず止まる(便 93b2fa03・えふさん確定①)
1279
1441
  #