@tyhld/conductor 0.12.90 → 0.12.92

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.
@@ -36,7 +36,7 @@ import os from 'node:os';
36
36
  import path from 'node:path';
37
37
  import { fileURLToPath } from 'node:url';
38
38
  import { CLAUDE_MIN_VERSION, compareVersions, isProcessAlive, parseClaudeVersion, resolveClaudeBin, } from './headless.js';
39
- import { defaultExec, fetchClaudeStable, npmBesideNode, pickCandidates } from './selfcheck.js';
39
+ import { defaultExec, fetchClaudeStable, npmBesideNode, pickCandidates, selfcheckDir } from './selfcheck.js';
40
40
  // ─────────────────────────────────────────────────────────────────────────────
41
41
  // 定数(★時間の値は設計 3ed9e2b2 の推奨値・管制の ROLLOUT_TIMES と同じ考え方)
42
42
  // ─────────────────────────────────────────────────────────────────────────────
@@ -55,6 +55,12 @@ export const RESTART_WAIT_MS = 120_000;
55
55
  export const RESTART_POLL_MS = 5_000;
56
56
  /** 戻しても直らなかった機で、自己診断を測り直す間隔。 */
57
57
  export const HELD_RECHECK_MS = 60 * 60 * 1000;
58
+ /**
59
+ * 自己診断が「人にしかできない操作(🔵)」だけで落ちているとき、測り直す間隔(便 4cf71dde)。
60
+ * ★人は直したあと何も打たない。この間隔で更新係が自己診断をやり直し、通れば先へ進む。
61
+ * 1回 約0.04ドルなので、待っているあいだも1時間に1回まで(戻しても直らない機の測り直しと同じ間隔)。
62
+ */
63
+ export const HUMAN_RECHECK_MS = 60 * 60 * 1000;
58
64
  /** 候補を届けられなかったとき、もう一度届けに行くまでの間隔。 */
59
65
  export const CANDIDATE_RETRY_MS = 24 * 60 * 60 * 1000;
60
66
  const SEMVER = /^\d+\.\d+\.\d+$/;
