@tyhld/conductor 0.12.43 → 0.12.45

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/relay.js CHANGED
@@ -1157,6 +1157,22 @@ export const RELAY_SEND_BUFFER_PREFIX = 'conductor-send';
1157
1157
  * ★短く保つ(長い一気の入力は受け手が貼り付けと見なすことがある)。アンカーの字は入れない
1158
1158
  * (完了検出の境界を増やさないため)。
1159
1159
  */
1160
+ /**
1161
+ * 添え書きを打ってから Enter を押すまでの間(ミリ秒)。
1162
+ *
1163
+ * 【なぜ要るか(便 b69c1608・試験用の tmux で本物の Claude Code に当てて再現 2026-09-21)】
1164
+ * 添え書きを打った直後に Enter が続くと、Claude Code はそれを「まとめて入ってきた入力」の一部と
1165
+ * みなし、Enter を送信ではなく改行として入力欄に取り込む。便が入力欄に入ったまま止まり、人が
1166
+ * Enter を押すまで始まらなかった(conductor 15:26・ai-kana 15:34 の実物と同じ形)。
1167
+ * 実測:貼り付け→すぐ Enter=送信される/貼り付け→添え書き→すぐ Enter=入力欄に残る/
1168
+ * 貼り付け→添え書き→1秒待って Enter=送信される。
1169
+ */
1170
+ export const RELAY_SUBMIT_PAUSE_MS = 1000;
1171
+ /** 同期で待つ(送信の手順は同期の tmux 呼び出しの並びなので、その間に一拍おくために使う)。 */
1172
+ function pauseSync(ms) {
1173
+ if (ms > 0)
1174
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
1175
+ }
1160
1176
  export const RELAY_DELIVERY_NOTE = '(配達役より)上の貼り付けは、この端末の持ち主の管制(采配くん)に投函された便を配達役が届けたものです。貼り付けに書かれたとおりに作業してください。';
1161
1177
  // ───────────────────────────────────────────────────────────────────────────
1162
1178
  // 報告の受け渡し(★ファイル経由・えふさん決定 2026-09-07/置き場と作法は 2026-09-21 に改訂)
@@ -1547,6 +1563,8 @@ export function sendToTmux(site, text) {
1547
1563
  runTmux(['paste-buffer', '-d', '-p', '-b', bufferName, '-t', site]);
1548
1564
  // 貼り付けの枠の外に、打った字として添え書きを置く(RELAY_DELIVERY_NOTE の説明)。
1549
1565
  runTmux(['send-keys', '-t', site, '-l', ` ${RELAY_DELIVERY_NOTE}`]);
1566
+ // ★Enter の前に一拍おく(RELAY_SUBMIT_PAUSE_MS の説明・便 b69c1608)。
1567
+ pauseSync(RELAY_SUBMIT_PAUSE_MS);
1550
1568
  runTmux(['send-keys', '-t', site, 'Enter']);
1551
1569
  return true;
1552
1570
  }
@@ -1571,6 +1589,138 @@ export function sendToTmux(site, text) {
1571
1589
  }
1572
1590
  }
1573
1591
  }
