@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,323 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""便の終わりの関門の検査(便 7aa63a97)。
|
|
3
|
+
|
|
4
|
+
実行: python3 scripts/hooks/test_finish_gate.py
|
|
5
|
+
1件でも期待と違えば exit 1。
|
|
6
|
+
|
|
7
|
+
【直したこと】職人が「合流・公開・本番の確かめ・報告」の前に手番を閉じるのを、仕組みで止める。
|
|
8
|
+
実地 2026-10-02〜03 に3回起きた(915694ac・47bdfe00・dc1c9ed2)。どの便の本文にも
|
|
9
|
+
「待ちを背景に残したまま手番を閉じない」と書いてあった=★指示の文では止まらない。
|
|
10
|
+
|
|
11
|
+
【ここで固定すること】
|
|
12
|
+
(A) 報告の判定 … 無い・空・囲みが無い・欄が足りない・outcome が変・evidence 0件・
|
|
13
|
+
本文が空・下書きの印が残っている → 残り。最終版 → 残り無し
|
|
14
|
+
(B) 背景の待ちの数え方 … 始まった印(backgroundTaskId)と知らせ(task-notification)の差。
|
|
15
|
+
★実地 915694ac・47bdfe00 と同じ形の記録で確かめる
|
|
16
|
+
(C) PR の判定 … 開いたまま/合流の予約中 → 残り。空・合流済み → 残り無し。
|
|
17
|
+
便の本文が「合流は人が行う」なら見ない
|
|
18
|
+
(D) 回数の上限 … 同じ理由で止めるのは上限まで。上限に達したら止めずに終わらせる
|
|
19
|
+
(E) 返す文 … 「何が残っているか」と「次にやること」が入る
|
|
20
|
+
(F) ★止めない便 … 便の印が無い/画面なし方式でない/人への問いかけで終わった
|
|
21
|
+
(G) ★実地の3件の再現 … 当時の実物を置くと、関門がそれぞれ止める
|
|
22
|
+
|
|
23
|
+
★空振り確認(隔離した worktree の中だけ):
|
|
24
|
+
・finish_gate.decide の上限の判定(`counts[k] < cap`)を外す → (D) が落ちる
|
|
25
|
+
・report_residue の下書きの印の判定を外す → (A) が落ちる
|
|
26
|
+
・stop_hook.main から関門の呼び出しを外す → (G) の「止める」が落ちる
|
|
27
|
+
"""
|
|
28
|
+
import json
|
|
29
|
+
import os
|
|
30
|
+
import sys
|
|
31
|
+
import tempfile
|
|
32
|
+
|
|
33
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
34
|
+
|
|
35
|
+
import finish_gate # noqa: E402
|
|
36
|
+
import stop_hook # noqa: E402
|
|
37
|
+
|
|
38
|
+
failures = []
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def check(label, got, want):
|
|
42
|
+
ok = got == want
|
|
43
|
+
if not ok:
|
|
44
|
+
failures.append(f'{label}: got={got!r} want={want!r}')
|
|
45
|
+
print(f'{"OK " if ok else "NG "} {label:66} {str(got)[:46]}')
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def truthy(label, got):
|
|
49
|
+
ok = bool(got)
|
|
50
|
+
if not ok:
|
|
51
|
+
failures.append(f'{label}: got={got!r} want=真')
|
|
52
|
+
print(f'{"OK " if ok else "NG "} {label:66} {str(got)[:46]}')
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
SANDBOX = tempfile.mkdtemp(prefix='finish-gate-test-')
|
|
56
|
+
ENV = {'HOME': SANDBOX, 'XDG_STATE_HOME': os.path.join(SANDBOX, 'state'),
|
|
57
|
+
'CONDUCTOR_RUNNER_MODE': 'headless', 'CONDUCTOR_SITE': 'demo'}
|
|
58
|
+
JOB = '7aa63a97-57e7-4c42-8b95-f3da87d88829'
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def report_text(outcome='pushed', body='やりました。', header=True, evidence=True):
|
|
62
|
+
head = ['---', f'instruction_id: {JOB}', 'site: demo', f'outcome: {outcome}',
|
|
63
|
+
'created_at: 2026-10-03T09:00:00+09:00', 'evidence:']
|
|
64
|
+
if evidence:
|
|
65
|
+
head.append(' - PR https://github.com/tyhld/conductor/pull/999')
|
|
66
|
+
head.append('---')
|
|
67
|
+
return ('\n'.join(head) + '\n\n' + body) if header else body
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def put_report(text, command_id=JOB):
|
|
71
|
+
d = os.path.join(ENV['XDG_STATE_HOME'], 'conductor', 'reports')
|
|
72
|
+
os.makedirs(d, exist_ok=True)
|
|
73
|
+
with open(os.path.join(d, f'{command_id}.md'), 'w', encoding='utf-8') as f:
|
|
74
|
+
f.write(text)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def drop_report(command_id=JOB):
|
|
78
|
+
for f in finish_gate.report_files(command_id, ENV):
|
|
79
|
+
if os.path.isfile(f):
|
|
80
|
+
os.remove(f)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def put_job_body(body, site='demo'):
|
|
84
|
+
d = os.path.join(ENV['XDG_STATE_HOME'], 'conductor')
|
|
85
|
+
os.makedirs(d, exist_ok=True)
|
|
86
|
+
with open(os.path.join(d, f'active-job-{site}.json'), 'w', encoding='utf-8') as f:
|
|
87
|
+
json.dump({'commandId': JOB, 'body': body, 'site': site}, f)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def transcript(lines, name='t.jsonl'):
|
|
91
|
+
p = os.path.join(SANDBOX, name)
|
|
92
|
+
with open(p, 'w', encoding='utf-8') as f:
|
|
93
|
+
f.write('\n'.join(lines) + '\n')
|
|
94
|
+
return p
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def clear_counts(command_id=JOB):
|
|
98
|
+
for p in (finish_gate.counts_path(command_id, ENV), finish_gate.residue_path(command_id, ENV)):
|
|
99
|
+
if os.path.isfile(p):
|
|
100
|
+
os.remove(p)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
104
|
+
print('=== (A) 報告の判定(★実物の形だけを見る) ===')
|
|
105
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
106
|
+
check('無い → 残り', bool(finish_gate.report_residue(None)), True)
|
|
107
|
+
check('空 → 残り', bool(finish_gate.report_residue(' \n ')), True)
|
|
108
|
+
check('囲みが無い → 残り', bool(finish_gate.report_residue('やりました。')), True)
|
|
109
|
+
check('囲みが閉じていない → 残り',
|
|
110
|
+
bool(finish_gate.report_residue('---\ninstruction_id: x\n\n本文')), True)
|
|
111
|
+
check('欄が足りない → 残り',
|
|
112
|
+
bool(finish_gate.report_residue('---\ninstruction_id: x\nsite: demo\n---\n\n本文')), True)
|
|
113
|
+
check('outcome が変 → 残り', bool(finish_gate.report_residue(report_text(outcome='やった'))), True)
|
|
114
|
+
check('outcome が空 → 残り', bool(finish_gate.report_residue(report_text(outcome=''))), True)
|
|
115
|
+
check('evidence 0件 → 残り', bool(finish_gate.report_residue(report_text(evidence=False))), True)
|
|
116
|
+
check('本文が空 → 残り', bool(finish_gate.report_residue(report_text(body=''))), True)
|
|
117
|
+
check('★最終版 → 残り無し', finish_gate.report_residue(report_text()), None)
|
|
118
|
+
check('no_change も通る', finish_gate.report_residue(report_text(outcome='no_change')), None)
|
|
119
|
+
check('blocked も通る', finish_gate.report_residue(report_text(outcome='blocked')), None)
|
|
120
|
+
|
|
121
|
+
print('--- ★下書きの印(実地 915694ac の本文そのまま) ---')
|
|
122
|
+
REAL_DRAFT = '# 便 915694ac 報告(作成中)\n\nこの見出しは最終版で差し替える。'
|
|
123
|
+
truthy('実地の下書き → 残り', finish_gate.report_residue(report_text(body=REAL_DRAFT)))
|
|
124
|
+
for m in ('作成中', '下書き', '差し替える', 'あとで書く', '(未記入)', 'TODO', 'TBD'):
|
|
125
|
+
truthy(f'下書きの印「{m}」→ 残り', finish_gate.report_residue(report_text(body=f'あ{m}い')))
|
|
126
|
+
|
|
127
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
128
|
+
print('\n=== (B) 背景の待ちの数え方(★実地と同じ形の記録) ===')
|
|
129
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
130
|
+
START = '{"toolUseResult":{"stdout":"","backgroundTaskId":"%s"}}'
|
|
131
|
+
NOTIFY = ('{"message":{"content":[{"type":"text","text":"<task-notification>\\n'
|
|
132
|
+
'<task-id>%s</task-id>\\n<status>%s</status>\\n</task-notification>"}]}}')
|
|
133
|
+
|
|
134
|
+
check('何も無い → 残り0', finish_gate.outstanding_background_tasks(transcript(['{}'])), [])
|
|
135
|
+
check('始まって知らせが来た → 残り0',
|
|
136
|
+
finish_gate.outstanding_background_tasks(
|
|
137
|
+
transcript([START % 'b1', NOTIFY % ('b1', 'completed')], 'a.jsonl')), [])
|
|
138
|
+
check('★始まったのに知らせが無い → 残る(915694ac の形)',
|
|
139
|
+
finish_gate.outstanding_background_tasks(
|
|
140
|
+
transcript([START % 'b4s4rn5ee', NOTIFY % ('b4s4rn5ee', 'completed'),
|
|
141
|
+
START % 'b32iul78k'], 'b.jsonl')), ['b32iul78k'])
|
|
142
|
+
check('★複数残る(47bdfe00 の形)',
|
|
143
|
+
finish_gate.outstanding_background_tasks(
|
|
144
|
+
transcript([START % 'b445wfjet', START % 'be6b727fc', START % 'buu5zuhgd',
|
|
145
|
+
NOTIFY % ('buu5zuhgd', 'completed')], 'c.jsonl')),
|
|
146
|
+
['b445wfjet', 'be6b727fc'])
|
|
147
|
+
check('知らせが killed でも「知らせは来た」=残らない',
|
|
148
|
+
finish_gate.outstanding_background_tasks(
|
|
149
|
+
transcript([START % 'b1', NOTIFY % ('b1', 'killed')], 'd.jsonl')), [])
|
|
150
|
+
check('★道具の説明文の中の字面は知らせと読まない',
|
|
151
|
+
finish_gate.outstanding_background_tasks(
|
|
152
|
+
transcript([START % 'b1',
|
|
153
|
+
'{"text":"<task-notification> arrives when the workflow completes"}'],
|
|
154
|
+
'e.jsonl')), ['b1'])
|
|
155
|
+
check('記録が無い → 残り0(読めないものは無い側へ倒す)',
|
|
156
|
+
finish_gate.outstanding_background_tasks('/nope/nothing.jsonl'), [])
|
|
157
|
+
check('残りが在れば理由になる', bool(finish_gate.background_residue(['b1'])), True)
|
|
158
|
+
check('残りが無ければ理由にならない', finish_gate.background_residue([]), None)
|
|
159
|
+
|
|
160
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
161
|
+
print('\n=== (C) PR の判定 ===')
|
|
162
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
163
|
+
check('開いた PR 無し → 残り無し', finish_gate.open_pr_residue([]), None)
|
|
164
|
+
check('聞けなかった(None)→ 残り無し(分からないものは止めない側)',
|
|
165
|
+
finish_gate.open_pr_residue(None), None)
|
|
166
|
+
truthy('★開いたまま → 残り(915694ac の PR #252 の形)',
|
|
167
|
+
finish_gate.open_pr_residue([{'number': 252, 'state': 'OPEN', 'autoMergeRequest': None}]))
|
|
168
|
+
r = finish_gate.open_pr_residue([{'number': 397, 'state': 'OPEN',
|
|
169
|
+
'autoMergeRequest': {'enabledAt': 'x'}}])
|
|
170
|
+
truthy('★合流の予約中のまま → 残り(47bdfe00 の形)', r)
|
|
171
|
+
check('予約中だと分かる文になっている', '予約' in (r or ''), True)
|
|
172
|
+
check('合流済み(MERGED)→ 残り無し',
|
|
173
|
+
finish_gate.open_pr_residue([{'number': 1, 'state': 'MERGED', 'autoMergeRequest': None}]), None)
|
|
174
|
+
|
|
175
|
+
print('--- 便の本文に「合流は人が行う」と書いてある便は見ない(ADR-007) ---')
|
|
176
|
+
for b in ['この便のマージは人間が行う', '合流は人が行う', 'マージは人間が行います',
|
|
177
|
+
'★この便では合流しない', '人がマージする']:
|
|
178
|
+
check(f'「{b}」', finish_gate.human_merge_requested(b), True)
|
|
179
|
+
for b in ['合流まで進めてください', '鉄則7のとおり合流する', '']:
|
|
180
|
+
check(f'ふつうの便「{b[:20]}」', finish_gate.human_merge_requested(b), False)
|
|
181
|
+
|
|
182
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
183
|
+
print('\n=== (D) 回数の上限(★止め続けて便が終わらなくなる形にしない) ===')
|
|
184
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
185
|
+
R1 = {'report': '報告が無い'}
|
|
186
|
+
d = finish_gate.decide(R1, {}, cap=3)
|
|
187
|
+
check('1回目は止める', d['block'], True)
|
|
188
|
+
check('回数が1になる', d['counts']['report'], 1)
|
|
189
|
+
d = finish_gate.decide(R1, {'report': 2}, cap=3)
|
|
190
|
+
check('3回目は止める', d['block'], True)
|
|
191
|
+
check('3回目は「最後の差し戻し」と分かる', d['last'], True)
|
|
192
|
+
d = finish_gate.decide(R1, {'report': 3}, cap=3)
|
|
193
|
+
check('★上限に達したら止めない(便が終わる)', d['block'], False)
|
|
194
|
+
check('★ただし「途中で終わった」と分かる(種類が残る)', d['kinds'], ['report'])
|
|
195
|
+
truthy('控えの文に理由が入る', '途中で終わった' in finish_gate.build_residue_text(d))
|
|
196
|
+
|
|
197
|
+
print('--- ★控えは「管制が機械で読める形」でも残す(便 461be339) ---')
|
|
198
|
+
rec = finish_gate.build_residue_record(d)
|
|
199
|
+
truthy('人向けの1行は今までどおり', '途中で終わった' in rec['text'])
|
|
200
|
+
check('★残っていた種類が入る', rec['reasons'], ['report'])
|
|
201
|
+
check('★止めた回数が3種類ぶん入る', rec['counts'], {'report': 3, 'pr': 0, 'background': 0})
|
|
202
|
+
rec3 = finish_gate.build_residue_record(
|
|
203
|
+
finish_gate.decide({'report': 'a', 'pr': 'b', 'background': 'c'},
|
|
204
|
+
{'report': 3, 'pr': 3, 'background': 3}, cap=3))
|
|
205
|
+
check('3つ残ったまま終わったら3つとも入る', rec3['reasons'], ['report', 'pr', 'background'])
|
|
206
|
+
|
|
207
|
+
check('残りが無ければ止めない', finish_gate.decide({}, {}, cap=3)['block'], False)
|
|
208
|
+
check('残りが無ければ種類も空', finish_gate.decide({}, {'report': 1}, cap=3)['kinds'], [])
|
|
209
|
+
|
|
210
|
+
d = finish_gate.decide({'report': 'a', 'pr': 'b', 'background': 'c'}, {}, cap=3)
|
|
211
|
+
check('3つ同時でも1回で止める', d['block'], True)
|
|
212
|
+
check('3つぶん数える', sorted(d['kinds']), ['background', 'pr', 'report'])
|
|
213
|
+
d = finish_gate.decide({'report': 'a', 'pr': 'b'}, {'report': 2, 'pr': 2, 'background': 2}, cap=3)
|
|
214
|
+
check('★合計の上限(本体の連続上限 8 の内側)で止めなくなる', d['block'], False)
|
|
215
|
+
check('cap=0 なら1回も止めない', finish_gate.decide(R1, {}, cap=0)['block'], False)
|
|
216
|
+
|
|
217
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
218
|
+
print('\n=== (E) 職人へ返す文 ===')
|
|
219
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
220
|
+
d = finish_gate.decide({'report': '報告が下書きのまま', 'pr': 'PR #252 が開いたまま',
|
|
221
|
+
'background': '背景に回した待ちが 1 件残っている'}, {}, cap=3)
|
|
222
|
+
msg = finish_gate.build_reason(d)
|
|
223
|
+
truthy('何が残っているかが入る', '残っているもの' in msg)
|
|
224
|
+
truthy('次にやることが入る', '次にやること' in msg)
|
|
225
|
+
truthy('残りの理由がそのまま入る', 'PR #252 が開いたまま' in msg)
|
|
226
|
+
truthy('★手番を閉じないでと書いてある', '閉じない' in msg)
|
|
227
|
+
truthy('★画面なし方式では手番の終わり=処理の終わりと書いてある', '手番の終わり' in msg)
|
|
228
|
+
truthy('鉄則7の4段を指している', '合流後' in msg or '鉄則7' in msg)
|
|
229
|
+
last = finish_gate.build_reason(finish_gate.decide(R1, {'report': 2}, cap=3))
|
|
230
|
+
truthy('最後の差し戻しだと伝える', '最後の差し戻し' in last)
|
|
231
|
+
truthy('終わらせ方(blocked)を示す', 'blocked' in last)
|
|
232
|
+
|
|
233
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
234
|
+
print('\n=== (F) ★止めない便 ===')
|
|
235
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
236
|
+
check('画面なし方式の印が在る → 効く', stop_hook.gate_enabled(ENV), True)
|
|
237
|
+
for mode in ('tmux', '', 'TMUX'):
|
|
238
|
+
e = dict(ENV, CONDUCTOR_RUNNER_MODE=mode)
|
|
239
|
+
check(f'画面の方式({mode or "印なし"})→ 効かない', stop_hook.gate_enabled(e), False)
|
|
240
|
+
check('印そのものが無い → 効かない', stop_hook.gate_enabled({}), False)
|
|
241
|
+
|
|
242
|
+
clear_counts()
|
|
243
|
+
drop_report()
|
|
244
|
+
check('★便の印が無い記録 → 止めない(便ではない)',
|
|
245
|
+
stop_hook.finish_gate_block(
|
|
246
|
+
{'transcript_path': transcript(['{"text":"ふつうの作業"}'], 'nojob.jsonl')}, ENV), None)
|
|
247
|
+
check('★人が答えるべき問いかけは関門より先(確認カードの経路へ)',
|
|
248
|
+
stop_hook.needs_human_answer('どちらにしますか?'), True)
|
|
249
|
+
check('進捗の報告は問いかけではない',
|
|
250
|
+
stop_hook.needs_human_answer('CI の完走を待っています。'), False)
|
|
251
|
+
|
|
252
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
253
|
+
print('\n=== (G) ★実地の3件の再現(実物を置いて、関門が止めるか) ===')
|
|
254
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
255
|
+
JOB_LINE = f'{{"text":"CONDUCTOR_JOB:{JOB}"}}'
|
|
256
|
+
put_job_body('鉄則7のとおり合流まで進めてください')
|
|
257
|
+
|
|
258
|
+
# 915694ac … 報告が下書きのまま + 背景の待ちが残っている
|
|
259
|
+
clear_counts()
|
|
260
|
+
put_report(report_text(body=REAL_DRAFT))
|
|
261
|
+
t = transcript([JOB_LINE, START % 'b32iul78k'], 'g1.jsonl')
|
|
262
|
+
msg = stop_hook.finish_gate_block({'transcript_path': t, 'cwd': SANDBOX}, ENV)
|
|
263
|
+
truthy('★915694ac の形 → 止める', msg)
|
|
264
|
+
truthy(' 下書きだと言う', '下書き' in (msg or ''))
|
|
265
|
+
truthy(' 背景の待ちが残っていると言う', '背景' in (msg or ''))
|
|
266
|
+
|
|
267
|
+
# 47bdfe00 … 報告本文が1文字も無い
|
|
268
|
+
clear_counts()
|
|
269
|
+
put_report(report_text(body=''))
|
|
270
|
+
t = transcript([JOB_LINE, START % 'be6b727fc'], 'g2.jsonl')
|
|
271
|
+
msg = stop_hook.finish_gate_block({'transcript_path': t, 'cwd': SANDBOX}, ENV)
|
|
272
|
+
truthy('★47bdfe00 の形 → 止める', msg)
|
|
273
|
+
|
|
274
|
+
# dc1c9ed2 … 報告が1つも無い
|
|
275
|
+
clear_counts()
|
|
276
|
+
drop_report()
|
|
277
|
+
t = transcript([JOB_LINE], 'g3.jsonl')
|
|
278
|
+
msg = stop_hook.finish_gate_block({'transcript_path': t, 'cwd': SANDBOX}, ENV)
|
|
279
|
+
truthy('★dc1c9ed2 の形(報告が無い)→ 止める', msg)
|
|
280
|
+
|
|
281
|
+
print('--- ★きちんと終わった便は止めない ---')
|
|
282
|
+
clear_counts()
|
|
283
|
+
put_report(report_text())
|
|
284
|
+
t = transcript([JOB_LINE, START % 'b1', NOTIFY % ('b1', 'completed')], 'g4.jsonl')
|
|
285
|
+
check('最終版の報告+待ち無し → 止めない',
|
|
286
|
+
stop_hook.finish_gate_block({'transcript_path': t, 'cwd': SANDBOX}, ENV), None)
|
|
287
|
+
|
|
288
|
+
print('--- ★上限に達したら終わらせ、理由の控えを残す ---')
|
|
289
|
+
clear_counts()
|
|
290
|
+
drop_report()
|
|
291
|
+
t = transcript([JOB_LINE], 'g5.jsonl')
|
|
292
|
+
stops = 0
|
|
293
|
+
for _ in range(8):
|
|
294
|
+
if stop_hook.finish_gate_block({'transcript_path': t, 'cwd': SANDBOX}, ENV):
|
|
295
|
+
stops += 1
|
|
296
|
+
else:
|
|
297
|
+
break
|
|
298
|
+
check('止めた回数は上限まで', stops, finish_gate.CAP_PER_KIND)
|
|
299
|
+
check('その後は止めない',
|
|
300
|
+
stop_hook.finish_gate_block({'transcript_path': t, 'cwd': SANDBOX}, ENV), None)
|
|
301
|
+
rp = finish_gate.residue_path(JOB, ENV)
|
|
302
|
+
truthy('理由の控えが残る', os.path.isfile(rp))
|
|
303
|
+
with open(rp, encoding='utf-8') as f:
|
|
304
|
+
got = json.load(f)
|
|
305
|
+
truthy('控えに「途中で終わった」と入っている', '途中で終わった' in got['text'])
|
|
306
|
+
check('★控えの種類を中継が機械で読める', got.get('reasons'), ['report'])
|
|
307
|
+
check('★控えの回数は上限ちょうど', (got.get('counts') or {}).get('report'), finish_gate.CAP_PER_KIND)
|
|
308
|
+
|
|
309
|
+
print('--- ★便の本文が「合流は人が行う」なら PR を見ない ---')
|
|
310
|
+
clear_counts()
|
|
311
|
+
put_report(report_text())
|
|
312
|
+
put_job_body('★この便のマージは人間が行う')
|
|
313
|
+
t = transcript([JOB_LINE, START % 'b1', NOTIFY % ('b1', 'completed')], 'g6.jsonl')
|
|
314
|
+
check('合流を人に任せた便は止めない',
|
|
315
|
+
stop_hook.finish_gate_block({'transcript_path': t, 'cwd': SANDBOX}, ENV), None)
|
|
316
|
+
|
|
317
|
+
print('\n' + '=' * 72)
|
|
318
|
+
if failures:
|
|
319
|
+
print(f'NG {len(failures)} 件')
|
|
320
|
+
for f in failures:
|
|
321
|
+
print(' ' + f)
|
|
322
|
+
sys.exit(1)
|
|
323
|
+
print('すべて期待どおり')
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""番人の守る場所を ~/.tyhld まるごとに広げたことの検査(便 7aa63a97・発見は便 b7bd5e43)。
|
|
3
|
+
|
|
4
|
+
実行: python3 scripts/hooks/test_guard_tyhld_whole_tree.py
|
|
5
|
+
1件でも期待と違えば exit 1。
|
|
6
|
+
|
|
7
|
+
【直した穴(2026-10-03 に実物で再現)】
|
|
8
|
+
Write・Edit は前から ~/.tyhld のどこでも止めていた。ところが Bash は hooks と bin の下しか
|
|
9
|
+
止めず、~/.tyhld の直下(kc の実体・ブラウザの道具・控え・notify の置き場)は職人の
|
|
10
|
+
touch・rm・リダイレクト・cp・mv・sed -i から書けた。
|
|
11
|
+
実測(直す前): `touch ~/.tyhld/kc.sh` → allow / `touch ~/.tyhld/hooks/x` → deny
|
|
12
|
+
=道具によって結果が違う穴。
|
|
13
|
+
|
|
14
|
+
【ここで固定すること】
|
|
15
|
+
(A) ★~/.tyhld の【どこへ】【どの書き方で】書こうとしても止まる(直下もぜんぶ)
|
|
16
|
+
(B) 前から止まっていた hooks・bin の下は1件も通らない(守りを弱めない)
|
|
17
|
+
(C) 読むのは今までどおり通る(cat・grep・ls・jq・cp で外へ写す)
|
|
18
|
+
(D) 使い捨ての作業場(/tmp の下)に作った同じ名前は止めない
|
|
19
|
+
(E) Write・Edit も今までどおり止まる(道具をまたいで結果が同じ)
|
|
20
|
+
(F) ★守る場所の一覧に ~/.tyhld が入っている(hooks・bin の2行も残っている=OR で広がる)
|
|
21
|
+
|
|
22
|
+
★空振り確認:policy.GUARD_PLACES から ('home-tree', '.tyhld') を外すと (A) の直下の件が落ちる。
|
|
23
|
+
"""
|
|
24
|
+
import os
|
|
25
|
+
import sys
|
|
26
|
+
|
|
27
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
28
|
+
|
|
29
|
+
import policy # noqa: E402
|
|
30
|
+
|
|
31
|
+
failures = []
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def check(label, got, want):
|
|
35
|
+
ok = got == want
|
|
36
|
+
if not ok:
|
|
37
|
+
failures.append(f'{label}: got={got!r} want={want!r}')
|
|
38
|
+
print(f'{"OK " if ok else "NG "} {label:72} {str(got)[:40]}')
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def bash(cmd):
|
|
42
|
+
return policy.decide_bash(cmd)['decision']
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
HOME = os.path.expanduser('~')
|
|
46
|
+
|
|
47
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
48
|
+
print('=== (F) 守る場所の一覧 ===')
|
|
49
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
50
|
+
_home_trees = tuple(p for k, p in policy.GUARD_PLACES if k == 'home-tree')
|
|
51
|
+
check('一覧に .tyhld まるごとが入っている', '.tyhld' in _home_trees, True)
|
|
52
|
+
check('★hooks の行は消していない(弱めない)', '.tyhld/hooks' in _home_trees, True)
|
|
53
|
+
check('★bin の行は消していない(弱めない)', '.tyhld/bin' in _home_trees, True)
|
|
54
|
+
|
|
55
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
56
|
+
print('\n=== (A) ~/.tyhld の直下への書き込みは、どの書き方でも止まる ===')
|
|
57
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
58
|
+
#: 直下の実在の物(kc の実体・ブラウザの道具・控え・notify の置き場)。
|
|
59
|
+
DIRECT = [
|
|
60
|
+
'touch ~/.tyhld/kc.sh',
|
|
61
|
+
'touch $HOME/.tyhld/kc.sh',
|
|
62
|
+
'touch ${HOME}/.tyhld/kc.sh',
|
|
63
|
+
f'touch {HOME}/.tyhld/kc.sh',
|
|
64
|
+
'rm -f ~/.tyhld/kc.sh',
|
|
65
|
+
'rm -rf ~/.tyhld',
|
|
66
|
+
'echo x > ~/.tyhld/kc.sh',
|
|
67
|
+
'echo x >> ~/.tyhld/kc.sh',
|
|
68
|
+
'printf x > $HOME/.tyhld/kc.sh',
|
|
69
|
+
'tee ~/.tyhld/kc.sh',
|
|
70
|
+
'cp /tmp/claude-1000/a ~/.tyhld/kc.sh',
|
|
71
|
+
'cp -r /tmp/claude-1000/d ~/.tyhld/',
|
|
72
|
+
'mv /tmp/claude-1000/a ~/.tyhld/kc.sh',
|
|
73
|
+
'mv ~/.tyhld /tmp/claude-1000/moved',
|
|
74
|
+
'sed -i s/a/b/ ~/.tyhld/kc.sh',
|
|
75
|
+
'chmod 777 ~/.tyhld/kc.sh',
|
|
76
|
+
'ln -s /tmp/claude-1000/a ~/.tyhld/kc.sh',
|
|
77
|
+
'install -m 755 /tmp/claude-1000/a ~/.tyhld/kc.sh',
|
|
78
|
+
'truncate -s 0 ~/.tyhld/kc.sh',
|
|
79
|
+
'tar -xzf /tmp/claude-1000/a.tgz -C ~/.tyhld',
|
|
80
|
+
'unzip /tmp/claude-1000/a.zip -d ~/.tyhld',
|
|
81
|
+
'curl -o ~/.tyhld/kc.sh https://example.invalid/x',
|
|
82
|
+
'wget -O ~/.tyhld/kc.sh https://example.invalid/x',
|
|
83
|
+
'touch ~/.tyhld/pw-venv/x',
|
|
84
|
+
'touch ~/.tyhld/pw-browsers/x',
|
|
85
|
+
'touch ~/.tyhld/skill-backups/x',
|
|
86
|
+
'touch ~/.tyhld/codex-notify/x',
|
|
87
|
+
'touch ~/.tyhld/hookset/x',
|
|
88
|
+
'bash -c "touch ~/.tyhld/kc.sh"',
|
|
89
|
+
'env X=1 touch ~/.tyhld/kc.sh',
|
|
90
|
+
'nohup touch ~/.tyhld/kc.sh',
|
|
91
|
+
'( touch ~/.tyhld/kc.sh )',
|
|
92
|
+
'echo ok; touch ~/.tyhld/kc.sh',
|
|
93
|
+
'D=~/.tyhld; touch $D/kc.sh',
|
|
94
|
+
'xargs touch ~/.tyhld/kc.sh',
|
|
95
|
+
'find ~/.tyhld -type f -exec rm -f {} +',
|
|
96
|
+
'python3 -c "import pathlib; (pathlib.Path.home() / \'.tyhld\' / \'kc.sh\').write_text(\'x\')"',
|
|
97
|
+
# ★この機体の本物のホームで書く(CI の機体では HOME が違うので、字面を焼き付けない)。
|
|
98
|
+
f'python3 -c "open(\'{HOME}/.tyhld/kc.sh\', \'w\').write(\'x\')"',
|
|
99
|
+
'node -e "require(\'fs\').writeFileSync(process.env.HOME + \'/.tyhld/kc.sh\', \'x\')"',
|
|
100
|
+
]
|
|
101
|
+
for c in DIRECT:
|
|
102
|
+
check(c, bash(c), policy.DENY)
|
|
103
|
+
|
|
104
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
105
|
+
print('\n=== (B) 前から止まっていた hooks・bin の下は1件も通らない ===')
|
|
106
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
107
|
+
OLD = [
|
|
108
|
+
'touch ~/.tyhld/hooks/x',
|
|
109
|
+
'touch ~/.tyhld/bin/x',
|
|
110
|
+
'echo x > ~/.tyhld/hooks/cc2_guard.py',
|
|
111
|
+
'cp /tmp/claude-1000/a ~/.tyhld/bin/conductor-run',
|
|
112
|
+
'rm -rf ~/.tyhld/hooks',
|
|
113
|
+
'rm -f ~/.tyhld/bin/autoupdate/drain',
|
|
114
|
+
'touch ~/.tyhld/bin/autoupdate/enabled',
|
|
115
|
+
'sed -i s/a/b/ ~/.tyhld/hooks/policy.py',
|
|
116
|
+
]
|
|
117
|
+
for c in OLD:
|
|
118
|
+
check(c, bash(c), policy.DENY)
|
|
119
|
+
|
|
120
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
121
|
+
print('\n=== (C) 読むのは今までどおり通る ===')
|
|
122
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
123
|
+
READS = [
|
|
124
|
+
'cat ~/.tyhld/kc.sh',
|
|
125
|
+
'ls -la ~/.tyhld',
|
|
126
|
+
'ls ~/.tyhld/bin',
|
|
127
|
+
'grep -n conductor ~/.tyhld/kc.sh',
|
|
128
|
+
'head -5 ~/.tyhld/bin/conductor-run',
|
|
129
|
+
'diff ~/.tyhld/kc.sh scripts/kc.sh',
|
|
130
|
+
'cp ~/.tyhld/kc.sh /tmp/claude-1000/copy.sh',
|
|
131
|
+
'wc -l ~/.tyhld/kc.sh',
|
|
132
|
+
]
|
|
133
|
+
for c in READS:
|
|
134
|
+
check(c, bash(c), policy.ALLOW)
|
|
135
|
+
|
|
136
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
137
|
+
print('\n=== (D) 使い捨ての作業場の同じ名前は止めない ===')
|
|
138
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
139
|
+
for c in [
|
|
140
|
+
'touch /tmp/claude-1000/.tyhld/x',
|
|
141
|
+
'echo x > /tmp/claude-1000/sagyou/.tyhld/kc.sh',
|
|
142
|
+
]:
|
|
143
|
+
check(c, bash(c), policy.ALLOW)
|
|
144
|
+
|
|
145
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
146
|
+
print('\n=== (E) Write・Edit も今までどおり止まる(道具をまたいで同じ結果) ===')
|
|
147
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
148
|
+
for p in [
|
|
149
|
+
f'{HOME}/.tyhld/kc.sh',
|
|
150
|
+
f'{HOME}/.tyhld/hooks/policy.py',
|
|
151
|
+
f'{HOME}/.tyhld/bin/conductor-run',
|
|
152
|
+
f'{HOME}/.tyhld/codex-notify/x',
|
|
153
|
+
]:
|
|
154
|
+
check(f'decide_write {p}', policy.decide_write(p)['decision'], policy.DENY)
|
|
155
|
+
for tool in ('Write', 'Edit', 'MultiEdit', 'NotebookEdit'):
|
|
156
|
+
d = policy.decide_event({'tool_name': tool, 'tool_input': {'file_path': p}})
|
|
157
|
+
check(f'{tool} {p}', d['decision'], policy.DENY)
|
|
158
|
+
|
|
159
|
+
print('\n' + '=' * 72)
|
|
160
|
+
if failures:
|
|
161
|
+
print(f'NG {len(failures)} 件')
|
|
162
|
+
for f in failures:
|
|
163
|
+
print(' ' + f)
|
|
164
|
+
sys.exit(1)
|
|
165
|
+
print('すべて期待どおり')
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""報告の「形」の判定が、決まりの正本どおりであることの検査(便 461be339)。
|
|
3
|
+
|
|
4
|
+
実行: python3 scripts/hooks/test_report_shape.py
|
|
5
|
+
1件でも期待と違えば exit 1。
|
|
6
|
+
|
|
7
|
+
【なぜ要るか】形の決まりは2か所から読まれる。
|
|
8
|
+
① 中継(`src/relay.ts`)… 報告を「届いた」と数える条件
|
|
9
|
+
② 便の終わりの関門(`finish_gate.py`)… 手番を閉じさせるかの判定
|
|
10
|
+
同じ決まりを2か所に書くと、片方だけ直して食い違う(=関門は差し戻すのに中継は届いたと数える、
|
|
11
|
+
またはその逆)。だから決まりは `report-shape.json` の1か所だけに置き、
|
|
12
|
+
**同じ字面に同じ答えを出すこと**を両方の試験で固定する。
|
|
13
|
+
★中継の側は `test/report-shape-gate.test.ts` が、この同じ `cases` を流して突き合わせる。
|
|
14
|
+
|
|
15
|
+
【ここで固定すること】
|
|
16
|
+
(A) 正本が読めること(配り忘れ・壊れた JSON に気づける)
|
|
17
|
+
(B) 正本の `cases` の答えと、関門の判定が1件も違わないこと
|
|
18
|
+
(C) 正本が読めないときは形の判定をしない(fail open=関門が足される前と同じ動き)
|
|
19
|
+
(D) ★下書きの印(本文の言葉)は正本に載せない
|
|
20
|
+
=中継からは言葉の決まりに手が届かない(えふさん確定 2026-09-23)
|
|
21
|
+
(E) 形と下書きの印は別の理由として返る
|
|
22
|
+
|
|
23
|
+
★空振り確認(隔離した worktree の中だけ):
|
|
24
|
+
・`report-shape.json` の `requiredFields` から1つ消す → (B) が落ちる
|
|
25
|
+
・`report_shape_missing` の outcome の確かめを外す → (B) が落ちる
|
|
26
|
+
"""
|
|
27
|
+
import json
|
|
28
|
+
import os
|
|
29
|
+
import sys
|
|
30
|
+
|
|
31
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
32
|
+
|
|
33
|
+
import finish_gate # noqa: E402
|
|
34
|
+
|
|
35
|
+
failures = []
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def check(label, got, want):
|
|
39
|
+
ok = got == want
|
|
40
|
+
if not ok:
|
|
41
|
+
failures.append(f'{label}: got={got!r} want={want!r}')
|
|
42
|
+
print(f'{"OK " if ok else "NG "} {label:70} {str(got)[:40]}')
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def truthy(label, got):
|
|
46
|
+
ok = bool(got)
|
|
47
|
+
if not ok:
|
|
48
|
+
failures.append(f'{label}: got={got!r} want=真')
|
|
49
|
+
print(f'{"OK " if ok else "NG "} {label:70} {str(got)[:40]}')
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
53
|
+
print('=== (A) 形の決まりの正本が読めるか ===')
|
|
54
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
55
|
+
path = finish_gate.shape_spec_path()
|
|
56
|
+
check('正本の置き場は自分と同じフォルダ', os.path.basename(path), 'report-shape.json')
|
|
57
|
+
truthy('正本が在る', os.path.isfile(path))
|
|
58
|
+
spec = finish_gate.load_shape_spec()
|
|
59
|
+
truthy('正本が読める', spec)
|
|
60
|
+
check('★関門は読み込み済みの正本を使う', finish_gate.SHAPE_SPEC is not None, True)
|
|
61
|
+
check('足りない物の並び順まで決まっている',
|
|
62
|
+
spec['requiredFields'],
|
|
63
|
+
['instruction_id', 'site', 'outcome', 'created_at', 'evidence', 'body'])
|
|
64
|
+
check('outcome に書ける語', spec['validOutcomes'], ['pushed', 'no_change', 'blocked'])
|
|
65
|
+
truthy('答え合わせの cases が在る', isinstance(spec.get('cases'), list) and spec['cases'])
|
|
66
|
+
|
|
67
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
68
|
+
print(f'\n=== (B) 正本の cases({len(spec["cases"])}件)の答えと1件も違わない ===')
|
|
69
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
70
|
+
for case in spec['cases']:
|
|
71
|
+
check(case['name'][:68], finish_gate.report_shape_missing(case['text']), case['missing'])
|
|
72
|
+
|
|
73
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
74
|
+
print('\n=== (C) 正本が読めないときは形の判定をしない(退行しない) ===')
|
|
75
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
76
|
+
check('正本が無い → 足りない物は並べない', finish_gate.report_shape_missing('やりました。', {}), [])
|
|
77
|
+
check('正本が壊れていても落ちない', finish_gate.load_shape_spec('/nope/report-shape.json'), None)
|
|
78
|
+
check('requiredFields が無い正本は使わない',
|
|
79
|
+
finish_gate.load_shape_spec(os.path.join(os.path.dirname(path), 'safe_verbs.json')), None)
|
|
80
|
+
good = spec['cases'][0]['text']
|
|
81
|
+
check('★正本が読めなくても「報告が無い」は今までどおり止める',
|
|
82
|
+
bool(finish_gate.report_residue(None, {})), True)
|
|
83
|
+
check('★正本が読めないときは形で止めない(fail open)',
|
|
84
|
+
finish_gate.report_residue(good, {}), None)
|
|
85
|
+
|
|
86
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
87
|
+
print('\n=== (D) ★下書きの印(本文の言葉)は正本に載せない ===')
|
|
88
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
89
|
+
raw = open(path, encoding='utf-8').read()
|
|
90
|
+
body_rules = json.loads(raw)
|
|
91
|
+
for marker in ('作成中', '下書き', '差し替える', 'あとで書く', 'TODO', 'TBD'):
|
|
92
|
+
check(f'正本の決まりに「{marker}」が入っていない',
|
|
93
|
+
any(marker in json.dumps(body_rules.get(k), ensure_ascii=False)
|
|
94
|
+
for k in ('requiredFields', 'validOutcomes')),
|
|
95
|
+
False)
|
|
96
|
+
truthy('下書きの印の正本は関門の側にある', finish_gate.DRAFT_MARKERS)
|
|
97
|
+
|
|
98
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
99
|
+
print('\n=== (E) 形と下書きの印は別の理由として返る ===')
|
|
100
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
101
|
+
draft = next(c for c in spec['cases'] if '下書きの言葉' in c['name'])
|
|
102
|
+
check('★下書きの言葉が残った報告は、形としては整っている',
|
|
103
|
+
finish_gate.report_shape_missing(draft['text']), [])
|
|
104
|
+
r = finish_gate.report_residue(draft['text'])
|
|
105
|
+
truthy('それでも関門は下書きとして差し戻す', r)
|
|
106
|
+
truthy(' 理由は「下書き」だと分かる', '下書き' in (r or ''))
|
|
107
|
+
broken = next(c for c in spec['cases'] if c['missing'] == ['body'])
|
|
108
|
+
r2 = finish_gate.report_residue(broken['text'])
|
|
109
|
+
truthy('形が整っていない報告は「形」の理由で差し戻す', r2)
|
|
110
|
+
truthy(' 理由は「形が整っていない」だと分かる', '形が整っていない' in (r2 or ''))
|
|
111
|
+
truthy(' 足りない物の名前が入る', '本文' in (r2 or ''))
|
|
112
|
+
check('★最終版は差し戻さない', finish_gate.report_residue(good), None)
|
|
113
|
+
check('本文の取り出しは囲みの下だけ', finish_gate.report_body(good).startswith('やりました'), True)
|
|
114
|
+
check('囲みが無ければ全文が本文', finish_gate.report_body('ただの文'), 'ただの文')
|
|
115
|
+
|
|
116
|
+
print('\n' + '=' * 72)
|
|
117
|
+
if failures:
|
|
118
|
+
print(f'NG {len(failures)} 件')
|
|
119
|
+
for f in failures:
|
|
120
|
+
print(' ' + f)
|
|
121
|
+
sys.exit(1)
|
|
122
|
+
print('すべて期待どおり')
|
package/scripts/update-guard.sh
CHANGED
|
@@ -436,8 +436,13 @@ INSTALL_FILES=(
|
|
|
436
436
|
policy.py # cc2_guard / permission_request_hook が使う判定本体
|
|
437
437
|
summary_ja.py # permission_request_hook / stop_hook が使う日本語の文言
|
|
438
438
|
hook_datalink.py # 各フックが使う管制との連絡線
|
|
439
|
+
finish_gate.py # stop_hook が使う「便の終わりの関門」の判定(便 7aa63a97)
|
|
439
440
|
)
|
|
440
|
-
|
|
441
|
+
# ★番人と同居させる設定(任意・あれば一緒に置く)。この一覧は sales-template/setup.sh の
|
|
442
|
+
# GUARD_CONFIGS と【同じ集合】にすること(test/setup-installer.test.ts が同一を固定する)。
|
|
443
|
+
# report-shape.json … 報告の「形」の決まりの正本(finish_gate.py が実行時に読む・便 461be339)。
|
|
444
|
+
# 配り忘れると関門が形を見られない(fail open=書きかけを差し戻せない)。
|
|
445
|
+
INSTALL_CONFIGS=(safe_verbs.json network_allow.json report-shape.json)
|
|
441
446
|
ENTRYPOINTS=(cc2_guard.py permission_request_hook.py stop_hook.py notification_hook.py)
|
|
442
447
|
|
|
443
448
|
# ①-0 見本と「配る中身」がズレていないか機械照合する(今回と同じ欠落事故の再発防止=根治)。
|