@tyhld/conductor 0.12.44 → 0.12.46

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/cli.js CHANGED
@@ -121,6 +121,8 @@ function loadConfig(args) {
121
121
  }
122
122
  // ★職人の種類(ADR-021)。未設定・知らない値は 'claude'(後退させない)。
123
123
  const agentKind = parseAgentKind(process.env.CONDUCTOR_AGENT_KIND);
124
+ // ★1便1context(便 87a1a28a)。既定オフ。1/true/yes/on のときだけ入る。
125
+ const freshContext = /^(1|true|yes|on)$/i.test((process.env.CONDUCTOR_FRESH_CONTEXT ?? '').trim());
124
126
  // relay 設定(既定 off。off の間は既存の heartbeat 挙動を一切変えない)。
125
127
  const relayEnabled = /^(1|true|yes|on)$/i.test((process.env.CONDUCTOR_RELAY_ENABLED ?? '').trim());
126
128
  let relayIntervalSec = 15;
@@ -154,6 +156,7 @@ function loadConfig(args) {
154
156
  relayIntervalSec,
155
157
  promptIntervalSec,
156
158
  agentKind,
159
+ freshContext,
157
160
  };
158
161
  }
159
162
  /** 1回分の報告を送る。送信結果(HTTPステータス)をログ表示する。 */
package/dist/relay.js CHANGED
@@ -1694,6 +1694,66 @@ export function hasPendingDeliveryInInput(cap) {
1694
1694
  }
1695
1695
  return block.join('\n').includes('(配達役より)');
1696
1696
  }