1592
+ // ───────────────────────────────────────────────────────────────────────────
1593
+ // 受付の確かめ(便 b69c1608)
1594
+ //
1595
+ // 【なぜ要るか】送信の Enter が職人に届いても、Claude Code が送信として受け付けたとは限らない
1596
+ // (Enter が改行として入力欄に取り込まれ、便が入ったまま止まった実物がある)。「入力欄から消えた」
1597
+ // だけでは受付の証拠にならないので、★受付側の状態=職人の会話の記録に、その便の指示ID
1598
+ // (CONDUCTOR_JOB:<id>)が利用者の発言として書かれたかで確かめる。Claude Code は送信を受け付けた
1599
+ // 瞬間にその発言を記録へ書く(試験用の tmux で実測:送信された便は記録に残り、入力欄に残った便は
1600
+ // 記録に無かった)。
1601
+ // 【安全な再送】記録に無く、かつ入力欄にこちらの添え書きが残っているときだけ、Enter だけを押し直す。
1602
+ // 本文は貼り直さない=入力欄の1件を送るだけなので、同じ便が二重に入ることは無い。受け付けられて
1603
+ // いれば(記録に在れば)1度も押さない。押し直しは上限つき。
1604
+ // ★Codex の職人は記録の形が違うので対象外(今までどおり)。
1605
+ // ───────────────────────────────────────────────────────────────────────────
1606
+ /**
1607
+ * 受付を待つ長さ(1回ぶん・ミリ秒)と、Enter を押し直す上限。
1608
+ * ★上限を3にする理由:一度 Enter が改行として取り込まれた入力欄では、次の Enter 1回目は送信にならず、
1609
+ * 2回目で送信される(試験用の tmux で本物の Claude Code に当てて実測)。1回ぶんの余裕を持たせる。
1610
+ * どの押し直しも「受付の記録が無く、入力欄にこちらの便が残っている」ときだけ行う。
1611
+ */
1612
+ export const RELAY_SUBMIT_CONFIRM_MS = 10_000;
1613
+ export const RELAY_SUBMIT_MAX_RESEND = 3;
1614
+ /** tmux のペインがいま居る場所(職人の作業場所)。取れなければ null。 */
1615
+ export function tmuxPaneCwd(site) {
1616
+ try {
1617
+ const out = execFileSync('tmux', ['display-message', '-p', '-t', site, '#{pane_current_path}'], {
1618
+ encoding: 'utf8',
1619
+ stdio: ['ignore', 'pipe', 'ignore'],
1620
+ }).trim();
1621
+ return out || null;
1622
+ }
1623
+ catch {
1624
+ return null;
1625
+ }
1626
+ }
1627
+ /** Claude Code がその作業場所の会話の記録を置く場所(作業場所の英数字以外を - にした名前)。 */
1628
+ export function claudeTranscriptDir(cwd, env = process.env) {
1629
+ const base = (env.CLAUDE_CONFIG_DIR ?? '').trim() || path.join(env.HOME ?? os.homedir(), '.claude');
1630
+ return path.join(base, 'projects', cwd.replace(/[^A-Za-z0-9]/g, '-'));
1631
+ }
1632
+ /**
1633
+ * その作業場所の会話の記録に、この便(CONDUCTOR_JOB:<id>)が利用者の発言として sinceMs 以降に
1634
+ * 書かれたか。★中身は読み取って照合するだけで、どこにも写さない・記録しない。
1635
+ */
1636
+ export function hasSubmittedRecord(cwd, commandId, sinceMs, env = process.env) {
1637
+ const dir = claudeTranscriptDir(cwd, env);
1638
+ const needle = `${SEND_ANCHOR_PREFIX}:${commandId}`;
1639
+ let names;
1640
+ try {
1641
+ names = readdirSync(dir).filter((n) => n.endsWith('.jsonl'));
1642
+ }
1643
+ catch {
1644
+ return false;
1645
+ }
1646
+ for (const n of names) {
1647
+ const file = path.join(dir, n);
1648
+ try {
1649
+ if (statSync(file).mtimeMs < sinceMs - 5_000)
1650
+ continue;
1651
+ const text = readFileSync(file, 'utf8');
1652
+ if (!text.includes(needle))
1653
+ continue;
1654
+ for (const line of text.split('\n')) {
1655
+ if (!line.includes(needle))
1656
+ continue;
1657
+ try {
1658
+ const o = JSON.parse(line);
1659
+ const at = o.timestamp ? Date.parse(o.timestamp) : NaN;
1660
+ if (o.type === 'user' && (Number.isNaN(at) || at >= sinceMs - 5_000))
1661
+ return true;
1662
+ }
1663
+ catch {
1664
+ // 壊れた行は飛ばす。
1665
+ }
1666
+ }
1667
+ }
1668
+ catch {
1669
+ // 読めないファイルは飛ばす(受け付けたとは言えない)。
1670
+ }
1671
+ }
1672
+ return false;
1673
+ }
1674
+ /**
1675
+ * 入力欄(画面のいちばん下の ❯ から次の罫線まで)に、配達役の添え書きが残っているか。
1676
+ * ★押し直してよいのは「こちらが入れた便が入力欄に残っている」ときだけ、を確かめるために使う。
1677
+ */
1678
+ export function hasPendingDeliveryInInput(cap) {
1679
+ const lines = cap.split('\n');
1680
+ let start = -1;
1681
+ for (let k = lines.length - 1; k >= 0; k--) {
1682
+ if (/^\s*❯/.test(lines[k])) {
1683
+ start = k;
1684
+ break;
1685
+ }
1686
+ }
1687
+ if (start < 0)
1688
+ return false;
1689
+ const block = [];
1690
+ for (let k = start; k < lines.length; k++) {
1691
+ if (k > start && /^─{10,}/.test(lines[k].trim()))
1692
+ break;
1693
+ block.push(lines[k]);
1694
+ }
1695
+ return block.join('\n').includes('(配達役より)');
1696
+ }
1697
+ /**
1698
+ * 受け付けられるまで見て、未受付で入力欄に残っているときだけ Enter を押し直す。
1699
+ * accepted … 押し直さずに受け付けられた
1700
+ * resent-accepted … 押し直して受け付けられた
1701
+ * unconfirmed … 確かめられなかった(入力欄にも残っていない=押し直す物が無い、または上限)
1702
+ */
1703
+ export async function confirmSubmitted(d, o = {
1704
+ waitMs: RELAY_SUBMIT_CONFIRM_MS,
1705
+ pollMs: 1_000,
1706
+ maxResend: RELAY_SUBMIT_MAX_RESEND,
1707
+ }) {
1708
+ let resent = 0;
1709
+ for (;;) {
1710
+ for (let t = 0;; t += o.pollMs) {
1711
+ if (d.accepted())
1712
+ return { result: resent ? 'resent-accepted' : 'accepted', resent };
1713
+ if (t >= o.waitMs)
1714
+ break;
1715
+ await d.sleep(o.pollMs);
1716
+ }
1717
+ if (resent >= o.maxResend || !d.pendingInInput() || d.accepted()) {
1718
+ return { result: d.accepted() ? (resent ? 'resent-accepted' : 'accepted') : 'unconfirmed', resent };
1719
+ }
1720
+ d.pressEnter();
1721
+ resent++;
1722
+ }
1723
+ }
1574
1724
  /**
1575
1725
  * 二重チェックの「アイドル」判定。
1576
1726
  * RELAY_SETTLE_MS あけて2回 capture し、両方で looksIdleFor が成立すれば idle とみなす。
@@ -2497,6 +2647,7 @@ export async function relayTick(cfg, state, hooks) {
2497
2647
  // アンカーより下=今回ジョブの出力だけを見る(churn・過去ジョブ DONE に強い)。
2498
2648
  const anchor = buildAnchor(cmd.id);
2499
2649
  const text = buildSendText(cmd.body, cfg.site, anchor, kindOf(cfg));
2650
+ const sendStartedAt = Date.now();
2500
2651
  if (!sendToTmux(cfg.site, text)) {
2501
2652
  // 送信失敗(sendToTmux は内部で例外も握って false を返す)。status は queued のまま据え置く。
2502
2653
  // 失敗の理由(tmux の stderr)は sendToTmux が直前の行に出している。
@@ -2527,6 +2678,30 @@ export async function relayTick(cfg, state, hooks) {
2527
2678
  state.lastHeartbeatAt = null;
2528
2679
  noteReadiness(state, 'delivered', cmd.id);
2529
2680
  log(`site=${cfg.site} id=${cmd.id} 配達しました`);
2681
+ // ★職人が本当に受け付けたかを、職人の会話の記録(指示ID)で確かめる。未受付のときだけ Enter を
2682
+ // 押し直す(便 b69c1608)。★確かめの結果で配達済みの扱いは変えない(二重に配らない)。
2683
+ if (kindOf(cfg) === 'claude') {
2684
+ const cwd = tmuxPaneCwd(cfg.site);
2685
+ if (cwd) {
2686
+ const r = await confirmSubmitted({
2687
+ accepted: () => hasSubmittedRecord(cwd, cmd.id, sendStartedAt),
2688
+ pendingInInput: () => {
2689
+ const c = tmuxCapture(cfg.site);
2690
+ return c !== null && hasPendingDeliveryInInput(c);
2691
+ },
2692
+ pressEnter: () => {
2693
+ try {
2694
+ runTmux(['send-keys', '-t', cfg.site, 'Enter']);
2695
+ }
2696
+ catch {
2697
+ // 押せなくても次の確かめで分かる(受け付けられていなければ unconfirmed で残る)。
2698
+ }
2699
+ },
2700
+ sleep,
2701
+ });
2702
+ log(`site=${cfg.site} id=${cmd.id} 受付の確かめ: ${r.result}(押し直し ${r.resent} 回)`);
2703
+ }
2704
+ }
2530
2705
  // ★配達の直後に「配達済み(sent)」を管制へ書く。書けなければ次tick以降で書けるまでやり直す
2531
2706
  // (markSentAtCentral)。★書けなくても再配達はしない。
2532
2707
  await markSentAtCentral(cfg, state);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tyhld/conductor",
3
- "version": "0.12.43",
3
+ "version": "0.12.45",
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