commit 2da2f61bc875e51d5b4c790147fb558fbde63a41
Author: Eiichi Shimazu <studio@adaptdesign.jp>
Date:   Sat Nov 14 04:46:33 2026 +0900

    実機推論の始めの確かめ (Enter) を, lab_start の 1 回目の試行だけにする (依頼 177・Log35). run_policy_client.py に --skip-first-confirm を足す (最初に実行する推論の前の確かめだけ待たずに実行し, 画面に [CONFIRM] の 1 行・そのチャンクの行に confirm_skipped. 2 回目からの確かめ・補正 (キー・ゲームパッド)・巻き戻し・止める操作・開始時に爪を開くのは今までどおり). log.jsonl の頭 (type=model) に skip_first_confirm. lab_start.sh は 1 回の起動の中の 2 回目からの試行 (round 2 から) にだけ --skip-first-confirm を渡す (7 の HOME・爪を開く・位置合わせは毎回). テストを足す (test_skip_first_confirm 6・lab_start 214. test_rewind_correct は新しい 1 項目を比べる対象から外す) (依頼 177. codex 1 回)
    
    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---
 lab_start.sh                    |  10 +++-
 pi05/run_policy_client.py       |  19 +++++-
 pi05/test_rewind_correct.py     |   3 +-
 pi05/test_skip_first_confirm.py | 125 ++++++++++++++++++++++++++++++++++++++++
 test_lab_start.sh               |  24 ++++++++
 5 files changed, 176 insertions(+), 5 deletions(-)

diff --git a/lab_start.sh b/lab_start.sh
index a4dd15f..4e9371c 100755
--- a/lab_start.sh
+++ b/lab_start.sh
@@ -59,6 +59,8 @@
 # 6. tools/daemon_ctl.sh status で 5555 の LISTEN を見る. なければ start
 # 7. HOME → 爪を開く → camera_align.py (q で閉じる)
 # 8. run_policy_client.py (LOG407 と同じ引数). 終わったら, もう1回やるかを聞く (y なら 7 へ)
+#    2 回目からは --skip-first-confirm を渡し, 始めの確かめ (最初の推論の前の Enter) を待たずに進む (依頼 177.
+#    7 の HOME・爪を開く・位置合わせは毎回行う. 2 回目からの確かめ・補正・巻き戻し・止める操作は今までどおり)
 # 9. Pod は消さない. 片付けは ./lab_stop.sh
 #
 # Pod の情報 (id・GPU・1時間の料金・作った時刻・サーバーを起動したモデル) は <Log>/pod/lab_pod.json に残す (IP とポートは残さない).
@@ -513,6 +515,8 @@ trap ':' INT
 round=0
 while :; do
     round=$((round + 1))
+    CONFIRM_ARGS=()      # 依頼 177: 1 回目の試行だけ始めの確かめを待つ. 2 回目からは飛ばす
+    [ $round -ge 2 ] && CONFIRM_ARGS=(--skip-first-confirm)
     if [ $DRY_RUN = 0 ]; then
         stage "7. HOME → 爪を開く → 位置合わせ ($round 回目)"
         python3 dynamic_tracking_limits/place/place_modules/08_home_from_anywhere.py || fail "HOME に戻せない"
@@ -525,13 +529,15 @@ while :; do
         stage "8. 実機推論 ($round 回目)"
         python3 pi05/run_policy_client.py --raw-dir "$RAW_RANGE" --steps-per-chunk 10 --via-stride 3 \
             --settle-retries 0 --state-from-target --execute --ignore-framing --model "$MODEL_TAG" --framing-raw-dir "$RAW" \
-            --tilt-raw-dir "$RAW_RANGE" ${TILT_FIX_ARGS[@]+"${TILT_FIX_ARGS[@]}"} ${GRIP_ARGS[@]+"${GRIP_ARGS[@]}"} ${FLOW_ARGS[@]+"${FLOW_ARGS[@]}"}
+            --tilt-raw-dir "$RAW_RANGE" ${TILT_FIX_ARGS[@]+"${TILT_FIX_ARGS[@]}"} ${GRIP_ARGS[@]+"${GRIP_ARGS[@]}"} ${FLOW_ARGS[@]+"${FLOW_ARGS[@]}"} \
+            ${CONFIRM_ARGS[@]+"${CONFIRM_ARGS[@]}"}
         rc=$?
     else
         stage "8. 推論のクライアント (ドライラン・--dry-keys. アームには触らない)"
         python3 pi05/run_policy_client.py --raw-dir "$RAW_RANGE" --steps-per-chunk 10 --via-stride 3 \
             --settle-retries 0 --state-from-target --dry-keys --ignore-framing --model "$MODEL_TAG" --framing-raw-dir "$RAW" \
