peertable 0.4.37 → 0.5.1

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.
@@ -107,7 +107,7 @@ sock=""
107
107
  sess=""
108
108
  url=""
109
109
  room=""
110
- seat_file="$proj/.team/seats/$name.json"
110
+ # 席の本人性は room 台帳の member 行が持つ(席file廃止 2026-08-22)。rollback は member DELETE が兼ねる
111
111
  cleanup_brief() {
112
112
  if [ -n "$brief_file" ]; then rm -f "$brief_file"; fi
113
113
  return 0
@@ -144,13 +144,6 @@ rollback_brief() {
144
144
  return 1
145
145
  fi
146
146
 
147
- if [ -n "$seat_file" ] && [ -e "$seat_file" ]; then
148
- if ! rm -f "$seat_file"; then
149
- rollback_failed=1
150
- echo "LAUNCH_BRIEF_ROLLBACK_FAILED: seat identity を撤去できない: ${seat_file}" >&2
151
- fi
152
- fi
153
-
154
147
  if [ -n "$url" ] && [ -n "$room" ]; then
155
148
  if ! encoded_name=$(python3 -c 'import sys,urllib.parse; print(urllib.parse.quote(sys.argv[1], safe=""))' "$name"); then
156
149
  rollback_failed=1
@@ -305,11 +298,6 @@ if ! node "$room_mcp_helper" "$proj" "$peertable_repo" "$mcp_ownership"; then
305
298
  echo "SEAT_ROOM_MCP_INVALID: Aiterm席のroom clientをcurrent treeへ束縛できない(席は立てない)" >&2
306
299
  exit 1
307
300
  fi
308
- if ! credential_file=$(env -u PEERTABLE_POST_TOKEN node "$credential_helper" prepare "$proj" "$room" "$name"); then
309
- echo "SEAT_CREDENTIAL_PREPARE_FAILED: 席別credentialを用意できない(席は立てない)" >&2
310
- exit 1
311
- fi
312
-
313
301
  # 同名の古いroom memberが残っていると、Codexの新しいroom clientが一度も登録していなくても
314
302
  # 下のready確認を通ってしまう。古いmemberを別席のまま黙って消すのは危険なので、起動前に同名を
315
303
  # conflictとして拒否し、明示的な退席後に再実行させる。一覧を読めない時も着席前に止める。
@@ -339,6 +327,15 @@ case "$stale_member_rc" in
339
327
  ;;
340
328
  esac
341
329
 
330
+ # credential は同名掃除の**後**に用意する。leave-seat は member ごと credential file
331
+ # (path は project/room/member から決定的)を撤去するので、先に作ると同名再着席の
332
+ # 初回だけ「消えた credential を席へ渡して client が initialize 前に死ぬ」レースになる
333
+ # (実被弾 2026-08-22: さくら再着席の初回必敗・再走で成功、の正体)。
334
+ if ! credential_file=$(env -u PEERTABLE_POST_TOKEN node "$credential_helper" prepare "$proj" "$room" "$name"); then
335
+ echo "SEAT_CREDENTIAL_PREPARE_FAILED: 席別credentialを用意できない(席は立てない)" >&2
336
+ exit 1
337
+ fi
338
+
342
339
  # leave-seat が席専用 GROK_HOME を消す。同名再着席では preflight 後に空になるので、
343
340
  # live 起動の直前に auth と ui だけを書き直す(user booth MCP は載せない)。
344
341
  if [ "$vendor" = grok ]; then
@@ -356,7 +353,6 @@ fi
356
353
  # direct CLI launch へ戻るfallbackは置かない。Aiterm が作る同名PTYと launch receipt が、
357
354
  # 以後の `pty_read` / `agent_configure` / room metadata を同じsessionへ束縛する。
358
355
  tmux_at kill-session -t "$sess" 2>/dev/null || true
359
- rm -f "$proj/.team/seats/$name.json"
360
356
 
361
357
  launch_env=(
362
358
  "PEERTABLE_URL=$url"
@@ -420,7 +416,34 @@ then
420
416
  exit 1
421
417
  fi
422
418
  aiterm_session_id="$sess"
423
- brief_dispatched=true
419
+ # **launch が返った事実は brief 配達の証拠にならない。** aiterm は TUI が入力受付前
420
+ # (update/trust ダイアログ表示中など)だと prompt を送らず、receipt の event_cursor=null で
421
+ # それを申告する。読まずに配達済み扱いにすると席が白紙のまま「briefed」と報告される
422
+ # (実被弾 2026-08-22: さくら再着席。席は起動したが着任指示ゼロで放置)。
423
+ # event_cursor が数値 → turn 開始をaitermが確認済み。submit_residue=true → 本文は
424
+ # composer に残ったが Enter が落ちた=後段で Enter だけ打ち直す。null → 未送信=後段で貼る。
425
+ brief_in_composer=false
426
+ if [ -n "$brief" ]; then
427
+ brief_receipt_state=$(python3 - "$launch_receipt" <<'PY'
428
+ import json, sys
429
+ r = json.loads(sys.argv[1])
430
+ cursor = r.get('event_cursor')
431
+ residue = r.get('submit_residue')
432
+ print('dispatched' if cursor is not None and residue is not True
433
+ else 'residue' if residue is True else 'not_sent')
434
+ PY
435
+ )
436
+ case "$brief_receipt_state" in
437
+ dispatched) brief_dispatched=true ;;
438
+ residue)
439
+ brief_in_composer=true
440
+ echo "SEAT_BRIEF_SUBMIT_RESIDUE: brief は入力欄に残り submit が落ちた。着席確認後に submit し直す" >&2
441
+ ;;
442
+ *)
443
+ echo "SEAT_BRIEF_LAUNCH_PROMPT_NOT_SENT: aiterm は TUI 入力受付前で brief を送っていない。着席確認後に貼り直す" >&2
444
+ ;;
445
+ esac
446
+ fi
424
447
  seat_tmux=$(tmux_at display-message -p -t "$sess" '#{socket_path}')
