@tyhld/conductor 0.12.87 → 0.12.89

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/README.md CHANGED
@@ -83,6 +83,8 @@ node dist/cli.js start # もしくは scripts/conductor-start.sh(
83
83
  > APPLY=1 bash "$(npm root -g)/@tyhld/conductor/scripts/autoupdate-install.sh" # 入れる
84
84
  > ~/.tyhld/bin/tyhld-selfcheck --now # 自己診断を1回
85
85
  > ```
86
+ > ★更新の窓で打つ行(ノートPC → メインPC → 社員PC・Ubuntu と PowerShell・再起動後の確かめ・止まったときの読み方)は1枚にまとめてあります: [`docs/自社PC-更新の窓-手順書.md`](docs/自社PC-更新の窓-手順書.md)。仕組みと決まりは [ADR-027](docs/adr/ADR-027-staged-autoupdate-own-pcs.md)。
87
+ >
86
88
  > 鍵(`~/.tyhld/bin/autoupdate/enabled`)・更新係と自己診断のタイマー・Claude Code 自身の自動更新を止める設定(`DISABLE_AUTOUPDATER=1`)を置きます。Claude Code が公式推奨の1方式(`curl -fsSL https://claude.ai/install.sh | bash`)だけになっていなければ、人が打つ行を出して止まります。お客さまの導入台本(`sales-template/install.sh`・`setup.sh`)には入っていません。
87
89
 
88
90
  ---
@@ -26,6 +26,8 @@
26
26
  * 指名(~/.tyhld/bin/autoupdate/claude-pin)を書く。戻すときも指名で戻す。最低版より古い版へは戻さない。
27
27
  * ・候補の版(conductor=公開 npm の latest/Claude Code=公式の安定版)を見つけたら、自己診断に
28
28
  * 載せて管制へ届ける。★先行機かどうかはこの機では決めない(機体名をコードに書かない・管制が決める)。
29
+ * 管制が目標の版の口で role:"follower" を返した機は、候補を探さない・候補の自己診断を流さない
30
+ * (欄が無い・読めないときは今までどおり=全PCが流す・便 9a81e458)。
29
31
  *
30
32
  * 【控え】~/.tyhld/bin/autoupdate/state.json(★職人が書けない場所。途中で電源が切れても続きから動く)。
31
33
  */
@@ -56,6 +58,8 @@ export const HELD_RECHECK_MS = 60 * 60 * 1000;
56
58
  /** 候補を届けられなかったとき、もう一度届けに行くまでの間隔。 */
57
59
  export const CANDIDATE_RETRY_MS = 24 * 60 * 60 * 1000;
58
60
  const SEMVER = /^\d+\.\d+\.\d+$/;
61
+ /** 管制が返す役の語(★読むだけ。どの機が先行機かは管制が決める)。 */
62
+ const TARGET_ROLES = ['leader', 'follower'];
59
63
  // ─────────────────────────────────────────────────────────────────────────────
60
64
  // 置き場
61
65
  // ─────────────────────────────────────────────────────────────────────────────
@@ -175,6 +179,7 @@ export async function fetchTarget(d, conductorVersion) {
175
179
  reason: typeof b.reason === 'string' ? b.reason : null,
176
180
  checkAgainMin: min,
177
181
  ready: b.ready === true,
182
+ role: TARGET_ROLES.includes(b.role) ? b.role : null,
178
183
  },
179
184
  detail: `HTTP ${res.status}`,
180
185
  };