-            --tilt-raw-dir "$RAW_RANGE" ${TILT_FIX_ARGS[@]+"${TILT_FIX_ARGS[@]}"} ${GRIP_ARGS[@]+"${GRIP_ARGS[@]}"} ${FLOW_ARGS[@]+"${FLOW_ARGS[@]}"}
+            --tilt-raw-dir "$RAW_RANGE" ${TILT_FIX_ARGS[@]+"${TILT_FIX_ARGS[@]}"} ${GRIP_ARGS[@]+"${GRIP_ARGS[@]}"} ${FLOW_ARGS[@]+"${FLOW_ARGS[@]}"} \
+            ${CONFIRM_ARGS[@]+"${CONFIRM_ARGS[@]}"}
         rc=$?
     fi
     echo "[CLIENT] run_policy_client.py が終わった (終了コード $rc. モデル $MODEL_TAG)"
diff --git a/pi05/run_policy_client.py b/pi05/run_policy_client.py
index 0e20e4c..cc6149a 100644
--- a/pi05/run_policy_client.py
+++ b/pi05/run_policy_client.py
@@ -12,6 +12,9 @@
 安全策:
   - 既定はドライラン (アームに送らない). 送るには --execute
   - --execute でも既定は 1 チャンクごとに Enter で確認 (--auto で連続)
+    --skip-first-confirm を付けると始めの確かめ (最初に実行する推論の前) だけ待たずに実行し, 画面に [CONFIRM] の 1 行・
+    log.jsonl の頭 (type=model) に skip_first_confirm・そのチャンクの行に confirm_skipped を残す. 2 回目からの確かめは
+    今までどおり (lab_start.sh が 1 回の起動の中の 2 回目からの試行に付ける. 依頼 177)
   - 学習データで通った関節範囲 ±ENVELOPE_MARGIN_DEG の外へは出さない (クランプ)
   - 肩 j1 の上限は J1_SAFE_MAX (88°) で更に切る. これを超える指令はアームが黙って捨てる (LOG352)
   - 送る直前に最終検査. 6 関節のどれかが安全範囲の外か非有限値なら, 送信もグリッパー操作もせず
@@ -1404,6 +1407,9 @@ def main():
     ap.add_argument("--flow-tol", type=float, default=FLOW_TOL_DEFAULT,
                     help=f"--flow で途中の点にこれだけ近づいたら次を送る (度. 既定 {FLOW_TOL_DEFAULT:.0f}. "
                          f"送る間隔は {FLOW_MIN_INTERVAL_SEC:.1f} 秒より短くしない)")
+    ap.add_argument("--skip-first-confirm", action="store_true",
+                    help="始めの確かめ (最初に実行する推論の前の Enter) だけを待たずに実行する. 2 回目からの確かめと "
+                         "補正・巻き戻し・止める操作は今までどおり (lab_start.sh が 2 回目からの試行に付ける. 依頼 177)")
     ap.add_argument("--speed", type=int, default=SPEED,
                     help=f"推論のチャンクを送る速さ (既定 {SPEED} = 今までと同じ. 補正・巻き戻し・HOME は {SPEED} のまま. 依頼 176)")
     args = ap.parse_args()
@@ -1434,7 +1440,8 @@ def main():
                             "framing_raw_dir": str(framing_raw_dir(args)),
                             "tilt_raw_dir": str(tilt_raw_dir(args)), "tilt_fix": not args.no_tilt_fix,
                             "grip_value": GRIP_VALUE, "flow": bool(args.flow), "flow_tol": args.flow_tol,
-                            "speed": CHUNK_SPEED, "host": args.host, "port": args.port, "t": time.time()},
+                            "speed": CHUNK_SPEED, "skip_first_confirm": bool(args.skip_first_confirm),
+                            "host": args.host, "port": args.port, "t": time.time()},
                            ensure_ascii=False) + "\n")
     log_f.flush()
 
@@ -1450,6 +1457,7 @@ def main():
     history = []                 # 巻き戻せる区間 (推論 1 回分 / 補正 1 回分). 新しいものが後ろ
     n_rewinds = n_corrections = 0
     end_reason, outcome = None, None
+    skip_confirm = bool(args.skip_first_confirm)   # 依頼 177: 始めの確かめを 1 回だけ飛ばす (使ったら False)
     try:
         cams = camera_resolver.resolve_cameras(require_top=True, require_side=False, verbose=False)
         cap = cv2.VideoCapture(cams["top"])