425
448
 
426
449
  # Aiterm管理席の process 起動は公開launch receiptで確定している。旧direct CLI launch向けの
@@ -441,42 +464,46 @@ codex_hooks_accepted=false
441
464
  codex_update_accepted=false
442
465
  codex_trust_accepted=false
443
466
  claude_trust_accepted=false
467
+ codex_dialog_helper="$peertable_script_dir/codex-dialog.mjs"
468
+ pass_codex_pane() {
469
+ local json key
470
+ json=$(printf '%s' "$1" | node "$codex_dialog_helper" || true)
471
+ [ -n "$json" ] && [ "$json" != "null" ] || return 1
472
+ while IFS= read -r key; do
473
+ [ -n "$key" ] || continue
474
+ tmux_at send-keys -t "$sess" "$key" || return 1
475
+ done < <(python3 -c 'import json,sys
476
+ a=json.loads(sys.stdin.read() or "null")
477
+ print("\n".join(a.get("keys") or []) if isinstance(a, dict) else "")' <<<"$json")
478
+ return 0
479
+ }
480
+ codex_pane_blocks_ready() {
481
+ printf '%s' "$1" | node "$codex_dialog_helper" --ready-ok
482
+ }
444
483
  while [ $SECONDS -lt "$room_ready_deadline" ]; do
445
484
  # Grok Build は初めて開く作業treeで、room MCPを初期化する前にworkspace trustを尋ねる。
446
485
  # Peertableが正式に着席させるtreeなので、この既知文言だけを一度通す。未知の確認画面を
447
486
  # 汎用的に承認するfallbackにはしない。承認後のMCP初期化時間は改めて30秒確保する。
448
487
  if [ "$vendor" = codex ]; then
449
488
  codex_screen=$(tmux_at capture-pane -t "$sess" -p 2>/dev/null || true)
450
- if [ "$codex_update_accepted" != true ]; then
451
- case "$codex_screen" in
452
- *"Update now"*)
453
- if tmux_at send-keys -t "$sess" Down && tmux_at send-keys -t "$sess" Enter; then
454
- codex_update_accepted=true
455
- room_ready_deadline=$((SECONDS + 90))
456
- echo "codex update prompt: skipped"
457
- fi
458
- ;;
459
- esac
460
- fi
461
- if [ "$codex_hooks_accepted" != true ]; then
489
+ if pass_codex_pane "$codex_screen"; then
490
+ room_ready_deadline=$((SECONDS + 90))
462
491
  case "$codex_screen" in
492
+ *"Allow the room MCP server to run tool"*) echo "codex mcp allow: always allow" ;;
463
493
  *"Hooks need review"*)
464
- if tmux_at send-keys -t "$sess" Down && tmux_at send-keys -t "$sess" Enter; then
465
- codex_hooks_accepted=true
466
- room_ready_deadline=$((SECONDS + 90))
467
- echo "codex hooks prompt: trust all"
468
- fi
494
+ codex_hooks_accepted=true
495
+ echo "codex hooks prompt: trust all"
496
+ ;;
497
+ *"Update now"*)
498
+ codex_update_accepted=true
499
+ echo "codex update prompt: skipped"
469
500
  ;;
470
- esac
471
- fi
472
- if [ "$codex_trust_accepted" != true ]; then
473
- case "$codex_screen" in
474
501
  *"Yes, continue"*)