@@ -352,7 +358,10 @@ async function afterSwap(d, s) {
352
358
  const failed = r.failedKeys.join(',') || '不明';
353
359
  if (!s.rollback) {
354
360
  // ★前の版へ戻す(そのPCだけ)。
355
- addFailed(s, comp, s.to);
361
+ // ★人にしかできない操作だけで落ちたときは、版の不具合と決めつけない(落ちた版の印を付けない)。
362
+ // 人が直した後、管制がまだその版を返していれば、もう一度入れ替えて確かめられる(便 4cf71dde)。
363
+ if (!r.humanOnly)
364
+ addFailed(s, comp, s.to);
356
365
  const back = s.from ?? null;
357
366
  if (comp === 'claude-code' && back && compareVersions(back, CLAUDE_MIN_VERSION) < 0) {
358
367
  s.phase = 'held';
@@ -409,6 +418,49 @@ async function rollBack(d, s, back) {
409
418
  removeDrain(d, s);
410
419
  return afterSwap(d, s);
411
420
  }
421
+ /** 手元の自己診断の控え(last.json)のうち、更新係が読む物。読めなければ null。 */
422
+ export function readLastSelfcheck(home) {
423
+ try {
424
+ const j = JSON.parse(readFileSync(path.join(selfcheckDir(home), 'last.json'), 'utf8'));
425
+ if (j.result !== 'PASS' && j.result !== 'FAIL')
426
+ return null;
427
+ const failed = (j.items ?? []).filter((i) => i.ok === false);
428
+ const at = typeof j.finishedAt === 'string' ? Date.parse(j.finishedAt) : NaN;
429
+ return {
430
+ pass: j.result === 'PASS',
431
+ humanOnly: failed.length > 0 && failed.every((i) => i.failKind === 'human'),
432
+ finishedAt: Number.isFinite(at) ? at : null,
433
+ };
434
+ }
435
+ catch {
436
+ return null;
437
+ }
438
+ }
439
+ /**
440
+ * 最後の自己診断が「人にしかできない操作」だけで落ちているあいだは、人の手番を待つ(便 4cf71dde)。
441
+ * ★人が直しても何も打たなくてよいように、HUMAN_RECHECK_MS ごとに自己診断をやり直す。通れば待ちは終わり。
442
+ * 返り値 waiting=true =まだ待つ(入れ替えない・候補の自己診断を流さない)。
443
+ * ★仕組みの異常(fault)が混ざる FAIL は、ここでは待たない(今までどおり入れ替え・戻しで確かめる)。
444
+ */
445
+ function waitingForHuman(d, now) {
446
+ const last = readLastSelfcheck(d.home);
447
+ if (!last || last.pass || !last.humanOnly)
448
+ return { waiting: false, detail: '' };
449
+ const age = last.finishedAt === null ? Infinity : now.getTime() - last.finishedAt;
450
+ if (age < HUMAN_RECHECK_MS) {
451
+ const next = new Date((last.finishedAt ?? now.getTime()) + HUMAN_RECHECK_MS).toISOString();
452
+ return { waiting: true, detail: `自己診断が人の操作待ち(🔵)で落ちたままです。次の測り直し ${next}` };
453
+ }
454
+ const r = d.runSelfcheck('daily');
455
+ if (r.pass) {
456
+ d.log('人の操作待ちだった自己診断が通りました。先へ進みます');
457
+ return { waiting: false, detail: '' };
458
+ }
459
+ return {
460
+ waiting: true,
461
+ detail: `自己診断を測り直しましたが、まだ通りません(${r.failedKeys.join(',') || '不明'}${r.humanOnly ? '・人の操作待ち' : ''})`,
462
+ };
463
+ }
412
464
  // ─────────────────────────────────────────────────────────────────────────────
413
465
  // 1回ぶん
414
466
  // ─────────────────────────────────────────────────────────────────────────────
@@ -465,6 +517,9 @@ export async function runUpdater(d) {
465
517
  const why = !target.ready ? '管制はまだ準備中です' : target.halted ? '管制で自動更新が止められています' : 'この機は自動更新の対象ではありません';
466
518
  return done(d, s, 'skip', `${why}(${target.reason ?? '理由なし'})。何もしません`);
467
519
  }
520
+ // ③-2 人の操作待ち(🔵)で自己診断が落ちたままなら、測り直してから先へ進む(便 4cf71dde)。
521
+ // ★落ちたまま入れ替えると、同じ理由で落ちて戻し、版に疑いが付く(実物 2026-10-03 02:43Z・ノートPC)。
522
+ const human = waitingForHuman(d, now);
468
523
  // ④ どの部品を動かすか(★conductor の目標が今と違うあいだは Claude Code を動かさない)。
469
524
  const claudeNow = d.resolveClaude();
470
525
  let comp = null;
@@ -495,11 +550,18 @@ export async function runUpdater(d) {
495
550
  if (!comp || !want) {
496
551
  removeDrain(d, s);
497
552
  s.busySince = null;
553
+ if (human.waiting)
554
+ return done(d, s, 'wait-human', human.detail);
498
555
  // ★後続の機は候補を探さない・候補の自己診断を流さない(役は管制が決める・欄が無ければ今までどおり)。
499
556
  if (target.role !== 'follower')
500
557
  await reportCandidates(d, s, installed, claudeNow?.version ?? null);
501
558
  return done(d, s, 'none', `目標の版と同じです(conductor ${installed ?? '不明'}・Claude Code ${claudeNow?.version ?? '不明'})`);
502
559
  }
560
+ if (human.waiting) {
561
+ removeDrain(d, s);
562
+ s.busySince = null;
563
+ return done(d, s, 'wait-human', `${comp} ${want} へは入れ替えません。${human.detail}`);
564
+ }
503
565
  // ⑤ 全現場が空きか。
504
566
  const busy = busySites(d.home, d.isAlive);
505
567
  if (busy.length > 0) {
@@ -603,11 +665,14 @@ export function selfcheckViaEntry(home, exec, env) {
603
665
  catch {
604
666
  // 読めない=FAIL として扱う(迷ったら戻す側)
605
667
  }
606
- const failedKeys = (parsed.items ?? []).filter((x) => x.ok === false).map((x) => String(x.key));
668
+ const failedItems = (parsed.items ?? []).filter((x) => x.ok === false);
669
+ const failedKeys = failedItems.map((x) => String(x.key));
607
670
  const pass = r.status === 0 && parsed.result === 'PASS';
671
+ // ★読めなかった FAIL(項目が無い)は人の操作待ちとは言わない(迷ったら版を疑う側)。
672
+ const humanOnly = !pass && failedItems.length > 0 && failedItems.every((x) => x.failKind === 'human');
608
673
  if (!pass && failedKeys.length === 0)
609
674
  failedKeys.push(`selfcheck(終了 ${r.status})`);
610
- return { pass, failedKeys, sent: typeof parsed.sent?.sent === 'boolean' ? parsed.sent.sent : null };
675
+ return { pass, failedKeys, sent: typeof parsed.sent?.sent === 'boolean' ? parsed.sent.sent : null, humanOnly };
611
676
  };
612
677
  }
613
678
  /** `conductor autoupdate [--boot]` の入口。返り値は終了コード(★失敗でも 0=タイマーを止めない。使い方の誤りだけ 2)。 */
@@ -132,7 +132,10 @@ export function buildCraftsmanPrompt(input) {
132
132
  // ★合流の作法は職人の鉄則7と同じ形にする(便 4a5bbb9f)。片方だけ古いと、
133
133
  // 職人は毎便読む鉄則と、ここで渡される文の【両方】を受け取って食い違う。
134
134
  // ★2026-09-17(便 01bd516b)に保護の有無で分けた形へ直した(鉄則7 と同じ中身)。
135
+ // ★push は上流の設定を書かない形(鉄則7-⓪・便 ec974d68)。`-u` は `.git/config` へ書き込むので、
136
+ // 囲いがそこを塞いでいる機体では push が成功しても「上流の設定を書けない」で止まる。
135
137
  '・テストが緑になってから push → プルリクエストを作成 → 合流する。合流の仕方は職人の鉄則7のとおり:',
138
+ ' push は `git push origin <作業ブランチ名>`(★`-u` を付けない=上流の設定を書かない形)。',
136
139
  ' 保護あり(必須の検査がある)… `gh pr merge <番号> --auto --merge --delete-branch` で予約する。',
137
140
  ' 保護なし … ★`--auto` を先に打たない。`gh pr checks <番号>` で CI(`/actions/runs/` の行)がすべて pass なのを',
138
141
  ' 確かめてから `gh pr merge <番号> --merge --delete-branch`。Vercel だけの緑は CI の緑と数えない。',
package/dist/selfcheck.js CHANGED
@@ -164,8 +164,9 @@ export function summarizeStream(stdout) {
164
164
  * init 行には `mcp_servers: [{ name, status }]` が載る。status は connected / pending /
165
165
  * needs-auth / failed など。このPCでは「claude.ai ai-kana」(アカウントのコネクタ)と、
166
166
  * 使っていない手元の登録「ai-kana」(いつも needs-auth)の2つが並ぶ。
167
- * ★1つでも connected なら接続済み。pending(つなぎかけで始まった)が在れば確定できない
168
- * (=要認証とは言わない)。connected も pending も無く needs-auth が在るときだけ要認証。
167
+ * ★1つでも connected なら接続済み(便が完了の知らせに使えるのは、どれか1つがつながっていれば足りる)。
168
+ * pending(つなぎかけ)・読めない状態が在れば確定できない(=要認証とは言わない)。
169
+ * connected も pending も無く needs-auth が在るときだけ要認証。
169
170
  */
170
171
  export function aiKanaMcpState(mcp) {
171
172
  if (mcp === null)
@@ -175,12 +176,88 @@ export function aiKanaMcpState(mcp) {
175
176
  return 'missing';
176
177
  if (mine.some((s) => s.status === 'connected'))
177
178
  return 'connected';
178
- if (mine.some((s) => s.status === 'pending'))
179
+ if (mine.some((s) => s.status === 'pending' || s.status === 'unknown'))
179
180
  return 'pending';
180
181
  if (mine.some((s) => /auth/i.test(s.status)))
181
182
  return 'needs-auth';
182
183
  return 'failed';
183
184
  }
185
+ /**
186
+ * MCP の状態が落ち着くのを待つ上限(★回数と時間の両方・便 4cf71dde)。
187
+ * ふつうは `claude mcp list` 1回(数秒)で落ち着く。上限まで落ち着かなければ PASS にも 🔵 にもしない(🟡)。
188
+ */
189
+ export const MCP_SETTLE_TRIES = 3;
190
+ export const MCP_SETTLE_WAIT_MS = 5_000;
191
+ export const MCP_LIST_TIMEOUT_MS = 45_000;
192
+ /**
193
+ * `claude mcp list` の出力を読む(★実機 2026-10-03・Claude Code 2.1.287 の形)。
194
+ * `claude.ai ai-kana: https://… - ✔ Connected` / `ai-kana: https://… (HTTP) - ! Needs authentication` /
195
+ * `名前: … - ✗ Failed to connect`。★知らない形の行は「読めない(unknown)」=落ち着いていない側に倒す。
196
+ */
197
+ export function parseMcpList(stdout) {
198
+ const out = [];
199
+ for (const raw of stdout.split('\n')) {
200
+ const line = raw.trim();
201
+ const m = line.match(/^(.+?): .* - (.+)$/);
202
+ if (!m)
203
+ continue;
204
+ const word = m[2];
205
+ const status = /connected/i.test(word) && !/not connected|disconnected/i.test(word)
206
+ ? 'connected'
207
+ : /auth/i.test(word)
208
+ ? 'needs-auth'
209
+ : /fail|error/i.test(word)
210
+ ? 'failed'
211
+ : /pending|connecting/i.test(word)
212
+ ? 'pending'
213
+ : 'unknown';
214
+ out.push({ name: m[1], status });
215
+ }
216
+ return out;
217
+ }
218
+ /**
219
+ * 便が実際に使う MCP の状態を、落ち着くまで確かめる(★便 4cf71dde の根治)。
220
+ *
221
+ * 【なぜ init 行だけでは足りないか(実物 2026-10-03・ノートPC)】
222
+ * 便(claude -p)が完了の知らせ・開発履歴に使っているのはアカウントのコネクタ「claude.ai ai-kana」
223
+ * (道具の名前 mcp__claude_ai_ai-kana__*)で、手元の登録「ai-kana」(User config・要認証)は使っていない。
224
+ * ところが常駐(systemd)から起こした claude -p の init 行には、コネクタがまだ1つも載らない
225
+ * (便の記録でも init は [ai-kana:needs-auth] だけ。コネクタは後から読み込まれ、便の中では使えている)。
226
+ * 人が端末から流すと載るが pending のまま。=init 行は「始まった瞬間」の写しで、落ち着いた状態ではない。
227
+ * そのため同じPC・同じ日に「つなぎかけ→PASS」と「要認証→🔵」に割れた。
228
+ * 【どう確かめるか】init 行で接続済みなら、それで足りる(追加の所要なし)。そうでなければ、同じ claude・
229
+ * 同じ環境で `claude mcp list`(コネクタも含めて1つずつつないで確かめる・型は使わない=費用なし)を流し、
230
+ * 落ち着いた状態(つながった/要認証/失敗)が出るまで、回数と時間の上限つきで待つ。
231
+ */
232
+ export async function settleMcp(d, claudeBin, cwd, init) {
233
+ const first = aiKanaMcpState(init);
234
+ if (first === 'connected')
235
+ return { state: 'connected', list: init, source: 'init', tries: 0, why: null };
236
+ if (!claudeBin) {
237
+ return { state: first === 'pending' ? 'unsettled' : first, list: init, source: init ? 'init' : 'none', tries: 0, why: 'claude が無いので確かめ直せません' };
238
+ }
239
+ const sleep = d.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
240
+ const env = probeEnv(d, claudeBin);
241
+ let last = null;
242
+ let why = null;
243
+ for (let i = 1; i <= MCP_SETTLE_TRIES; i++) {
244
+ const r = d.exec(claudeBin, ['mcp', 'list'], { cwd, env, timeoutMs: MCP_LIST_TIMEOUT_MS });
245
+ const list = parseMcpList(r.stdout);
246
+ if (list.length > 0)
247
+ last = list;
248
+ const st = list.length > 0 ? aiKanaMcpState(list) : 'unknown';
249
+ if (st === 'connected' || st === 'needs-auth' || st === 'failed' || st === 'missing') {
250
+ return { state: st, list, source: 'mcp-list', tries: i, why: null };
251
+ }
252
+ why =
253
+ list.length === 0
254
+ ? `claude mcp list の一覧を読めません(終了 ${r.status}${r.error ? `・${r.error.slice(0, 80)}` : ''})`
255
+ : 'ai-kana の MCP が つなぎかけのまま';
256
+ if (i < MCP_SETTLE_TRIES)
257
+ await sleep(MCP_SETTLE_WAIT_MS);
258
+ }
259
+ return { state: 'unsettled', list: last ?? init, source: last ? 'mcp-list' : init ? 'init' : 'none', tries: MCP_SETTLE_TRIES, why };
260
+ }
184
261
  // ─────────────────────────────────────────────────────────────────────────────
185
262
  // 本体
186
263
  // ─────────────────────────────────────────────────────────────────────────────
@@ -456,6 +533,16 @@ function siteSettingsFor(distRoot) {
456
533
  return null;
457
534
  }
458
535
  }
536
+ /** 小さな試験と MCP の確かめで claude に渡す環境(★中継と同じ形・src/headless.ts)。 */
537
+ function probeEnv(d, claudeBin) {
538
+ const env = { ...d.env };
539
+ delete env.CLAUDECODE;
540
+ delete env.ANTHROPIC_API_KEY;
541
+ const binDir = path.dirname(claudeBin);
542
+ if (!(env.PATH ?? '').split(':').includes(binDir))
543
+ env.PATH = env.PATH ? `${binDir}:${env.PATH}` : binDir;
544
+ return env;
545
+ }
459
546
  /** ⑥ 小さな headless 試験を1回流す。 */
460
547
  function runHeadlessProbe(d, claudeBin, runDir) {
461
548
  const res = { summary: null, exitCode: null, outsideWrite: 'unknown', net: {}, probeRan: false, error: null };
@@ -475,12 +562,7 @@ function runHeadlessProbe(d, claudeBin, runDir) {
475
562
  }
476
563
  writeFileSync(path.join(work, '.claude', 'settings.json'), settings);
477
564
  writeFileSync(path.join(work, 'probe.sh'), probeScript(outsideFile), { mode: 0o755 });
478
- const env = { ...d.env };
479
- delete env.CLAUDECODE;
480
- delete env.ANTHROPIC_API_KEY;
481
- const binDir = path.dirname(claudeBin);
482
- if (!(env.PATH ?? '').split(':').includes(binDir))
483
- env.PATH = env.PATH ? `${binDir}:${env.PATH}` : binDir;
565
+ const env = probeEnv(d, claudeBin);
484
566
  const r = d.exec(claudeBin, [
485
567
  '-p', PROBE_PROMPT,
486
568
  '--output-format', 'stream-json',
@@ -683,23 +765,32 @@ export async function runSelfcheck(d, trigger) {
683
765
  else
684
766
  fail(headless, 'fault', 'tool の結果に台本の目印がありません(Bash が動いていない)');
685
767
  }
686
- // ③ MCP
687
- const mcpState = aiKanaMcpState(sum?.mcp ?? null);
688
- if (mcpState === 'connected')
689
- pass(central, 'ai-kana の MCP が接続済み');
690
- else if (mcpState === 'needs-auth') {
691
- fail(central, 'human', 'ai-kana の MCP が要認証です(完了の知らせを出せません)', {
768
+ // ③ MCP(★落ち着くまで確かめる・便が実際に使う登録を見る=settleMcp)
769
+ const mcp = await settleMcp(d, claude.bin, path.join(runDir, 'work'), sum?.mcp ?? null);
770
+ const seen = mcp.source === 'mcp-list' ? `claude mcp list で確かめた・${mcp.tries}回目` : mcp.source === 'init' ? '小さな試験の最初の行' : '一覧なし';
771
+ const mineOf = (st) => (mcp.list ?? []).filter((s) => /ai-kana/i.test(s.name) && s.status === st).map((s) => s.name);
772
+ if (mcp.state === 'connected') {
773
+ pass(central, `ai-kana の MCP が接続済み(${mineOf('connected').join('・')}/${seen})`);
774
+ const unused = mineOf('needs-auth');
775
+ if (unused.length > 0)
776
+ note(central, `要認証の登録 ${unused.join('・')} は、つながっている登録があるので便は使いません(直さなくて構いません)`);
777
+ }
778
+ else if (mcp.state === 'needs-auth') {
779
+ const names = mineOf('needs-auth');
780
+ fail(central, 'human', `ai-kana の MCP が要認証です(${names.join('・')}/${seen}・つながっている登録が無いので完了の知らせを出せません)`, {
692
781
  machine: d.machine,
693
- what: 'Ubuntu で claude を開き、/mcp → ai-kana → Authenticate を選んでブラウザでログインする',
782
+ what: `Ubuntu で claude を開き、/mcp → ${names[0] ?? 'ai-kana'} → Authenticate を選んでブラウザでログインする`,
694
783
  command: 'claude',
695
784
  });
696
785
  }
697
- else if (mcpState === 'pending')
698
- note(central, 'ai-kana の MCP は つなぎかけのまま試験が始まりました(接続済みか要認証かは確定できません)');
699
- else if (mcpState === 'missing')
700
- fail(central, 'fault', 'ai-kana の MCP が一覧にありません');
701
- else if (mcpState === 'failed')
702
- fail(central, 'fault', 'ai-kana の MCP につながりません(failed)');
786
+ else if (mcp.state === 'unsettled' || mcp.state === 'pending') {
787
+ // ★PASS にも 🔵 にもしない(確かめきれていない=🟡)。
788
+ fail(central, 'fault', `ai-kana の MCP の状態が、上限(${MCP_SETTLE_TRIES}回)まで待っても落ち着きませんでした(${mcp.why ?? 'つなぎかけのまま'})`);
789
+ }
790
+ else if (mcp.state === 'missing')
791
+ fail(central, 'fault', `ai-kana の MCP が一覧にありません(${seen})`);
792
+ else if (mcp.state === 'failed')
793
+ fail(central, 'fault', `ai-kana の MCP につながりません(failed・${seen})`);
703
794
  else
704
795
  note(central, 'MCP の状態を読めませんでした(小さな試験の最初の行が無い)');
705
796
  // ④ 書き込み
@@ -731,7 +822,7 @@ export async function runSelfcheck(d, trigger) {
731
822
  result: items.some((i) => i.ok === false) ? 'FAIL' : 'PASS',
732
823
  conductorVersion: version,
733
824
  claude: { bin: claude.bin, version: claude.version, loggedIn: claude.loggedIn, pin: lastClaudePinStatus() },
734
- mcp: sum?.mcp ?? null,
825
+ mcp: mcp.list,
735
826
  drain: readDrain().active,
736
827
  items,
737
828
  humanActions,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tyhld/conductor",
3
- "version": "0.12.90",
3
+ "version": "0.12.92",
4
4
  "description": "采配くん管制の見守りアプリ(conductor-agent)。各PCで常駐し、中央(devlog-tracker)へ定期的に生存報告(heartbeat)を送る常駐CLI。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -136,10 +136,20 @@ def read_report_text(command_id, env=None):
136
136
  # 【★本文の言葉だけは別】下書きの印(作成中・TODO など)は関門だけの決まり。
137
137
  # 報告の中身の言葉で完了を判定しない(えふさん確定 2026-09-23)ので、中継からは手が届かない
138
138
  # ところ(この下の DRAFT_MARKERS)に置く。正本はここ1か所。
139
+ #
140
+ # 【★印を探す場所は本文の「頭」と evidence だけ(便 ec974d68)】
141
+ # 以前は本文の全体を見ていた。そのため【関門そのものを説明した報告】が、説明の中に出てくる
142
+ # 印の語(「作成中」など)に当たって差し戻された(実地・便 461be339=言い回しを替えて通した=
143
+ # 3手番の無駄)。仕上げ前の報告では、印は「頭(題の行とその次の段落)」か evidence に現れる
144
+ # (実地 915694ac はその形)。だから見る場所を頭と evidence に絞る=塞ぎたい物は塞いだまま、
145
+ # 説明での誤当たりだけが消える。★印の一覧(下)は1語も減らさない。
139
146
  # ─────────────────────────────────────────────────────────────────────────────
140
147
  #: 「最終版で差し替える前」の印。★決まった字面の一覧だけを見る(文意は読まない)。
141
148
  DRAFT_MARKERS = ('作成中', '下書き', '差し替える', 'あとで書く', '(未記入)', '(未記入)', 'TODO', 'TBD')
142
149
 
150
+ #: 見出しの行(`#` 〜 `######`)。★本文の「頭」を切り出すためだけに使う。
151
+ _HEADING_RE = re.compile(r'^ {0,3}#{1,6}\s')
152
+
143
153
  #: 形の決まりの正本(★この python と同じフォルダに配られる)。
144
154
  SHAPE_SPEC_NAME = 'report-shape.json'
145
155
 
@@ -262,6 +272,36 @@ def report_body(text):
262
272
  return _parse_report(text)[2]
263
273
 
264
274
 
275
+ def _body_head(body):
276
+ """本文の「頭」の行だけを並べる=最初の見出しの行と、その直後の1段落。★純粋関数。
277
+
278
+ 見出しが無い本文では、最初の1段落だけ(頭は段落から始まっている)。
279
+ """
280
+ lines = ('' if body is None else str(body)).split('\n')
281
+ i = 0
282
+ while i < len(lines) and lines[i].strip() == '':
283
+ i += 1
284
+ out = []
285
+ if i < len(lines) and _HEADING_RE.match(lines[i]):
286
+ out.append(lines[i])
287
+ i += 1
288
+ while i < len(lines) and lines[i].strip() == '':
289
+ i += 1
290
+ while i < len(lines) and lines[i].strip() != '':
291
+ out.append(lines[i])
292
+ i += 1
293
+ return out
294
+
295
+
296
+ def report_draft_places(text):
297
+ """下書きの印を探す場所だけを並べる(★本文の全体は見ない・便 ec974d68)。★純粋関数。
298
+
299
+ ① 囲みのすぐ下の最初の見出しの行 ② その直後の1段落 ③ evidence の各行。
300
+ """
301
+ _, evidence, body = _parse_report(text)
302
+ return [str(e) for e in evidence] + _body_head(body)
303
+
304
+
265
305
  def report_residue(text, spec=None):
266
306
  """報告が「無い・形が整っていない・下書きのまま」なら理由、出来上がっていれば None。"""
267
307
  if text is None or not text.strip():
@@ -270,9 +310,11 @@ def report_residue(text, spec=None):
270
310
  if missing:
271
311
  return ('報告の形が整っていない(足りない: '
272
312
  + '・'.join(_SHAPE_JA.get(k, k) for k in missing) + ')')
273
- hit = next((m for m in DRAFT_MARKERS if m in report_body(text)), None)
274
- if hit is not None:
275
- return f'報告が下書きのまま(本文に「{hit}」が残っている)'
313
+ for place in report_draft_places(text):
314
+ hit = next((m for m in DRAFT_MARKERS if m in place), None)
315
+ if hit is not None:
316
+ return ('報告が下書きのまま(報告の頭か evidence に「'
317
+ + hit + '」が残っている)')
276
318
  return None
277
319
 
278
320
 
@@ -3586,6 +3586,7 @@ def _gate_command(command: str, depth: int = 0):
3586
3586
  # 【何のため】次の3件は、毎便の本文に「やらない」と書いてきたが、番人は通していた(棚卸し 8ceae57d の
3587
3587
  # 実測で allow)。番人で止められれば、便の本文からその行を外せる(書かなくても守られる)。
3588
3588
  # ① git add -A(--all・-A を含む短い旗のまとめ・git add . ・git add :/)
3589
+ # と git commit -a(--all・-am などのまとめ・便 4cf71dde で足した)
3589
3590
  # ② cd を && でほかの命令とつなげる/cd とリダイレクトを同じ命令の行に混ぜる
3590
3591
  # ③ リポ全体・ホーム全体を指す再帰の検索(grep -r・rg・ag・ack・find)
3591
3592
  # 【★確実にその形と言える物だけを止める】命令の1行目を、引用符を守って字句に割ってから見る
@@ -3600,6 +3601,8 @@ _DISCIPLINE_REDIRECTS = frozenset({'>', '>>', '<', '>&', '&>', '>|', '<>', '&>>'
3600
3601
 
3601
3602
  _DISC_GIT_ADD = ('git add はまとめて足さない(-A・--all・. は秘密や無関係の変更まで入る)。'
3602
3603
  '足すファイルを名指しで git add path/to/file(鉄則1)')
3604
+ _DISC_GIT_COMMIT_ALL = ('git commit -a・-am・--all はまとめて記録に入れない(着手前から変わっていた設定ファイルまで入る)。'
3605
+ 'ファイルを名指しで git add path/to/file してから git commit(鉄則1)')
3603
3606
  _DISC_CD = ('cd を && でほかの命令とつなげない・cd とリダイレクトを同じ行に混ぜない(許可の確認で止まる)。'
3604
3607
  'cd は作業のはじめに1回だけ単独で打ち、以後は絶対パスか git -C を使う(鉄則2)')
3605
3608
  _DISC_SEARCH = ('リポ全体・ホーム全体を再帰で検索しない(重く、秘密の置き場にも当たる)。'
@@ -3678,6 +3681,56 @@ def _disc_git_add(words):
3678
3681
  return None
3679
3682
 
3680
3683
 
3684
+ # git commit の短い旗のうち、値を取る物(-m 説明・-F ファイル・-C/-c 記録・-t 型)と、
3685
+ # 値を続けて書ける物(-u 形・-S 鍵)。★これらより後ろの字は旗ではなく値(`-ma` は説明 "a")。
3686
+ _COMMIT_VALUE_SHORT = frozenset('mFCct')
3687
+ _COMMIT_OPTVALUE_SHORT = frozenset('uS')
3688
+ _COMMIT_VALUE_LONG = frozenset({'--message', '--file', '--reuse-message', '--reedit-message',
3689
+ '--template', '--author', '--date', '--fixup', '--squash',
3690
+ '--cleanup', '--trailer', '--pathspec-from-file'})
3691
+
3692
+
3693
+ def _disc_git_commit_all(words):
3694
+ """git commit -a/--all/-am(追跡しているファイルの変更を全部まとめて記録に入れる形)。
3695
+
3696
+ ★便 4cf71dde:着手前から M だった .claude/settings.json が、空振り確認の `git commit -a` で
3697
+ 手元の記録に入った(ai-kana の2便が続けて踏んだ)。`git add -A` と同じ「まとめて入れる」形なので、
3698
+ 同じ扱いで止める(止める物を足すだけ)。
3699
+ """
3700
+ if not words or words[0].split('/')[-1] != 'git':
3701
+ return None
3702
+ i = 1
3703
+ while i < len(words) and words[i].startswith('-'):
3704
+ i += 2 if words[i] in ('-C', '-c') else 1
3705
+ if i >= len(words) or words[i] != 'commit':
3706
+ return None
3707
+ rest = words[i + 1:]
3708
+ k = 0
3709
+ while k < len(rest):
3710
+ a = rest[k]
3711
+ k += 1
3712
+ if a == '--':
3713
+ break
3714
+ if a.startswith('--'):
3715
+ if a == '--all':
3716
+ return _DISC_GIT_COMMIT_ALL
3717
+ if a in _COMMIT_VALUE_LONG:
3718
+ k += 1 # 値は次の語(`--message=…` の形は1語で終わる)
3719
+ continue
3720
+ if not a.startswith('-') or a == '-':
3721
+ continue
3722
+ for j, ch in enumerate(a[1:], 1):
3723
+ if ch == 'a':
3724
+ return _DISC_GIT_COMMIT_ALL
3725
+ if ch in _COMMIT_VALUE_SHORT:
3726
+ if j == len(a) - 1:
3727
+ k += 1 # `-m 説明` … 値は次の語
3728
+ break # `-mabc` … 残りは値
3729
+ if ch in _COMMIT_OPTVALUE_SHORT:
3730
+ break
3731
+ return None
3732
+
3733
+
3681
3734
  def _disc_repo_top(cwd):
3682
3735
  d = os.path.normpath(cwd) if cwd and os.path.isabs(cwd) else None
3683
3736
  while d:
@@ -3808,7 +3861,7 @@ def _discipline_scan(command: str, cwd=None):
3808
3861
  while k < len(words) and (_ASSIGN_RE.match(words[k]) or words[k].split('/')[-1] in _WRAPPERS):
3809
3862
  k += 1
3810
3863
  core = words[k:]
3811
- hit = _disc_git_add(core) or _disc_search(core, cwd)
3864
+ hit = _disc_git_add(core) or _disc_git_commit_all(core) or _disc_search(core, cwd)
3812
3865
  if hit:
3813
3866
  return hit
3814
3867
  return None
@@ -6,6 +6,7 @@
6
6
 
7
7
  【何を守っているか】
8
8
  (n) ★止まる形:git add -A/--all/./-Av/git -C 場所 add -A、
9
+ git commit -a/-am/--all/-qam/git -C 場所 commit -av(便 4cf71dde)、
9
10
  cd を && でつなぐ(前でも後ろでも・括弧の中でも)・cd とリダイレクトを同じ行に混ぜる、
10
11
  リポ全体・ホーム全体・/ を指す再帰の検索(grep -r・rg・find、行き先を省いた形も)
11
12
  (p) 止まってはいけない似た形:git add ファイル名・git add -- ファイル名・cd 単独・cd X; 命令・
@@ -44,6 +45,7 @@ os.mkdir(os.path.join(REPO, 'src'))
44
45
  SUB = os.path.join(REPO, 'src')
45
46
  HOME = os.path.expanduser('~')
46
47
  ADD = 'git ' + 'add'
48
+ COMMIT = 'git ' + 'commit'
47
49
 
48
50
 
49
51
  def decide(cmd, cwd=REPO):
@@ -76,6 +78,14 @@ NEGATIVE = [
76
78
  ('n22 リポの一番上を名指しした find', f'find {REPO} -name x'),
77
79
  ('n23 リポより広い所(リポの親)', f'find {os.path.dirname(REPO)} -name x'),
78
80
  ('n24 env 変数=… を前に付けても', f'GIT_TRACE=1 {ADD} -A'),
81
+ # ★便 4cf71dde:git commit -a は git add -A と同じ「まとめて入れる」形(ai-kana の2便が踏んだ)。
82
+ ('n25 git commit -a', f'{COMMIT} -a'),
83
+ ('n26 git commit -am 説明', f'{COMMIT} -am "x"'),
84
+ ('n27 git commit --all', f'{COMMIT} --all -m x'),
85
+ ('n28 git commit -m 説明 -a(後ろに置いても)', f'{COMMIT} -m "x" -a'),
86
+ ('n29 git -C 場所 commit -av', f'git -C /x commit -av'),
87
+ ('n30 git commit -qam 説明(短い旗のまとめ)', f'{COMMIT} -qam x'),
88
+ ('n31 命令 && git commit -am 説明', f'git status && {COMMIT} -am x'),
79
89
  ]
80
90
  for label, cmd in NEGATIVE:
81
91
  r = decide(cmd)
@@ -101,6 +111,14 @@ POSITIVE = [
101
111
  ('p16 下の場所から find .(リポ全体ではない)', 'find . -name x'),
102
112
  ('p17 grep(再帰でない)', 'grep -n foo src/a.ts'),
103
113
  ('p18 リダイレクトだけ(cd なし)', 'ls > /tmp/claude-1000/o'),
114
+ ('p19 git commit -m 説明(名指しで add した後)', f'{COMMIT} -m "fix: 直す"'),
115
+ ('p20 説明の中の -a は旗ではない', f'{COMMIT} -m "-a を止める"'),
116
+ ('p21 -ma は説明 "a"(-a ではない)', f'{COMMIT} -ma'),
117
+ ('p22 git commit --amend --no-edit', f'{COMMIT} --amend --no-edit'),
118
+ ('p23 git commit -F ファイル', f'{COMMIT} -F /tmp/claude-1000/msg.txt'),
119
+ ('p24 git commit --author 名前', f'{COMMIT} --author "-a <a@b>" -m x'),
120
+ ('p25 git commit -- ファイル名', f'{COMMIT} -m x -- src/a.ts'),
121
+ ('p26 git log --all(commit ではない)', 'git log --all --oneline'),
104
122
  ]
105
123
  for label, cmd in POSITIVE:
106
124
  cwd = SUB if label.startswith('p16') else REPO
@@ -108,6 +126,8 @@ for label, cmd in POSITIVE:
108
126
 
109
127
  print('── (m) 止めたときの文 ' + '─' * 44)
110
128
  check('(m) git add は名指しを案内', 'git add path/to/file' in decide(f'{ADD} -A')['reason'], True)
129
+ check('(m) git commit -a は「名指しで add してから commit」を案内',
130
+ all(w in decide(f'{COMMIT} -am x')['reason'] for w in ('git add path/to/file', 'してから git commit')), True)
111
131
  check('(m) cd は絶対パスか git -C を案内', 'git -C' in decide('cd x && ls')['reason'], True)
112
132
  check('(m) 検索は名指しの場所を案内', '名指し' in decide('find . -name x')['reason'], True)
113
133
  for label, cmd in [('git add', f'{ADD} -A'), ('cd', 'cd x && ls'), ('検索', 'find / -name x')]:
@@ -11,6 +11,9 @@
11
11
  【ここで固定すること】
12
12
  (A) 報告の判定 … 無い・空・囲みが無い・欄が足りない・outcome が変・evidence 0件・
13
13
  本文が空・下書きの印が残っている → 残り。最終版 → 残り無し
14
+ (A2) ★下書きの印を見る場所(便 ec974d68) … 囲みのすぐ下の最初の見出しの行・その直後の
15
+ 1段落・evidence の各行だけ。実地 915694ac の下書きは止まり、関門そのものを説明した
16
+ 整った報告は止まらない(本文の全体を見ていたため誤って差し戻していた=便 461be339)
14
17
  (B) 背景の待ちの数え方 … 始まった印(backgroundTaskId)と知らせ(task-notification)の差。
15
18
  ★実地 915694ac・47bdfe00 と同じ形の記録で確かめる
16
19
  (C) PR の判定 … 開いたまま/合流の予約中 → 残り。空・合流済み → 残り無し。
@@ -23,6 +26,8 @@
23
26
  ★空振り確認(隔離した worktree の中だけ):
24
27
  ・finish_gate.decide の上限の判定(`counts[k] < cap`)を外す → (D) が落ちる
25
28
  ・report_residue の下書きの印の判定を外す → (A) が落ちる
29
+ ・report_draft_places を空の一覧にする(どこも見ない) → (A2) の「実地の下書きを止める」が落ちる
30
+ ・report_draft_places を本文の全体に戻す → (A2) の「説明した報告を止めない」が落ちる
26
31
  ・stop_hook.main から関門の呼び出しを外す → (G) の「止める」が落ちる
27
32
  """
28
33
  import json
@@ -58,10 +63,13 @@ ENV = {'HOME': SANDBOX, 'XDG_STATE_HOME': os.path.join(SANDBOX, 'state'),
58
63
  JOB = '7aa63a97-57e7-4c42-8b95-f3da87d88829'
59
64
 
60
65
 
61
- def report_text(outcome='pushed', body='やりました。', header=True, evidence=True):
66
+ def report_text(outcome='pushed', body='やりました。', header=True, evidence=True,
67
+ evidence_lines=None):
62
68
  head = ['---', f'instruction_id: {JOB}', 'site: demo', f'outcome: {outcome}',
63
69
  'created_at: 2026-10-03T09:00:00+09:00', 'evidence:']
64
- if evidence:
70
+ if evidence_lines is not None:
71
+ head.extend(f' - {line}' for line in evidence_lines)
72
+ elif evidence:
65
73
  head.append(' - PR https://github.com/tyhld/conductor/pull/999')
66
74
  head.append('---')
67
75
  return ('\n'.join(head) + '\n\n' + body) if header else body
@@ -124,6 +132,89 @@ truthy('実地の下書き → 残り', finish_gate.report_residue(report_text(b
124
132
  for m in ('作成中', '下書き', '差し替える', 'あとで書く', '(未記入)', 'TODO', 'TBD'):
125
133
  truthy(f'下書きの印「{m}」→ 残り', finish_gate.report_residue(report_text(body=f'あ{m}い')))
126
134
 
135
+ # ─────────────────────────────────────────────────────────────────────────────
136
+ print('\n--- ★(A2) 下書きの印を見る場所(頭と evidence だけ・便 ec974d68) ---')
137
+ # 【なぜ要るか(実地・便 461be339)】本文の全体を見ていたので、★関門そのものを説明した
138
+ # 整った報告が、説明の中の語に当たって差し戻された(言い回しを替えて通した=3手番の無駄)。
139
+ # 見る場所を「囲みのすぐ下の最初の見出しの行・その直後の1段落・evidence の各行」に絞る。
140
+ # ★印の一覧(DRAFT_MARKERS)は1語も減らしていない=塞ぎたい物は塞いだまま。
141
+ # 【書き方】report-shape.json の cases と同じ流儀(name / text / 期待)で並べる。
142
+ # ★ただし言葉の決まりは正本(report-shape.json)に置かない(中継から手が届かない形を保つ)。
143
+ # ─────────────────────────────────────────────────────────────────────────────
144
+ #: 実地 915694ac の報告の頭(囲みの evidence の行まで含めて、当時の実物のまま)。
145
+ REAL_DRAFT_EVIDENCE = '(CI・合流・公開版は下の「合流と公開」節に実物を書く)'
146
+
147
+ #: 関門を説明した「整った報告」の本文(★この便の報告そのものに近い形)。
148
+ GATE_EXPLAINED = '\n'.join([
149
+ '# 結論',
150
+ '',
151
+ '便の終わりの関門が、仕上げ前の印を見る場所を狭めました。',
152
+ '',
153
+ '## 狭めた後に見る場所',
154
+ '',
155
+ '囲みのすぐ下の最初の見出しの行と、その直後の1段落、evidence の各行だけです。',
156
+ '以前は本文の全体を見ていたので、印の語(作成中・下書き・差し替える・あとで書く・TODO・TBD)を',
157
+ 'この表のように並べて説明した報告が、そのまま差し戻されました。',
158
+ ])
159
+
160
+ DRAFT_CASES = [
161
+ {
162
+ 'name': '★実地の下書き(見出しに印・次の行にも印)→ 止める',
163
+ 'text': report_text(body=REAL_DRAFT, evidence=True),
164
+ 'draft': True,
165
+ },
166
+ {
167
+ 'name': '★関門を説明した整った報告 → 止めない(これが直した本体)',
168
+ 'text': report_text(body=GATE_EXPLAINED),
169
+ 'draft': False,
170
+ },
171
+ {
172
+ 'name': '印が evidence の行に在る → 止める',
173
+ 'text': report_text(body='# 結論\n\nやりました。',
174
+ evidence_lines=['CI の結果はあとで書く']),
175
+ 'draft': True,
176
+ },
177
+ {
178
+ 'name': '印が見出しの直後の1段落に在る → 止める',
179
+ 'text': report_text(body='# 結論\n\nここは作成中です。'),
180
+ 'draft': True,
181
+ },
182
+ {
183
+ 'name': '見出しが無く最初の段落に印 → 止める(頭は段落から始まる)',
184
+ 'text': report_text(body='まだ下書きです。\n\n# 結論\n\nやりました。'),
185
+ 'draft': True,
186
+ },
187
+ {
188
+ 'name': '見出しの前に空行が在っても頭として見る → 止める',
189
+ 'text': report_text(body='\n\n# 報告(作成中)\n\nやりました。'),
190
+ 'draft': True,
191
+ },
192
+ {
193
+ 'name': '★頭より後ろの節の中の語は見ない → 止めない(狭めた結果・正直に書く)',
194
+ 'text': report_text(body='# 結論\n\nやりました。\n\n## 補足\n\n印の語は TODO などです。'),
195
+ 'draft': False,
196
+ },
197
+ {
198
+ 'name': '頭も evidence も印が無い最終版 → 止めない',
199
+ 'text': report_text(body='# 結論\n\nやりました。'),
200
+ 'draft': False,
201
+ },
202
+ ]
203
+
204
+ for case in DRAFT_CASES:
205
+ check(case['name'][:64], finish_gate.report_residue(case['text']) is not None, case['draft'])
206
+
207
+ # 実地 915694ac の evidence の行は、狭めた後の「見る場所」に入っている(=見落とさない)。
208
+ truthy('実地 915694ac の evidence の行が見る場所に入る',
209
+ REAL_DRAFT_EVIDENCE in finish_gate.report_draft_places(
210
+ report_text(body=REAL_DRAFT, evidence_lines=[REAL_DRAFT_EVIDENCE])))
211
+ # ★見る場所は3種類だけ=頭より後ろの行は1行も入らない。
212
+ places = finish_gate.report_draft_places(
213
+ report_text(body='# 結論\n\nやりました。\n\n## 補足\n\nここは TODO と書いてある。'))
214
+ check('見る場所は evidence と頭だけ(後ろの節は入らない)',
215
+ any('TODO' in p for p in places), False)
216
+ check('見る場所に見出しの行が入る', any(p.startswith('# 結論') for p in places), True)
217
+
127
218
  # ─────────────────────────────────────────────────────────────────────────────
128
219
  print('\n=== (B) 背景の待ちの数え方(★実地と同じ形の記録) ===')
129
220
  # ─────────────────────────────────────────────────────────────────────────────
@@ -22,14 +22,15 @@ description: 采配くんの便(プロンプト末尾に CONDUCTOR_JOB:<id>
22
22
  - ① そのファイルを**触らない・開かない・`git diff` の対象にしない**。「読めるようにする」方向へ回避しない(鉄則4)。
23
23
  - ② 差分は**対象を名指しして**見る。例 `git diff -- src docs`。全体で見たいときは除いて見る=`git diff -- . ':(exclude).npmrc'`。
24
24
  - ③ `M` と出ていても、**自分が触っていなければ触っていない**。★職人には本当に変わったかを確かめられない。`git status --porcelain -- <そのファイル>` の**表示だけ**を報告に書き、**中身は1文字も書かない**。
25
- - ④ **コミットに含めない**(`git add` は対象を名指しする=`git add -A` を使わない)。
25
+ - ④ **コミットに含めない**(`git add` は対象を名指しする=`git add -A` を使わない)。★`git commit -a`・`-am`・`--all` も同じ「まとめて全部を入れる」形なので使わない(番人が止める)。**ファイルを名指しで `git add` してから `git commit -m …`**(便 4cf71dde:着手前から `M` だった `.claude/settings.json` が、空振り確認の `git commit -a` で手元の記録に入った)。
26
26
  - ⑤ それで進めないときだけ、回避せず**人の手番として報告して止まる**。
27
27
  2. **cd は1回だけ** — cd は作業開始時に1回だけ実行する。以後 cd を `&&` で他コマンドと連結しない・リダイレクトと混ぜない。
28
28
  3. **持ち場の外に出ない** — 指示された作業ディレクトリの外を変更しない。
29
29
  4. **壁に当たったら止まる** — サンドボックス制約・保護されたパスに当たったら回避せず中断して報告する(★通信で塞がれたときの読み方は鉄則12)。
30
30
  5. **エージェント設定に書き込まない** — `.mcp.json`、`.claude/` 配下、hooks 等のエージェント設定パスには書き込まない。
31
31
  6. **本番に触らない** — 本番DB・deploy・secret に触らない。SQL/DDL は作って提示のみ(適用は人間)。
32
- 7. **便の終点はマージまで(★保護を迂回しない)** — push → PR作成 → 合流する。★合流の仕方は、そのリポの合流先に**保護(必須の検査)**があるかで分ける。リポの名前は覚えなくてよい。
32
+ 7. **便の終点はマージまで(★保護を迂回しない)** — push → PR作成 → 合流する。★push は**上流の設定を書かない形**で打つ(⓪)。★合流の仕方は、そのリポの合流先に**保護(必須の検査)**があるかで分ける。リポの名前は覚えなくてよい。
33
+ - ⓪ **上流の設定を書かない形で push する**=`git push origin <作業ブランチ名>`。★`-u`(`--set-upstream`)を付けない。`-u` と `git checkout -b <枝> origin/main` は `.git/config` へ書き込むので、囲いがその書き込みを塞いでいるPCでは「上流の設定を書けない」で止まる(**push 自体は成功している**ので、打ち直すと食い違って見える=実測 便 2cb1adf6・ef8def38)。枝を切るときも同じで `git switch -c <枝> --no-track origin/main`(または `git branch --no-track <枝> origin/main` のあと `git switch <枝>`)。★これは回避の近道ではなく**どのPCでも同じに通る形**なので、塞がれていないPCでもこの形で打つ(上流の設定は職人の作業に要らない)。
33
34
  - ① **保護があるかを読む。** 合流先は `gh pr view <番号> --json baseRefName -q .baseRefName`(ふつうは main)。`gh api 'repos/{owner}/{repo}/branches/<合流先>/protection/required_status_checks' --jq '.contexts | length'` が **1 以上の数を返したときだけ「保護あり」**。0・404(Branch not protected)・403・その他の失敗は、すべて**「保護なし」**として扱う。
34
35
  - ② **保護あり** … `gh pr merge <番号> --auto --merge --delete-branch` で**合流を予約する**。必須の検査が緑になった後に GitHub が合流する(赤のままなら合流されない)。
35
36
  - 「auto-merge が使えない」と断られたときは、`gh pr checks <番号>` で必須の検査が pass なのを確かめてから `gh pr merge <番号> --merge --delete-branch`。
@@ -163,7 +164,8 @@ description: 采配くんの便(プロンプト末尾に CONDUCTOR_JOB:<id>
163
164
  - 通る例: `rm -r /tmp/claude-1000/作業名` / `rm -r node_modules/.tmp-作業名`(★`-f` を付けない・自分で作った物だけ)
164
165
  - 止まる例: `rm -rf /tmp/claude-1000/作業名`(禁止の一覧)/ `rm -r "$DIR/x"`・`rm -r build/*`(番人)/ `rm -r .git`・`rm -r .claude/hooks`・`rm -r .tyhld`(番人=相対パスでも消せない)
165
166
  - ★**探すだけの道具の「探す言葉」に `rm …` と書いても、もう止まらない**(便 915694ac で直した)。鉄則や台本を調べるときの `grep -rn 'rm -rf .git' skills`・`git grep -n 'rm -r' -- skills`・`rg 'rm -rf /' docs` は通る。★囲みの外に書いた `rm` は今までどおり測るので、`grep -rn 'x' docs; rm -rf .git` のように**後ろで消す形は止まる**。
166
- - **作業場所が変数のままの `git push`**(`git -C "$DIR" push`・行き先のブランチを確かめられない形)→ 止まる。**リポの中で、ブランチを名指しして** `git push -u origin <作業ブランチ名>`。
167
+ - **まとめて全部を記録に入れる `git add`・`git commit`**(`git add -A`・`git add .`・`git commit -a`・`git commit -am 説明`・`git commit --all`)→ 止まる。着手前から変わっていたファイル(例 `.claude/settings.json`)まで入り、番人がそれを記録から外す操作も止めるので取り返せない(便 4cf71dde)。**ファイルを名指しで `git add <ファイル>` してから `git commit -m 説明`**(鉄則1-④)。
168
+ - **作業場所が変数のままの `git push`**(`git -C "$DIR" push`・行き先のブランチを確かめられない形)→ 止まる。**リポの中で、ブランチを名指しして** `git push origin <作業ブランチ名>`(★`-u` は付けない=鉄則7-⓪。上流の設定を書くと、囲いが `.git/config` を塞いでいるPCで止まる)。
167
169
 
168
170
  ## 便の終え方
169
171