@tyhld/conductor 0.12.97 → 0.12.99

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.
@@ -17,7 +17,7 @@
17
17
  * ・入れ替えは全現場が空きのときだけ: 札を置く → 中継の1周ぶん待つ → もう一度空きを確かめる →
18
18
  * 版を名指しして入れる(取得先は毎回 https://registry.npmjs.org・@latest にしない)→
19
19
  * 全現場の常駐を起こし直す → 札を外す → 自己診断 → 管制へ(自己診断が届ける)。
20
- * ・空きでなければ次の回に再挑戦。6時間空かなければ札を置いて新しい便を取らない形に移り、
20
+ * ・空きでなければ次の回に再挑戦。30分空かなければ札を置いて新しい便を取らない形に移り、
21
21
  * 走っている便の終わりを待つ(★走っている便は止めない)。
22
22
  * ・自己診断が FAIL → そのPCだけ前の版へ戻す → 自己診断(after-rollback=rolledBack が管制へ届く)。
23
23
  * 戻しても FAIL なら、その版のまま止まって🟡(Claude Code・囲い・小さな試験が落ちたままなら
@@ -47,8 +47,18 @@ export const CONDUCTOR_REGISTRY_ARG = '--@tyhld:registry=https://registry.npmjs.
47
47
  export const CLAUDE_INSTALL_URL = 'https://claude.ai/install.sh';
48
48
  /** 管制が checkAgainMin を返さないときの間隔(分)。 */
49
49
  export const DEFAULT_CHECK_MIN = 15;
50
- /** 空きが来ないとき、札を置いて新しい便を取らない形に移るまでの長さ。 */
51
- export const BUSY_LIMIT_MS = 6 * 60 * 60 * 1000;
50
+ /**
51
+ * 空きが来ないとき、札を置いて新しい便を取らない形に移るまでの長さ。
52
+ *
53
+ * 【30分にした理由(えふさん確定 2026-10-05・便 684ac785)】
54
+ * 以前は6時間。便が続いている機体(メインPC)は「全現場が同時に空く時」が来ないので、
55
+ * 新しい版への入れ替えが6時間待たされた。実物: メインPCの配り役が 0.12.92 のままで、
56
+ * Codex 向けの配達文(0.12.95 から入った)が便に入らなかった。
57
+ * 札を置けば新しい便を取らなくなるので、走っている便が終わった時点で空きになる=
58
+ * 30分で札を置くほうが、入れ替えまでの時間がはるかに短くなる。
59
+ * ★走っている便は止めない(今までどおり)。札は入れ替えが済んだら外す(今までどおり)。
60
+ */
61
+ export const BUSY_LIMIT_MS = 30 * 60 * 1000;
52
62
  /** 起こし直した常駐が立ち上がるのを待つ上限。 */
53
63
  export const RESTART_WAIT_MS = 120_000;
54
64
  /** 起こし直した常駐の様子を見る間隔。 */
@@ -569,7 +579,7 @@ export async function runUpdater(d) {
569
579
  const waited = now.getTime() - Date.parse(s.busySince);
570
580
  if (waited >= BUSY_LIMIT_MS) {
571
581
  placeDrain(d, s);
572
- return done(d, s, 'draining', `${comp} ${want} へ入れ替えたいが、6時間空きが来ません。札を置いて、走っている便(${busy.join(',')})の終わりを待ちます`);
582
+ return done(d, s, 'draining', `${comp} ${want} へ入れ替えたいが、30分空きが来ません。札を置いて、走っている便(${busy.join(',')})の終わりを待ちます`);
573
583
  }
574
584
  return done(d, s, 'busy', `作業中の便があるので入れ替えません(${busy.join(',')})。次の回に再挑戦します`);
575
585
  }
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
@@ -316,6 +316,11 @@ export const RELAY_SKIP_WARN_THRESHOLD = 6;
316
316
  * 中央が便を抱える上限(CENTRAL_BUSY_HOLD_MAX_MS=3時間)と同じ値にそろえる。
317
317
  */
318
318
  export const RELAY_CODEX_TURN_HOLD_MAX_MS = CENTRAL_BUSY_HOLD_MAX_MS;
319
+ /**
320
+ * 受け皿の名前をそろえる仕事(登録簿の書き出し・読まれない受け皿の始末)の間隔。
321
+ * ★配達の判断には1バイトも関わらない。フォルダを見に行くので毎周はやらない。
322
+ */
323
+ export const RELAY_CODEX_TRAY_TIDY_MS = 10 * 60 * 1000;
319
324
  /** 初期状態を作る。 */
320
325
  export function createRelayState() {
321
326
  return {
@@ -337,6 +342,7 @@ export function createRelayState() {
337
342
  lastNudgeLogAt: null,
338
343
  codexTurnOpen: null,
339
344
  codexNoticeSeen: false,
345
+ codexTrayTidiedAt: null,
340
346
  central: { failures: 0, detail: '', downSince: null },
341
347
  readiness: createReadiness(),
342
348
  };
@@ -1046,11 +1052,22 @@ export function buildAnchor(id) {
1046
1052
  // 現場ごとの受け皿へ1行書く。relay はそれを読み、**既存の完了経路**(patchStatus done+report)
1047
1053
  // へそのまま橋渡しする。=完了を中央へ伝える道は今までどおり1本のまま。
1048
1054
  // ───────────────────────────────────────────────────────────────────────────
1049
- /** Codex の受け皿の置き場。受け口スクリプトと同じ決め方にする。 */
1055
+ /**
1056
+ * Codex の受け皿の置き場。受け口スクリプトと同じ決め方にする。
1057
+ *
1058
+ * ★試験のあいだは、置き場を名指ししていなければ【使い捨ての置き場】へ倒す(便 684ac785)。
1059
+ * 受け皿には本物の便の完了が入っている(~/.tyhld/codex-notify)。試験が置き場を渡し忘れると、
1060
+ * 本物の受け皿を読んで消す・控えや登録簿を書き足す、が起こり得る。tmux の隔てと同じ考えで、
1061
+ * 「試験を書く人が渡し忘れても本物に触れない」ようにしておく(人の約束に頼らない)。
1062
+ */
1050
1063
  export function codexNotifyDir(env = process.env) {
1051
1064
  const explicit = (env.CONDUCTOR_CODEX_NOTIFY_DIR ?? '').trim();
1052
1065
  if (explicit)
1053
1066
  return explicit;
1067
+ if (isUnderTest(env)) {
1068
+ // ★プロセスごとに別の置き場(試験ファイルは別プロセスで走るので、互いに混ざらない)。
1069
+ return path.join(os.tmpdir(), `conductor-test-codex-notify-${process.pid}`);
1070
+ }
1054
1071
  const home = (env.HOME ?? '').trim();
1055
1072
  return home ? path.join(home, '.tyhld', 'codex-notify') : '';
1056
1073
  }
@@ -1261,6 +1278,254 @@ export function pickCodexNoticeFor(site, job, env = process.env) {
1261
1278
  return { notice, skipped, skippedFile: recordSkippedCodexNotices(site, skipped, env) };
1262
1279
  }
1263
1280
  // ───────────────────────────────────────────────────────────────────────────
1281
+ // 現場の登録簿と、読まれない受け皿の始末(★便 684ac785)
1282
+ //
1283
+ // 【何が起きていたか(実物 2026-10-04)】受け口(scripts/codex/codex-notify.py)は現場名を
1284
+ // 「Codex を起動したフォルダの名前」から決めていた。Codex を【現場の下のフォルダ】で
1285
+ // 起動すると、配り役が読まない名前の受け皿ができる。
1286
+ // 現場 codex-probe(配り役は codex-probe.jsonl を読む)
1287
+ // ~/codex-probe で起動 → codex-probe.jsonl … 読まれる
1288
+ // ~/codex-probe/video-lab で起動 → video-lab.jsonl … ★誰にも読まれない(30件・36KB)
1289
+ // 同じずれは「現場名とフォルダ名が違う現場」でも起きる(sites.sh: plus1 → plus1-foryou)。
1290
+ //
1291
+ // 【根治の2本立て】
1292
+ // ① 受け口が【配り役の現場名】で書く。そのために配り役が現場名と作業フォルダを
1293
+ // 登録簿(<受け皿の置き場>/sites.json)へ書き出す。受け口は配達文の `DONE:<現場>` を
1294
+ // 第一に見て、無いときこの登録簿を引く(codex-notify.py の resolve_site)。
1295
+ // ② 既に溜まっている【読まれない受け皿】を、人の手を借りずに控えへ回す
1296
+ // (sweepStrayCodexNotices)。★消さない=中身は1行も失わずに控えへ移す。
1297
+ // ───────────────────────────────────────────────────────────────────────────
1298
+ /** 現場の登録簿のファイル名(★codex-notify.py の SITES_REGISTRY と同じ名前)。 */
1299
+ export const CODEX_SITES_REGISTRY = 'sites.json';
1300
+ /** 現場名として使える字(★scripts/sites.sh の is_valid_site_name と同じ)。 */
1301
+ const SITE_NAME_OK = /^[A-Za-z0-9_-]+$/;
1302
+ /** 登録簿のパス。受け皿の置き場が分からなければ ''。 */
1303
+ export function codexSitesRegistryPath(env = process.env) {
1304
+ const dir = codexNotifyDir(env);
1305
+ return dir ? path.join(dir, CODEX_SITES_REGISTRY) : '';
1306
+ }
1307
+ /** 登録簿を読む(無い・壊れている → 空)。 */
1308
+ export function readCodexSiteRegistry(env = process.env) {
1309
+ const file = codexSitesRegistryPath(env);
1310
+ if (!file)
1311
+ return [];
1312
+ let raw;
1313
+ try {
1314
+ raw = readFileSync(file, 'utf8');
1315
+ }
1316
+ catch {
1317
+ return [];
1318
+ }
1319
+ try {
1320
+ const o = JSON.parse(raw);
1321
+ if (!Array.isArray(o.sites))
1322
+ return [];
1323
+ const out = [];
1324
+ for (const e of o.sites) {
1325
+ if (typeof e !== 'object' || e === null)
1326
+ continue;
1327
+ const r = e;
1328
+ const site = typeof r.site === 'string' ? r.site.trim() : '';
1329
+ const workDir = typeof r.workDir === 'string' ? r.workDir.trim() : '';
1330
+ if (!SITE_NAME_OK.test(site) || workDir === '')
1331
+ continue;
1332
+ out.push({
1333
+ site,
1334
+ workDir,
1335
+ machine: typeof r.machine === 'string' ? r.machine : undefined,
1336
+ at: typeof r.at === 'string' ? r.at : undefined,
1337
+ });
1338
+ }
1339
+ return out;
1340
+ }
1341
+ catch {
1342
+ return []; // 壊れていたら空から書き直す(配達の道は止めない)
1343
+ }
1344
+ }
1345
+ /**
1346
+ * 自分の現場を登録簿へ書き込む(ほかの現場の行は1つも落とさない)。
1347
+ * 返り: 実際に書いたか(中身が同じなら書かない=毎周の書き込みを増やさない)。
1348
+ * ★失敗しても false を返すだけ(登録簿が無くても受け口は ③④ の決め方へ倒れる)。
1349
+ */
1350
+ export function recordCodexSite(entry, env = process.env, now = new Date()) {
1351
+ const file = codexSitesRegistryPath(env);
1352
+ if (!file)
1353
+ return false;
1354
+ if (!SITE_NAME_OK.test(entry.site) || entry.workDir.trim() === '')
1355
+ return false;
1356
+ const current = readCodexSiteRegistry(env);
1357
+ const mine = current.find((e) => e.site === entry.site);
1358
+ if (mine && mine.workDir === entry.workDir && mine.machine === entry.machine)
1359
+ return false;
1360
+ const next = [
1361
+ ...current.filter((e) => e.site !== entry.site),
1362
+ { site: entry.site, workDir: entry.workDir, machine: entry.machine, at: now.toISOString() },
1363
+ ].sort((a, b) => (a.site < b.site ? -1 : a.site > b.site ? 1 : 0));
1364
+ try {
1365
+ mkdirSync(path.dirname(file), { recursive: true });
1366
+ const tmp = `${file}.tmp`;
1367
+ writeFileSync(tmp, `${JSON.stringify({ sites: next }, null, 2)}\n`, 'utf8');
1368
+ renameSync(tmp, file); // 置き換えは原子的=受け口が書きかけを読まない
1369
+ return true;
1370
+ }
1371
+ catch {
1372
+ return false;
1373
+ }
1374
+ }
1375
+ /**
1376
+ * その現場の作業フォルダを決める(登録簿に書く値・★純粋関数)。
1377
+ *
1378
+ * paneCwd(職人の窓がいま居る場所)から上へたどり、名前が現場名と同じ先祖が在ればそこ。
1379
+ * 無ければ、現場の置き場の定番(`~/projects/<現場>` → `~/<現場>`)のうち paneCwd を含む物。
1380
+ * どれでもなければ paneCwd そのまま(★その下で動く Codex の知らせは正しく届く)。
1381
+ * paneCwd が読めなければ `~/projects/<現場>`(headless と同じ既定)。
1382
+ */
1383
+ export function codexSiteWorkDir(site, paneCwd, home) {
1384
+ const fallback = path.join(home, 'projects', site);
1385
+ const cwd = (paneCwd ?? '').trim();
1386
+ if (cwd === '' || !path.isAbsolute(cwd))
1387
+ return fallback;
1388
+ const norm = path.normalize(cwd);
1389
+ for (let cur = norm;;) {
1390
+ if (path.basename(cur) === site)
1391
+ return cur;
1392
+ const parent = path.dirname(cur);
1393
+ if (parent === cur)
1394
+ break;
1395
+ cur = parent;
1396
+ }
1397
+ const under = (dir) => norm === dir || norm.startsWith(`${dir}${path.sep}`);
1398
+ for (const cand of [path.join(home, 'projects', site), path.join(home, site)]) {
1399
+ if (under(cand))
1400
+ return cand;
1401
+ }
1402
+ return norm;
1403
+ }
1404
+ /**
1405
+ * 【配り役が読まない名前の受け皿】を、控えへ回す(★消さない・人が手で片付けなくて済む形)。
1406
+ *
1407
+ * 対象の見分け方: 受け皿の置き場に在る `<名前>.jsonl` のうち、
1408
+ * ・自分の受け皿(`<現場>.jsonl`)ではない
1409
+ * ・控え(`*.skipped.jsonl`)でも登録簿でもない
1410
+ * ・★`<名前>` が【この現場の作業フォルダの下に実在するフォルダの名前】
1411
+ * =古い受け口が「現場の下のフォルダで起動した Codex」のために作ってしまった受け皿だけ。
1412
+ * ほかの現場の受け皿を巻き込まない(名前が自分の作業フォルダの下に無ければ触らない)。
1413
+ *
1414
+ * 中身は1行も捨てず `<現場>.skipped.jsonl` へ理由つきで移し、空になった受け皿を取り除く。
1415
+ * ★どの便の完了にも使わない(控えは配り役が完了として読む場所ではない)。
1416
+ */
1417
+ export function sweepStrayCodexNotices(site, workDir, env = process.env) {
1418
+ const dir = codexNotifyDir(env);
1419
+ if (!dir || workDir.trim() === '')
1420
+ return [];
1421
+ let names;
1422
+ try {
1423
+ names = readdirSync(dir);
1424
+ }
1425
+ catch {
1426
+ return [];
1427
+ }
1428
+ const subNames = subdirNamesUnder(workDir);
1429
+ const out = [];
1430
+ for (const name of names) {
1431
+ if (!name.endsWith('.jsonl'))
1432
+ continue;
1433
+ if (name.endsWith('.skipped.jsonl'))
1434
+ continue;
1435
+ const base = name.slice(0, -'.jsonl'.length);
1436
+ if (base === site || !SITE_NAME_OK.test(base))
1437
+ continue;
1438
+ if (!subNames.has(base))
1439
+ continue;
1440
+ const file = path.join(dir, name);
1441
+ let lines;
1442
+ try {
1443
+ lines = completeJsonlLines(readFileSync(file, 'utf8')).filter((l) => l.trim() !== '');
1444
+ }
1445
+ catch {
1446
+ continue;
1447
+ }
1448
+ const entries = lines.map((line) => {
1449
+ let report = '';
1450
+ let jobId = null;
1451
+ let threadId = null;
1452
+ let turnId = null;
1453
+ let at = null;
1454
+ try {
1455
+ const o = JSON.parse(line);
1456
+ report = typeof o.report === 'string' ? o.report : line;
1457
+ jobId = typeof o.jobId === 'string' && o.jobId.trim() !== '' ? o.jobId.trim() : null;
1458
+ threadId = o.threadId ?? null;
1459
+ turnId = o.turnId ?? null;
1460
+ at = parseNoticeAt(o.at);
1461
+ }
1462
+ catch {
1463
+ report = line; // 読めない行も中身をそのまま残す(捨てない)
1464
+ }
1465
+ return {
1466
+ notice: { site, report, threadId, turnId, jobId, at },
1467
+ why: `配り役が読まない名前の受け皿に入っていた知らせ(受け口の古い版・受け皿=${name})`,
1468
+ };
1469
+ });
1470
+ recordSkippedCodexNotices(site, entries, env);
1471
+ try {
1472
+ unlinkSync(file); // 中身は控えへ移してある=1行も失わない
1473
+ }
1474
+ catch {
1475
+ /* 消せなくても次の回にまた移すだけ(控えは同じ内容が増えるが、完了には使われない) */
1476
+ }
1477
+ out.push({ tray: name, moved: entries.length });
1478
+ }
1479
+ return out;
1480
+ }
1481
+ /**
1482
+ * 受け皿の名前をそろえる(1周ぶん・★Codex の現場だけ)。
1483
+ * ① この現場の「現場名 ↔ 作業フォルダ」を登録簿へ書く(受け口がこれを引いて受け皿の名前を決める)。
1484
+ * ② 配り役が読まない名前の受け皿を控えへ回す(中身は1行も捨てない)。
1485
+ * ★RELAY_CODEX_TRAY_TIDY_MS ごとに1回だけ。配達の判断には関わらない(失敗しても配達は進む)。
1486
+ */
1487
+ export function tidyCodexTrays(cfg, state, deps = {}) {
1488
+ const now = deps.now ?? Date.now();
1489
+ if (state.codexTrayTidiedAt !== null && now - state.codexTrayTidiedAt < RELAY_CODEX_TRAY_TIDY_MS) {
1490
+ return;
1491
+ }
1492
+ state.codexTrayTidiedAt = now;
1493
+ const logLine = deps.logLine ?? log;
1494
+ const home = deps.home ?? os.homedir();
1495
+ const paneCwd = (deps.paneCwd ?? tmuxPaneCwd)(cfg.site);
1496
+ const workDir = codexSiteWorkDir(cfg.site, paneCwd, home);
1497
+ if (recordCodexSite({ site: cfg.site, workDir, machine: cfg.machine }, deps.env)) {
1498
+ logLine(`site=${cfg.site} 現場の登録簿を書きました(作業フォルダ=${workDir})` +
1499
+ '=この下のどのフォルダで Codex が動いても、知らせはこの現場の受け皿に入ります');
1500
+ }
1501
+ for (const swept of sweepStrayCodexNotices(cfg.site, workDir, deps.env)) {
1502
+ logLine(`site=${cfg.site} 配り役が読まない受け皿 ${swept.tray} の ${swept.moved} 件を、` +
1503
+ 'どの便の完了にも使わず控えへ回しました(中身は消していません)');
1504
+ }
1505
+ }
1506
+ /** 作業フォルダの下(深さ2まで)に在るフォルダの名前。読めなければ空。 */
1507
+ function subdirNamesUnder(workDir) {
1508
+ const out = new Set();
1509
+ const walk = (dir, depth) => {
1510
+ let kids;
1511
+ try {
1512
+ kids = readdirSync(dir, { withFileTypes: true })
1513
+ .filter((d) => d.isDirectory() && !d.name.startsWith('.') && d.name !== 'node_modules')
1514
+ .map((d) => d.name);
1515
+ }
1516
+ catch {
1517
+ return;
1518
+ }
1519
+ for (const k of kids) {
1520
+ out.add(k);
1521
+ if (depth > 1)
1522
+ walk(path.join(dir, k), depth - 1);
1523
+ }
1524
+ };
1525
+ walk(workDir, 2);
1526
+ return out;
1527
+ }
1528
+ // ───────────────────────────────────────────────────────────────────────────
1264
1529
  // Codex の最後の返事に書いてもらう「機械で読める囲み」(便 cfa9eeec)
1265
1530
  //
1266
1531
  // 【なぜ要るか(実測 2026-10-04・codex-cli 0.160.0・便 f8ac8674 / 4c9f5049)】
@@ -1588,6 +1853,38 @@ export function isCentralFinished(status) {
1588
1853
  const s = status.trim().toLowerCase();
1589
1854
  return isTerminalCommandStatus(s) || CENTRAL_EXTRA_TERMINAL_STATUSES.has(s);
1590
1855
  }
1856
+ /**
1857
+ * 管制が「人の手番で待っている(=まだ終わっていない)」と言うときの status(★綴りは1か所)。
1858
+ *
1859
+ * 本番操作の承認が長引いた便は、職人が `conductor_complete` を `waitForProdOp:true` で呼ぶと
1860
+ * `done` にならずこの状態のまま閉じられる(管制 ai-kana PR #425)。本番操作が済むと管制の合図で
1861
+ * 【同じ便の続き】から起きる(headless.ts の RESUMABLE_CENTRAL_STATUSES も同じ1語を使う)。
1862
+ */
1863
+ export const WAIT_CLOSED_STATUS = 'perm_wait';
1864
+ /**
1865
+ * 中央が「この便は人の手番で待っている=まだ終わっていない」と言っているか(純粋関数)。
1866
+ *
1867
+ * 【なぜ要るか(便 f20dd761)】待ちのまま閉じた便を、職人の `DONE:` の1行やプロセスの正常終了で
1868
+ * 配り役が `done` にしてしまうと、合図では二度と起きない(「終端は起こさない=Fail Closed」は
1869
+ * 正しいので緩めない)。だから【完了を書く前に】中央へ聞いて、待ちで閉じられていれば書かない。
1870
+ *
1871
+ * 【★確かめられる材料はこれだけ】配り役がいま使っている口
1872
+ * `GET /api/conductor/commands/{id}` は status しか返さない(statusNote も依頼の行も返らない)。
1873
+ * つまり「本番操作の待ち」か「それ以外の人待ち」かは、この口では見分けられない。
1874
+ * 【どちらへ倒すか=書かない側】理由は取り返しのつきかた:
1875
+ * ・書いてしまうと終端=二度と起こせない(作業がまるごと失われる)。
1876
+ * ・書かないでおけば、この状態は管制の RECOVERABLE_STATUSES に入っているので、職人の
1877
+ * `conductor_complete` でも人の操作でも、あとから `done` へ回収できる。
1878
+ * =「待ちでない便の完了」は取りこぼしても回収できるが、「待ちの便の完了」は回収できない。
1879
+ * 【★中央不達(null)は今までどおり】status を読めないときは false=従来の動きへ無損失で縮退する
1880
+ * (読めないだけで全部の便が終われなくなる形にしない)。この穴を塞ぐには管制側が要る
1881
+ * (報告の「要判断」に、どの口に何が要るかを書いた)。
1882
+ */
1883
+ export function isWaitClosedCommandStatus(status) {
1884
+ if (status === null)
1885
+ return false;
1886
+ return status.trim().toLowerCase() === WAIT_CLOSED_STATUS;
1887
+ }
1591
1888
  /**
1592
1889
  * sent孤児を pending 構造へ変換する純粋関数(★送信はしない)。
1593
1890
  * - anchor は buildAnchor(id) で再生成(id 不変=アンカー不変)=完了検出の境界が復元前と揃う。
@@ -1757,7 +2054,17 @@ export function fullCompletionGuide(site, commandId) {
1757
2054
  `reportに理由を書いてください。ブランチを切らない読み取り専用の作業では outcome=no_change を渡してください` +
1758
2055
  `(branchは省略可)。` +
1759
2056
  // ★nextWork(次の独立作業候補)は必ず書く(conductor_complete の説明と同じ・便 a5306df2 で両方にそろえた)。
1760
- `nextWork(次の独立作業候補)は必ず書いてください(無ければ「なし」と理由)。\n\n`);
2057
+ `nextWork(次の独立作業候補)は必ず書いてください(無ければ「なし」と理由)。\n\n` +
2058
+ // ★本番操作の承認待ちで「待ち」のまま閉じる手番だけの例外(便 f20dd761・管制 PR #425)。
2059
+ // ここに書いてある決まりは、鉄則の「便の終え方」の⑤と中身を同じに保つ(等しさの試験が見る)。
2060
+ `★本番操作の承認が長引いて、便を「待ち」のまま閉じるときだけは例外です。` +
2061
+ `conductor_complete を waitForProdOp:true(分かれば prodOpRequestId も)で呼ぶと、` +
2062
+ `便は完了にならず「本番操作の承認待ち」のまま閉じます。` +
2063
+ `★この手番では ${marker} の行を出さないでください` +
2064
+ `(出すと配り役の完了検出がこの便を完了=終端にしてしまい、本番操作が済んでも続きから起こせなくなります)。` +
2065
+ `報告ファイルは置いてよいです(報告ファイルだけでは便は完了になりません)。` +
2066
+ `本番操作が済むと、管制の合図で同じ便の続きから起きます。続きを行い、` +
2067
+ `本当に終わったときに今までどおり(${marker} も出して)終えてください。\n\n`);
1761
2068
  }
1762
2069
  /**
1763
2070
  * Codex の職人へ付ける完了案内(便 cfa9eeec・★Claude 向けは shortCompletionLine)。
@@ -1815,11 +2122,54 @@ export function codexCompletionGuide(site, commandId) {
1815
2122
  }
1816
2123
  // ───────────────────────────────────────────────────────────────────────────
1817
2124
  // tmux I/O(失敗は握りつぶして null/false。送らない側に倒すため)
2125
+ //
2126
+ // 【★試験が本物の職人の画面へ1文字も送れない作り(便 684ac785)】
2127
+ // 実地の事故 2026-10-04(便 99fc49c0): 配達の道を通る試験を【実物と同じ現場名】
2128
+ // (codex-probe)で書いてしまい、動いている Codex の画面へ試験の文が貼られ、
2129
+ // Codex が 38 秒の追加ターンを走らせた。
2130
+ // そのときの直しは「試験の現場名を存在しない名前に変える」=★人の約束だった。
2131
+ // 約束は破られる(次に試験を書く人が実物の名前を使えば、また届く)。
2132
+ //
2133
+ // ★だから【送り先そのもの】を仕組みで隔てる。tmux は `-L <名前>` で
2134
+ // 【別のサーバ(別のソケット)】に話しかける。本物の職人は【既定のサーバ】に居るので、
2135
+ // 試験のあいだ必ず `-L` を挟めば、同じ現場名を書いても本物には構造的に届かない。
2136
+ // ★この挟み込みは、tmux を呼ぶ道を1本(tmuxExec)に寄せたうえで、そこだけで行う。
2137
+ // 「試験かどうか」は Node の試験ランナーが自分で立てる印(NODE_TEST_CONTEXT)で測る=
2138
+ // 試験を書く人が設定を書き忘れても必ず効く(約束に頼らない)。
1818
2139
  // ───────────────────────────────────────────────────────────────────────────
2140
+ /** 試験のあいだ使う tmux サーバの名前(★本物の職人が居る既定のサーバとは別物)。 */
2141
+ export const TMUX_TEST_SERVER = 'conductor-test';
2142
+ /**
2143
+ * いま試験の中で動いているか。★Node の試験ランナー(`node --test`)が子プロセスへ必ず渡す印を見る。
2144
+ * 実測(node 24.15.0): 試験ファイルのプロセスでは `NODE_TEST_CONTEXT=child-v8`。
2145
+ * =試験を書く人が何も書かなくても効く(人の約束に頼らない)。
2146
+ */
2147
+ export function isUnderTest(env = process.env) {
2148
+ return (env.NODE_TEST_CONTEXT ?? '').trim() !== '';
2149
+ }
2150
+ /**
2151
+ * tmux を呼ぶときに必ず前へ置く引数(どのサーバに話しかけるか)。
2152
+ * - `CONDUCTOR_TMUX_SERVER` が在れば、その名前のサーバ(試験が自分で立てたサーバを指すため)。
2153
+ * - 無く、かつ試験の中なら、試験用のサーバ(★本物には届かない)。
2154
+ * - ふだんの運用では空=既定のサーバ(今までと1バイトも変わらない)。
2155
+ */
2156
+ export function tmuxServerArgs(env = process.env) {
2157
+ const explicit = (env.CONDUCTOR_TMUX_SERVER ?? '').trim();
2158
+ if (explicit !== '')
2159
+ return ['-L', explicit];
2160
+ return isUnderTest(env) ? ['-L', TMUX_TEST_SERVER] : [];
2161
+ }
2162
+ /**
2163
+ * tmux を呼ぶ【唯一の口】。★ここ以外から tmux を起動しない
2164
+ * (見張りの試験 relay-test-tmux-isolation が、口が1つのままかを字面で確かめる)。
2165
+ */
2166
+ function tmuxExec(args, opts) {
2167
+ return execFileSync('tmux', [...tmuxServerArgs(), ...args], opts);
2168
+ }
1819
2169
  /** tmux セッション {site} が存在するか。tmux 未インストール時も false。 */
1820
2170
  export function tmuxHasSession(site) {
1821
2171
  try {
1822
- execFileSync('tmux', ['has-session', '-t', site], { stdio: 'ignore' });
2172
+ tmuxExec(['has-session', '-t', site], { stdio: 'ignore' });
1823
2173
  return true;
1824
2174
  }
1825
2175
  catch {
@@ -1841,7 +2191,7 @@ export const RELAY_CAPTURE_SCROLLBACK_LINES = 2000;
1841
2191
  */
1842
2192
  export function tmuxCapture(site) {
1843
2193
  try {
1844
- return execFileSync('tmux', ['capture-pane', '-t', site, '-p', '-S', `-${RELAY_CAPTURE_SCROLLBACK_LINES}`], {
2194
+ return tmuxExec(['capture-pane', '-t', site, '-p', '-S', `-${RELAY_CAPTURE_SCROLLBACK_LINES}`], {
1845
2195
  stdio: ['ignore', 'pipe', 'ignore'],
1846
2196
  encoding: 'utf8',
1847
2197
  });
@@ -2500,7 +2850,7 @@ function describeExecError(err) {
2500
2850
  }
2501
2851
  /** tmux を1回呼ぶ。失敗時は stderr を積んだ例外がそのまま飛ぶ。 */
2502
2852
  function runTmux(args) {
2503
- execFileSync('tmux', args, { stdio: ['ignore', 'ignore', 'pipe'] });
2853
+ tmuxExec(args, { stdio: ['ignore', 'ignore', 'pipe'] });
2504
2854
  }
2505
2855
  /**
2506
2856
  * tmux へ本文を1メッセージとして送り、貼り付けの枠の外に添え書き(RELAY_DELIVERY_NOTE)を打ち、
@@ -2563,7 +2913,7 @@ function pasteToTmux(site, text) {
2563
2913
  }
2564
2914
  catch (err) {
2565
2915
  try {
2566
- execFileSync('tmux', ['delete-buffer', '-b', bufferName], { stdio: 'ignore' });
2916
+ tmuxExec(['delete-buffer', '-b', bufferName], { stdio: 'ignore' });
2567
2917
  }
2568
2918
  catch {
2569
2919
  // 貼れていれば -d で既に消えている。無いバッファを消そうとしたエラーは無視してよい。
@@ -2765,10 +3115,10 @@ export const RELAY_SUBMIT_MAX_RESEND = 3;
2765
3115
  /** tmux のペインがいま居る場所(職人の作業場所)。取れなければ null。 */
2766
3116
  export function tmuxPaneCwd(site) {
2767
3117
  try {
2768
- const out = execFileSync('tmux', ['display-message', '-p', '-t', site, '#{pane_current_path}'], {
3118
+ const out = String(tmuxExec(['display-message', '-p', '-t', site, '#{pane_current_path}'], {
2769
3119
  encoding: 'utf8',
2770
3120
  stdio: ['ignore', 'pipe', 'ignore'],
2771
- }).trim();
3121
+ })).trim();
2772
3122
  return out || null;
2773
3123
  }
2774
3124
  catch {
@@ -3251,8 +3601,27 @@ export async function fetchCommand(cfg) {
3251
3601
  * null/未指定なら report は送らず status のみ(従来どおり)。
3252
3602
  * - 409(他が sent 中などの安全弁)→ false(送らない)。
3253
3603
  * - 200 → true。
3604
+ *
3605
+ * 【★完了(done)だけは、書く前に中央へ聞く(便 f20dd761)】
3606
+ * 待ちのまま閉じられた便(isWaitClosedCommandStatus)に `done` を書くと、その便は終端になり
3607
+ * 管制の合図では二度と起きない。職人の作法(「待ちで閉じた手番では DONE: を出さない」)だけに
3608
+ * 頼らず、★完了を書く道そのものを1か所でふさぐ(ここを通らずに done を書く道は無い)。
3609
+ * ・`done` 以外(delivering / sent / failed)は1バイトも動きが変わらない。
3610
+ * ★failed は塞がない:起こし直しに失敗した等の「動かせない事実」は管制へ届かないと困る
3611
+ * (headless の再開の道は、起こせなかったとき perm_wait の便へ failed と理由を書く)。
3612
+ * ・返りは true=「書かなかったが、やり直す意味も無い(便の状態は管制が持っている)」。
3613
+ * 呼び出し側は追跡を畳んでよい。false(=次の周でやり直す)に倒すと、画面なし方式が
3614
+ * 上限回数のあとに `failed` を書いてしまい、待ちの便を殺す。
3254
3615
  */
3255
3616
  export async function patchStatus(cfg, id, status, report) {
3617
+ if (status === 'done') {
3618
+ const held = await fetchCommandStatus(cfg, id);
3619
+ if (isWaitClosedCommandStatus(held)) {
3620
+ log(`site=${cfg.site} id=${id} 完了は書きません(管制は status=${held}=人の手番で待っています)` +
3621
+ '→本番操作が済めば管制の合図で同じ便の続きから起きます');
3622
+ return true;
3623
+ }
3624
+ }
3256
3625
  try {
3257
3626
  const payload = report != null ? { status, report } : { status };
3258
3627
  const res = await fetch(`${cfg.url}/api/conductor/commands/${encodeURIComponent(id)}`, {
@@ -3330,6 +3699,18 @@ export async function completeWithReport(cfg, id, logLine = log) {
3330
3699
  : '→relayからは送りません(報告は控えとして処理済みにしました)'));
3331
3700
  return true;
3332
3701
  }
3702
+ if (isWaitClosedCommandStatus(already)) {
3703
+ // ★待ちのまま閉じられた便(本番操作の承認待ち・管制 PR #425)。★完了を1バイトも書かない。
3704
+ // 職人の作法(「待ちで閉じた手番では DONE: を出さない」)が守られなくても、画面の DONE でも
3705
+ // プロセスの正常終了でも、ここで止まる=合図で同じ便の続きから起こせる状態を壊さない。
3706
+ // ★報告ファイルは「処理済み」に移さない。移すと職人が続きで書き直した報告と混ざる
3707
+ // (移さなくても回収の道が送る=その口は便を完了にしない)。
3708
+ // ★返りは true=追跡を畳んでよい。管制はこの便で現場を占有し続けるので、次の便は配られない。
3709
+ logLine(`site=${cfg.site} id=${id} は待ちのまま閉じられています(status=${already})` +
3710
+ '→完了は書きません(本番操作が済めば管制の合図で同じ便の続きから起きます)' +
3711
+ (phase === 'none' ? '' : `/報告は手元の控えに残します(${phase})`));
3712
+ return true;
3713
+ }
3333
3714
  if (phase === 'processed') {
3334
3715
  // ★①は済み・②は未了。本文は渡してあるので、状態だけを書く(開発履歴を2件にしない)。
3335
3716
  // 書けなければ false=次の周でやり直す(完了を落とさない)。
@@ -4511,6 +4892,9 @@ export async function relayTick(cfg, state, hooks) {
4511
4892
  // 配ったときに受け皿に残っていて誤って取り込まれる、という筋道が構造的に消える。
4512
4893
  // ★併せて「前のターンがまだ途中」の札を下ろす(終わったと分かったので次を配れる)。
4513
4894
  if (kindOf(cfg) === 'codex') {
4895
+ // ★受け皿の名前をそろえる(登録簿の書き出し・読まれない受け皿の始末・便 684ac785)。
4896
+ // 配達の判断には関わらない=ここで何が起きても、この下の始末と配達は今までどおり進む。
4897
+ tidyCodexTrays(cfg, state);
4514
4898
  const left = pickCodexNoticeFor(cfg.site, null);
4515
4899
  if (left.skipped.length > 0) {
4516
4900
  state.codexNoticeSeen = true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tyhld/conductor",
3
- "version": "0.12.97",
3
+ "version": "0.12.99",
4
4
  "description": "采配くん管制の見守りアプリ(conductor-agent)。各PCで常駐し、中央(devlog-tracker)へ定期的に生存報告(heartbeat)を送る常駐CLI。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -37,6 +37,28 @@
37
37
  これが無かったせいで、配り役は「最新の1行=いま追っている便の完了」としか読めず、
38
38
  前の便のターンの知らせを次の便の完了として取り込んだ(便 1427b428 が19秒で done)。
39
39
 
40
+ 【★受け皿の名前を、配り役が読む現場名に必ず合わせる(2026-10-05・便 684ac785)】
41
+ 旧: 現場名=`os.path.basename(cwd)`。Codex を【現場の下のフォルダ】で起動すると、
42
+ 配り役が読まない名前の受け皿ができ、そこへ入れた知らせは誰にも読まれなかった。
43
+ 実物: 現場 codex-probe(配り役は codex-probe.jsonl を読む)で、Codex を
44
+ ~/codex-probe/video-lab で起動 → video-lab.jsonl に 30 件・36KB が溜まった。
45
+ ★同じずれは「現場名とフォルダ名が違う現場」でも起きる(sites.sh の対応表:
46
+ plus1 → ~/projects/plus1-foryou)。だから【フォルダ名から推測しない】のが根治。
47
+ 新: 上から順に決める(resolve_site)。
48
+ ① 配達文に書かれた現場名 … 配り役は便の本文へ必ず完了の合図 `DONE:<現場>` を書く
49
+ (relay.ts の buildSendText → shortCompletionLine / codexCompletionGuide)。
50
+ ★これは配り役が「自分の現場名」として書いた字そのもの=受け皿の名前の正本。
51
+ 案内は本文の【後ろ】に足されるので、本文が別の現場名を引用していても
52
+ 【最後の DONE:】が配り役の現場名になる。
53
+ ② 現場の登録簿 … 配り役が書く `<受け皿の置き場>/sites.json`(現場名 ↔ 作業フォルダ)。
54
+ cwd を含む作業フォルダの現場名を引く(いちばん長く一致する物)。
55
+ ★人が手で打ったターン(配達文が無い)でも、ここで正しい現場に入る。
56
+ ③ git の頂上フォルダ名 … 配り役の現場名の決め方(cli.ts の detectSite)と同じ。
57
+ ★ホームより上へはたどらない(現場の作業フォルダは必ずホームの下に在るので、
58
+ ホームより上に置かれた `.git` を現場名として拾わない)。
59
+ ④ それも分からなければ作業フォルダの名前(今までどおり)。
60
+ ★どれでも決められない知らせは【捨てない】。理由つきで控え(UNRESOLVED_FILE)へ残す。
61
+
40
62
  【やること】残す通知だけを、現場ごとの受け皿へ1行足す。
41
63
  置き場: $CONDUCTOR_CODEX_NOTIFY_DIR(既定 $HOME/.tyhld/codex-notify)/<現場>.jsonl
42
64
  ★中央へは送らない。送るのは relay の仕事(完了の経路は1本に保つ)。
@@ -57,6 +79,25 @@ REPORT_MARK = 'CONDUCTOR_REPORT'
57
79
  # 指示本文の末尾に付く管理用ジョブ印。
58
80
  JOB_STAMP = re.compile(r'CONDUCTOR_JOB:\s*([0-9A-Za-z][0-9A-Za-z-]{7,})')
59
81
 
82
+ # 配達文に必ず入る完了の合図(`DONE:<現場>`)。★配り役が書いた現場名そのもの。
83
+ # 空白は挟まない形だけを見る(配り役が出す字は必ずこの形。地の文の「DONE: 〜」を拾わない)。
84
+ SITE_STAMP = re.compile(r'DONE:([A-Za-z0-9_-]+)')
85
+
86
+ # 現場名として使える字(scripts/sites.sh の is_valid_site_name と同じ)。
87
+ # ★受け皿の名前はこの字だけ=パスの区切りや `..` が名前に入らない(置き場の外へ出さない)。
88
+ SITE_NAME_OK = re.compile(r'^[A-Za-z0-9_-]+$')
89
+
90
+ # 配り役が書く現場の登録簿(現場名 ↔ 作業フォルダ)。★relay.ts の CODEX_SITES_REGISTRY と同じ名前。
91
+ SITES_REGISTRY = 'sites.json'
92
+
93
+ # 現場を1つに決められなかった知らせの控え。
94
+ # ★`.skipped.jsonl` で終わる名前にする=配り役は受け皿として読まない・始末の対象にもしない。
95
+ UNRESOLVED_FILE = '_unresolved.skipped.jsonl'
96
+
97
+ # 控えに残す行数の上限(古い物から落とす=受け皿の隣で際限なく育たせない)。
98
+ # ★relay.ts の CODEX_SKIPPED_NOTICE_MAX_LINES と同じ値にそろえる。
99
+ UNRESOLVED_MAX_LINES = 200
100
+
60
101
 
61
102
  def notify_dir(env=None):
62
103
  env = os.environ if env is None else env
@@ -68,10 +109,115 @@ def notify_dir(env=None):
68
109
 
69
110
 
70
111
  def site_of(cwd):
71
- """現場名=作業フォルダの名前(relay の現場名と同じ決め方)。"""
112
+ """作業フォルダの名前(★これ単体では現場名にならない。resolve_site の最後の手段)。"""
72
113
  return os.path.basename(str(cwd or '').rstrip('/')) or ''
73
114
 
74
115
 
116
+ def site_from_instruction(payload):
117
+ """配達文に書かれた現場名(`DONE:<現場>` の【最後】の出現)。無ければ ''。
118
+
119
+ 配り役は便の本文のうしろへ完了の合図を必ず足すので、本文が別の現場名を引用していても
120
+ 最後の出現が配り役の現場名になる(relay.ts の buildSendText の組み立て順)。
121
+ """
122
+ msgs = payload.get('input-messages')
123
+ if not isinstance(msgs, list):
124
+ return ''
125
+ found = ''
126
+ for m in msgs:
127
+ if not isinstance(m, str):
128
+ continue
129
+ for hit in SITE_STAMP.finditer(m):
130
+ found = hit.group(1)
131
+ return found
132
+
133
+
134
+ def site_from_registry(cwd, env=None):
135
+ """配り役の登録簿から、cwd を含む作業フォルダの現場名を引く。引けなければ ''。
136
+
137
+ ★いちばん長く一致する作業フォルダの現場を選ぶ(入れ子の現場でも取り違えない)。
138
+ """
139
+ target = str(cwd or '').strip()
140
+ if not target:
141
+ return ''
142
+ target = os.path.normpath(target)
143
+ base = notify_dir(env)
144
+ if not base:
145
+ return ''
146
+ try:
147
+ with open(os.path.join(base, SITES_REGISTRY), encoding='utf-8') as f:
148
+ data = json.load(f)
149
+ except Exception: # noqa: BLE001
150
+ return ''
151
+ entries = data.get('sites') if isinstance(data, dict) else None
152
+ if not isinstance(entries, list):
153
+ return ''
154
+ best_site, best_len = '', -1
155
+ for e in entries:
156
+ if not isinstance(e, dict):
157
+ continue
158
+ name = str(e.get('site') or '').strip()
159
+ work = str(e.get('workDir') or '').strip()
160
+ if not SITE_NAME_OK.match(name) or not work:
161
+ continue
162
+ work = os.path.normpath(work)
163
+ if target == work or target.startswith(work.rstrip(os.sep) + os.sep):
164
+ if len(work) > best_len:
165
+ best_site, best_len = name, len(work)
166
+ return best_site
167
+
168
+
169
+ def git_top_name(cwd, env=None):
170
+ """git の頂上フォルダの名前(配り役の detectSite と同じ決め方)。無ければ ''。
171
+
172
+ ★git は起動しない(受け口は Codex の終わりに毎回走るので、速く・絶対に転ばないこと)。
173
+ `.git` が在るいちばん近い先祖をたどるだけ。worktree(`.git` がファイル)も同じに見つかる。
174
+ ★cwd がホームの下に在るときは、ホームより上へは出ない。現場の作業フォルダは必ずホームの下に在る
175
+ (sites.sh: `$HOME/projects/<現場>` か `$HOME/<現場>`)ので、ホームより上に置かれた `.git`
176
+ (例 `/tmp/.git`)を現場名として拾ってしまわないため。
177
+ """
178
+ env = os.environ if env is None else env
179
+ cur = str(cwd or '').strip()
180
+ if not cur or not os.path.isabs(cur):
181
+ return ''
182
+ cur = os.path.normpath(cur)
183
+ home = (env.get('HOME') or '').strip()
184
+ home = os.path.normpath(home) if home else ''
185
+ stop_at_home = bool(home) and (cur == home or cur.startswith(home.rstrip(os.sep) + os.sep))
186
+ while True:
187
+ if os.path.exists(os.path.join(cur, '.git')):
188
+ return os.path.basename(cur)
189
+ if stop_at_home and cur == home:
190
+ return ''
191
+ parent = os.path.dirname(cur)
192
+ if parent == cur:
193
+ return ''
194
+ cur = parent
195
+
196
+
197
+ def resolve_site(payload, env=None):
198
+ """受け皿の名前(=配り役が読む現場名)を決める。返り: (現場名, 決め方/決められない理由)。
199
+
200
+ 決められなければ ('', 理由)。★呼ぶ側はその知らせを捨てず、控えへ残す。
201
+ """
202
+ stamped = site_from_instruction(payload)
203
+ if stamped and SITE_NAME_OK.match(stamped):
204
+ return stamped, 'job-stamp'
205
+ cwd = str(payload.get('cwd') or '')
206
+ from_reg = site_from_registry(cwd, env)
207
+ if from_reg:
208
+ return from_reg, 'site-registry'
209
+ top = git_top_name(cwd, env)
210
+ if top and SITE_NAME_OK.match(top):
211
+ return top, 'git-toplevel'
212
+ base = site_of(cwd)
213
+ if base and SITE_NAME_OK.match(base):
214
+ return base, 'cwd-basename'
215
+ return '', (
216
+ '現場を1つに決められない(配達文の DONE: も登録簿も git の頂上も無く、'
217
+ f'作業フォルダの名前も現場名に使えない: cwd={cwd!r})'
218
+ )
219
+
220
+
75
221
  def is_internal_turn(payload):
76
222
  """Codex 内部のターン(題名を付ける・会話をまとめ直す)なら True(=人の指示の完了ではない)。"""
77
223
  msgs = payload.get('input-messages')
@@ -107,19 +253,76 @@ def job_id_of(payload):
107
253
  return found
108
254
 
109
255
 
110
- def decide(payload):
111
- """受け取った通知をどう扱うか。返り: (残すか, 現場名, 報告本文, 理由)"""
256
+ def decide(payload, env=None):
257
+ """受け取った通知をどう扱うか。返り: (残すか, 現場名, 報告本文, 理由)
258
+
259
+ ★現場名が決まらなかったときは (False, '', 報告本文, 理由) を返す。呼ぶ側は【捨てずに】
260
+ 控えへ残す(理由が 'unresolved-site:' で始まる物がそれ)。
261
+ """
112
262
  if not isinstance(payload, dict):
113
263
  return False, '', '', 'not-json-object'
114
264
  if payload.get('type') != 'agent-turn-complete':
115
265
  return False, '', '', 'other-type'
116
266
  if is_internal_turn(payload):
117
267
  return False, '', '', 'internal-turn'
118
- site = site_of(payload.get('cwd'))
119
- if not site:
120
- return False, '', '', 'no-site'
121
268
  report = payload.get('last-assistant-message')
122
- return True, site, report if isinstance(report, str) else '', ''
269
+ report = report if isinstance(report, str) else ''
270
+ site, how = resolve_site(payload, env)
271
+ if not site:
272
+ return False, '', report, f'unresolved-site:{how}'
273
+ return True, site, report, how
274
+
275
+
276
+ def notice_line(payload, site, report):
277
+ """受け皿へ書く1行。"""
278
+ return json.dumps(
279
+ {
280
+ 'site': site,
281
+ 'report': report,
282
+ 'threadId': payload.get('thread-id'),
283
+ 'turnId': payload.get('turn-id'),
284
+ # ★どの便のターンの知らせかを配り役が確かめられるようにする(便 99fc49c0)。
285
+ 'jobId': job_id_of(payload),
286
+ 'at': datetime.datetime.now(datetime.timezone.utc).isoformat(),
287
+ },
288
+ ensure_ascii=False,
289
+ )
290
+
291
+
292
+ def keep_unresolved(payload, report, why, env=None):
293
+ """現場を決められなかった知らせを、理由つきで控えへ残す(★捨てない)。
294
+
295
+ 返り: 書けた控えのパス/''。古い行から落として UNRESOLVED_MAX_LINES 行までに収める。
296
+ """
297
+ target_dir = notify_dir(env)
298
+ if not target_dir:
299
+ return ''
300
+ file = os.path.join(target_dir, UNRESOLVED_FILE)
301
+ line = json.dumps(
302
+ {
303
+ 'skippedAt': datetime.datetime.now(datetime.timezone.utc).isoformat(),
304
+ 'why': why,
305
+ 'site': None,
306
+ 'cwd': payload.get('cwd'),
307
+ 'jobId': job_id_of(payload),
308
+ 'threadId': payload.get('thread-id'),
309
+ 'turnId': payload.get('turn-id'),
310
+ 'report': report,
311
+ },
312
+ ensure_ascii=False,
313
+ )
314
+ try:
315
+ os.makedirs(target_dir, exist_ok=True)
316
+ kept = []
317
+ if os.path.exists(file):
318
+ with open(file, encoding='utf-8') as f:
319
+ kept = [l for l in f.read().split('\n') if l.strip()]
320
+ lines = (kept + [line])[-UNRESOLVED_MAX_LINES:]
321
+ with open(file, 'w', encoding='utf-8') as f:
322
+ f.write('\n'.join(lines) + '\n')
323
+ return file
324
+ except Exception: # noqa: BLE001
325
+ return ''
123
326
 
124
327
 
125
328
  def main(argv):
@@ -130,28 +333,19 @@ def main(argv):
130
333
  payload = json.loads(raw)
131
334
  except Exception: # noqa: BLE001
132
335
  return 0 # 読めない通知は黙って捨てる(Codex を止めない)
133
- keep, site, report, _reason = decide(payload)
336
+ keep, site, report, reason = decide(payload)
134
337
  if not keep:
338
+ # ★現場を決められなかった知らせだけは、捨てずに控えへ残す(便 684ac785)。
339
+ if reason.startswith('unresolved-site:'):
340
+ keep_unresolved(payload, report, reason[len('unresolved-site:'):])
135
341
  return 0
136
342
  target_dir = notify_dir()
137
343
  if not target_dir:
138
344
  return 0
139
345
  try:
140
346
  os.makedirs(target_dir, exist_ok=True)
141
- line = json.dumps(
142
- {
143
- 'site': site,
144
- 'report': report,
145
- 'threadId': payload.get('thread-id'),
146
- 'turnId': payload.get('turn-id'),
147
- # ★どの便のターンの知らせかを配り役が確かめられるようにする(便 99fc49c0)。
148
- 'jobId': job_id_of(payload),
149
- 'at': datetime.datetime.now(datetime.timezone.utc).isoformat(),
150
- },
151
- ensure_ascii=False,
152
- )
153
347
  with open(os.path.join(target_dir, f'{site}.jsonl'), 'a', encoding='utf-8') as f:
154
- f.write(line + '\n')
348
+ f.write(notice_line(payload, site, report) + '\n')
155
349
  except Exception: # noqa: BLE001
156
350
  return 0
157
351
  return 0
@@ -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)を英語ではなく日本語で、「プルリクエストを作成する」「テストを実行する」のように「何をするか」が一目で分かる簡潔な形(〜を作成する/実行する/削除する 等)で書く。この説明は管制の確認カードにそのまま表示され、非エンジニアのお客さまが読んで許可を判断する。