475
- if tmux_at send-keys -t "$sess" Enter; then
476
- codex_trust_accepted=true
477
- room_ready_deadline=$((SECONDS + 90))
478
- echo "codex directory trust: accepted"
479
- fi
502
+ codex_trust_accepted=true
503
+ echo "codex directory trust: accepted"
504
+ ;;
505
+ *"Would you like to run the following command?"*)
506
+ echo "codex command approval: don't ask again"
480
507
  ;;
481
508
  esac
482
509
  fi
@@ -529,6 +556,13 @@ while [ $SECONDS -lt "$room_ready_deadline" ]; do
529
556
  fi
530
557
  room_members=$(curl -sf "$url/api/$room/members" 2>/dev/null || true)
531
558
  if printf '%s' "$room_members" | python3 -c 'import json,sys; name=sys.argv[1]; members=json.load(sys.stdin).get("members",[]); raise SystemExit(0 if any(m.get("name") == name for m in members) else 1)' "$name"; then
559
+ if [ "$vendor" = codex ]; then
560
+ codex_screen=$(tmux_at capture-pane -t "$sess" -p 2>/dev/null || true)
561
+ if ! codex_pane_blocks_ready "$codex_screen"; then
562
+ sleep 1
563
+ continue
564
+ fi
565
+ fi
532
566
  room_ready=true
533
567
  break
534
568
  fi
@@ -541,6 +575,24 @@ if [ "$room_ready" != true ]; then
541
575
  exit 1
542
576
  fi
543
577
  echo "room ready: ${room}/${name}"
578
+ if [ "$vendor" = codex ]; then
579
+ # member 登録後の最初の tool 呼び出しで MCP Allow が出る。出ている間だけ通し、沈黙5秒で抜ける。
580
+ codex_post_ready_deadline=$((SECONDS + 90))
581
+ codex_post_ready_idle=0
582
+ while [ $SECONDS -lt "$codex_post_ready_deadline" ]; do
583
+ codex_screen=$(tmux_at capture-pane -t "$sess" -p 2>/dev/null || true)
584
+ if pass_codex_pane "$codex_screen"; then
585
+ case "$codex_screen" in
586
+ *"Allow the room MCP server to run tool"*) echo "codex mcp allow: always allow" ;;
587
+ esac
588
+ codex_post_ready_idle=0
589
+ else
590
+ codex_post_ready_idle=$((codex_post_ready_idle + 1))
591
+ [ "$codex_post_ready_idle" -ge 5 ] && break
592
+ fi
593
+ sleep 1
594
+ done
595
+ fi
544
596
  if [ "$brief_dispatched" = true ]; then
545
597
  brief_completed=true
546
598
  echo "briefed: ${sess}(Aiterm launch prompt)"
@@ -575,17 +627,20 @@ if [ -n "$brief" ] && [ "$brief_dispatched" != true ]; then
575
627
  sleep 1
576
628
 
577
629
  brief_before=$(tmux_at capture-pane -S -1000 -t "$sess" -p 2>/dev/null || true)
578
- brief_buffer="peertable-brief-${name}-$$"
579
- if ! tmux_at load-buffer -b "$brief_buffer" "$brief_file"; then
580
- echo "LAUNCH_BRIEF_SEND_FAILED: brief の tmux buffer 読み込みに失敗(席は着席済み)" >&2
581
- exit 1
582
- fi
583
- if ! tmux_at paste-buffer -b "$brief_buffer" -d -t "$sess"; then
584
- tmux_at delete-buffer -b "$brief_buffer" 2>/dev/null || true
585
- echo "LAUNCH_BRIEF_SEND_FAILED: brief tmux paste に失敗(席は着席済み)" >&2
586
- exit 1
630
+ # submit_residue の席は本文が composer に残っている。貼り直すと二重になるので Enter だけ打つ。
631
+ if [ "$brief_in_composer" != true ]; then
632
+ brief_buffer="peertable-brief-${name}-$$"
633
+ if ! tmux_at load-buffer -b "$brief_buffer" "$brief_file"; then
634
+ echo "LAUNCH_BRIEF_SEND_FAILED: brief の tmux buffer 読み込みに失敗(席は着席済み)" >&2
635
+ exit 1
636
+ fi
637
+ if ! tmux_at paste-buffer -b "$brief_buffer" -d -t "$sess"; then
638
+ tmux_at delete-buffer -b "$brief_buffer" 2>/dev/null || true
639
+ echo "LAUNCH_BRIEF_SEND_FAILED: brief の tmux paste に失敗(席は着席済み)" >&2
640
+ exit 1
641
+ fi
642
+ sleep 1
587
643
  fi
588
- sleep 1
589
644
  if ! tmux_at send-keys -t "$sess" Enter; then
590
645
  echo "LAUNCH_BRIEF_SEND_FAILED: brief の submit に失敗(席は着席済み)" >&2
591
646
  exit 1
