@tyhld/conductor 0.12.97 → 0.12.98

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 CHANGED
@@ -19,7 +19,7 @@ import { createInterface } from 'node:readline';
19
19
  import { createWriteStream, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
20
20
  import path from 'node:path';
21
21
  import os from 'node:os';
22
- import { CENTRAL_BUSY_HOLD_MAX_MS, RELAY_DELIVER_MAX, RELAY_FETCH_TIMEOUT_MS, RELAY_HEARTBEAT_INTERVAL_MS, RELAY_RESTORE_ORPHANS_ENABLED, ackResumeSignal, buildAnchor, buildSendText, canDeliver, completeWithReport, createRelayState, deliverJobEndFacts, deliveryReport, fetchCommand, fetchCommandStatus, fetchControl, fetchOrphans, flushPendingJobEndFacts, isTerminalCommandStatus, kindOf, markDelivered, noteCentralDown, noteCentralUp, noteDrain, noteReadiness, patchStatus, recoverStuckProcessedReports, reconnectDelayMs, FINISH_GATE_KINDS, reportDelivery, selectOrphanToRestore, sendHeartbeat, } from './relay.js';
22
+ import { CENTRAL_BUSY_HOLD_MAX_MS, RELAY_DELIVER_MAX, RELAY_FETCH_TIMEOUT_MS, RELAY_HEARTBEAT_INTERVAL_MS, RELAY_RESTORE_ORPHANS_ENABLED, ackResumeSignal, buildAnchor, buildSendText, canDeliver, completeWithReport, createRelayState, deliverJobEndFacts, deliveryReport, fetchCommand, fetchCommandStatus, fetchControl, fetchOrphans, flushPendingJobEndFacts, isTerminalCommandStatus, kindOf, markDelivered, noteCentralDown, noteCentralUp, noteDrain, noteReadiness, patchStatus, recoverStuckProcessedReports, reconnectDelayMs, WAIT_CLOSED_STATUS, FINISH_GATE_KINDS, reportDelivery, selectOrphanToRestore, sendHeartbeat, } from './relay.js';
23
23
  import { claudePinPath, readClaudePinLine, } from './autoupdate-flags.js';
24
24
  const log = (msg) => {
25
25
  console.log(`[headless] ${msg}`);
@@ -1792,8 +1792,10 @@ export const RESUME_MEMO_MAX = 200;
1792
1792
  * perm_wait … 人の手番で止まっている(本番操作待ちの便はここで待つ)。この現場を占有し続ける。
1793
1793
  * sent … 配達済みのまま(管制が戻した・まだ閉じていない)。
1794
1794
  * ★done / failed / dismissed / queued では起こさない(終わった便・まだ配っていない便を起こさない)。
1795
+ * ★待ちの綴りは1か所(relay.ts の WAIT_CLOSED_STATUS)。「起こせる状態」と「完了を書かない状態」が
1796
+ * 食い違うと、起こせない便を完了にする/完了にできる便を起こそうとする、のどちらかが起きる。
1795
1797
  */
1796
- export const RESUMABLE_CENTRAL_STATUSES = new Set(['perm_wait', 'sent']);
1798
+ export const RESUMABLE_CENTRAL_STATUSES = new Set([WAIT_CLOSED_STATUS, 'sent']);
1797
1799
  /** 「この便は続きから起こせる」の控えを覚えておく件数の上限。 */
1798
1800
  export const RESUMABLE_MEMO_MAX = 20;
1799
1801
  /** 現場ごとの「使い終わった合図」の記録ファイル。 */
package/dist/relay.js CHANGED
@@ -1588,6 +1588,38 @@ export function isCentralFinished(status) {
1588
1588
  const s = status.trim().toLowerCase();
1589
1589
  return isTerminalCommandStatus(s) || CENTRAL_EXTRA_TERMINAL_STATUSES.has(s);
1590
1590
  }
1591
+ /**
1592
+ * 管制が「人の手番で待っている(=まだ終わっていない)」と言うときの status(★綴りは1か所)。
1593
+ *
1594
+ * 本番操作の承認が長引いた便は、職人が `conductor_complete` を `waitForProdOp:true` で呼ぶと
1595
+ * `done` にならずこの状態のまま閉じられる(管制 ai-kana PR #425)。本番操作が済むと管制の合図で
1596
+ * 【同じ便の続き】から起きる(headless.ts の RESUMABLE_CENTRAL_STATUSES も同じ1語を使う)。
1597
+ */
1598
+ export const WAIT_CLOSED_STATUS = 'perm_wait';
1599
+ /**
1600
+ * 中央が「この便は人の手番で待っている=まだ終わっていない」と言っているか(純粋関数)。
1601
+ *
1602
+ * 【なぜ要るか(便 f20dd761)】待ちのまま閉じた便を、職人の `DONE:` の1行やプロセスの正常終了で
1603
+ * 配り役が `done` にしてしまうと、合図では二度と起きない(「終端は起こさない=Fail Closed」は
1604
+ * 正しいので緩めない)。だから【完了を書く前に】中央へ聞いて、待ちで閉じられていれば書かない。
1605
+ *
1606
+ * 【★確かめられる材料はこれだけ】配り役がいま使っている口
1607
+ * `GET /api/conductor/commands/{id}` は status しか返さない(statusNote も依頼の行も返らない)。
1608
+ * つまり「本番操作の待ち」か「それ以外の人待ち」かは、この口では見分けられない。
1609
+ * 【どちらへ倒すか=書かない側】理由は取り返しのつきかた:
1610
+ * ・書いてしまうと終端=二度と起こせない(作業がまるごと失われる)。
1611
+ * ・書かないでおけば、この状態は管制の RECOVERABLE_STATUSES に入っているので、職人の
1612
+ * `conductor_complete` でも人の操作でも、あとから `done` へ回収できる。
1613
+ * =「待ちでない便の完了」は取りこぼしても回収できるが、「待ちの便の完了」は回収できない。
1614
+ * 【★中央不達(null)は今までどおり】status を読めないときは false=従来の動きへ無損失で縮退する
1615
+ * (読めないだけで全部の便が終われなくなる形にしない)。この穴を塞ぐには管制側が要る
1616
+ * (報告の「要判断」に、どの口に何が要るかを書いた)。
1617
+ */
1618
+ export function isWaitClosedCommandStatus(status) {
1619
+ if (status === null)
1620
+ return false;
1621
+ return status.trim().toLowerCase() === WAIT_CLOSED_STATUS;
1622
+ }
1591
1623
  /**
1592
1624
  * sent孤児を pending 構造へ変換する純粋関数(★送信はしない)。
1593
1625
  * - anchor は buildAnchor(id) で再生成(id 不変=アンカー不変)=完了検出の境界が復元前と揃う。
@@ -1757,7 +1789,17 @@ export function fullCompletionGuide(site, commandId) {
1757
1789
  `reportに理由を書いてください。ブランチを切らない読み取り専用の作業では outcome=no_change を渡してください` +
1758
1790
  `(branchは省略可)。` +
1759
1791
  // ★nextWork(次の独立作業候補)は必ず書く(conductor_complete の説明と同じ・便 a5306df2 で両方にそろえた)。
1760
- `nextWork(次の独立作業候補)は必ず書いてください(無ければ「なし」と理由)。\n\n`);
1792
+ `nextWork(次の独立作業候補)は必ず書いてください(無ければ「なし」と理由)。\n\n` +
1793
+ // ★本番操作の承認待ちで「待ち」のまま閉じる手番だけの例外(便 f20dd761・管制 PR #425)。
1794
+ // ここに書いてある決まりは、鉄則の「便の終え方」の⑤と中身を同じに保つ(等しさの試験が見る)。
1795
+ `★本番操作の承認が長引いて、便を「待ち」のまま閉じるときだけは例外です。` +
1796
+ `conductor_complete を waitForProdOp:true(分かれば prodOpRequestId も)で呼ぶと、` +
1797
+ `便は完了にならず「本番操作の承認待ち」のまま閉じます。` +
1798
+ `★この手番では ${marker} の行を出さないでください` +
1799
+ `(出すと配り役の完了検出がこの便を完了=終端にしてしまい、本番操作が済んでも続きから起こせなくなります)。` +
1800
+ `報告ファイルは置いてよいです(報告ファイルだけでは便は完了になりません)。` +
1801
+ `本番操作が済むと、管制の合図で同じ便の続きから起きます。続きを行い、` +
1802
+ `本当に終わったときに今までどおり(${marker} も出して)終えてください。\n\n`);
1761
1803
  }
1762
1804
  /**
1763
1805
  * Codex の職人へ付ける完了案内(便 cfa9eeec・★Claude 向けは shortCompletionLine)。
@@ -3251,8 +3293,27 @@ export async function fetchCommand(cfg) {
3251
3293
  * null/未指定なら report は送らず status のみ(従来どおり)。
3252
3294
  * - 409(他が sent 中などの安全弁)→ false(送らない)。
3253
3295
  * - 200 → true。
3296
+ *
3297
+ * 【★完了(done)だけは、書く前に中央へ聞く(便 f20dd761)】
3298
+ * 待ちのまま閉じられた便(isWaitClosedCommandStatus)に `done` を書くと、その便は終端になり
3299
+ * 管制の合図では二度と起きない。職人の作法(「待ちで閉じた手番では DONE: を出さない」)だけに
3300
+ * 頼らず、★完了を書く道そのものを1か所でふさぐ(ここを通らずに done を書く道は無い)。
3301
+ * ・`done` 以外(delivering / sent / failed)は1バイトも動きが変わらない。
3302
+ * ★failed は塞がない:起こし直しに失敗した等の「動かせない事実」は管制へ届かないと困る
3303
+ * (headless の再開の道は、起こせなかったとき perm_wait の便へ failed と理由を書く)。
3304
+ * ・返りは true=「書かなかったが、やり直す意味も無い(便の状態は管制が持っている)」。
3305
+ * 呼び出し側は追跡を畳んでよい。false(=次の周でやり直す)に倒すと、画面なし方式が
3306
+ * 上限回数のあとに `failed` を書いてしまい、待ちの便を殺す。
3254
3307
  */
3255
3308
  export async function patchStatus(cfg, id, status, report) {
3309
+ if (status === 'done') {
3310
+ const held = await fetchCommandStatus(cfg, id);
3311
+ if (isWaitClosedCommandStatus(held)) {
3312
+ log(`site=${cfg.site} id=${id} 完了は書きません(管制は status=${held}=人の手番で待っています)` +
3313
+ '→本番操作が済めば管制の合図で同じ便の続きから起きます');
3314
+ return true;
3315
+ }
3316
+ }
3256
3317
  try {
3257
3318
  const payload = report != null ? { status, report } : { status };
3258
3319
  const res = await fetch(`${cfg.url}/api/conductor/commands/${encodeURIComponent(id)}`, {
@@ -3330,6 +3391,18 @@ export async function completeWithReport(cfg, id, logLine = log) {
3330
3391
  : '→relayからは送りません(報告は控えとして処理済みにしました)'));
3331
3392
  return true;
3332
3393
  }
3394
+ if (isWaitClosedCommandStatus(already)) {
3395
+ // ★待ちのまま閉じられた便(本番操作の承認待ち・管制 PR #425)。★完了を1バイトも書かない。
3396
+ // 職人の作法(「待ちで閉じた手番では DONE: を出さない」)が守られなくても、画面の DONE でも
3397
+ // プロセスの正常終了でも、ここで止まる=合図で同じ便の続きから起こせる状態を壊さない。
3398
+ // ★報告ファイルは「処理済み」に移さない。移すと職人が続きで書き直した報告と混ざる
3399
+ // (移さなくても回収の道が送る=その口は便を完了にしない)。
3400
+ // ★返りは true=追跡を畳んでよい。管制はこの便で現場を占有し続けるので、次の便は配られない。
3401
+ logLine(`site=${cfg.site} id=${id} は待ちのまま閉じられています(status=${already})` +
3402
+ '→完了は書きません(本番操作が済めば管制の合図で同じ便の続きから起きます)' +
3403
+ (phase === 'none' ? '' : `/報告は手元の控えに残します(${phase})`));
3404
+ return true;
3405
+ }
3333
3406
  if (phase === 'processed') {
3334
3407
  // ★①は済み・②は未了。本文は渡してあるので、状態だけを書く(開発履歴を2件にしない)。
3335
3408
  // 書けなければ false=次の周でやり直す(完了を落とさない)。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tyhld/conductor",
3
- "version": "0.12.97",
3
+ "version": "0.12.98",
4
4
  "description": "采配くん管制の見守りアプリ(conductor-agent)。各PCで常駐し、中央(devlog-tracker)へ定期的に生存報告(heartbeat)を送る常駐CLI。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -37,6 +37,8 @@
37
37
  ・人が答えるべき問いかけで終わった … 人の判断が先(ADR-024 §3 の取りこぼしを作らない)
38
38
  ・便の本文に「合流は人が行う」と書いてある … (1) を見ない(ADR-007)
39
39
  ・PR を作っていない便・読むだけの便 … (1) は当たらない(開いた PR が無いので何も起きない)
40
+ ・★本番操作の承認待ちで「待ち」のまま閉じた手番 … 便が終わる手番ではないので (1)(2) を見ない
41
+ (便 f20dd761・closed_as_prod_op_wait)。(3) は今までどおり見る=背景の待ちは殺されるため。
40
42
  """
41
43
  import json
42
44
  import os
@@ -357,6 +359,55 @@ def background_residue(task_ids):
357
359
  + '・'.join(task_ids[:5]) + ')')
358
360
 
359
361
 
362
+ # ─────────────────────────────────────────────────────────────────────────────
363
+ # ★本番操作の承認待ちで「待ち」のまま閉じた手番(便 f20dd761・管制 PR #425)
364
+ #
365
+ # 【なぜ要るか】本番操作の承認が長引いたとき、職人は `conductor_complete` を
366
+ # `waitForProdOp:true` で呼び、便を完了にせず「本番操作の承認待ち」のまま閉じて手番を終える。
367
+ # この手番は【便が終わる手番ではない】ので、関門が「終え方を満たしていない」として
368
+ # 差し戻してはいけない。差し戻すと、承認を待つために閉じたい手番が閉じられず、
369
+ # 上限まで止めたあと「途中で終わった」の控えだけが残る(実際には何も途中ではない)。
370
+ #
371
+ # 【何を見ないことにするか】
372
+ # ・report(報告が無い・下書き) … 便はまだ終わっていない=最終版の報告を求める場面ではない。
373
+ # ・pr(PR が開いたまま) … 承認待ちの本番操作が PR の中身そのものであることがある。
374
+ # 【★background は今までどおり見る】手番を閉じれば背景の待ちは殺される。これは待ちで閉じても
375
+ # 同じなので、ここだけは差し戻す(起きたときに同じ所からやり直せる)。
376
+ #
377
+ # 【どう見分けるか=実物だけ】会話の記録に残る道具の返りを見る。管制は待ちで閉じたとき
378
+ # `waitClosed: true` と `doneLineForbidden: true` を返す(便IDも同じ返りに入る)。
379
+ # ★3つが【同じ1行(=同じ記録)】にそろったときだけ「待ちで閉じた」と読む
380
+ # =報告の文章の中でこの機能を説明しても、取り違えない(便 99fc49c0 と同じ作法で便IDに縛る)。
381
+ # ─────────────────────────────────────────────────────────────────────────────
382
+ #: 管制が「待ちのまま閉じた」と返す印(★字面は ai-kana の conductor_complete の返りと同じ)。
383
+ #: ★会話の記録では、道具の返りが「JSON の中の文字列」として入ることがあり、そのときは
384
+ #: 引用符が `\"` に escape されている。どちらの形でも読めるようにする(実物2通り)。
385
+ _WAIT_CLOSED_RE = re.compile(r'waitClosed[\\"\s]*:[\\"\s]*true')
386
+ #: 「この手番では DONE: を出さないでください」の機械で読む印。
387
+ _DONE_FORBIDDEN_RE = re.compile(r'doneLineForbidden[\\"\s]*:[\\"\s]*true')
388
+
389
+
390
+ def closed_as_prod_op_wait(transcript_path, command_id):
391
+ """この手番が「本番操作の承認待ちで閉じた」ものか(読めなければ False=今までどおり見る)。"""
392
+ if not transcript_path or not os.path.isfile(transcript_path) or not command_id:
393
+ return False
394
+ needle = str(command_id)
395
+ try:
396
+ with open(transcript_path, encoding='utf-8', errors='replace') as f:
397
+ for line in f:
398
+ if needle not in line:
399
+ continue
400
+ if _WAIT_CLOSED_RE.search(line) and _DONE_FORBIDDEN_RE.search(line):
401
+ return True
402
+ except Exception: # noqa: BLE001
403
+ return False
404
+ return False
405
+
406
+
407
+ #: 待ちで閉じた手番では見ない残りの種類(★background はここに入れない)。
408
+ WAIT_CLOSED_SKIP_KINDS = ('report', 'pr')
409
+
410
+
360
411
  # ─────────────────────────────────────────────────────────────────────────────
361
412
  # (1) PR が開いたまま・合流の予約中のまま
362
413
  # ─────────────────────────────────────────────────────────────────────────────
@@ -269,16 +269,23 @@ def collect_residues(data, command_id, env=None):
269
269
  work_dir = (env.get('CONDUCTOR_WORK_DIR') or '').strip() or (data.get('cwd') or '')
270
270
  residues = {}
271
271
 
272
+ # ★本番操作の承認待ちで「待ち」のまま閉じた手番か(便 f20dd761)。
273
+ # 便が終わる手番ではないので、報告と PR は見ない(背景の待ちだけは今までどおり見る)。
274
+ wait_closed = finish_gate.closed_as_prod_op_wait(
275
+ data.get('transcript_path'), command_id)
276
+
272
277
  # (2) 報告 … 置き場の実物を見る。
273
- residues['report'] = finish_gate.report_residue(
274
- finish_gate.read_report_text(command_id, env))
278
+ if not wait_closed:
279
+ residues['report'] = finish_gate.report_residue(
280
+ finish_gate.read_report_text(command_id, env))
275
281
 
276
282
  # (3) 背景に回した待ち … 会話の記録の実物を見る。
283
+ # ★待ちで閉じた手番でも見る(手番を閉じれば背景の待ちは殺される=同じ事故が起きる)。
277
284
  residues['background'] = finish_gate.background_residue(
278
285
  finish_gate.outstanding_background_tasks(data.get('transcript_path')))
279
286
 
280
287
  # (1) PR … 便の本文が「合流は人が行う/合流しない」と言っていれば見ない(ADR-007)。
281
- if not finish_gate.human_merge_requested(load_job_body(site, env)) and work_dir:
288
+ if not wait_closed and not finish_gate.human_merge_requested(load_job_body(site, env)) and work_dir:
282
289
  residues['pr'] = finish_gate.open_pr_residue(
283
290
  finish_gate.read_open_prs(work_dir, finish_gate.current_branch(work_dir)))
284
291
  return {k: v for k, v in residues.items() if v}
@@ -0,0 +1,208 @@
1
+ #!/usr/bin/env python3
2
+ """便の終わりの関門は「本番操作の承認待ちで閉じた手番」を差し戻さない(便 f20dd761)。
3
+
4
+ 実行: python3 scripts/hooks/test_gate_prod_op_wait.py
5
+ 1件でも期待と違えば exit 1。
6
+
7
+ 【なぜ要るか】
8
+ 管制(ai-kana PR #425)は、本番操作の承認が長引いた便を `conductor_complete` の
9
+ `waitForProdOp:true` で【完了にせず】「本番操作の承認待ち」のまま閉じられるようにした。
10
+ その手番は【便が終わる手番ではない】。ところが関門は、報告が最終版でないこと・PR が
11
+ 開いたままであることを「終え方を満たしていない」として差し戻す。
12
+ =承認を待つために閉じたい手番が閉じられず、上限まで止めたあとで「途中で終わった」の
13
+ 控えだけが残る(実際には何も途中ではない)。
14
+
15
+ 【ここで固定すること】
16
+ (A) 待ちで閉じた印の読み方 … 便ID・waitClosed・doneLineForbidden が【同じ1行】に
17
+ そろったときだけ「待ちで閉じた」と読む(★よその便の印・説明の文では読まない)
18
+ (B) 待ちで閉じた手番は、報告が無い/下書き/PR が開いたままでも差し戻さない
19
+ (C) ★背景に回した待ちは、待ちで閉じた手番でも今までどおり差し戻す
20
+ (手番を閉じれば背景の待ちは殺される=同じ事故が起きる)
21
+ (D) ★空振り確認:印が無い(今までの便の)手番は、今までどおり差し戻す
22
+
23
+ ★空振り確認(この試験の中だけ):
24
+ ・印の行を外す → (B) が落ちる(=(B) は印が効いていることを見ている)
25
+ ・closed_as_prod_op_wait を常に False にする → (B) が落ちる
26
+ ・WAIT_CLOSED_SKIP_KINDS に 'background' を足す → (C) が落ちる
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='gate-prod-op-wait-')
56
+ ENV = {'HOME': SANDBOX, 'XDG_STATE_HOME': os.path.join(SANDBOX, 'state'),
57
+ 'CONDUCTOR_RUNNER_MODE': 'headless', 'CONDUCTOR_SITE': 'demo'}
58
+ JOB = 'f20dd761-a8e9-4696-aefe-b639b0834bc2'
59
+ OTHER = '97f99a17-4941-4479-8e56-f1e735cb0b90'
60
+
61
+ JOB_LINE = f'{{"text":"CONDUCTOR_JOB:{JOB}"}}'
62
+ #: 背景の仕事が始まった印(★test_finish_gate.py と同じ、会話の記録に残る実物の形)。
63
+ START = '{"toolUseResult":{"stdout":"","backgroundTaskId":"%s"}}'
64
+
65
+ #: 管制が待ちで閉じたときに返す1行(★実物と同じ字面=ai-kana の conductor_complete の返り)。
66
+ #: 形① 道具の返りが「JSON の中の文字列」として入る(引用符が \" に escape される)。
67
+ WAIT_REPLY = (
68
+ '{"type":"tool_result","content":"{\\"ok\\":true,\\"commandId\\":\\"%s\\",'
69
+ '\\"completed\\":false,\\"waitClosed\\":true,\\"status\\":\\"perm_wait\\",'
70
+ '\\"waitLabel\\":\\"本番操作の承認待ち\\",\\"doneLineForbidden\\":true}"}'
71
+ )
72
+ #: 形② 道具の返りが素の JSON として入る(escape なし)。
73
+ WAIT_REPLY_PLAIN = (
74
+ '{"toolUseResult":{"ok":true,"commandId":"%s","completed":false,'
75
+ '"waitClosed":true,"status":"perm_wait","doneLineForbidden":true}}'
76
+ )
77
+
78
+
79
+ def transcript(lines, name):
80
+ p = os.path.join(SANDBOX, name)
81
+ with open(p, 'w', encoding='utf-8') as f:
82
+ f.write('\n'.join(lines) + '\n')
83
+ return p
84
+
85
+
86
+ def put_job_body(body, site='demo'):
87
+ d = os.path.join(ENV['XDG_STATE_HOME'], 'conductor')
88
+ os.makedirs(d, exist_ok=True)
89
+ with open(os.path.join(d, f'active-job-{site}.json'), 'w', encoding='utf-8') as f:
90
+ json.dump({'commandId': JOB, 'body': body, 'site': site}, f)
91
+
92
+
93
+ def drop_report(command_id=JOB):
94
+ for f in finish_gate.report_files(command_id, ENV):
95
+ if os.path.isfile(f):
96
+ os.remove(f)
97
+
98
+
99
+ def put_report(text, command_id=JOB):
100
+ d = os.path.join(ENV['XDG_STATE_HOME'], 'conductor', 'reports')
101
+ os.makedirs(d, exist_ok=True)
102
+ with open(os.path.join(d, f'{command_id}.md'), 'w', encoding='utf-8') as f:
103
+ f.write(text)
104
+
105
+
106
+ def clear_counts(command_id=JOB):
107
+ for p in (finish_gate.counts_path(command_id, ENV), finish_gate.residue_path(command_id, ENV)):
108
+ if os.path.isfile(p):
109
+ os.remove(p)
110
+
111
+
112
+ def block(t):
113
+ return stop_hook.finish_gate_block({'transcript_path': t, 'cwd': SANDBOX}, ENV)
114
+
115
+
116
+ put_job_body('本番操作の承認を待って、待ちのまま閉じてください')
117
+
118
+ # ─────────────────────────────────────────────────────────────────────────────
119
+ print('=== (A) 待ちで閉じた印の読み方(★便IDに縛る) ===')
120
+ # ─────────────────────────────────────────────────────────────────────────────
121
+ t_ok = transcript([JOB_LINE, WAIT_REPLY % JOB], 'a-ok.jsonl')
122
+ check('便ID+waitClosed+doneLineForbidden が同じ1行 → 待ちで閉じた',
123
+ finish_gate.closed_as_prod_op_wait(t_ok, JOB), True)
124
+
125
+ t_plainjson = transcript([JOB_LINE, WAIT_REPLY_PLAIN % JOB], 'a-plain.jsonl')
126
+ check('★escape されていない形(素の JSON)でも読める',
127
+ finish_gate.closed_as_prod_op_wait(t_plainjson, JOB), True)
128
+
129
+ t_other = transcript([JOB_LINE, WAIT_REPLY % OTHER], 'a-other.jsonl')
130
+ check('★よその便の印では読まない',
131
+ finish_gate.closed_as_prod_op_wait(t_other, JOB), False)
132
+
133
+ t_split = transcript([JOB_LINE,
134
+ '{"text":"この便は \\"waitClosed\\": true で閉じました"}',
135
+ f'{{"text":"{JOB} の話"}}'], 'a-split.jsonl')
136
+ check('★印が別の行に散っている(説明の文)→ 読まない',
137
+ finish_gate.closed_as_prod_op_wait(t_split, JOB), False)
138
+
139
+ t_half = transcript([JOB_LINE,
140
+ '{"type":"tool_result","content":"{\\"commandId\\":\\"%s\\",'
141
+ '\\"waitClosed\\":true}"}' % JOB], 'a-half.jsonl')
142
+ check('★片方の印だけ(doneLineForbidden が無い)→ 読まない',
143
+ finish_gate.closed_as_prod_op_wait(t_half, JOB), False)
144
+
145
+ check('会話の記録が無い → 読まない(今までどおり見る側へ倒す)',
146
+ finish_gate.closed_as_prod_op_wait(os.path.join(SANDBOX, 'nope.jsonl'), JOB), False)
147
+ check('便IDが分からない → 読まない',
148
+ finish_gate.closed_as_prod_op_wait(t_ok, ''), False)
149
+ check('見ない残りは報告と PR の2つだけ(背景は入れない)',
150
+ sorted(finish_gate.WAIT_CLOSED_SKIP_KINDS), ['pr', 'report'])
151
+
152
+ # ─────────────────────────────────────────────────────────────────────────────
153
+ print('\n=== (B) 待ちで閉じた手番は差し戻さない ===')
154
+ # ─────────────────────────────────────────────────────────────────────────────
155
+ clear_counts()
156
+ drop_report()
157
+ check('報告が1つも無くても止めない', block(t_ok), None)
158
+
159
+ clear_counts()
160
+ put_report('---\ninstruction_id: x\n---\n\n報告(作成中)この見出しは最終版で差し替える')
161
+ check('報告が下書きのままでも止めない', block(t_ok), None)
162
+
163
+ clear_counts()
164
+ put_report('やりました。') # 囲みが無い=形が整っていない
165
+ check('報告の形が整っていなくても止めない', block(t_ok), None)
166
+
167
+ clear_counts()
168
+ check('★残りを集める段で、報告と PR が1つも入っていない',
169
+ sorted(stop_hook.collect_residues({'transcript_path': t_ok, 'cwd': SANDBOX}, JOB, ENV)),
170
+ [])
171
+
172
+ # ─────────────────────────────────────────────────────────────────────────────
173
+ print('\n=== (C) ★背景に回した待ちは、待ちで閉じた手番でも差し戻す ===')
174
+ # ─────────────────────────────────────────────────────────────────────────────
175
+ clear_counts()
176
+ drop_report()
177
+ t_bg = transcript([JOB_LINE, WAIT_REPLY % JOB, START % 'b32iul78k'], 'c-bg.jsonl')
178
+ msg = block(t_bg)
179
+ truthy('背景の待ちが残っていれば止める', msg)
180
+ truthy(' 背景の待ちだと言う', '背景' in (msg or ''))
181
+ check('★止める理由は背景だけ(報告・PR は混ざらない)',
182
+ sorted(stop_hook.collect_residues({'transcript_path': t_bg, 'cwd': SANDBOX}, JOB, ENV)),
183
+ ['background'])
184
+
185
+ # ─────────────────────────────────────────────────────────────────────────────
186
+ print('\n=== (D) ★空振り確認:印が無い手番は今までどおり差し戻す ===')
187
+ # ─────────────────────────────────────────────────────────────────────────────
188
+ clear_counts()
189
+ drop_report()
190
+ t_plain = transcript([JOB_LINE], 'd-plain.jsonl')
191
+ truthy('印が無い+報告が無い → 止める(関門は効いたまま)', block(t_plain))
192
+
193
+ clear_counts()
194
+ put_report('---\ninstruction_id: x\n---\n\n報告(作成中)差し替える')
195
+ truthy('印が無い+下書き → 止める', block(t_plain))
196
+
197
+ clear_counts()
198
+ drop_report()
199
+ truthy('★印の行を外すと (B) の形でも止まる=(B) は印が効いていることを見ている',
200
+ block(transcript([JOB_LINE], 'd-noflag.jsonl')))
201
+
202
+ print('\n' + '=' * 72)
203
+ if failures:
204
+ print(f'NG {len(failures)} 件')
205
+ for f in failures:
206
+ print(' - ' + f)
207
+ sys.exit(1)
208
+ print('OK すべて期待どおり')
@@ -201,5 +201,12 @@ description: 采配くんの便(プロンプト末尾に CONDUCTOR_JOB:<id>
201
201
  - 可能なら次も一緒に渡す(すべて任意・分かる範囲でよく、渡さなくても完了する):`branch=`作った作業ブランチ名(`git rev-parse --abbrev-ref HEAD`)、`baseBranch=`分岐元(通常 main)、`headSha=`最後のコミット(短縮可)、`pushed=`push したか(true/false)、`filesChanged=`変更したファイル数、`prTitle=`お客さま(非エンジニア)がそのまま読む題名(rebase/squash/lint 等の専門用語を避け、やさしい日本語で)、`prNumber=`既に PR がある場合のみ、`outcome=`pushed(変更して push した)/no_change(変更不要だった)/blocked(衝突・要判断・許可待ち等で完了できなかった)のどれか。
202
202
  - **blocked のときも必ず conductor_complete を呼び、report に理由を書く。**
203
203
  - ブランチを切らない**読み取り専用の作業では outcome=no_change** を渡す(branch は省略可)。
204
+ ⑤ **★本番操作の承認を待って「待ち」のまま閉じる手番だけは、`DONE:` の行を出さない**(えふさん確定 2026-10-04 の 3・便 97f99a17)。
205
+ - **どんな手番か** … 本番操作(本番の反映など)の承認が長引き、便をいったん「待ち」にして手番を閉じるとき。④を `waitForProdOp:true`(分かれば `prodOpRequestId` も)で呼ぶ。便は `done` にならず**本番操作の承認待ち**のまま閉じ、返事に `waitClosed: true` と `doneLineForbidden: true` が返る。
206
+ - **この手番ですること** … ①②のとおり**報告ファイルは置いてよい**(報告ファイルだけでは便は完了にならない)。④は `waitForProdOp:true` で呼ぶ。★**③の `DONE:<現場>` の行は出さない。**
207
+ - **なぜ出さないか** … `DONE:` を出すと配り役の完了検出がこの便を完了(終端)にしてしまい、本番操作が済んでも**続きから起こせなくなる**(「終端は起こさない」は正しい決まりなので緩めない)。
208
+ - **そのあと** … 本番操作が済むと、管制の**合図で同じ便の続きから起きる**。続きを行い、**本当に終わったときに ⓪〜④ のとおり(`DONE:<現場>` も出して)終える**。★却下・失敗・時間切れでは起きない(理由つきで人の手番で止まる)。
209
+ - ★**待ちで閉じていない便の終え方は1つも変わらない。** `waitForProdOp` を渡さない呼び出しは今までどおり(③の `DONE:<現場>` も出す)。
210
+ - ★**便の終わりの関門は、待ちで閉じた手番を「報告が無い」「PR が開いたまま」では差し戻さない**(背景に回した待ちが残っていれば、今までどおり差し戻す=手番を閉じると殺されるのは待ちで閉じても同じ)。
204
211
 
205
212
  **作業中の確認の一言説明** — 作業中にユーザー確認(許可を求めるプロンプト)が出る操作では、その一言説明(description)を英語ではなく日本語で、「プルリクエストを作成する」「テストを実行する」のように「何をするか」が一目で分かる簡潔な形(〜を作成する/実行する/削除する 等)で書く。この説明は管制の確認カードにそのまま表示され、非エンジニアのお客さまが読んで許可を判断する。