@@ -1636,7 +1644,14 @@ def main():
                 # 実測を渡すために None のままにする (--state-from-target 用).
                 last_sent_target = None
                 grasp = None         # つかんだ区間を巻き戻すときの答え (GRASP_PLACED / GRASP_DROPPED)
-                if not args.auto:
+                skip_now, skip_confirm = skip_confirm and not args.auto, False
+                if skip_now:
+                    # 依頼 177: lab_start.sh の 2 回目からの試行は, 始めの確かめ (Enter) を待たずに実行する.
+                    # 2 回目からの確かめ・補正・巻き戻し・止める操作は今までどおり
+                    print("       [CONFIRM] 2 回目からの試行なので始めの確かめ (Enter) を待たずに実行します "
+                          "(次の推論からは今までどおり確かめます)")
+                    rec["confirm_skipped"] = True
+                elif not args.auto:
                     # 入力を待っている間は wait (人が手でブロックを置き直す. 学習データに使わない. 依頼 27)
                     if recorder is not None:
                         recorder.set_mode(MODE_WAIT)
diff --git a/pi05/test_rewind_correct.py b/pi05/test_rewind_correct.py
index ad59e3e..f20e313 100644
--- a/pi05/test_rewind_correct.py
+++ b/pi05/test_rewind_correct.py
@@ -605,7 +605,8 @@ class TestClientFlow(unittest.TestCase):
             new_keys = ("t_exec_start", "t_exec_end", "sent_targets", "framing_raw_dir",   # framing_raw_dir は依頼 137
                         "tilt_raw_dir", "tilt_fix", "measured_on", "rotate_deg",            # 依頼 144
                         "grip_value",                                                       # 依頼 152
-                        "flow", "flow_tol", "speed")                                        # 依頼 176 (type=model の頭)
+                        "flow", "flow_tol", "speed",                                        # 依頼 176 (type=model の頭)
+                        "skip_first_confirm")                                               # 依頼 177 (type=model の頭)
             # HEAD が依頼 25 より前なら new_keys は新しい方にだけある. 依頼 25 以降なら両方にある (両方から外す)
             drop = ("t", "latency_s", "reference") + new_keys
             so, sn = strip(l_old, drop=drop), strip(l_new, drop=drop)