@@ -614,15 +669,12 @@ if [ -n "$brief" ] && [ "$brief_dispatched" != true ]; then
614
669
  fi
615
670
  fi
616
671
 
617
- # 席の素性を `.team/seats/<name>.json` へ置く。**席が自分の pid を知るための唯一の経路**である
618
- # (Lattice `run intake attach` は expected identity を要求し、pid を推定しない)。
672
+ # 席の本人性(pid / 起動時刻 / argv digest)を room 台帳の member 行へ登録する。
673
+ # member に帰属する情報の正本は台帳だけ(オーナー裁定 2026-08-22・席fileは廃止)。
619
674
  # 着席の**後**に取る——起動途中の process を掴むと、ダイアログ通過で子が入れ替わりうる。
620
- #
621
- # 持たせるのは6欄だけで、**`lattice.pull_worker_attach_input.v1` exact 集合から `schema` を
622
- # 除いたもの**と一致する。席は読んで `schema` を被せるだけで attach input になる(変換不要)。
623
- # **raw argv を持たせない**——秘密値はargvから除いたが、将来の引数も含めて複製しない。digest だけを持つ。
624
- # この file が主張するのは「この pid はこの席だった」という**識別**であって、生死ではない。
625
- # 生きているかは attach する側(Lattice)が lstart+argv の再観測で確かめる。
675
+ # **raw argv を持たせない**——digest だけを持つ。この記録が主張するのは「この pid は
676
+ # この席だった」という**識別**であって、生死ではない。生きているかは attach する側
677
+ # (Lattice)が lstart+argv の再観測で確かめる。
626
678
  seat_pid=""
627
679
  pane_pid=$(tmux_at list-panes -t "$sess" -F '#{pane_pid}' 2>/dev/null | head -1 || true)
628
680
  seat_ident=""
@@ -639,82 +691,51 @@ if [ -z "$seat_pid" ]; then
639
691
  # 記録が無ければ席は attach できず、装置の介入は協調 hold のままになる。**黙らない。**
640
692
  echo "seat identity を記録できなかった: ${sess} の process group leader を1つに確定できない(席は着席済み)" >&2
641
693
  else
642
- mkdir -p "$proj/.team/seats"
643
- # **`session` tmux 名(`peer-<name>`)を入れてはいけない。** Lattice の attach は
644
- # `input.session === actor.session` を要求し(`runtime-pull-intake.mjs:674`)、actor session は
645
- # 上の env_line が入れる `LATTICE_TODO_ACTOR_SESSION=$name` である。tmux 識別子を混ぜると
646
- # attach が必ず `WORKER_ACTOR_MISMATCH` で拒否される(mio の監査で発覚・room [937])。
647
- if ! python3 - "$proj/.team/seats/$name.json" "$name" "$name" "$seat_ident" <<'PY'
648
- import hashlib, json, os, subprocess, sys, tempfile
649
- out, name, session, ident_raw = sys.argv[1:5]
694
+ # aiterm_session_id の書き手はランチャー(launch receipt の所有者)。席の client は
695
+ # Codex closed-mode env AITERM_SESSION_ID を受け取れないため、ここで台帳へ載せる。
696
+ ident_body=$(python3 - "$name" "$seat_ident" "$aiterm_session_id" <<'PY'
697
+ import json, subprocess, sys
698
+ name, ident_raw, aiterm_session = sys.argv[1:4]
650
699
  ident = json.loads(ident_raw)
651
- pid = int(ident['pid'])
652
- started = ident['started_identity']
653
- argv = ident['argv']
654
- if not started or not argv:
700
+ if not ident.get('started_identity') or not ident.get('argv') or not ident.get('argv_digest'):
655
701
  sys.exit('pid の lstart/args を観測できない')
656
- record = {
657
- 'argv_digest': hashlib.sha256(argv.encode()).hexdigest(),
702
+ print(json.dumps({
658
703
  'name': name,
659
- 'pid': pid,
660
- 'recorded_at': subprocess.run(['date', '-u', '+%Y-%m-%dT%H:%M:%S.000Z'],
661
- capture_output=True, text=True, check=True).stdout.strip(),
662
- 'session': session,
663
- 'started_identity': started,
664
- }
665
- # canonical JSON(key 昇順・空白なし)+ 0600 + 一時file→fsync→rename で原子的に置く。
666
- # 着席直後に席が読むので、部分読取が起きない形にする。
667
- body = json.dumps(record, ensure_ascii=False, sort_keys=True, separators=(',', ':')) + '\n'
668
- fd, tmp = tempfile.mkstemp(dir=os.path.dirname(out), prefix='.seat-', suffix='.tmp')
669
- try:
670
- with os.fdopen(fd, 'w') as handle:
671
- handle.write(body)
672
- handle.flush()
673
- os.fsync(handle.fileno())
674
- os.chmod(tmp, 0o600)
675
- os.replace(tmp, out)
676
- except BaseException:
677
- os.unlink(tmp)
678
- raise
704
+ 'pid': int(ident['pid']),
705
+ 'started_identity': ident['started_identity'],
706
+ 'argv_digest': ident['argv_digest'],
707
+ 'aiterm_session_id': aiterm_session,
708
+ 'identity_recorded_at': subprocess.run(['date', '-u', '+%Y-%m-%dT%H:%M:%S.000Z'],
709
+ capture_output=True, text=True, check=True).stdout.strip(),
710
+ }, ensure_ascii=False))
679
711
  PY
680
- then
681
- echo "seat identity を記録できなかった: ${sess}(席は着席済み・attach は協調 hold のままになる)" >&2
712
+ ) || ident_body=""
713
+ if [ -z "$ident_body" ] || ! env -u PEERTABLE_POST_TOKEN node "$credential_helper" request "$credential_file" POST \
714
+ "$url/api/$room/members" "$ident_body" >/dev/null; then
715
+ echo "seat identity を台帳へ登録できなかった: ${sess}(席は着席済み・attach は協調 hold のままになる)" >&2
682
716
  fi