1697
+ // ───────────────────────────────────────────────────────────────────────────
1698
+ // 1便1context:配る直前に職人の会話を新しくする(便 87a1a28a・★既定オフ)
1699
+ //
1700
+ // 【なぜ /clear か(試験用の tmux で本物の Claude Code に当てて実測 2026-09-21)】
1701
+ // ・/clear で会話の記録のファイルが新しくなる(新しい会話の番号・前のファイルは閉じる)。
1702
+ // 終わりと始まりのフック(SessionEnd:clear・SessionStart:clear)も鳴る=旧context の終了。
1703
+ // ・/clear のあとの開始時の背負いは 59,849 トークン=起動した直後(59,713)とほぼ同じ。前の会話は持ち越さない。
1704
+ // ・職人を止めないので、囲いのふた(6bef35c8)も起動待ちも起きない。
1705
+ // ★道具のつなぎ直しはしない(起動時の「つなぎ始め」の記録が /clear のあとには無い)=あとから足した
1706
+ // 口は /clear では使えるようにならない。それが要る日は起こし直し(restart-cc2.sh)を使う。
1707
+ // 【新しくできたかの確かめ】受付側の状態で見る:その作業場所の会話の記録に、打つ前には無かった
1708
+ // ファイルができたか。確かめられなければ false(呼び出し側は配らない)。
1709
+ // ───────────────────────────────────────────────────────────────────────────
1710
+ /** 会話を新しくできたかを待つ長さ(ミリ秒)。 */
1711
+ export const RELAY_FRESH_CONFIRM_MS = 10_000;
1712
+ /**
1713
+ * 会話が新しくなってから配るまでに置く間(ミリ秒)。
1714
+ * ★実測(試験用の tmux・本物の Claude Code):新しい会話のファイルができた1秒後に貼り付けると、
1715
+ * 入力欄がまだ整っておらず【貼り付けた本文が落ち、添え書きだけが送信された】(職人は「貼り付けが
1716
+ * 届いていない」と答えて止まった)。間を置いたうえで、画面が入力待ちで落ち着いたことも確かめる。
1717
+ */
1718
+ export const RELAY_FRESH_SETTLE_MS = 3_000;
1719
+ /** その作業場所の会話の記録のファイル名の一覧。読めなければ空。 */
1720
+ export function listTranscripts(cwd, env = process.env) {
1721
+ try {
1722
+ return readdirSync(claudeTranscriptDir(cwd, env)).filter((n) => n.endsWith('.jsonl'));
1723
+ }
1724
+ catch {
1725
+ return [];
1726
+ }
1727
+ }
1728
+ export async function freshenContext(site, cwd, d = {
1729
+ listSessions: () => listTranscripts(cwd),
1730
+ type: (text) => runTmux(['send-keys', '-t', site, '-l', text]),
1731
+ enter: () => runTmux(['send-keys', '-t', site, 'Enter']),
1732
+ pause: pauseSync,
1733
+ sleep,
1734
+ ready: () => isIdleStable(site, 'claude'),
1735
+ }, o = { waitMs: RELAY_FRESH_CONFIRM_MS, pollMs: 500 }) {
1736
+ const before = new Set(d.listSessions());
1737
+ try {
1738
+ d.type('/clear');
1739
+ // ★打った字と Enter の間は一拍おく(b69c1608:すぐ続く Enter は改行に取り込まれることがある)。
1740
+ d.pause(RELAY_SUBMIT_PAUSE_MS);
1741
+ d.enter();
1742
+ }
1743
+ catch {
1744
+ return false;
1745
+ }
1746
+ for (let t = 0;; t += o.pollMs) {
1747
+ if (d.listSessions().some((n) => !before.has(n))) {
1748
+ // ★新しい会話ができた直後は入力欄が整っていない(RELAY_FRESH_SETTLE_MS の説明)。
1749
+ await d.sleep(RELAY_FRESH_SETTLE_MS);
1750
+ return d.ready();
1751
+ }
1752
+ if (t >= o.waitMs)
1753
+ return false;
1754
+ await d.sleep(o.pollMs);
1755
+ }
1756
+ }
1697
1757
  /**
1698
1758
  * 受け付けられるまで見て、未受付で入力欄に残っているときだけ Enter を押し直す。
1699
1759
  * accepted … 押し直さずに受け付けられた
@@ -2641,6 +2701,19 @@ export async function relayTick(cfg, state, hooks) {
2641
2701
  noteReadiness(state, 'not-agent', cmd.id);
2642
2702
  return;
2643
2703
  }
2704
+ // ── 1便1context(★既定オフ・便 87a1a28a) ──
2705
+ // 便を配る直前に職人の会話を新しくする(/clear)。★便を追っている間(state.pending)はここへ
2706
+ // 来ない=質問・確認待ちの便は同じ会話のまま続く。新しくできたと確かめられなければ配らない
2707
+ // (次の周でやり直す)。Codex の職人は対象外。
2708
+ if (cfg.freshContext && kindOf(cfg) === 'claude') {
2709
+ const cwd = tmuxPaneCwd(cfg.site);
2710
+ const fresh = cwd !== null && (await freshenContext(cfg.site, cwd));
2711
+ if (!fresh) {
2712
+ log(`WARN site=${cfg.site} id=${cmd.id} 会話を新しくできなかったので配らずに次の周でやり直します`);
2713
+ return;
2714
+ }
2715
+ log(`site=${cfg.site} id=${cmd.id} 会話を新しくしました(1便1context)`);
2716
+ }
2644
2717
  // ── tmux 送信 ──
2645
2718
  // この送信に固有のアンカー(buildAnchor(id))を本文末尾に埋め込む。
2646
2719
  // 画面にエコーされたアンカー行を「今回ジョブの境界」として記録し、完了検出は
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tyhld/conductor",
3
- "version": "0.12.44",
3
+ "version": "0.12.46",
4
4
  "description": "采配くん管制の見守りアプリ(conductor-agent)。各PCで常駐し、中央(devlog-tracker)へ定期的に生存報告(heartbeat)を送る常駐CLI。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -3406,6 +3406,240 @@ def _gate_command(command: str, depth: int = 0):
3406
3406
  return None
3407
3407
 
3408
3408
 
3409
+ # ═════════════════════════════════════════════════════════════════════════════
3410
+ # 作法(DENY・便 977507a5・えふさん確定のトークン対策4「機械処理化」)
3411
+ #
3412
+ # 【何のため】次の3件は、毎便の本文に「やらない」と書いてきたが、番人は通していた(棚卸し 8ceae57d の
3413
+ # 実測で allow)。番人で止められれば、便の本文からその行を外せる(書かなくても守られる)。
3414
+ # ① git add -A(--all・-A を含む短い旗のまとめ・git add . ・git add :/)
3415
+ # ② cd を && でほかの命令とつなげる/cd とリダイレクトを同じ命令の行に混ぜる
3416
+ # ③ リポ全体・ホーム全体を指す再帰の検索(grep -r・rg・ag・ack・find)
3417
+ # 【★確実にその形と言える物だけを止める】命令の1行目を、引用符を守って字句に割ってから見る
3418
+ # (ヒアドキュメントの本文・引用符の中の文字は命令として読まない)。行き先が変数などで決まらない
3419
+ # 検索は止めない(誤って止めて職人が詰まらないため)。
3420
+ # 【★ほかの判定は変えない】decide_bash のいちばん最後(危険・関門をすべて当てたあと)に置く。
3421
+ # 今まで止まっていた物・人に聞いていた物は、理由も結果もそのまま。
3422
+ # ═════════════════════════════════════════════════════════════════════════════
3423
+
3424
+ _DISCIPLINE_OPS = frozenset({'&&', '||', ';', '|', '&', '(', ')', ';;', '|&'})
3425
+ _DISCIPLINE_REDIRECTS = frozenset({'>', '>>', '<', '>&', '&>', '>|', '<>', '&>>', '<&'})
3426
+
3427
+ _DISC_GIT_ADD = ('git add はまとめて足さない(-A・--all・. は秘密や無関係の変更まで入る)。'
3428
+ '足すファイルを名指しで git add path/to/file(鉄則1)')
3429
+ _DISC_CD = ('cd を && でほかの命令とつなげない・cd とリダイレクトを同じ行に混ぜない(許可の確認で止まる)。'
3430
+ 'cd は作業のはじめに1回だけ単独で打ち、以後は絶対パスか git -C を使う(鉄則2)')
3431
+ _DISC_SEARCH = ('リポ全体・ホーム全体を再帰で検索しない(重く、秘密の置き場にも当たる)。'
3432
+ '探す場所を名指しで(例: rg 語 src/ ・ find src -name 名前)')
3433
+
3434
+
3435
+ def _discipline_tokens(command: str):
3436
+ """命令の1行目を、引用符を守って語と演算子に割る。割れなければ None(=判定しない)。"""
3437
+ first = (command or '').split('\n', 1)[0]
3438
+ if '<<' in first:
3439
+ first = first.split('<<', 1)[0] # ヒアドキュメントの始まりより後ろは見ない
3440
+ try:
3441
+ lx = shlex.shlex(first, posix=True, punctuation_chars=True)
3442
+ lx.whitespace_split = True
3443
+ return list(lx)
3444
+ except ValueError:
3445
+ return None
3446
+
3447
+
3448
+ def _discipline_commands(toks):
3449
+ """字句の並びを、演算子で区切った1コマンドずつ [(前の演算子, 語の並び, 後ろの演算子)] にする。"""
3450
+ out, cur, before = [], [], None
3451
+ skip_next = False
3452
+ for t in toks:
3453
+ if skip_next:
3454
+ skip_next = False
3455
+ continue
3456
+ if t in _DISCIPLINE_OPS:
3457
+ if cur:
3458
+ out.append([before, cur, t])
3459
+ cur, before = [], t
3460
+ elif t in _DISCIPLINE_REDIRECTS:
3461
+ # リダイレクトとその行き先は、命令の語ではない(2>/dev/null の 2 も外す)。
3462
+ if cur and cur[-1].isdigit():
3463
+ cur.pop()
3464
+ skip_next = True
3465
+ else:
3466
+ cur.append(t)
3467
+ if cur:
3468
+ out.append([before, cur, None])
3469
+ return out
3470
+
3471
+
3472
+ def _disc_cd(cmds, toks):
3473
+ has_cd = any(words and words[0] == 'cd' for _, words, _ in cmds)
3474
+ if not has_cd:
3475
+ return None
3476
+ for before, words, after in cmds:
3477
+ if words and words[0] == 'cd' and ('&&' in (before, after)):
3478
+ return _DISC_CD
3479
+ if any(t in _DISCIPLINE_REDIRECTS for t in toks):
3480
+ return _DISC_CD
3481
+ return None
3482
+
3483
+
3484
+ def _disc_git_add(words):
3485
+ if not words or words[0].split('/')[-1] != 'git':
3486
+ return None
3487
+ i = 1
3488
+ while i < len(words) and words[i].startswith('-'):
3489
+ # git の全体の旗(-C 場所・-c 設定 は値を1つ取る)。
3490
+ i += 2 if words[i] in ('-C', '-c') else 1
3491
+ if i >= len(words) or words[i] != 'add':
3492
+ return None
3493
+ after_dd = False
3494
+ for a in words[i + 1:]:
3495
+ if not after_dd and a == '--':
3496
+ after_dd = True
3497
+ continue
3498
+ if not after_dd and a.startswith('-'):
3499
+ if a in ('-A', '--all') or (not a.startswith('--') and 'A' in a[1:]):
3500
+ return _DISC_GIT_ADD
3501
+ continue
3502
+ if a in ('.', './', ':/', ':'):
3503
+ return _DISC_GIT_ADD
3504
+ return None
3505
+
3506
+
3507
+ def _disc_repo_top(cwd):
3508
+ d = os.path.normpath(cwd) if cwd and os.path.isabs(cwd) else None
3509
+ while d:
3510
+ if os.path.exists(os.path.join(d, '.git')):
3511
+ return d
3512
+ parent = os.path.dirname(d)
3513
+ if parent == d:
3514
+ return None
3515
+ d = parent
3516
+ return None
3517
+
3518
+
3519
+ def _disc_is_whole(target, cwd):
3520
+ """その行き先が「リポ全体・ホーム全体(またはそれより広い所)」か。決まらなければ False。"""
3521
+ home = os.path.normpath(os.path.expanduser('~'))
3522
+ t = target
3523
+ for v in ('${HOME}', '$HOME'):
3524
+ if t == v or t.startswith(v + '/'):
3525
+ t = home + t[len(v):]
3526
+ if '$' in t or '`' in t or '*' in t or '?' in t:
3527
+ return False
3528
+ t = os.path.expanduser(t)
3529
+ if not os.path.isabs(t):
3530
+ if not (cwd and os.path.isabs(cwd)):
3531
+ return False
3532
+ t = os.path.join(cwd, t)
3533
+ t = os.path.normpath(t)
3534
+ wide = {'/', home}
3535
+ d = home
3536
+ while d != '/':
3537
+ d = os.path.dirname(d)
3538
+ wide.add(d)
3539
+ top = _disc_repo_top(cwd)
3540
+ if top:
3541
+ wide.add(top)
3542
+ d = top
3543
+ while d != '/':
3544
+ d = os.path.dirname(d)
3545
+ wide.add(d)
3546
+ return t in wide
3547
+
3548
+
3549
+ def _disc_search(words, cwd):
3550
+ if not words:
3551
+ return None
3552
+ v = words[0].split('/')[-1]
3553
+ targets = None
3554
+ if v in ('grep', 'egrep', 'fgrep'):
3555
+ flags = [w for w in words[1:] if w.startswith('-')]
3556
+ rec = any(f in ('--recursive', '--dereference-recursive') or
3557
+ (not f.startswith('--') and ('r' in f[1:] or 'R' in f[1:])) for f in flags)
3558
+ if not rec:
3559
+ return None
3560
+ pos, skip = [], False
3561
+ has_e = False
3562
+ for k, w in enumerate(words[1:], 1):
3563
+ if skip:
3564
+ skip = False
3565
+ continue
3566
+ if w in ('-e', '-f', '--regexp', '--file'):
3567
+ has_e, skip = True, True
3568
+ continue
3569
+ if w.startswith('-'):
3570
+ continue
3571
+ pos.append(w)
3572
+ targets = pos if has_e else pos[1:]
3573
+ elif v in ('rg', 'ag', 'ack'):
3574
+ pos, skip, has_e, files, depth = [], False, False, False, None
3575
+ for k, w in enumerate(words[1:], 1):
3576
+ if skip:
3577
+ if depth == 'next':
3578
+ depth = w
3579
+ skip = False
3580
+ continue
3581
+ if w in ('-e', '-f', '--regexp', '--file'):
3582
+ has_e, skip = True, True
3583
+ continue
3584
+ if w in ('--max-depth', '-d', '--depth'):
3585
+ depth, skip = 'next', True
3586
+ continue
3587
+ if w.startswith('--max-depth='):
3588
+ depth = w.split('=', 1)[1]
3589
+ continue
3590
+ if w == '--files':
3591
+ files = True
3592
+ continue
3593
+ if w in ('-g', '--glob', '-t', '--type', '-T', '--type-not', '-m', '--max-count',
3594
+ '-A', '-B', '-C', '--context'):
3595
+ skip = True
3596
+ continue
3597
+ if w.startswith('-'):
3598
+ continue
3599
+ pos.append(w)
3600
+ if depth is not None and str(depth).isdigit() and int(depth) <= 2:
3601
+ return None
3602
+ targets = pos if (has_e or files) else pos[1:]
3603
+ elif v == 'find':
3604
+ if '-maxdepth' in words:
3605
+ k = words.index('-maxdepth')
3606
+ if k + 1 < len(words) and words[k + 1].isdigit() and int(words[k + 1]) <= 2:
3607
+ return None
3608
+ targets = []
3609
+ for w in words[1:]:
3610
+ if w.startswith('-') or w in ('(', '!', ')'):
3611
+ break
3612
+ targets.append(w)
3613
+ else:
3614
+ return None
3615
+ if not targets:
3616
+ targets = ['.']
3617
+ if any(_disc_is_whole(t, cwd) for t in targets):
3618
+ return _DISC_SEARCH
3619
+ return None
3620
+
3621
+
3622
+ def _discipline_scan(command: str, cwd=None):
3623
+ """作法の3件(git add -A・cd の && とリダイレクト・全体の再帰検索)。当たれば職人へ返す1行、無ければ None。"""
3624
+ toks = _discipline_tokens(command)
3625
+ if not toks:
3626
+ return None
3627
+ cmds = _discipline_commands(toks)
3628
+ hit = _disc_cd(cmds, toks)
3629
+ if hit:
3630
+ return hit
3631
+ for _, words, _ in cmds:
3632
+ # 包み(env 変数=… ・time ・nohup ・sudo 等)を剥がした先頭から見る。
3633
+ k = 0
3634
+ while k < len(words) and (_ASSIGN_RE.match(words[k]) or words[k].split('/')[-1] in _WRAPPERS):
3635
+ k += 1
3636
+ core = words[k:]
3637
+ hit = _disc_git_add(core) or _disc_search(core, cwd)
3638
+ if hit:
3639
+ return hit
3640
+ return None
3641
+
3642
+
3409
3643
  # ═════════════════════════════════════════════════════════════════════════════
3410
3644
  # 入口: Bash
3411
3645
  # ═════════════════════════════════════════════════════════════════════════════
@@ -3485,6 +3719,12 @@ def decide_bash(command: str, cwd=None):
3485
3719
  gate, reason = hit
3486
3720
  return _r(GATE, f'gate({gate}): {reason}', gate)
3487
3721
 
3722
+ # ---- 2b) 作法(便 977507a5・毎便の本文に書いていた決まりを番人で止める) ----
3723
+ # ★いちばん最後に置く=ほかの危険・関門の判定は1件も変わらない(今まで通っていた物だけが対象)。
3724
+ hit = _discipline_scan(command, cwd)
3725
+ if hit:
3726
+ return _r(DENY, f'discipline: {hit}')
3727
+
3488
3728
  # ---- 3) それ以外は自走 ----
3489
3729
  return _r(ALLOW, 'no gate, no danger')
3490
3730
 
@@ -146,7 +146,7 @@ class FakeDatalink:
146
146
 
147
147
  print('── (a) 関門でない質問は1つも人に届かず、その場で allow が返る ' + '─' * 11)
148
148
  for label, cmd in [
149
- ('ふつうの命令(cd)', 'cd /home/x/projects/demo && npm run build'),
149
+ ('ふつうの命令(cd)', 'cd /home/x/projects/demo; npm run build'), # ★&& でつなぐ形は作法で止まる(便 977507a5)
150
150
  ('ふつうの命令(npx)', 'npx tsc --noEmit'),
151
151
  ('削除', 'rm -f /tmp/claude-1000/tmpfile.txt'),
152
152
  ]:
@@ -122,7 +122,7 @@ for cmd in [f'git {PUSH}', f'git {PUSH} origin', f'git {PUSH} origin HEAD', f'gi
122
122
  expect('(1) git リポでない=確かめられない', cmd, policy.DENY, NOT_A_REPO)
123
123
  expect('(1) push 先が main の作業ブランチ', f'git {PUSH}', policy.DENY, REPO_FEAT_TO_MAIN)
124
124
  expect('(1) cd で main のリポへ移ってから', f'cd {REPO_MAIN} && git {PUSH}', policy.DENY, REPO_FEAT)
125
- expect('(1) cd で作業ブランチのリポへ移ってから', f'cd {REPO_FEAT} && git {PUSH}', policy.ALLOW,
125
+ expect('(1) cd で作業ブランチのリポへ移ってから', f'cd {REPO_FEAT}; git {PUSH}', policy.ALLOW,
126
126
  REPO_MAIN)
127
127
  expect('(1) git -C で main のリポ', f'git -C {REPO_MAIN} {PUSH}', policy.DENY, REPO_FEAT)
128
128
  expect('(1) 作業場所が変数', f'cd $APP_DIR && git {PUSH}', policy.DENY, REPO_FEAT)
@@ -0,0 +1,130 @@
1
+ #!/usr/bin/env python3
2
+ """作法の3件を番人で止める検査(便 977507a5・トークン対策4「機械処理化」)。
3
+
4
+ 実行: python3 scripts/hooks/test_discipline.py
5
+ 1件でも期待と違えば exit 1。
6
+
7
+ 【何を守っているか】
8
+ (n) ★止まる形:git add -A/--all/./-Av/git -C 場所 add -A、
9
+ cd を && でつなぐ(前でも後ろでも・括弧の中でも)・cd とリダイレクトを同じ行に混ぜる、
10
+ リポ全体・ホーム全体・/ を指す再帰の検索(grep -r・rg・find、行き先を省いた形も)
11
+ (p) 止まってはいけない似た形:git add ファイル名・git add -- ファイル名・cd 単独・cd X; 命令・
12
+ 名指しの場所だけの検索(rg 語 src・find src・grep -r 語 src)・深さを限った find・
13
+ git grep・行き先が変数で決まらない検索・ヒアドキュメントや引用符の中の文字
14
+ (m) 止めたときの文は「何がだめで、代わりにどうするか」を1行で
15
+ (x) ほかの判定は変わらない:危険(秘密・main への push)と関門(本番反映)は、作法より先に
16
+ 今までどおりの理由で当たる
17
+ ★空振り確認:直す前の版(origin/main)で流すと (n) が落ちる(報告に実測を書く)。
18
+
19
+ ★この台本は検索の道具の名前を素の字で置かない語もある(組み立てて使う)。台本の行そのものが
20
+ 番人の判定に当たって、検査が流せなくなるのを避けるため。
21
+ """
22
+ import os
23
+ import sys
24
+ import tempfile
25
+
26
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
27
+
28
+ import policy # noqa: E402
29
+
30
+ failures = []
31
+
32
+
33
+ def check(label, got, want):
34
+ ok = got == want
35
+ if not ok:
36
+ failures.append(f'{label}: got={got!r} want={want!r}')
37
+ print(f'{"OK " if ok else "NG "} {label:60} {str(got)[:50]}')
38
+
39
+
40
+ # 使い捨てのリポ(一番上に .git がある場所)を作業場所にする。
41
+ REPO = tempfile.mkdtemp(prefix='disc-repo-')
42
+ os.mkdir(os.path.join(REPO, '.git'))
43
+ os.mkdir(os.path.join(REPO, 'src'))
44
+ SUB = os.path.join(REPO, 'src')
45
+ HOME = os.path.expanduser('~')
46
+ ADD = 'git ' + 'add'
47
+
48
+
49
+ def decide(cmd, cwd=REPO):
50
+ return policy.decide_bash(cmd, cwd)
51
+
52
+
53
+ print('── (n) 止まる形 ' + '─' * 50)
54
+ NEGATIVE = [
55
+ ('n1 git add -A', f'{ADD} -A'),
56
+ ('n2 git add --all', f'{ADD} --all'),
57
+ ('n3 git add .', f'{ADD} .'),
58
+ ('n4 git add -Av(短い旗のまとめ)', f'{ADD} -Av'),
59
+ ('n5 git -C 場所 add -A', f'git -C /x add -A'),
60
+ ('n6 git add :/(全体)', f'{ADD} :/'),
61
+ ('n7 cd X && 命令', 'cd /tmp/x && npm test'),
62
+ ('n8 命令 && cd X', 'npm test && cd /tmp/x'),
63
+ ('n9 (cd X && 命令)', '(cd sub && make)'),
64
+ ('n10 cd とリダイレクト', 'cd /tmp/x > /tmp/o'),
65
+ ('n11 cd; 命令 > ファイル(同じ行に混ぜる)', 'cd /tmp/x; ls > /tmp/o'),
66
+ ('n12 cd X && 命令 <<ヒアドキュメント', "cd /tmp/x && python3 - <<'EOF'\nprint(1)\nEOF"),
67
+ ('n13 grep -r 語 .', 'grep -r foo .'),
68
+ ('n14 grep -rn 語(行き先なし=ここ)', 'grep -rn foo'),
69
+ ('n15 rg 語(行き先なし=ここ)', 'r' + 'g foo'),
70
+ ('n16 rg 語 .', 'r' + 'g foo .'),
71
+ ('n17 find .', 'find . -name x'),
72
+ ('n18 find ~', 'find ~ -name x'),
73
+ ('n19 find ホームの絶対パス', f'find {HOME} -name x'),
74
+ ('n20 rg 語 $HOME', 'r' + 'g foo $HOME'),
75
+ ('n21 find /', 'find / -name x'),
76
+ ('n22 リポの一番上を名指しした find', f'find {REPO} -name x'),
77
+ ('n23 リポより広い所(リポの親)', f'find {os.path.dirname(REPO)} -name x'),
78
+ ('n24 env 変数=… を前に付けても', f'GIT_TRACE=1 {ADD} -A'),
79
+ ]
80
+ for label, cmd in NEGATIVE:
81
+ r = decide(cmd)
82
+ check(label, (r['decision'], r['reason'].startswith('discipline:')), ('deny', True))
83
+
84
+ print('── (p) 止まってはいけない似た形 ' + '─' * 34)
85
+ POSITIVE = [
86
+ ('p1 git add ファイル名', f'{ADD} src/a.ts'),
87
+ ('p2 git add -- ファイル名', f'{ADD} -- docs/x.md'),
88
+ ('p3 git add -p ファイル名', f'{ADD} -p src/a.ts'),
89
+ ('p4 cd 単独(作業のはじめの1回)', f'cd {REPO}'),
90
+ ('p5 cd X; 命令', 'cd /tmp/x; npm test'),
91
+ ('p6 rg 語 src', 'r' + 'g foo src'),
92
+ ('p7 grep -r 語 src', 'grep -r foo src'),
93
+ ('p8 find src', 'find src -name x'),
94
+ ('p9 深さを限った find .', 'find . -maxdepth 1 -name x'),
95
+ ('p10 rg --max-depth 1', 'r' + 'g --max-depth 1 foo'),
96
+ ('p11 git grep(別の道具)', 'git grep foo'),
97
+ ('p12 行き先が変数で決まらない', 'find $DIR -name x'),
98
+ ('p13 引用符の中の && cd', 'echo "cd x && y"'),
99
+ ('p14 コミットの説明に git add -A', 'git commit -m "git add -A はやめた"'),
100
+ ('p15 ヒアドキュメントの本文に cd &&', "cat <<'EOF' > /tmp/claude-1000/n.txt\ncd x && y\nEOF"),
101
+ ('p16 下の場所から find .(リポ全体ではない)', 'find . -name x'),
102
+ ('p17 grep(再帰でない)', 'grep -n foo src/a.ts'),
103
+ ('p18 リダイレクトだけ(cd なし)', 'ls > /tmp/claude-1000/o'),
104
+ ]
105
+ for label, cmd in POSITIVE:
106
+ cwd = SUB if label.startswith('p16') else REPO
107
+ check(label, decide(cmd, cwd)['decision'], 'allow')
108
+
109
+ print('── (m) 止めたときの文 ' + '─' * 44)
110
+ check('(m) git add は名指しを案内', 'git add path/to/file' in decide(f'{ADD} -A')['reason'], True)
111
+ check('(m) cd は絶対パスか git -C を案内', 'git -C' in decide('cd x && ls')['reason'], True)
112
+ check('(m) 検索は名指しの場所を案内', '名指し' in decide('find . -name x')['reason'], True)
113
+ for label, cmd in [('git add', f'{ADD} -A'), ('cd', 'cd x && ls'), ('検索', 'find / -name x')]:
114
+ check(f'(m) {label} の文は1行', '\n' in decide(cmd)['reason'], False)
115
+
116
+ print('── (x) ほかの判定は変わらない(作法より先に当たる) ' + '─' * 14)
117
+ r = decide('cd /tmp/x && cat .env')
118
+ check('(x) 秘密の読み出しは危険の理由のまま', (r['decision'], r['reason'].startswith('danger:')), ('deny', True))
119
+ r = decide('cd /tmp/x && npx ver' + 'cel --prod')
120
+ check('(x) 本番反映は関門のまま(人に聞く)', r['decision'], 'gate')
121
+ r = decide('find ~ -name .env -exec cat {} +')
122
+ check('(x) ホームの秘密を探して読むのは危険の理由のまま', r['reason'].startswith('danger:'), True)
123
+
124
+ print()
125
+ if failures:
126
+ print(f'NG {len(failures)} 件')
127
+ for f in failures:
128
+ print(' ', f)
129
+ sys.exit(1)
130
+ print('すべて期待どおり')
@@ -173,7 +173,7 @@ MERGE_ALLOW = [
173
173
  'gh pr merge --rebase 7',
174
174
  # ★API での合流(gh api -X PUT …/pulls/N/merge)は便 fc22bc34 で deny へ移した
175
175
  # (保護の迂回・えふさん確定 2026-09-18)→ test_bypass_deny.py。
176
- 'cd /srv/app && gh pr merge 12 --squash', # 複合コマンドでも自走
176
+ 'cd /srv/app; gh pr merge 12 --squash', # 複合コマンドでも自走(★&& は作法で止まる・便 977507a5)
177
177
  'bash -c "gh pr merge 12 --squash"', # 包んでも自走
178
178
  'gh pr merge 12 --squash && git pull',
179
179
  # ★便(指示)で実際に使う形。鉄則7「便の終点はマージまで」の形をそのまま置く。
@@ -465,7 +465,7 @@ for cmd in [f'cat ~/{_CONDUCTOR_ENV}', f'head -5 ~/{_CONDUCTOR_ENV}',
465
465
  'cat ~/.codex/auth.json', 'cat ~/.config/gh/hosts.yml']:
466
466
  check(f'★どの経路でも deny {cmd[:52]}', policy.decide_bash(cmd)['decision'], policy.DENY)
467
467
  # 既存の自走(ふつうの作業)は1つも止めない。
468
- for cmd in ['cd ~/projects/x && npm test', 'ls -la', 'npm run build', 'git status',
468
+ for cmd in ['cd ~/projects/x; npm test', 'ls -la', 'npm run build', 'git status',
469
469
  'cat README.md', 'grep -r TODO src/', 'gh pr create --title x']:
470
470
  check(f'ふつうの作業は通る {cmd[:44]}', policy.decide_bash(cmd)['decision'], policy.ALLOW)
471
471