diff --git a/pi05/test_skip_first_confirm.py b/pi05/test_skip_first_confirm.py
new file mode 100644
index 0000000..ede6f39
--- /dev/null
+++ b/pi05/test_skip_first_confirm.py
@@ -0,0 +1,125 @@
+#!/usr/bin/env python3
+"""test_skip_first_confirm.py — 始めの確かめ (Enter) を飛ばす --skip-first-confirm の確かめ (依頼 177. アームなし).
+
+  python3 pi05/test_skip_first_confirm.py          # 全部
+  python3 pi05/test_skip_first_confirm.py -v
+
+lab_start.sh は 1 回の起動の中の 2 回目からの試行に --skip-first-confirm を付ける (test_lab_start.sh で確かめる).
+ここでは run_policy_client を test_rewind_correct.run_client (偽の daemon / カメラ / モデル) で通し,
+  - 最初に実行する推論だけ Enter を待たずに実行し, 画面に [CONFIRM] の 1 行, log.jsonl の頭に skip_first_confirm
+  - 2 回目からの確かめ・b / c・q / h は今までどおり
+  - 開始時に爪を開くのは今までどおり行う
+  - 付けなければ今までと同じ (頭の skip_first_confirm は false)
+を見る. KIOXIA にもアームにも触らない (一時フォルダだけ).
+"""
+import sys
+import tempfile
+import time
+import unittest
+from pathlib import Path
+
+_HERE = Path(__file__).resolve().parent
+sys.path.insert(0, str(_HERE))
+
+import run_policy_client as rpc  # noqa: E402
+from test_rewind_correct import run_client  # noqa: E402
+
+EXEC = ["--execute", "--steps-per-chunk", "10", "--via-stride", "3", "--settle-retries", "0",
+        "--state-from-target", "--ignore-framing", "--max-chunks", "6"]
+SKIP_LINE = "[CONFIRM] 2 回目からの試行なので始めの確かめ (Enter) を待たずに実行します"
+
+
+def chunks(lines):
+    return [x for x in lines if x.get("type") is None and "chunk" in x]
+
+
+def model_head(lines):
+    return lines[0]
+
+
+class TestSkipFirstConfirm(unittest.TestCase):
+    def setUp(self):
+        self.td = Path(tempfile.mkdtemp())
+        time.sleep(1.1)       # run のフォルダ名は秒単位なので重ならないように
+
+    def test_execute_skips_only_first(self):
+        # 推論 0 は待たずに実行 → 推論 1 は Enter で実行 → 推論 2 で q → 結果は記録しない
+        code, world, run, lines, out = run_client(rpc, EXEC + ["--skip-first-confirm"], ["", "q", ""], self.td)
+        self.assertEqual(code, 0)
+        head = model_head(lines)
+        self.assertEqual(head["type"], "model")
+        self.assertIs(head["skip_first_confirm"], True)
+        ch = chunks(lines)
+        self.assertEqual([x["chunk"] for x in ch], [0, 1, 2])
+        self.assertEqual([bool(x.get("executed")) for x in ch], [True, True, False])
+        self.assertIs(ch[0].get("confirm_skipped"), True)
+        self.assertNotIn("confirm_skipped", ch[1])
+        self.assertEqual(out.count(SKIP_LINE), 1)
+        self.assertEqual(out.count("[CONFIRM]"), 1)
+        # 開始時に爪を開くのは今までどおり (推論 0 の送信より前)
+        self.assertIs(ch[0].get("initial_gripper_open"), True)
+        self.assertEqual(world.grips[0], 0)
+        self.assertEqual(world.events[0], ("grip", 0))
+        labels = [s[0] for s in world.sends]
+        self.assertEqual(labels[:4], ["pi05_chunk000_via02", "pi05_chunk000_via05",
+                                      "pi05_chunk000_via08", "pi05_chunk000_via09"])
+        self.assertTrue(any(lb.startswith("pi05_chunk001") for lb in labels))
+        self.assertFalse(any(lb.startswith("pi05_chunk002") for lb in labels))
+        # 飛ばした推論 0 の間は wait にしない. 推論 1 の前からは今までどおり wait
+        stages = [l.split(",")[2] for l in (run / "recording" / "states.csv").read_text().splitlines()[1:]
+                  if l.split(",")[1] == "stage"]
+        self.assertEqual(stages[:3], ["model", "wait", "model"])
+
+    def test_without_flag_waits_first(self):
+        # 同じ入力で付けなければ, 推論 0 を Enter で実行 → 推論 1 で q (今までどおり)
+        code, world, run, lines, out = run_client(rpc, EXEC, ["", "q", ""], self.td)
+        self.assertEqual(code, 0)
+        self.assertIs(model_head(lines)["skip_first_confirm"], False)
+        ch = chunks(lines)
+        self.assertEqual([bool(x.get("executed")) for x in ch], [True, False])
+        self.assertFalse(any("confirm_skipped" in x for x in ch))
+        self.assertNotIn("[CONFIRM]", out)
+        self.assertIs(ch[0].get("initial_gripper_open"), True)
+
+    def test_correction_and_rewind_unchanged_after_skip(self):
+        # 推論 0 は待たずに実行 → 推論 1 の確かめで c (j1 を + 2 刻み) → Enter で戻る → 推論 2 で b (補正を巻き戻す)
+        # → 推論 3 で h → 結果 y
+        code, world, run, lines, out = run_client(rpc, EXEC + ["--skip-first-confirm"],
+                                                  ["c", "b", "h", "y"], self.td, keys="1++\n")
+        self.assertEqual(code, 0)
+        corr = [x for x in lines if x.get("type") == "correction"]
+        self.assertEqual([x["action"] for x in corr], ["move", "move", "accept"])
+        rw = [x for x in lines if x.get("type") == "rewind"]
+        self.assertEqual(len(rw), 1)
+        self.assertTrue(rw[0]["finished"])
+        self.assertEqual([s[0] for s in world.sends][-1], "pi05_home")
+        self.assertEqual(out.count("[CONFIRM]"), 1)
+
+    def test_q_on_second_confirm(self):
+        # 2 回目の確かめで止められる (推論 0 だけ実行)
+        code, world, run, lines, out = run_client(rpc, EXEC + ["--skip-first-confirm"], ["q", ""], self.td)
+        self.assertEqual(code, 0)
+        self.assertEqual([bool(x.get("executed")) for x in chunks(lines)], [True, False])
+
+    def test_dry_keys(self):
+        code, world, run, lines, out = run_client(
+            rpc, ["--dry-keys", "--steps-per-chunk", "10", "--via-stride", "3", "--max-chunks", "5",
+                  "--skip-first-confirm"], ["", "q"], self.td)
+        self.assertEqual(code, 0)
+        self.assertEqual(world.sends, [])
+        ch = chunks(lines)
+        self.assertEqual([bool(x.get("simulated")) for x in ch], [True, True, False])
+        self.assertIs(ch[0].get("confirm_skipped"), True)
+        self.assertEqual(out.count(SKIP_LINE), 1)
+
+    def test_auto_has_nothing_to_skip(self):
+        code, world, run, lines, out = run_client(
+            rpc, EXEC[:-1] + ["2", "--auto", "--skip-first-confirm"], [""], self.td)
+        self.assertEqual(code, 0)
+        self.assertNotIn("[CONFIRM]", out)
+        self.assertFalse(any("confirm_skipped" in x for x in chunks(lines)))
+        self.assertIs(model_head(lines)["skip_first_confirm"], True)
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/test_lab_start.sh b/test_lab_start.sh
index d3addf6..626f1eb 100755
--- a/test_lab_start.sh
+++ b/test_lab_start.sh
@@ -628,6 +628,30 @@ for bad in 0 101 abc 1000 ""; do
     rm -rf "$T"
 done
 