@@ -490,7 +495,9 @@ export async function runUpdater(d) {
490
495
  if (!comp || !want) {
491
496
  removeDrain(d, s);
492
497
  s.busySince = null;
493
- await reportCandidates(d, s, installed, claudeNow?.version ?? null);
498
+ // ★後続の機は候補を探さない・候補の自己診断を流さない(役は管制が決める・欄が無ければ今までどおり)。
499
+ if (target.role !== 'follower')
500
+ await reportCandidates(d, s, installed, claudeNow?.version ?? null);
494
501
  return done(d, s, 'none', `目標の版と同じです(conductor ${installed ?? '不明'}・Claude Code ${claudeNow?.version ?? '不明'})`);
495
502
  }
496
503
  // ⑤ 全現場が空きか。
package/dist/headless.js CHANGED
@@ -557,6 +557,36 @@ export function jobEndKindOf(j) {
557
557
  export function shouldAbortForBodyHandoff(j) {
558
558
  return j.bodyHandoff === 'failed' && !j.stopped && !j.timedOut;
559
559
  }
560
+ /**
561
+ * 便の終わりの関門が残した「途中で終わった」理由の控え(便 7aa63a97)。
562
+ *
563
+ * ★番人側(`scripts/hooks/finish_gate.py`)が、同じ理由で止める回数の上限に達したときだけ
564
+ * 1行だけ書く。中継はそれを【読んで消すだけ】で、判定はしない(判定は関門が持つ)。
565
+ * これが要るのは「管制には『途中で終わった』と分かる形で届く」ため。新しい状態は作らず、
566
+ * 既にある「終了の事実」の `jobEndDetail` に相乗りする(管制の口を変えない)。
567
+ * 控えが無い便=関門が止めずに済んだ便なので、今までどおり null。
568
+ */
569
+ export function takeFinishGateResidue(commandId, env = process.env) {
570
+ const xdg = (env.XDG_STATE_HOME ?? '').trim();
571
+ const root = xdg || path.join(env.HOME ?? os.homedir(), '.local', 'state');
572
+ const safe = String(commandId ?? '').replace(/[^A-Za-z0-9_-]/g, '').slice(0, 128);
573
+ if (!safe)
574
+ return null;
575
+ const file = path.join(root, 'conductor', 'finish-gate', `${safe}.residue`);
576
+ let text;
577
+ try {
578
+ text = readFileSync(file, 'utf8').trim();
579
+ }
580
+ catch {
581
+ return null;
582
+ }
583
+ // ★読んだら消す(次の周で二重に届けない)。消せなくても届けることを優先する。
584
+ try {
585
+ unlinkSync(file);
586
+ }
587
+ catch { /* 消せなくても害は無い(同じ便の控えは1つだけ) */ }
588
+ return text.length > 0 ? text.slice(0, 500) : null;
589
+ }
560
590
  /**
561
591
  * いま画面なし方式が追っている便の指示ID(★回収の道から除く物・便 6ebba0d0 の b)。
562
592
  *
@@ -1263,7 +1293,11 @@ async function checkRunningJob(cfg, state) {
1263
1293
  // つかめていないときは false を送る(1-3 の「つかめていない」と同じ形)。
1264
1294
  processEndObserved: job.finishedAt !== null,
1265
1295
  startedAt: new Date(job.startedAt).toISOString(),
1266
- detail: job.bodyHandoffError ?? `attempt ${job.attempt}/${HEADLESS_MAX_ATTEMPTS}`,
1296
+ // ★終わりの関門が「途中で終わった」と残していれば、その理由を先に載せる(便 7aa63a97)。
1297
+ // 本文を渡せなかった理由がいちばん強い(職人は何も受け取っていない)ので、それが先。
1298
+ detail: job.bodyHandoffError
1299
+ ?? takeFinishGateResidue(job.commandId)
1300
+ ?? `attempt ${job.attempt}/${HEADLESS_MAX_ATTEMPTS}`,
1267
1301
  }, { logLine: log });
1268
1302
  }
1269
1303
  if (job.timedOut) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tyhld/conductor",
3
- "version": "0.12.87",
3
+ "version": "0.12.89",
4
4
  "description": "采配くん管制の見守りアプリ(conductor-agent)。各PCで常駐し、中央(devlog-tracker)へ定期的に生存報告(heartbeat)を送る常駐CLI。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -859,6 +859,7 @@ GUARD_FILES=(
859
859
  policy.py # cc2_guard / permission_request_hook が使う判定本体
860
860
  summary_ja.py # permission_request_hook / stop_hook が使う日本語の文言
861
861
  hook_datalink.py # 各フックが使う管制との連絡線
862
+ finish_gate.py # stop_hook が使う「便の終わりの関門」の判定(便 7aa63a97)
862
863
  )
863
864
  GUARD_ENTRYPOINTS=(cc2_guard.py permission_request_hook.py stop_hook.py notification_hook.py)
864
865
  GUARD_CONFIGS=(safe_verbs.json network_allow.json) # 任意・あれば同居させる
@@ -0,0 +1,398 @@
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
+ #: 囲み(front matter)に要る欄。鉄則「便の終え方」②のとおり。
129
+ REQUIRED_KEYS = ('instruction_id', 'site', 'outcome', 'created_at', 'evidence')
130
+ #: `outcome` に書ける値。
131
+ VALID_OUTCOMES = ('pushed', 'no_change', 'blocked')
132
+ #: 「最終版で差し替える前」の印。★決まった字面の一覧だけを見る(文意は読まない)。
133
+ DRAFT_MARKERS = ('作成中', '下書き', '差し替える', 'あとで書く', '(未記入)', '(未記入)', 'TODO', 'TBD')
134
+
135
+
136
+ def report_residue(text):
137
+ """報告が「無い・下書きのまま」なら理由、出来上がっていれば None。★純粋関数。"""
138
+ if text is None or not text.strip():
139
+ return '報告ファイルが無い(または空)'
140
+ lines = text.strip().splitlines()
141
+ if lines[0].strip() != '---':
142
+ return '報告の1行目に囲み(---)が無い'
143
+ try:
144
+ end = next(i for i in range(1, len(lines)) if lines[i].strip() == '---')
145
+ except StopIteration:
146
+ return '報告の囲みが閉じていない(2つめの --- が無い)'
147
+ head = lines[1:end]
148
+ body = '\n'.join(lines[end + 1:]).strip()
149
+ keys = {}
150
+ for ln in head:
151
+ m = re.match(r'^([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(.*)$', ln)
152
+ if m:
153
+ keys[m.group(1)] = m.group(2).strip()
154
+ missing = [k for k in REQUIRED_KEYS if k not in keys]
155
+ if missing:
156
+ return f'報告の囲みに足りない欄がある({"・".join(missing)})'
157
+ if keys['outcome'] not in VALID_OUTCOMES:
158
+ return f'報告の outcome が pushed / no_change / blocked のどれでもない({keys["outcome"] or "空"})'
159
+ # evidence は「- 」で始まる行が1つ以上要る。
160
+ if not any(ln.strip().startswith('- ') for ln in head):
161
+ return '報告の evidence が1件も無い'
162
+ if not body:
163
+ return '報告の本文が空(囲みだけ)'
164
+ hit = next((m for m in DRAFT_MARKERS if m in body), None)
165
+ if hit is not None:
166
+ return f'報告が下書きのまま(本文に「{hit}」が残っている)'
167
+ return None
168
+
169
+
170
+ # ─────────────────────────────────────────────────────────────────────────────
171
+ # (3) 背景に回した待ちが残っている
172
+ # ─────────────────────────────────────────────────────────────────────────────
173
+ #: 背景に回した仕事が始まった印(道具の戻りに入る・実測 2026-10-03)。
174
+ _BG_START_RE = re.compile(r'"backgroundTaskId"\s*:\s*"([A-Za-z0-9_-]+)"')
175
+ #: 背景の仕事から知らせが届いた印。★`<task-notification>` の塊の中の `<task-id>` だけを拾う
176
+ #: (道具の説明文に出てくる `<task-notification>` の字面を取り違えない)。
177
+ _BG_NOTIFY_RE = re.compile(r'<task-notification>(.*?)</task-notification>', re.S)
178
+ _BG_TASK_ID_RE = re.compile(r'<task-id>([A-Za-z0-9_-]+)</task-id>')
179
+
180
+
181
+ def outstanding_background_tasks(transcript_path):
182
+ """知らせがまだ届いていない背景の仕事の番号(順番は出てきた順)。読めなければ空。"""
183
+ if not transcript_path or not os.path.isfile(transcript_path):
184
+ return []
185
+ started, settled = [], set()
186
+ try:
187
+ with open(transcript_path, encoding='utf-8', errors='replace') as f:
188
+ for line in f:
189
+ for m in _BG_START_RE.finditer(line):
190
+ if m.group(1) not in started:
191
+ started.append(m.group(1))
192
+ if '<task-notification>' not in line:
193
+ continue
194
+ for blk in _BG_NOTIFY_RE.findall(line.replace('\\n', '\n')):
195
+ settled.update(_BG_TASK_ID_RE.findall(blk))
196
+ except Exception: # noqa: BLE001
197
+ return []
198
+ return [t for t in started if t not in settled]
199
+
200
+
201
+ def background_residue(task_ids):
202
+ """★純粋関数。残っていれば理由、無ければ None。"""
203
+ if not task_ids:
204
+ return None
205
+ return ('背景に回した待ちが ' + str(len(task_ids)) + ' 件残っている('
206
+ + '・'.join(task_ids[:5]) + ')')
207
+
208
+
209
+ # ─────────────────────────────────────────────────────────────────────────────
210
+ # (1) PR が開いたまま・合流の予約中のまま
211
+ # ─────────────────────────────────────────────────────────────────────────────
212
+ #: 便の本文に書かれた「合流は人が行う」(ADR-007)。★これが在れば (1) を見ない。
213
+ #: 「〜する」の言い回し(する・します・行う・行います・やる・やります …)。
214
+ _DO = r'(?:行(?:う|います|います。)|やり(?:ます)?|やる|し(?:ます)?|する)'
215
+ _HUMAN_MERGE_RE = re.compile(
216
+ r'(?:マージ|合流)は(?:人間|人|えふさん|持ち主)が' + _DO
217
+ + r'|(?:人間|人|えふさん|持ち主)が(?:マージ|合流)(?:を)?' + _DO
218
+ + r'|(?:マージ|合流)しない',
219
+ )
220
+
221
+
222
+ def human_merge_requested(job_body):
223
+ """便の本文が「合流は人が行う/合流しない」と言っているか。★純粋関数。"""
224
+ return bool(_HUMAN_MERGE_RE.search(job_body or ''))
225
+
226
+
227
+ def open_pr_residue(prs):
228
+ """`gh pr list` の結果から理由を作る。★純粋関数(通信はしない)。
229
+
230
+ prs は [{'number':…, 'url':…, 'state':'OPEN', 'autoMergeRequest':{…}|None}, …]。
231
+ """
232
+ if not isinstance(prs, list):
233
+ return None
234
+ out = []
235
+ for pr in prs:
236
+ if not isinstance(pr, dict):
237
+ continue
238
+ if str(pr.get('state') or 'OPEN').upper() != 'OPEN':
239
+ continue
240
+ num = pr.get('number')
241
+ if pr.get('autoMergeRequest'):
242
+ out.append(f'#{num}(合流の予約中)')
243
+ else:
244
+ out.append(f'#{num}(開いたまま)')
245
+ if not out:
246
+ return None
247
+ return 'この便で作った PR が合流していない: ' + '・'.join(out)
248
+
249
+
250
+ def current_branch(work_dir):
251
+ """いまの作業ブランチ名(読めない・main のときは None)。"""
252
+ try:
253
+ r = subprocess.run(
254
+ ['git', '-C', work_dir, 'rev-parse', '--abbrev-ref', 'HEAD'],
255
+ capture_output=True, text=True, timeout=15, check=False,
256
+ )
257
+ except Exception: # noqa: BLE001
258
+ return None
259
+ b = (r.stdout or '').strip()
260
+ if r.returncode != 0 or not b or b in ('HEAD', 'main', 'master'):
261
+ return None
262
+ return b
263
+
264
+
265
+ def read_open_prs(work_dir, branch):
266
+ """その枝の開いた PR を聞く。聞けなければ None(=分からない=止めない側へ倒す)。"""
267
+ if not branch:
268
+ return None
269
+ try:
270
+ r = subprocess.run(
271
+ ['gh', 'pr', 'list', '--head', branch, '--state', 'open',
272
+ '--json', 'number,url,state,autoMergeRequest'],
273
+ cwd=work_dir, capture_output=True, text=True, timeout=30, check=False,
274
+ )
275
+ except Exception: # noqa: BLE001
276
+ return None
277
+ if r.returncode != 0:
278
+ return None
279
+ try:
280
+ got = json.loads(r.stdout or '[]')
281
+ except Exception: # noqa: BLE001
282
+ return None
283
+ return got if isinstance(got, list) else None
284
+
285
+
286
+ # ─────────────────────────────────────────────────────────────────────────────
287
+ # 回数の数え方
288
+ # ─────────────────────────────────────────────────────────────────────────────
289
+ def load_counts(command_id, env=None):
290
+ try:
291
+ with open(counts_path(command_id, env), encoding='utf-8') as f:
292
+ got = json.load(f)
293
+ except Exception: # noqa: BLE001
294
+ return {}
295
+ return {k: int(v) for k, v in got.items() if k in GATE_KINDS and isinstance(v, (int, float))} \
296
+ if isinstance(got, dict) else {}
297
+
298
+
299
+ def save_counts(command_id, counts, env=None):
300
+ p = counts_path(command_id, env)
301
+ try:
302
+ os.makedirs(os.path.dirname(p), mode=0o700, exist_ok=True)
303
+ with open(p, 'w', encoding='utf-8') as f:
304
+ json.dump(counts, f)
305
+ except Exception: # noqa: BLE001
306
+ pass
307
+
308
+
309
+ def save_residue(command_id, text, env=None):
310
+ """上限に達して終わらせた理由を残す(中継が終了の事実へ載せる)。"""
311
+ p = residue_path(command_id, env)
312
+ try:
313
+ os.makedirs(os.path.dirname(p), mode=0o700, exist_ok=True)
314
+ with open(p, 'w', encoding='utf-8') as f:
315
+ f.write(text.strip()[:500])
316
+ except Exception: # noqa: BLE001
317
+ return False
318
+ return True
319
+
320
+
321
+ def cap_per_kind(env=None):
322
+ env = os.environ if env is None else env
323
+ try:
324
+ n = int((env.get('CONDUCTOR_FINISH_GATE_CAP') or '').strip())
325
+ except Exception: # noqa: BLE001
326
+ return CAP_PER_KIND
327
+ return n if n >= 0 else CAP_PER_KIND
328
+
329
+
330
+ # ─────────────────────────────────────────────────────────────────────────────
331
+ # 判定(★純粋関数。ここに通信もファイル読みも入れない)
332
+ # ─────────────────────────────────────────────────────────────────────────────
333
+ def decide(residues, counts, cap=CAP_PER_KIND, cap_total=CAP_TOTAL):
334
+ """残り({種類: 理由})と今までの回数から、止めるかどうかを決める。
335
+
336
+ 返り: {'block': bool, 'kinds': [種類], 'reasons': [理由], 'counts': 次の回数, 'last': bool}
337
+ block=True … 続けさせる(reason を職人へ返す)
338
+ block=False … 終わらせる(kinds が空でなければ「途中で終わった」=控えを残す)
339
+ last=True … これが最後の差し戻し(次は止めない)
340
+ """
341
+ counts = {k: int(counts.get(k, 0)) for k in GATE_KINDS}
342
+ live = [k for k in GATE_KINDS if residues.get(k)]
343
+ if not live:
344
+ return {'block': False, 'kinds': [], 'reasons': [], 'counts': counts, 'last': False}
345
+ total = sum(counts.values())
346
+ allowed = [k for k in live if counts[k] < cap] if total < cap_total else []
347
+ if not allowed:
348
+ # 上限に達した。止めずに、理由を付けて終わらせる。
349
+ return {'block': False, 'kinds': live, 'reasons': [residues[k] for k in live],
350
+ 'counts': counts, 'last': False}
351
+ nxt = dict(counts)
352
+ for k in allowed:
353
+ nxt[k] += 1
354
+ last = sum(nxt.values()) >= cap_total or all(nxt[k] >= cap for k in allowed)
355
+ return {'block': True, 'kinds': allowed, 'reasons': [residues[k] for k in allowed],
356
+ 'counts': nxt, 'last': last}
357
+
358
+
359
+ #: 残りの種類ごとの「次にやること」。★職人へ返す文(Conductor が報告の中身を作ることはしない)。
360
+ NEXT_STEPS = {
361
+ 'background': (
362
+ '背景に回した待ちを、いまこの手番の中で終わらせてください。'
363
+ '★画面なし方式(claude -p)では【手番の終わり=処理の終わり】です。'
364
+ '手番を閉じると背景の待ちは殺され、職人は二度と起きません(実地 915694ac・47bdfe00)。'
365
+ '待つなら Monitor か until ループで、この手番の中で待ち切ってください。'
366
+ ),
367
+ 'pr': (
368
+ '鉄則7のとおり、保護の確認 → CI → 合流 → 合流後の確かめ(4段)を終わらせてください。'
369
+ '合流を予約しただけでは終わりではありません。`gh pr view <番号> --json state` が '
370
+ 'MERGED になり、合流でできたコミットが合流先に入っていることを実物で確かめてから報告します。'
371
+ '★`--admin` など保護を迂回する旗は使いません。合流できない理由が分かったら、'
372
+ 'その理由を報告に書いて終えてください(それなら止めません)。'
373
+ ),
374
+ 'report': (
375
+ '報告を最終版まで書き上げてください(置き場は配達役の1行が指すファイル1か所)。'
376
+ '1行目から6欄の囲み(instruction_id・site・outcome・created_at・evidence)を置き、'
377
+ 'その下に本文を書きます。★「作成中」「差し替える」などの下書きの印を1つも残さないでください。'
378
+ ),
379
+ }
380
+
381
+
382
+ def build_reason(result):
383
+ """職人へ返す文(何が残っているか+次にやること)。"""
384
+ head = '【便の終わりの関門】まだ終わっていません。この手番を閉じないでください。'
385
+ lines = [head, '', '■ 残っているもの(実物で確かめた事実)']
386
+ lines += [f' ・{r}' for r in result['reasons']]
387
+ lines += ['', '■ 次にやること']
388
+ lines += [f' ・{NEXT_STEPS[k]}' for k in result['kinds']]
389
+ if result.get('last'):
390
+ lines += ['', '★これが最後の差し戻しです。次に手番を閉じると便は終わります。'
391
+ 'どうしても終わらせられないときは、報告の outcome を blocked にして、'
392
+ '「どこまで進んだか・何が残っているか・なぜ進められないか」を書いて終えてください。']
393
+ return '\n'.join(lines)
394
+
395
+
396
+ def build_residue_text(result):
397
+ """上限に達して終わらせたときに控えへ残す1行(管制へ「途中で終わった」として届く)。"""
398
+ return '途中で終わった(便の終わりの関門の上限): ' + ' / '.join(result['reasons'])
@@ -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
- r'''|['"]\.tyhld['"]\s*[,/+]\s*['"](hooks|bin)['"/]'''
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})['"`]''')
@@ -3,10 +3,17 @@
3
3
 
4
4
  【いつ走るか】職人が応答を返し終えた瞬間(=入力待ちに入る瞬間)。
5
5
 
6
- 【何をするか】
7
- 紐づく指示(ConductorCommand)が【まだ終わっていない】、かつ最後の発言が【人の判断を
8
- 求める問いかけ】のときだけ、その発言を kind='message' として管制へ送る。
9
- 管制側ではフックが待っている間だけカードが出る。
6
+ 【何をするか】★2つの役目を持つ。順番は「① 終わりの関門 → ② 確認カード」ではなく
7
+ 「① 人の問いかけなら確認カード → ② そうでなければ終わりの関門」。人の判断が先。
8
+
9
+ ① 確認カード(前からの役目)… 紐づく指示(ConductorCommand)が【まだ終わっていない】、
10
+ かつ最後の発言が【人の判断を求める問いかけ】のときだけ、その発言を kind='message' として
11
+ 管制へ送る。管制側ではフックが待っている間だけカードが出る。
12
+
13
+ ② ★便の終わりの関門(便 7aa63a97 で足した)… 人への問いかけではない手番の終わりでは、
14
+ 【実物】を見て「まだ残っている」なら `{"decision":"block","reason":…}` を返して続けさせる。
15
+ 見るのは PR・報告ファイル・背景に回した待ちの3つだけで、職人の言葉は読まない。
16
+ 判定と回数の上限は finish_gate.py にある(そこの説明文に実地の3件と理由を書いた)。
10
17
 
11
18
  【★カードを出す条件は2つ(両方満たしたときだけ出す)】
12
19
  (1) 紐づく指示がまだ終わっていないこと
@@ -40,6 +47,7 @@ import sys
40
47
 
41
48
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
42
49
 
50
+ import finish_gate # noqa: E402
43
51
  import hook_datalink # noqa: E402
44
52
  import summary_ja # noqa: E402
45
53
 
@@ -223,6 +231,86 @@ def build_payload(data, command_id):
223
231
  }
224
232
 
225
233
 
234
+ # ─────────────────────────────────────────────────────────────────────────────
235
+ # ★便の終わりの関門(finish_gate.py の判定へ、実物を集めて渡すだけの層)
236
+ # ─────────────────────────────────────────────────────────────────────────────
237
+ def load_job_body(site, env=None):
238
+ """中継が残した「いま走っている便」の本文(無ければ空)。★便の本文だけを読む。"""
239
+ env = os.environ if env is None else env
240
+ if not site:
241
+ return ''
242
+ xdg = (env.get('XDG_STATE_HOME') or '').strip()
243
+ root = xdg if xdg else os.path.join((env.get('HOME') or os.path.expanduser('~')), '.local', 'state')
244
+ for name in (f'active-job-{site}.json', f'last-job-{site}.json'):
245
+ try:
246
+ with open(os.path.join(root, 'conductor', name), encoding='utf-8') as f:
247
+ got = json.load(f)
248
+ except Exception: # noqa: BLE001
249
+ continue
250
+ if isinstance(got, dict) and isinstance(got.get('body'), str):
251
+ return got['body']
252
+ return ''
253
+
254
+
255
+ def gate_enabled(env=None):
256
+ """関門を効かせる機体か。★画面なし方式のときだけ(対話の画面は1文字も変えない)。
257
+
258
+ 対話の画面では「手番の終わり」は人への受け渡しなので、止めると人と話せなくなる。
259
+ 印が無い機体では何もしない(fail open=いまと同じ動き・退行しない)。
260
+ """
261
+ env = os.environ if env is None else env
262
+ return (env.get('CONDUCTOR_RUNNER_MODE') or '').strip().lower() == 'headless'
263
+
264
+
265
+ def collect_residues(data, command_id, env=None):
266
+ """残っている物を【実物】で集める({種類: 理由})。読めないものは「無い」側へ倒す。"""
267
+ env = os.environ if env is None else env
268
+ site = (env.get('CONDUCTOR_SITE') or '').strip()
269
+ work_dir = (env.get('CONDUCTOR_WORK_DIR') or '').strip() or (data.get('cwd') or '')
270
+ residues = {}
271
+
272
+ # (2) 報告 … 置き場の実物を見る。
273
+ residues['report'] = finish_gate.report_residue(
274
+ finish_gate.read_report_text(command_id, env))
275
+
276
+ # (3) 背景に回した待ち … 会話の記録の実物を見る。
277
+ residues['background'] = finish_gate.background_residue(
278
+ finish_gate.outstanding_background_tasks(data.get('transcript_path')))
279
+
280
+ # (1) PR … 便の本文が「合流は人が行う/合流しない」と言っていれば見ない(ADR-007)。
281
+ if not finish_gate.human_merge_requested(load_job_body(site, env)) and work_dir:
282
+ residues['pr'] = finish_gate.open_pr_residue(
283
+ finish_gate.read_open_prs(work_dir, finish_gate.current_branch(work_dir)))
284
+ return {k: v for k, v in residues.items() if v}
285
+
286
+
287
+ def finish_gate_block(data, env=None):
288
+ """関門の判定。続けさせるなら職人へ返す文、終わらせるなら None。
289
+
290
+ ★止めるのは「実物が残っていて、かつ回数の上限に達していない」ときだけ。
291
+ 上限に達したら止めず、理由を控えに残す(管制へ「途中で終わった」として届く)。
292
+ """
293
+ env = os.environ if env is None else env
294
+ if not gate_enabled(env):
295
+ return None
296
+ command_id = find_command_id(data.get('transcript_path'))
297
+ if not command_id:
298
+ return None # 便ではない(対話の作業・自己診断)→ 止めない
299
+ residues = collect_residues(data, command_id, env)
300
+ result = finish_gate.decide(
301
+ residues,
302
+ finish_gate.load_counts(command_id, env),
303
+ cap=finish_gate.cap_per_kind(env),
304
+ )
305
+ if result['block']:
306
+ finish_gate.save_counts(command_id, result['counts'], env)
307
+ return finish_gate.build_reason(result)
308
+ if result['kinds']:
309
+ # 上限に達した=これ以上止めない。理由だけ残して終わらせる。
310
+ finish_gate.save_residue(command_id, finish_gate.build_residue_text(result), env)
311
+ return None
312
+
313
+
226
314
  def run(data, cfg=None, datalink=hook_datalink):
227
315
  """返り値: 送った確認のID / None(送らなかった)。"""
228
316
  message = data.get('last_assistant_message')
@@ -264,11 +352,33 @@ def main() -> int:
264
352
  data = json.loads(raw) if raw.strip() else {}
265
353
  except Exception: # noqa: BLE001
266
354
  return 0
355
+
356
+ # ① 人が答えるべき問いかけで終わった手番は、前からの確認カードの経路だけを通す。
357
+ # ★関門で止めると人の答えが流れる(ADR-024 §3 の取りこぼしを作らない)。
358
+ message = data.get('last_assistant_message')
359
+ asks_human = isinstance(message, str) and needs_human_answer(message)
360
+ if asks_human:
361
+ try:
362
+ run(data)
363
+ except Exception: # noqa: BLE001
364
+ pass
365
+ return 0
366
+
367
+ # ② 便の終わりの関門。残っていれば続けさせる。
368
+ # ★ここで何が起きても便を壊さない(例外は握って、いまと同じ「何もしない」へ倒す)。
369
+ try:
370
+ reason = finish_gate_block(data)
371
+ except Exception: # noqa: BLE001
372
+ reason = None
373
+ if reason:
374
+ print(json.dumps({'decision': 'block', 'reason': reason}, ensure_ascii=False))
375
+ return 0
376
+
267
377
  try:
268
378
  run(data)
269
379
  except Exception: # noqa: BLE001
270
380
  pass
271
- return 0 # ★Stop フックは会話を止めない(何があっても exit 0・出力なし)
381
+ return 0 # ★止めないときは今までどおり(exit 0・出力なし)
272
382
 
273
383
 
274
384
  if __name__ == '__main__':
@@ -0,0 +1,310 @@
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
+ check('残りが無ければ止めない', finish_gate.decide({}, {}, cap=3)['block'], False)
198
+ check('残りが無ければ種類も空', finish_gate.decide({}, {'report': 1}, cap=3)['kinds'], [])
199
+
200
+ d = finish_gate.decide({'report': 'a', 'pr': 'b', 'background': 'c'}, {}, cap=3)
201
+ check('3つ同時でも1回で止める', d['block'], True)
202
+ check('3つぶん数える', sorted(d['kinds']), ['background', 'pr', 'report'])
203
+ d = finish_gate.decide({'report': 'a', 'pr': 'b'}, {'report': 2, 'pr': 2, 'background': 2}, cap=3)
204
+ check('★合計の上限(本体の連続上限 8 の内側)で止めなくなる', d['block'], False)
205
+ check('cap=0 なら1回も止めない', finish_gate.decide(R1, {}, cap=0)['block'], False)
206
+
207
+ # ─────────────────────────────────────────────────────────────────────────────
208
+ print('\n=== (E) 職人へ返す文 ===')
209
+ # ─────────────────────────────────────────────────────────────────────────────
210
+ d = finish_gate.decide({'report': '報告が下書きのまま', 'pr': 'PR #252 が開いたまま',
211
+ 'background': '背景に回した待ちが 1 件残っている'}, {}, cap=3)
212
+ msg = finish_gate.build_reason(d)
213
+ truthy('何が残っているかが入る', '残っているもの' in msg)
214
+ truthy('次にやることが入る', '次にやること' in msg)
215
+ truthy('残りの理由がそのまま入る', 'PR #252 が開いたまま' in msg)
216
+ truthy('★手番を閉じないでと書いてある', '閉じない' in msg)
217
+ truthy('★画面なし方式では手番の終わり=処理の終わりと書いてある', '手番の終わり' in msg)
218
+ truthy('鉄則7の4段を指している', '合流後' in msg or '鉄則7' in msg)
219
+ last = finish_gate.build_reason(finish_gate.decide(R1, {'report': 2}, cap=3))
220
+ truthy('最後の差し戻しだと伝える', '最後の差し戻し' in last)
221
+ truthy('終わらせ方(blocked)を示す', 'blocked' in last)
222
+
223
+ # ─────────────────────────────────────────────────────────────────────────────
224
+ print('\n=== (F) ★止めない便 ===')
225
+ # ─────────────────────────────────────────────────────────────────────────────
226
+ check('画面なし方式の印が在る → 効く', stop_hook.gate_enabled(ENV), True)
227
+ for mode in ('tmux', '', 'TMUX'):
228
+ e = dict(ENV, CONDUCTOR_RUNNER_MODE=mode)
229
+ check(f'画面の方式({mode or "印なし"})→ 効かない', stop_hook.gate_enabled(e), False)
230
+ check('印そのものが無い → 効かない', stop_hook.gate_enabled({}), False)
231
+
232
+ clear_counts()
233
+ drop_report()
234
+ check('★便の印が無い記録 → 止めない(便ではない)',
235
+ stop_hook.finish_gate_block(
236
+ {'transcript_path': transcript(['{"text":"ふつうの作業"}'], 'nojob.jsonl')}, ENV), None)
237
+ check('★人が答えるべき問いかけは関門より先(確認カードの経路へ)',
238
+ stop_hook.needs_human_answer('どちらにしますか?'), True)
239
+ check('進捗の報告は問いかけではない',
240
+ stop_hook.needs_human_answer('CI の完走を待っています。'), False)
241
+
242
+ # ─────────────────────────────────────────────────────────────────────────────
243
+ print('\n=== (G) ★実地の3件の再現(実物を置いて、関門が止めるか) ===')
244
+ # ─────────────────────────────────────────────────────────────────────────────
245
+ JOB_LINE = f'{{"text":"CONDUCTOR_JOB:{JOB}"}}'
246
+ put_job_body('鉄則7のとおり合流まで進めてください')
247
+
248
+ # 915694ac … 報告が下書きのまま + 背景の待ちが残っている
249
+ clear_counts()
250
+ put_report(report_text(body=REAL_DRAFT))
251
+ t = transcript([JOB_LINE, START % 'b32iul78k'], 'g1.jsonl')
252
+ msg = stop_hook.finish_gate_block({'transcript_path': t, 'cwd': SANDBOX}, ENV)
253
+ truthy('★915694ac の形 → 止める', msg)
254
+ truthy(' 下書きだと言う', '下書き' in (msg or ''))
255
+ truthy(' 背景の待ちが残っていると言う', '背景' in (msg or ''))
256
+
257
+ # 47bdfe00 … 報告本文が1文字も無い
258
+ clear_counts()
259
+ put_report(report_text(body=''))
260
+ t = transcript([JOB_LINE, START % 'be6b727fc'], 'g2.jsonl')
261
+ msg = stop_hook.finish_gate_block({'transcript_path': t, 'cwd': SANDBOX}, ENV)
262
+ truthy('★47bdfe00 の形 → 止める', msg)
263
+
264
+ # dc1c9ed2 … 報告が1つも無い
265
+ clear_counts()
266
+ drop_report()
267
+ t = transcript([JOB_LINE], 'g3.jsonl')
268
+ msg = stop_hook.finish_gate_block({'transcript_path': t, 'cwd': SANDBOX}, ENV)
269
+ truthy('★dc1c9ed2 の形(報告が無い)→ 止める', msg)
270
+
271
+ print('--- ★きちんと終わった便は止めない ---')
272
+ clear_counts()
273
+ put_report(report_text())
274
+ t = transcript([JOB_LINE, START % 'b1', NOTIFY % ('b1', 'completed')], 'g4.jsonl')
275
+ check('最終版の報告+待ち無し → 止めない',
276
+ stop_hook.finish_gate_block({'transcript_path': t, 'cwd': SANDBOX}, ENV), None)
277
+
278
+ print('--- ★上限に達したら終わらせ、理由の控えを残す ---')
279
+ clear_counts()
280
+ drop_report()
281
+ t = transcript([JOB_LINE], 'g5.jsonl')
282
+ stops = 0
283
+ for _ in range(8):
284
+ if stop_hook.finish_gate_block({'transcript_path': t, 'cwd': SANDBOX}, ENV):
285
+ stops += 1
286
+ else:
287
+ break
288
+ check('止めた回数は上限まで', stops, finish_gate.CAP_PER_KIND)
289
+ check('その後は止めない',
290
+ stop_hook.finish_gate_block({'transcript_path': t, 'cwd': SANDBOX}, ENV), None)
291
+ rp = finish_gate.residue_path(JOB, ENV)
292
+ truthy('理由の控えが残る', os.path.isfile(rp))
293
+ with open(rp, encoding='utf-8') as f:
294
+ truthy('控えに「途中で終わった」と入っている', '途中で終わった' in f.read())
295
+
296
+ print('--- ★便の本文が「合流は人が行う」なら PR を見ない ---')
297
+ clear_counts()
298
+ put_report(report_text())
299
+ put_job_body('★この便のマージは人間が行う')
300
+ t = transcript([JOB_LINE, START % 'b1', NOTIFY % ('b1', 'completed')], 'g6.jsonl')
301
+ check('合流を人に任せた便は止めない',
302
+ stop_hook.finish_gate_block({'transcript_path': t, 'cwd': SANDBOX}, ENV), None)
303
+
304
+ print('\n' + '=' * 72)
305
+ if failures:
306
+ print(f'NG {len(failures)} 件')
307
+ for f in failures:
308
+ print(' ' + f)
309
+ sys.exit(1)
310
+ 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('すべて期待どおり')
@@ -436,6 +436,7 @@ 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
  INSTALL_CONFIGS=(safe_verbs.json network_allow.json)
441
442
  ENTRYPOINTS=(cc2_guard.py permission_request_hook.py stop_hook.py notification_hook.py)
@@ -169,6 +169,12 @@ description: 采配くんの便(プロンプト末尾に CONDUCTOR_JOB:<id>
169
169
 
170
170
  便の最後には、配達役が付けた1行「【便の終え方】…報告の置き場: ~/最終行: DONE:<現場> とだけ出力/完了の知らせ: conductor_complete を commandId="<指示ID>" で1回だけ」と、管理用ジョブ印 `CONDUCTOR_JOB:<指示ID>` がある。作業がすべて終わったら、この節のとおりに次の順で終える(便 a5306df2:以前は便ごとに付いていた全文を、ここへ寄せた)。
171
171
 
172
+ ⓪ **★報告を書く前に、「反映された」を実物で確かめる**(えふさん確定 2026-10-02 夜・便 7aa63a97)。**報告到着 ≠ 完了/職人終了 ≠ 受入完了。** 職人への指示は反映(合流・公開・本番反映)まで終わらせる。次の3つを、**自分の言葉ではなく実物の出力で**確かめてから①へ進む。
173
+ - **(a) 合流したか** … 鉄則7-★の確かめ方(`gh pr view <番号> --json state,baseRefName,mergeCommit` が `MERGED` + `git merge-base --is-ancestor <合流コミット> origin/<合流先>`)。★**合流を予約しただけ(`--auto`)は「終わり」ではない。** 予約は GitHub が後から合流するので、**予約したまま手番を閉じると便が死ぬ**(実地 47bdfe00)。鉄則7-★の4段の塊を使えば ④ が最後まで待つ。
174
+ - **(b) 公開版/本番が新しくなったか** … 配布物を変えた便なら `npm view <パッケージ> version` が上げた版になっていること。画面を変えた便なら配備先の版。★**変えていない便は「変えていない」と確かめる**(確かめを省くのではない)。
175
+ - **(c) 背景に回した待ちが残っていないか** … ★**待ちを背景に残したまま手番を閉じない。** 画面なし方式(`claude -p`)では**手番の終わり=処理の終わり**で、背景の待ちはその瞬間に殺され、職人は二度と起きない(実地 915694ac:「まだ実行中です。終わり次第、結果を確かめます。」で手番を閉じ、報告は下書きのまま便が死んだ)。待つなら `Monitor` か `until <条件>; do sleep 2; done` で、**この手番の中で待ち切る**。
176
+ - ★(a)(b)(c) のどれかが終わらせられないときは、**終わったふりをしない**。報告の `outcome` を `blocked` にして、どこまで進んだか・何が残っているか・なぜ進められないかを書いて終える(それが正しい終わり方)。
177
+ - ★ここを飛ばすと、**便の終わりの関門**(Stop フックの `finish_gate.py`)が実物を見て手番を閉じさせない(PR が開いたまま・報告が下書きのまま・背景の待ちが残っている のどれかなら差し戻す)。同じ理由で止まるのは回数の上限つきなので、**止められたら回避せず、残りを終わらせる**。
172
178
  ① **報告をファイルに書く。** 置き場は配達役の1行が指すファイル**だけ**(正本は `~/.local/state/conductor/reports/<指示ID>.md`)。★便の本文に別の置き場(`/tmp` など)が書かれていても、**配達役の1行の置き場を正とする**(置き場は1か所)。フォルダが無ければ作る。長さの制限は無い。画面に出す必要は無い。中身は変更ファイル・要点・チェック結果を簡潔に。
173
179
  ② **ファイルの先頭(1行目から)に、次の囲みを付ける**(管制が機械で読む口)。
174
180
  ```
@@ -182,6 +188,7 @@ description: 采配くんの便(プロンプト末尾に CONDUCTOR_JOB:<id>
182
188
  ---
183
189
  ```
184
190
  この囲みの下に、いつもどおりの報告本文を書く(本文が空だと未完成と見なされる)。
191
+ - ★**置いた瞬間から「報告が届いた」と数えられる。下書きを置かない。** 実地 915694ac では「報告(作成中)この見出しは最終版で差し替える」だけの下書きを置いたまま便が死に、管制では「終わりました・✅」になった(合流も公開もしていなかった)。**途中の控えを正本の置き場に置かない**(途中で書き留めたいものは `/tmp/claude-1000/` に置く=鉄則13)。関門も「作成中」「差し替える」「下書き」「あとで書く」「TODO」「TBD」が本文に残っていれば下書きとして差し戻す。
185
192
  ③ **その後の最終行に、配達役の1行にある `DONE:<現場>` とだけ出力する**(完了の合図)。
186
193
  ④ **ai-kana の MCP ツール `conductor_complete` を、配達役の1行にある commandId で1回だけ呼ぶ**(relay に依存しない確実な完了通知)。report 引数に上の報告本文を添えてもよい。
187
194
  - **nextWork(次の独立作業候補)は必ず書く。** 無ければ「なし」と、なぜ無いかの理由を書く。