@tyhld/conductor 0.12.88 → 0.12.90
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/dist/headless.js +83 -3
- package/dist/relay.js +129 -16
- package/package.json +1 -1
- package/sales-template/setup.sh +4 -1
- package/scripts/hooks/finish_gate.py +536 -0
- package/scripts/hooks/policy.py +11 -1
- package/scripts/hooks/report-shape.json +243 -0
- package/scripts/hooks/stop_hook.py +115 -5
- package/scripts/hooks/test_finish_gate.py +323 -0
- package/scripts/hooks/test_guard_tyhld_whole_tree.py +165 -0
- package/scripts/hooks/test_report_shape.py +122 -0
- package/scripts/update-guard.sh +6 -1
- package/skills/conductor-craftsman/SKILL.md +7 -0
|
@@ -0,0 +1,536 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""finish_gate.py — 便の終わりの関門(職人が「まだ残っているのに終わる」のを仕組みで止める)。
|
|
3
|
+
|
|
4
|
+
【なぜ要るか(実地・2026-10-02〜03 に3回)】
|
|
5
|
+
どの便の本文にも「待ちを背景に残したまま手番を閉じない。待ち終わってから、報告を書いて終える」と
|
|
6
|
+
書いてあったのに、同じ終わり方が3回起きた。★指示の文では止まらない。
|
|
7
|
+
|
|
8
|
+
・915694ac(このPC・conductor)… 最後の発言は「CI を真似た試験はまだ実行中です。終わり次第、
|
|
9
|
+
結果を確かめます。」で `stop_reason: end_turn`。その直後に背景の仕事 `b32iul78k` が
|
|
10
|
+
`status: killed` になった(実行の記録で確認)。報告は「報告(作成中)この見出しは最終版で
|
|
11
|
+
差し替える」の下書きだけ。PR #252 は開いたまま。管制では「終わりました」になった。
|
|
12
|
+
・47bdfe00(このPC・ai-kana)… 最後の発言は「Auto-merge is still armed … Waiting.」で end_turn。
|
|
13
|
+
直後に背景の仕事 `be6b727fc`(合流されるまで待つ)が killed。報告本文は1文字も無い。
|
|
14
|
+
・dc1c9ed2(ノートPC・ai-kana)… 同じ形(このPCに記録が無いので実物では確かめていない)。
|
|
15
|
+
|
|
16
|
+
★原因は1つ。画面なし方式(`claude -p`)では【手番の終わり=処理の終わり】。
|
|
17
|
+
「背景の待ちが終わったら続ける」つもりで手番を閉じると、その瞬間に背景の待ちは殺され、
|
|
18
|
+
職人は二度と起きない。対話の画面と違って、誰も起こしてくれない。
|
|
19
|
+
|
|
20
|
+
【どう止めるか】Stop フック(ADR-024 の報告の関門)を土台に広げる。職人が手番を閉じようとした
|
|
21
|
+
ときに【実物】を見て、残っていれば `{"decision":"block","reason":…}` を返して続けさせる。
|
|
22
|
+
★職人の言葉・報告の文意は読まない。見るのは次の3つだけ。
|
|
23
|
+
(1) この便で作った PR が開いたまま・合流の予約中のまま … `gh pr list` の実物
|
|
24
|
+
(2) 報告が無い・下書きのまま … 報告ファイルの形(囲み・本文・下書きの印)
|
|
25
|
+
(3) 背景に回した待ちが残っている … 会話の記録の `backgroundTaskId` と
|
|
26
|
+
`<task-notification>` の差
|
|
27
|
+
|
|
28
|
+
【止め続けない】同じ理由で止めるのは `CAP_PER_KIND` 回まで(合計 `CAP_TOTAL` 回まで)。
|
|
29
|
+
上限に達したら止めず、理由を控え(residue)に残して終わらせる。中継がその控えを
|
|
30
|
+
「終了の事実」の `jobEndDetail` に載せるので、管制には「途中で終わった」と分かる形で届く。
|
|
31
|
+
★本体にも歯止めがある(連続 8 回で打ち切り・`CLAUDE_CODE_STOP_HOOK_BLOCK_CAP`)。
|
|
32
|
+
上限はその内側に収める(3 × 2 系統 = 最大 6 < 8)。
|
|
33
|
+
|
|
34
|
+
【止めない便】
|
|
35
|
+
・便の印(CONDUCTOR_JOB)が無い … そもそも便ではない(対話の作業・自己診断)
|
|
36
|
+
・画面なし方式でない … 対話の画面では「手番の終わり」は人への受け渡しなので止めない
|
|
37
|
+
・人が答えるべき問いかけで終わった … 人の判断が先(ADR-024 §3 の取りこぼしを作らない)
|
|
38
|
+
・便の本文に「合流は人が行う」と書いてある … (1) を見ない(ADR-007)
|
|
39
|
+
・PR を作っていない便・読むだけの便 … (1) は当たらない(開いた PR が無いので何も起きない)
|
|
40
|
+
"""
|
|
41
|
+
import json
|
|
42
|
+
import os
|
|
43
|
+
import re
|
|
44
|
+
import subprocess
|
|
45
|
+
|
|
46
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
47
|
+
# 回数の上限(★止め続けて便が終わらなくなる形にしない)
|
|
48
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
49
|
+
#: 同じ理由で止める回数の上限。
|
|
50
|
+
CAP_PER_KIND = 3
|
|
51
|
+
#: 全部の理由を合わせた上限。★本体の連続上限(既定 8)より小さくする。
|
|
52
|
+
CAP_TOTAL = 6
|
|
53
|
+
|
|
54
|
+
#: 残りの種類(控えの数え方の見出し)。
|
|
55
|
+
GATE_KINDS = ('report', 'pr', 'background')
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
59
|
+
# 置き場
|
|
60
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
61
|
+
def _home(env):
|
|
62
|
+
return (env.get('HOME') or os.path.expanduser('~')).rstrip('/')
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _state_root(env):
|
|
66
|
+
"""`~/.local/state`(`XDG_STATE_HOME` があればそちら)。★中継の reportStoreDirs と同じ決め方。"""
|
|
67
|
+
xdg = (env.get('XDG_STATE_HOME') or '').strip()
|
|
68
|
+
return xdg if xdg else os.path.join(_home(env), '.local', 'state')
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def gate_dir(env=None):
|
|
72
|
+
"""関門の控えの置き場。"""
|
|
73
|
+
env = os.environ if env is None else env
|
|
74
|
+
return os.path.join(_state_root(env), 'conductor', 'finish-gate')
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _safe_id(command_id):
|
|
78
|
+
return re.sub(r'[^A-Za-z0-9_-]', '', str(command_id or ''))[:128] or 'unknown'
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def counts_path(command_id, env=None):
|
|
82
|
+
"""その便で「どの理由で何回止めたか」の控え。"""
|
|
83
|
+
return os.path.join(gate_dir(env), f'{_safe_id(command_id)}.json')
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def residue_path(command_id, env=None):
|
|
87
|
+
"""上限に達して終わらせたときの理由の控え(中継が終了の事実へ載せる)。"""
|
|
88
|
+
return os.path.join(gate_dir(env), f'{_safe_id(command_id)}.residue')
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def report_dirs(env=None):
|
|
92
|
+
"""報告の置き場(★中継の reportStoreDirs と同じ順序・同じ決め方)。"""
|
|
93
|
+
env = os.environ if env is None else env
|
|
94
|
+
explicit = (env.get('CONDUCTOR_REPORT_DIR') or '').strip()
|
|
95
|
+
if explicit:
|
|
96
|
+
return [explicit]
|
|
97
|
+
uid = os.getuid() if hasattr(os, 'getuid') else 0
|
|
98
|
+
return [
|
|
99
|
+
os.path.join(_state_root(env), 'conductor', 'reports'),
|
|
100
|
+
os.path.join('/tmp', f'claude-{uid}', 'conductor-report'),
|
|
101
|
+
os.path.join('/tmp', f'conductor-report-{uid}'),
|
|
102
|
+
]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def report_files(command_id, env=None):
|
|
106
|
+
"""その便の報告ファイルの候補(未処理 → 処理済みの順)。"""
|
|
107
|
+
name = f'{_safe_id(command_id)}.md'
|
|
108
|
+
dirs = report_dirs(env)
|
|
109
|
+
return [os.path.join(d, name) for d in dirs] + [os.path.join(d, 'processed', name) for d in dirs]
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def read_report_text(command_id, env=None):
|
|
113
|
+
"""その便の報告の本文(無ければ None)。"""
|
|
114
|
+
for f in report_files(command_id, env):
|
|
115
|
+
try:
|
|
116
|
+
with open(f, encoding='utf-8', errors='replace') as fh:
|
|
117
|
+
text = fh.read().strip()
|
|
118
|
+
except Exception: # noqa: BLE001
|
|
119
|
+
continue
|
|
120
|
+
if text:
|
|
121
|
+
return text
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
126
|
+
# (2) 報告が無い・下書きのまま
|
|
127
|
+
#
|
|
128
|
+
# 【★形の決まりの正本は1か所(便 461be339)】
|
|
129
|
+
# 「囲みが閉じている/5つの欄が在る/outcome が決まった語/evidence が1件以上/本文が空でない」は
|
|
130
|
+
# 中継(src/relay.ts の parseReport / missingOf)も同じ判定をする。同じ決まりを2か所に書くと、
|
|
131
|
+
# 片方だけ直して食い違う(=関門は差し戻すのに中継は届いたと数える、またはその逆)。
|
|
132
|
+
# だから決まりは `report-shape.json`(この python と同じフォルダに配られる)だけに置き、
|
|
133
|
+
# ここは【読み手】にする。中継の側は定数がこの JSON と同じであることを試験で固定する。
|
|
134
|
+
# ★読めなかったときは形の判定をしない(fail open=関門が足される前と同じ動き)。
|
|
135
|
+
#
|
|
136
|
+
# 【★本文の言葉だけは別】下書きの印(作成中・TODO など)は関門だけの決まり。
|
|
137
|
+
# 報告の中身の言葉で完了を判定しない(えふさん確定 2026-09-23)ので、中継からは手が届かない
|
|
138
|
+
# ところ(この下の DRAFT_MARKERS)に置く。正本はここ1か所。
|
|
139
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
140
|
+
#: 「最終版で差し替える前」の印。★決まった字面の一覧だけを見る(文意は読まない)。
|
|
141
|
+
DRAFT_MARKERS = ('作成中', '下書き', '差し替える', 'あとで書く', '(未記入)', '(未記入)', 'TODO', 'TBD')
|
|
142
|
+
|
|
143
|
+
#: 形の決まりの正本(★この python と同じフォルダに配られる)。
|
|
144
|
+
SHAPE_SPEC_NAME = 'report-shape.json'
|
|
145
|
+
|
|
146
|
+
#: 足りない物の名前 → 職人へ見せる日本語。
|
|
147
|
+
_SHAPE_JA = {
|
|
148
|
+
'instruction_id': 'instruction_id の欄',
|
|
149
|
+
'site': 'site の欄',
|
|
150
|
+
'outcome': 'outcome(pushed / no_change / blocked のどれか1つ)',
|
|
151
|
+
'created_at': 'created_at の欄',
|
|
152
|
+
'evidence': 'evidence(1件以上)',
|
|
153
|
+
'body': '囲みの下の本文',
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def shape_spec_path():
|
|
158
|
+
"""形の決まりの正本の置き場(自分と同じフォルダ)。"""
|
|
159
|
+
return os.path.join(os.path.dirname(os.path.abspath(__file__)), SHAPE_SPEC_NAME)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def load_shape_spec(path=None):
|
|
163
|
+
"""形の決まりを読む。読めない・形が違うなら None(=形の判定をしない)。"""
|
|
164
|
+
try:
|
|
165
|
+
with open(path or shape_spec_path(), encoding='utf-8') as f:
|
|
166
|
+
got = json.load(f)
|
|
167
|
+
except Exception: # noqa: BLE001
|
|
168
|
+
return None
|
|
169
|
+
if not isinstance(got, dict):
|
|
170
|
+
return None
|
|
171
|
+
fields = got.get('requiredFields')
|
|
172
|
+
outcomes = got.get('validOutcomes')
|
|
173
|
+
if not isinstance(fields, list) or not fields or not all(isinstance(k, str) for k in fields):
|
|
174
|
+
return None
|
|
175
|
+
if not isinstance(outcomes, list) or not outcomes or not all(isinstance(k, str) for k in outcomes):
|
|
176
|
+
return None
|
|
177
|
+
return got
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
#: 読み込んだ正本(無ければ None)。
|
|
181
|
+
SHAPE_SPEC = load_shape_spec()
|
|
182
|
+
|
|
183
|
+
_KV_RE = re.compile(r'^([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(.*)$')
|
|
184
|
+
_BULLET_RE = re.compile(r'^\s*-\s+(.*)$')
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _parse_report(text):
|
|
188
|
+
"""報告を欄と本文に分ける(★中継の parseReport と同じ読み方)。
|
|
189
|
+
|
|
190
|
+
返り: (欄の辞書, evidence の一覧, 本文)。囲みが無い・閉じていないときは
|
|
191
|
+
欄も evidence も空で、全文が本文になる(中継と同じ倒れ方)。
|
|
192
|
+
"""
|
|
193
|
+
raw = '' if text is None else str(text)
|
|
194
|
+
if raw.startswith(''):
|
|
195
|
+
raw = raw[1:]
|
|
196
|
+
lines = raw.split('\n')
|
|
197
|
+
i = 0
|
|
198
|
+
while i < len(lines) and lines[i].strip() == '':
|
|
199
|
+
i += 1
|
|
200
|
+
if i >= len(lines) or lines[i].strip() != '---':
|
|
201
|
+
return {}, [], raw.strip()
|
|
202
|
+
fields, evidence, last_key = {}, [], ''
|
|
203
|
+
j = i + 1
|
|
204
|
+
closed = False
|
|
205
|
+
while j < len(lines):
|
|
206
|
+
line = lines[j]
|
|
207
|
+
if line.strip() == '---':
|
|
208
|
+
closed = True
|
|
209
|
+
break
|
|
210
|
+
bullet = _BULLET_RE.match(line)
|
|
211
|
+
if bullet is not None:
|
|
212
|
+
if last_key == 'evidence' and bullet.group(1).strip() != '':
|
|
213
|
+
evidence.append(bullet.group(1).strip())
|
|
214
|
+
j += 1
|
|
215
|
+
continue
|
|
216
|
+
kv = _KV_RE.match(line)
|
|
217
|
+
if kv is None:
|
|
218
|
+
j += 1
|
|
219
|
+
continue
|
|
220
|
+
last_key = kv.group(1).lower()
|
|
221
|
+
value = kv.group(2).strip()
|
|
222
|
+
if last_key == 'evidence':
|
|
223
|
+
if value != '':
|
|
224
|
+
evidence.append(value)
|
|
225
|
+
j += 1
|
|
226
|
+
continue
|
|
227
|
+
fields[last_key] = value
|
|
228
|
+
j += 1
|
|
229
|
+
if not closed:
|
|
230
|
+
return {}, [], raw.strip()
|
|
231
|
+
return fields, evidence, '\n'.join(lines[j + 1:]).strip()
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def report_shape_missing(text, spec=None):
|
|
235
|
+
"""報告の「形」で足りない物を並べる(空なら整っている)。★純粋関数。
|
|
236
|
+
|
|
237
|
+
★正本が読めないときは [] を返す(形の判定をしない=退行しない)。
|
|
238
|
+
"""
|
|
239
|
+
rules = SHAPE_SPEC if spec is None else spec
|
|
240
|
+
if not rules:
|
|
241
|
+
return []
|
|
242
|
+
fields, evidence, body = _parse_report(text)
|
|
243
|
+
outcomes = tuple(rules['validOutcomes'])
|
|
244
|
+
out = []
|
|
245
|
+
for key in rules['requiredFields']:
|
|
246
|
+
if key == 'body':
|
|
247
|
+
if body.strip() == '':
|
|
248
|
+
out.append(key)
|
|
249
|
+
elif key == 'evidence':
|
|
250
|
+
if not evidence:
|
|
251
|
+
out.append(key)
|
|
252
|
+
elif key == 'outcome':
|
|
253
|
+
if (fields.get('outcome') or '').strip() not in outcomes:
|
|
254
|
+
out.append(key)
|
|
255
|
+
elif not (fields.get(key) or '').strip():
|
|
256
|
+
out.append(key)
|
|
257
|
+
return out
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def report_body(text):
|
|
261
|
+
"""報告の本文(囲みの下)。囲みが無い・閉じていないときは全文。★純粋関数。"""
|
|
262
|
+
return _parse_report(text)[2]
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def report_residue(text, spec=None):
|
|
266
|
+
"""報告が「無い・形が整っていない・下書きのまま」なら理由、出来上がっていれば None。"""
|
|
267
|
+
if text is None or not text.strip():
|
|
268
|
+
return '報告ファイルが無い(または空)'
|
|
269
|
+
missing = report_shape_missing(text, spec)
|
|
270
|
+
if missing:
|
|
271
|
+
return ('報告の形が整っていない(足りない: '
|
|
272
|
+
+ '・'.join(_SHAPE_JA.get(k, k) for k in missing) + ')')
|
|
273
|
+
hit = next((m for m in DRAFT_MARKERS if m in report_body(text)), None)
|
|
274
|
+
if hit is not None:
|
|
275
|
+
return f'報告が下書きのまま(本文に「{hit}」が残っている)'
|
|
276
|
+
return None
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
280
|
+
# (3) 背景に回した待ちが残っている
|
|
281
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
282
|
+
#: 背景に回した仕事が始まった印(道具の戻りに入る・実測 2026-10-03)。
|
|
283
|
+
_BG_START_RE = re.compile(r'"backgroundTaskId"\s*:\s*"([A-Za-z0-9_-]+)"')
|
|
284
|
+
#: 背景の仕事から知らせが届いた印。★`<task-notification>` の塊の中の `<task-id>` だけを拾う
|
|
285
|
+
#: (道具の説明文に出てくる `<task-notification>` の字面を取り違えない)。
|
|
286
|
+
_BG_NOTIFY_RE = re.compile(r'<task-notification>(.*?)</task-notification>', re.S)
|
|
287
|
+
_BG_TASK_ID_RE = re.compile(r'<task-id>([A-Za-z0-9_-]+)</task-id>')
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def outstanding_background_tasks(transcript_path):
|
|
291
|
+
"""知らせがまだ届いていない背景の仕事の番号(順番は出てきた順)。読めなければ空。"""
|
|
292
|
+
if not transcript_path or not os.path.isfile(transcript_path):
|
|
293
|
+
return []
|
|
294
|
+
started, settled = [], set()
|
|
295
|
+
try:
|
|
296
|
+
with open(transcript_path, encoding='utf-8', errors='replace') as f:
|
|
297
|
+
for line in f:
|
|
298
|
+
for m in _BG_START_RE.finditer(line):
|
|
299
|
+
if m.group(1) not in started:
|
|
300
|
+
started.append(m.group(1))
|
|
301
|
+
if '<task-notification>' not in line:
|
|
302
|
+
continue
|
|
303
|
+
for blk in _BG_NOTIFY_RE.findall(line.replace('\\n', '\n')):
|
|
304
|
+
settled.update(_BG_TASK_ID_RE.findall(blk))
|
|
305
|
+
except Exception: # noqa: BLE001
|
|
306
|
+
return []
|
|
307
|
+
return [t for t in started if t not in settled]
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def background_residue(task_ids):
|
|
311
|
+
"""★純粋関数。残っていれば理由、無ければ None。"""
|
|
312
|
+
if not task_ids:
|
|
313
|
+
return None
|
|
314
|
+
return ('背景に回した待ちが ' + str(len(task_ids)) + ' 件残っている('
|
|
315
|
+
+ '・'.join(task_ids[:5]) + ')')
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
319
|
+
# (1) PR が開いたまま・合流の予約中のまま
|
|
320
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
321
|
+
#: 便の本文に書かれた「合流は人が行う」(ADR-007)。★これが在れば (1) を見ない。
|
|
322
|
+
#: 「〜する」の言い回し(する・します・行う・行います・やる・やります …)。
|
|
323
|
+
_DO = r'(?:行(?:う|います|います。)|やり(?:ます)?|やる|し(?:ます)?|する)'
|
|
324
|
+
_HUMAN_MERGE_RE = re.compile(
|
|
325
|
+
r'(?:マージ|合流)は(?:人間|人|えふさん|持ち主)が' + _DO
|
|
326
|
+
+ r'|(?:人間|人|えふさん|持ち主)が(?:マージ|合流)(?:を)?' + _DO
|
|
327
|
+
+ r'|(?:マージ|合流)しない',
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def human_merge_requested(job_body):
|
|
332
|
+
"""便の本文が「合流は人が行う/合流しない」と言っているか。★純粋関数。"""
|
|
333
|
+
return bool(_HUMAN_MERGE_RE.search(job_body or ''))
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def open_pr_residue(prs):
|
|
337
|
+
"""`gh pr list` の結果から理由を作る。★純粋関数(通信はしない)。
|
|
338
|
+
|
|
339
|
+
prs は [{'number':…, 'url':…, 'state':'OPEN', 'autoMergeRequest':{…}|None}, …]。
|
|
340
|
+
"""
|
|
341
|
+
if not isinstance(prs, list):
|
|
342
|
+
return None
|
|
343
|
+
out = []
|
|
344
|
+
for pr in prs:
|
|
345
|
+
if not isinstance(pr, dict):
|
|
346
|
+
continue
|
|
347
|
+
if str(pr.get('state') or 'OPEN').upper() != 'OPEN':
|
|
348
|
+
continue
|
|
349
|
+
num = pr.get('number')
|
|
350
|
+
if pr.get('autoMergeRequest'):
|
|
351
|
+
out.append(f'#{num}(合流の予約中)')
|
|
352
|
+
else:
|
|
353
|
+
out.append(f'#{num}(開いたまま)')
|
|
354
|
+
if not out:
|
|
355
|
+
return None
|
|
356
|
+
return 'この便で作った PR が合流していない: ' + '・'.join(out)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def current_branch(work_dir):
|
|
360
|
+
"""いまの作業ブランチ名(読めない・main のときは None)。"""
|
|
361
|
+
try:
|
|
362
|
+
r = subprocess.run(
|
|
363
|
+
['git', '-C', work_dir, 'rev-parse', '--abbrev-ref', 'HEAD'],
|
|
364
|
+
capture_output=True, text=True, timeout=15, check=False,
|
|
365
|
+
)
|
|
366
|
+
except Exception: # noqa: BLE001
|
|
367
|
+
return None
|
|
368
|
+
b = (r.stdout or '').strip()
|
|
369
|
+
if r.returncode != 0 or not b or b in ('HEAD', 'main', 'master'):
|
|
370
|
+
return None
|
|
371
|
+
return b
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def read_open_prs(work_dir, branch):
|
|
375
|
+
"""その枝の開いた PR を聞く。聞けなければ None(=分からない=止めない側へ倒す)。"""
|
|
376
|
+
if not branch:
|
|
377
|
+
return None
|
|
378
|
+
try:
|
|
379
|
+
r = subprocess.run(
|
|
380
|
+
['gh', 'pr', 'list', '--head', branch, '--state', 'open',
|
|
381
|
+
'--json', 'number,url,state,autoMergeRequest'],
|
|
382
|
+
cwd=work_dir, capture_output=True, text=True, timeout=30, check=False,
|
|
383
|
+
)
|
|
384
|
+
except Exception: # noqa: BLE001
|
|
385
|
+
return None
|
|
386
|
+
if r.returncode != 0:
|
|
387
|
+
return None
|
|
388
|
+
try:
|
|
389
|
+
got = json.loads(r.stdout or '[]')
|
|
390
|
+
except Exception: # noqa: BLE001
|
|
391
|
+
return None
|
|
392
|
+
return got if isinstance(got, list) else None
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
396
|
+
# 回数の数え方
|
|
397
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
398
|
+
def load_counts(command_id, env=None):
|
|
399
|
+
try:
|
|
400
|
+
with open(counts_path(command_id, env), encoding='utf-8') as f:
|
|
401
|
+
got = json.load(f)
|
|
402
|
+
except Exception: # noqa: BLE001
|
|
403
|
+
return {}
|
|
404
|
+
return {k: int(v) for k, v in got.items() if k in GATE_KINDS and isinstance(v, (int, float))} \
|
|
405
|
+
if isinstance(got, dict) else {}
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def save_counts(command_id, counts, env=None):
|
|
409
|
+
p = counts_path(command_id, env)
|
|
410
|
+
try:
|
|
411
|
+
os.makedirs(os.path.dirname(p), mode=0o700, exist_ok=True)
|
|
412
|
+
with open(p, 'w', encoding='utf-8') as f:
|
|
413
|
+
json.dump(counts, f)
|
|
414
|
+
except Exception: # noqa: BLE001
|
|
415
|
+
pass
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def save_residue(command_id, record, env=None):
|
|
419
|
+
"""上限に達して終わらせた理由を残す(中継が終了の事実へ載せる)。
|
|
420
|
+
|
|
421
|
+
record は `build_residue_record()` の辞書(人向けの1行+機械が読む形)。
|
|
422
|
+
★文字列を渡したときは人向けの1行だけとして扱う(古い呼び方も通る)。
|
|
423
|
+
"""
|
|
424
|
+
if isinstance(record, str):
|
|
425
|
+
record = {'text': record}
|
|
426
|
+
text = str(record.get('text') or '').strip()[:500]
|
|
427
|
+
out = {'text': text}
|
|
428
|
+
if isinstance(record.get('reasons'), list):
|
|
429
|
+
out['reasons'] = [k for k in record['reasons'] if k in GATE_KINDS]
|
|
430
|
+
if isinstance(record.get('counts'), dict):
|
|
431
|
+
out['counts'] = {k: int(record['counts'].get(k, 0)) for k in GATE_KINDS}
|
|
432
|
+
p = residue_path(command_id, env)
|
|
433
|
+
try:
|
|
434
|
+
os.makedirs(os.path.dirname(p), mode=0o700, exist_ok=True)
|
|
435
|
+
with open(p, 'w', encoding='utf-8') as f:
|
|
436
|
+
json.dump(out, f, ensure_ascii=False)
|
|
437
|
+
except Exception: # noqa: BLE001
|
|
438
|
+
return False
|
|
439
|
+
return True
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def cap_per_kind(env=None):
|
|
443
|
+
env = os.environ if env is None else env
|
|
444
|
+
try:
|
|
445
|
+
n = int((env.get('CONDUCTOR_FINISH_GATE_CAP') or '').strip())
|
|
446
|
+
except Exception: # noqa: BLE001
|
|
447
|
+
return CAP_PER_KIND
|
|
448
|
+
return n if n >= 0 else CAP_PER_KIND
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
452
|
+
# 判定(★純粋関数。ここに通信もファイル読みも入れない)
|
|
453
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
454
|
+
def decide(residues, counts, cap=CAP_PER_KIND, cap_total=CAP_TOTAL):
|
|
455
|
+
"""残り({種類: 理由})と今までの回数から、止めるかどうかを決める。
|
|
456
|
+
|
|
457
|
+
返り: {'block': bool, 'kinds': [種類], 'reasons': [理由], 'counts': 次の回数, 'last': bool}
|
|
458
|
+
block=True … 続けさせる(reason を職人へ返す)
|
|
459
|
+
block=False … 終わらせる(kinds が空でなければ「途中で終わった」=控えを残す)
|
|
460
|
+
last=True … これが最後の差し戻し(次は止めない)
|
|
461
|
+
"""
|
|
462
|
+
counts = {k: int(counts.get(k, 0)) for k in GATE_KINDS}
|
|
463
|
+
live = [k for k in GATE_KINDS if residues.get(k)]
|
|
464
|
+
if not live:
|
|
465
|
+
return {'block': False, 'kinds': [], 'reasons': [], 'counts': counts, 'last': False}
|
|
466
|
+
total = sum(counts.values())
|
|
467
|
+
allowed = [k for k in live if counts[k] < cap] if total < cap_total else []
|
|
468
|
+
if not allowed:
|
|
469
|
+
# 上限に達した。止めずに、理由を付けて終わらせる。
|
|
470
|
+
return {'block': False, 'kinds': live, 'reasons': [residues[k] for k in live],
|
|
471
|
+
'counts': counts, 'last': False}
|
|
472
|
+
nxt = dict(counts)
|
|
473
|
+
for k in allowed:
|
|
474
|
+
nxt[k] += 1
|
|
475
|
+
last = sum(nxt.values()) >= cap_total or all(nxt[k] >= cap for k in allowed)
|
|
476
|
+
return {'block': True, 'kinds': allowed, 'reasons': [residues[k] for k in allowed],
|
|
477
|
+
'counts': nxt, 'last': last}
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
#: 残りの種類ごとの「次にやること」。★職人へ返す文(Conductor が報告の中身を作ることはしない)。
|
|
481
|
+
NEXT_STEPS = {
|
|
482
|
+
'background': (
|
|
483
|
+
'背景に回した待ちを、いまこの手番の中で終わらせてください。'
|
|
484
|
+
'★画面なし方式(claude -p)では【手番の終わり=処理の終わり】です。'
|
|
485
|
+
'手番を閉じると背景の待ちは殺され、職人は二度と起きません(実地 915694ac・47bdfe00)。'
|
|
486
|
+
'待つなら Monitor か until ループで、この手番の中で待ち切ってください。'
|
|
487
|
+
),
|
|
488
|
+
'pr': (
|
|
489
|
+
'鉄則7のとおり、保護の確認 → CI → 合流 → 合流後の確かめ(4段)を終わらせてください。'
|
|
490
|
+
'合流を予約しただけでは終わりではありません。`gh pr view <番号> --json state` が '
|
|
491
|
+
'MERGED になり、合流でできたコミットが合流先に入っていることを実物で確かめてから報告します。'
|
|
492
|
+
'★`--admin` など保護を迂回する旗は使いません。合流できない理由が分かったら、'
|
|
493
|
+
'その理由を報告に書いて終えてください(それなら止めません)。'
|
|
494
|
+
),
|
|
495
|
+
'report': (
|
|
496
|
+
'報告を最終版まで書き上げてください(置き場は配達役の1行が指すファイル1か所)。'
|
|
497
|
+
'1行目から6欄の囲み(instruction_id・site・outcome・created_at・evidence)を置き、'
|
|
498
|
+
'その下に本文を書きます。★「作成中」「差し替える」などの下書きの印を1つも残さないでください。'
|
|
499
|
+
),
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def build_reason(result):
|
|
504
|
+
"""職人へ返す文(何が残っているか+次にやること)。"""
|
|
505
|
+
head = '【便の終わりの関門】まだ終わっていません。この手番を閉じないでください。'
|
|
506
|
+
lines = [head, '', '■ 残っているもの(実物で確かめた事実)']
|
|
507
|
+
lines += [f' ・{r}' for r in result['reasons']]
|
|
508
|
+
lines += ['', '■ 次にやること']
|
|
509
|
+
lines += [f' ・{NEXT_STEPS[k]}' for k in result['kinds']]
|
|
510
|
+
if result.get('last'):
|
|
511
|
+
lines += ['', '★これが最後の差し戻しです。次に手番を閉じると便は終わります。'
|
|
512
|
+
'どうしても終わらせられないときは、報告の outcome を blocked にして、'
|
|
513
|
+
'「どこまで進んだか・何が残っているか・なぜ進められないか」を書いて終えてください。']
|
|
514
|
+
return '\n'.join(lines)
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def build_residue_text(result):
|
|
518
|
+
"""上限に達して終わらせたときに控えへ残す1行(管制へ「途中で終わった」として届く)。"""
|
|
519
|
+
return '途中で終わった(便の終わりの関門の上限): ' + ' / '.join(result['reasons'])
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def build_residue_record(result):
|
|
523
|
+
"""控えに残す中身(人向けの1行+★管制が機械で読む形)。★純粋関数。
|
|
524
|
+
|
|
525
|
+
【なぜ機械で読む形も要るか(便 461be339)】上限に達して終わらせた便は、これまで
|
|
526
|
+
人向けの自由な文(`jobEndDetail`)にしか理由が乗らなかった。管制では「終わった」と
|
|
527
|
+
「途中で終わった」を機械で見分けられない(実地 7aa63a97 の残った穴)。
|
|
528
|
+
★新しい状態は作らず、終了の事実に `finishGate` の欄を足して届ける。
|
|
529
|
+
|
|
530
|
+
返り: {'text': 人向けの1行, 'reasons': 残っていた種類, 'counts': 止めた回数}
|
|
531
|
+
"""
|
|
532
|
+
return {
|
|
533
|
+
'text': build_residue_text(result),
|
|
534
|
+
'reasons': [k for k in GATE_KINDS if k in result['kinds']],
|
|
535
|
+
'counts': {k: int(result['counts'].get(k, 0)) for k in GATE_KINDS},
|
|
536
|
+
}
|
package/scripts/hooks/policy.py
CHANGED
|
@@ -2343,6 +2343,14 @@ def _real_segments(command: str):
|
|
|
2343
2343
|
GUARD_PLACES = (
|
|
2344
2344
|
('site-file', '.claude/settings'),
|
|
2345
2345
|
('home-tree', '.claude/skills'),
|
|
2346
|
+
# ★~/.tyhld は【まるごと】守る(便 7aa63a97)。
|
|
2347
|
+
# 【実地の穴(便 b7bd5e43 の発見・2026-10-03 に再現)】Write・Edit は前から ~/.tyhld の
|
|
2348
|
+
# どこでも止めていた(下の「前からの広い守り」)。ところが Bash は hooks と bin の下しか
|
|
2349
|
+
# 止めず、~/.tyhld の直下(kc の実体・ブラウザの道具・控え)は職人の touch・rm・リダイレクト・
|
|
2350
|
+
# cp・mv・sed -i から書けた(実測で allow)。=道具によって結果が違う穴。
|
|
2351
|
+
# ★hooks・bin の2行は【消さない】。一覧は OR で照らすので、残しておけば
|
|
2352
|
+
# 「前から止まっていた物が通る」ことが構造的に起きない(守りを強める方向だけ)。
|
|
2353
|
+
('home-tree', '.tyhld'),
|
|
2346
2354
|
('home-tree', '.tyhld/hooks'),
|
|
2347
2355
|
('home-tree', '.tyhld/bin'),
|
|
2348
2356
|
('home-tree', '.config/systemd'),
|
|
@@ -3297,7 +3305,9 @@ def _expand_vars(text: str, env) -> str:
|
|
|
3297
3305
|
# ★報告の文の中に場所の名前が出てくるだけ(空白を含む文字列)は場所と読まない。
|
|
3298
3306
|
_CODE_GUARD_JOIN_RE = re.compile(
|
|
3299
3307
|
r'''['"]\.claude['"]\s*[,/+]\s*['"](settings[\w.]*\.json|skills)['"/]'''
|
|
3300
|
-
|
|
3308
|
+
# ★`'.tyhld'` に【何を】つないでも場所と読む(便 7aa63a97)。前は hooks|bin だけだったので、
|
|
3309
|
+
# Path.home() / '.tyhld' / 'kc.sh' のような組み立てが素通りしていた。広げる方向だけ。
|
|
3310
|
+
r'''|['"]\.tyhld['"]\s*[,/+]\s*['"]'''
|
|
3301
3311
|
r'''|['"]\.[\w-]+['"]\s*[,/+]\s*['"](hooks\.json|config\.toml)['"]'''
|
|
3302
3312
|
r'''|['"]\.config['"]\s*[,/+]\s*['"]systemd['"/]''')
|
|
3303
3313
|
_CODE_PATH_LITERAL_RE = re.compile(r'''['"`]>{0,2}([^'"`\s*?\[\]]{1,300})['"`]''')
|