+echo "== 始めの確かめは 1 回目の試行だけ (依頼 177): 2 回目からは --skip-first-confirm を渡す"
+setup; state $ID_OLD; pod 200 "$RUNNING"; probe 1 1 1 1 1; run
+check "1 回だけの試行には --skip-first-confirm を渡さない" '[ "$(grep -c "pi05/run_policy_client.py" "$T/py.log")" = 1 ] && ! has py.log "--skip-first-confirm"'
+rm -rf "$T"
+# 「もう1回やりますか?」は端末のときだけ聞くので, script で端末を作って y・y・n を打つ (LAB_LOGGED=1 で記録の script は飛ばす)
+setup; state $ID_OLD; pod 200 "$RUNNING"; probe 1 1 1 1 1
+# 偽物の pod_ssh は標準入力を読み捨てる. 端末では終わりが来ず止まり y を食べるので, この場合だけ端末のときは読まない
+sed -i '' 's#^    cat > /dev/null$#    [ -t 0 ] || cat > /dev/null#' "$T/fakes.sh"
+# 先に打つと端末が待つ前に入力が尽きて script が終わるので, 問いが出るたびに 1 つずつ打つ (問いが出なければ 60 秒であきらめる)
+feed() {
+    local n k
+    for n in 1 2 3; do
+        for k in $(seq 1 300); do [ "$(grep -ac "もう1回やりますか" "$T/out.txt" 2>/dev/null)" -ge $n ] && break; sleep 0.2; done
+        if [ $n -lt 3 ]; then printf 'y\n'; else printf 'n\n'; fi
+    done
+    sleep 1
+}
+(cd "$T" && feed | PATH="$T/bin:$PATH" LAB_LOGGED=1 script -q /dev/null ./lab_start.sh --log LogT --dry-run --flow > "$T/out.txt" 2>&1); RC=$?
+grep "pi05/run_policy_client.py" "$T/py.log" > "$T/client.log"
+check "3 回の試行でクライアントを 3 回呼ぶ (終了コード 0)" '[ $RC = 0 ] && [ "$(grep -c . "$T/client.log")" = 3 ] && [ "$(grep -ac "=== 8. " "$T/out.txt")" = 3 ]'
+check "1 回目には付けず, 2 回目・3 回目の終わりに --skip-first-confirm を付ける (ほかの引数は同じ)" '! sed -n 1p "$T/client.log" | grep -q -- "--skip-first-confirm" && sed -n 2p "$T/client.log" | grep -q -- "--flow --skip-first-confirm$" && sed -n 3p "$T/client.log" | grep -q -- "--flow --skip-first-confirm$" && [ "$(sed -n 2p "$T/client.log" | sed "s/ --skip-first-confirm$//")" = "$(sed -n 1p "$T/client.log")" ]'
+rm -rf "$T"
+check "実機の 8 も 2 回目からは --skip-first-confirm を渡し, 7 の HOME・爪を開くは毎回流す" '[ "$(grep -c '"'"'${CONFIRM_ARGS\[@\]+"${CONFIRM_ARGS\[@\]}"}'"'"' "$HERE/lab_start.sh")" = 2 ] && grep -qF "[ \$round -ge 2 ] && CONFIRM_ARGS=(--skip-first-confirm)" "$HERE/lab_start.sh" && [ "$(awk "/^while :; do/,/^done/" "$HERE/lab_start.sh" | grep -c "08_home_from_anywhere.py\|daemon_set_gripper(0)")" = 2 ]'
+
 echo
 echo "OK $OK / NG $NG"
 [ $NG = 0 ]