683
717
  fi
684
718
 
685
- # 席の素性(roles / settings / mission)を room へ渡す。一覧チップと席の名簿が同じ欄を見る。
686
- tmux_ns=$(node "$(dirname "$0")/tmux-socket.mjs" --namespace)
687
- # python stdout Windows cp932 になり 実装 が壊れる。Node が UTF-8 JSON を出す。
688
- meta=$(node "$(dirname "$0")/seat-metadata.mjs" "$name" "$vendor" "$model" "$effort" "$sock" "$sess" "$role" "$mission" "$aiterm_session_id" "$tmux_ns")
689
- if env -u PEERTABLE_POST_TOKEN node "$credential_helper" request "$credential_file" POST \
690
- "$url/api/$room/members" "$meta" >/dev/null; then
691
- # **200 は保存の証拠にならない**。素性欄を知らない server も 200 {"ok":true} を返して黙って捨てる
692
- # (登録が `if (!members.has(name))` の no-op になる経路もある)。読み返して実際に載ったかを見る。
693
- # **パイプと heredoc を同じ stdin へ重ねない**。`curl | python3 - <<'PY'` は
694
- # 「プログラムを stdin から読む」と「データを stdin から読む」が衝突して、必ず失敗する
695
- # (そして try/except で包むと、失敗が「保存されていない」という**もっともらしい答え**に化ける。実測)
696
- listing=$(curl -sf "$url/api/$room/members" || true)
697
- stored=$(python3 - "$name" "$listing" <<'PY'
719
+ # 素性(vendor/model/roles/mission/observe/aiterm ID)の書き手は**席の room client だけ**。
720
+ # ランチャーはここで台帳を読み返し、実際に載ったかを確認するだけにする(重複書込の禁止)。
721
+ member_row=$(curl -sf "$url/api/$room/members/$(python3 -c 'import sys,urllib.parse;print(urllib.parse.quote(sys.argv[1],safe=""))' "$name")" || true)
722
+ stored=$(python3 - "$member_row" <<'PY'
698
723
  import json, sys
699
- # 読み返しに失敗しても生の traceback を出さない。ここは「保存されたか」を見るだけの確認段で、
700
- # 判定不能は「保存されていない」と同じ扱いでよい(席は既に着席している)
701
724
  try:
702
- members = json.loads(sys.argv[2])['members']
725
+ m = json.loads(sys.argv[1])['member']
703
726
  except Exception:
704
- members = []
705
- m = next((x for x in members if x.get('name') == sys.argv[1]), {})
706
- print('yes' if m.get('model') and (m.get('roles') or m.get('role')) and m.get('aiterm_session_id') and m.get('observe', {}).get('tmux_target') else 'no')
727
+ m = {}
728
+ print('yes' if m.get('model') and m.get('roles') and m.get('aiterm_session_id')
729
+ and (m.get('observe') or {}).get('tmux_target') and m.get('pid') else 'no')
707
730
  PY
708
731
  )
709
- if [ "$stored" = yes ]; then
710
- echo "metadata: ${vendor} / ${model}${effort:+ / $effort}${role:+ / $role}"
711
- else
712
- echo "metadata は保存されなかった: この room サーバーは素性欄を持たない版(席は着席済み・一覧に素性が出ないだけ)" >&2
713
- fi
732
+ if [ "$stored" = yes ]; then
733
+ echo "台帳確認: ${vendor} / ${model}${effort:+ / $effort}${role:+ / $role}(素性・本人性とも登録済み)"
714
734
  else
715
- echo "metadata の登録に失敗した: 席は着席済みで、参加者一覧に素性が出ないだけ(room の到達性とトークンを確認)" >&2
735
+ echo "SEAT_LEDGER_INCOMPLETE: 台帳の member 行に素性または本人性が欠けている(席は着席済み。doctor で確認)" >&2
716
736
  fi
717
737
 
738
+ PEERTABLE_CREDENTIAL_FILE="$credential_file" "$(dirname "$0")/ensure-bridge.sh" "$proj" alarm || echo "alarm-bridge の起動確認に失敗した(席は着席済み)" >&2
718
739
  if PEERTABLE_CREDENTIAL_FILE="$credential_file" "$(dirname "$0")/ensure-bridge.sh" "$proj" seat-status; then
719
740
  echo "seat-status-bridge: 起動確認済み"
720
741
  else
@@ -76,10 +76,7 @@ if ! env -u PEERTABLE_POST_TOKEN node "$credential_helper" request "$credential_
76
76
  failed=1
77
77
  fi
78
78
 
79
- if ! rm -f "$proj/.team/seats/$name.json"; then
80
- echo "SEAT_LEAVE_IDENTITY_FAILED: $name" >&2
81
- failed=1
82
- fi
79
+ # 席の本人性は台帳の member 行が持つ(席file廃止 2026-08-22)。上の member DELETE が identity も撤去する
83
80
  rm -rf "$proj/.team/seats/${name}.grok-home"
84
81
  rm -rf "$proj/.team/seats/${name}.codex"
85
82
  if ! env -u PEERTABLE_POST_TOKEN node "$credential_helper" remove "$proj" "$credential_file"; then
@@ -34,21 +34,14 @@ fi
34
34
  member=$(python3 - "$name" "$model" "$effort" "$parent_vendor" "$mission" <<'PY'
35
35
  import json, sys
36
36
  name, model, effort, vendor, mission = sys.argv[1:6]
37
- body = {'name': name, 'roles': ['統括'], 'role': '統括'}
37
+ # 台帳には canonical 欄だけを登録する(settings / role 単数の重複欄は 2026-08-22 廃止)
38
+ body = {'name': name, 'roles': ['統括']}
38
39
  if vendor:
39
40
  body['vendor'] = vendor
40
41
  if model:
41
42
  body['model'] = model
42
- settings = {}
43
- if vendor:
44
- settings['vendor'] = vendor
45
- if model:
46
- settings['model'] = model
47
43
  if effort:
48
44
  body['effort'] = effort
49
- settings['effort'] = effort
50
- if settings:
51
- body['settings'] = settings
52
45
  if mission:
53
46
  body['mission'] = mission
54
47
  body['observe'] = None
@@ -78,6 +71,7 @@ fi
78
71
  if [ -f "$proj/.team/roles/parent.md" ]; then
79
72
  echo "親役割は $proj/.team/roles/parent.md を読むこと"
80
73
  fi
74
+ echo "親の権限境界: 各ToDoのクローズは監査担当が行い、親は裁定しない。親の仕事はオーナー窓口・環境修理・終端監査だけ(オーナー裁定 2026-08-22。詳細は skill/SKILL.md「親の operating notes」冒頭)"
81
75
 
82
76
  # parent-watchが初回headを固定する。以後のDMはwatcher不在時間を含め
83
77
  # 永続cursorからcatch-upされる。host内のbackground task自体は親セッションだけが所有できる。
@@ -90,6 +84,25 @@ if PEERTABLE_PARENT_HOST="$parent_vendor" node "$here/parent-watch.mjs" "$proj"
90
84
  else
91
85
  echo "PARENT_WATCH_START_REQUIRED: Claude Monitor(persistent)で ${here}/parent-watch.mjs ${proj} ${name} --follow を1回だけ起動し、通知後も同じMonitorで待機を続けること"
92
86
  fi
87
+ # 耳の疎通probe: 番犬が生きていても、その出力を受け取る監視(Monitor等)が現在の
88
+ # 親セッションに繋がっている保証はない(番犬が前セッションの耳へ吠え続け、親宛DMが
89
+ # 全損する実被弾 2026-08-22)。cursor確定後に親宛のprobe DMを1通置き、
90
+ # 「監視イベントとしてこのnonceを受信した」ことを着卓完了の条件にする。
91
+ probe_nonce="$(date +%s)-$$"
92
+ probe_body=$(python3 - "$name" "$probe_nonce" <<'PY'
93
+ import json, sys
94
+ name, nonce = sys.argv[1:3]
95
+ print(json.dumps({'from': 'ear-probe', 'to': name,
96
+ 'body': f'[耳疎通probe {nonce}] この通知を監視イベントとして受信できていれば耳は接続済み。受信するまで着卓完了と言わないこと。'}, ensure_ascii=False))
97
+ PY
98
+ )
99
+ if curl -sf -X POST "$url/api/$room/messages" \
100
+ -H "X-Peertable-Token: $PEERTABLE_POST_TOKEN" -H 'content-type: application/json' \
101
+ -d "$probe_body" > /dev/null; then
102
+ echo "EAR_PROBE_SENT: nonce=${probe_nonce}。着卓完了の条件は、上記の監視経由でこのnonceを受信すること。受信できないなら耳は繋がっていない=着卓失敗として扱い、監視を張り直す"
103
+ else
104
+ echo "WARN: EAR_PROBE_POST_FAILED: 耳疎通probeを投稿できない(監視の受信確認ができないまま)" >&2
105
+ fi
93
106
  else
94
107
  echo "WARN: PARENT_WATCH_PRIME_FAILED: 親(${name})のDM cursorを準備できない" >&2
95
108
  fi
@@ -4,6 +4,39 @@ export function latticeStaffingChanged(previous, next) {
4
4
  return previous.ready !== next.ready || previous.active !== next.active
5
5
  }
6
6
 
7
+ const STATUS_LABEL = { busy: '作業中', blocked: '承認待ち', idle: '待機', dead: '停止' }
8
+
9
+ // 「工程があるのに作業中の席が1つも無い」が holdMs 続いたら1回だけ警報を出す。
10
+ // observation: { ready, active, workers: [{name, status}] }(membersを読めた時だけ渡す)
11
+ // previous: 保存済み stall 状態({ since, alerted } | null | undefined)
12
+ // 返り値: { stall, event }。event が非null なら親へ届ける。
13
+ export function tableStallUpdate(previous, observation, nowMs, holdMs) {
14
+ if (!observation) return { stall: previous ?? null, event: null }
15
+ const { ready, active, workers } = observation
16
+ const workExists = ready + active > 0
17
+ const anyoneBusy = workers.some(w => w.status === 'busy')
18
+ if (!workExists || anyoneBusy) return { stall: null, event: null }
19
+ const since = previous?.since ?? nowMs
20
+ const alerted = previous?.alerted === true
21
+ if (alerted || nowMs - since < holdMs) {
22
+ return { stall: { since, alerted }, event: null }
23
+ }
24
+ const seatList = workers.length === 0
25
+ ? '席が1つもありません'
26
+ : workers.map(w => `${w.name}=${STATUS_LABEL[w.status] ?? w.status ?? '未報告'}`).join('、')
27
+ return {
28
+ stall: { since, alerted: true },
29
+ event: {
30
+ type: 'parent_table_stalled',
31
+ ready,
32
+ active,
33
+ workers,
34
+ body: `警報: 着手可能 ${ready} 件・着手中 ${active} 件の工程があるのに、作業中の席が1つもありません`
35
+ + `(${Math.round(holdMs / 60000)}分継続)。席: ${seatList}。各席の状態を確認して動かしてください。`,
36
+ },
37
+ }
38
+ }
39
+
7
40
  export function addressedToParent(message, parent) {
8
41
  return message?.from !== parent
9
42
  && (message?.to === 'all' || message?.to === parent
@@ -11,7 +11,7 @@ import { execFileSync } from 'node:child_process'
11
11
  import { existsSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs'
12
12
  import { join } from 'node:path'
13
13
  import { resolveLatticeExecutable } from './seat-usage.mjs'
14
- import { addressedToParent as messageAddressedToParent, latticeStaffingChanged } from './parent-watch-logic.mjs'
14
+ import { addressedToParent as messageAddressedToParent, latticeStaffingChanged, tableStallUpdate } from './parent-watch-logic.mjs'
15
15
 
16
16
  const args = process.argv.slice(2)
17
17
  const project = args.shift()
@@ -231,8 +231,40 @@ async function acceptMessage(message) {
231
231
  return matched
232
232
  }
233
233
 
234
+ // 「工程があるのに作業中の席が1つも無い」を親へ知らせる停滞警報。席の状態は
235
+ // seat-status-bridge が room members へ書く status を使う(親は延べ配信対象外)。
236
+ // members か Lattice を読めない tick は判定を進めず、前回の stall 状態を保つ。
237
+ const stallHoldMs = Number(process.env.PEERTABLE_STALL_ALARM_MS ?? 180_000)
238
+ async function checkStall() {
239
+ const lattice = state.lattice
240
+ if (!lattice || lattice.error !== undefined) return false
241
+ let members
242
+ try { members = (await readJson('/members')).members ?? [] } catch { return false }
243
+ const workers = members
244
+ .filter(m => m.name !== parent && m.delivery?.kind !== 'parent_watch')
245
+ .map(m => ({ name: m.name, status: m.status ?? null }))
246
+ const { stall, event } = tableStallUpdate(
247
+ state.stall ?? null,
248
+ { ready: lattice.ready, active: lattice.active, workers },
249
+ Date.now(),
250
+ stallHoldMs,
251
+ )
252
+ if (JSON.stringify(stall) !== JSON.stringify(state.stall ?? null)) {
253
+ state = { ...state, stall, last_event_at: event ? now() : state.last_event_at }
254
+ saveState(state)
255
+ }
256
+ if (!event) return false
257
+ await writeEvent({
258
+ schema: 'peertable.parent-watch-event.v1',
259
+ parent,
260
+ ...event,
261
+ })
262
+ return true
263
+ }
264
+
234
265
  async function catchUp() {
235
266
  if (await acceptLatticeState(readLatticeState(state.lattice)) && mode === '--next') return true
267
+ if (await checkStall() && mode === '--next') return true
236
268
  const body = await readJson(`/messages?since=${state.last_seq}`)
237
269
  for (const message of body.messages ?? []) {
238
270
  if (await acceptMessage(message) && mode === '--next') return true
@@ -295,6 +327,7 @@ for (;;) {
295
327
  if (eventName === 'ping') {
296
328
  const head = Number(data)
297
329
  if (await acceptLatticeState(readLatticeState(state.lattice)) && mode === '--next') process.exit(0)
330
+ if (await checkStall() && mode === '--next') process.exit(0)
298
331
  if (Number.isSafeInteger(head) && head > state.last_seq && await catchUp()) process.exit(0)
299
332
  continue
300
333
  }
@@ -0,0 +1,53 @@
1
+ #!/usr/bin/env node
2
+ // attach 入力は room 台帳(member 行の本人性欄)だけから作る。呼び出し側の pid 推定を受け付けない。
3
+ // usage: pull-attach-input.mjs <project_dir> <name> [refusedPid]
4
+ import { readFileSync } from 'node:fs'
5
+ import { join, resolve } from 'node:path'
6
+
7
+ const fail = (code, message) => {
8
+ process.stderr.write(`${code}: ${message}\n`)
9
+ process.exit(2)
10
+ }
11
+
12
+ const [projectDir, name, maybePid] = process.argv.slice(2)
13
+ if (!projectDir || !name) fail('ATTACH_INPUT_ARGS', 'usage: pull-attach-input.mjs <project_dir> <name> [refusedPid]')
14
+
15
+ let setup
16
+ try {
17
+ setup = JSON.parse(readFileSync(join(resolve(projectDir), '.team', 'setup-state.json'), 'utf8'))
18
+ } catch (error) {
19
+ fail('ATTACH_INPUT_STATE_UNREADABLE', error.message)
20
+ }
21
+
22
+ let member
23
+ try {
24
+ const response = await fetch(`${setup.server_url.replace(/\/$/u, '')}/api/${encodeURIComponent(setup.room)}/members/${encodeURIComponent(name)}`)
25
+ if (!response.ok) fail('ATTACH_INPUT_MEMBER_MISSING', `台帳に ${name} が無い(HTTP ${response.status})`)
26
+ member = (await response.json()).member
27
+ } catch (error) {
28
+ if (error?.code === undefined) fail('ATTACH_INPUT_LEDGER_UNREACHABLE', String(error?.message ?? error))
29
+ throw error
30
+ }
31
+
32
+ const pid = member.pid
33
+ if (!Number.isSafeInteger(pid) || pid < 1) fail('ATTACH_INPUT_NO_PID', '台帳の member 行に pid が無い')
34
+ if (maybePid !== undefined && Number(maybePid) !== pid) {
35
+ fail('WORKER_IDENTITY_MISMATCH',
36
+ `invented pid ${maybePid} is not the ledger pid ${pid}(実行層死亡ではない)`)
37
+ }
38
+ if (!member.started_identity || !member.argv_digest) {
39
+ fail('ATTACH_INPUT_IDENTITY_INCOMPLETE', '台帳の member 行に started_identity / argv_digest が無い')
40
+ }
41
+
42
+ // `session` は席名。tmux 名(peer-<name>)を入れると Lattice の attach が
43
+ // `WORKER_ACTOR_MISMATCH` で必ず拒否される(actor session は LATTICE_TODO_ACTOR_SESSION=<name>)。
44
+ const input = {
45
+ schema: 'lattice.pull_worker_attach_input.v1',
46
+ name: member.name,
47
+ session: member.name,
48
+ pid,
49
+ started_identity: member.started_identity,
50
+ argv_digest: member.argv_digest,
51
+ recorded_at: member.identity_recorded_at,
52
+ }
53
+ process.stdout.write(`${JSON.stringify(input)}\n`)