peertable 0.4.36 → 0.5.0

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.
@@ -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()
@@ -159,8 +159,12 @@ const addressedToParent = message => messageAddressedToParent(message, parent)
159
159
 
160
160
  function exitWhenParentStdinCloses() {
161
161
  if (mode !== '--follow') return
162
+ // Claude Monitor など stdin を与えない persistent 実行では、stdin が最初から
163
+ // 閉じているのを親端末の終了と誤認して即死する。stdin が TTY でない、または
164
+ // PEERTABLE_WATCH_NO_STDIN=1 の時は stdin 追従を張らない(SKILL は Monitor 起動を指示する)。
165
+ if (process.env.PEERTABLE_WATCH_NO_STDIN === '1') return
162
166
  const stdin = process.stdin
163
- if (!stdin || stdin.destroyed) return
167
+ if (!stdin || stdin.destroyed || stdin.isTTY !== true) return
164
168
  const quit = () => process.exit(0)
165
169
  stdin.on('end', quit)
166
170
  stdin.on('close', quit)
@@ -227,8 +231,40 @@ async function acceptMessage(message) {
227
231
  return matched
228
232
  }
229
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
+
230
265
  async function catchUp() {
231
266
  if (await acceptLatticeState(readLatticeState(state.lattice)) && mode === '--next') return true
267
+ if (await checkStall() && mode === '--next') return true
232
268
  const body = await readJson(`/messages?since=${state.last_seq}`)
233
269
  for (const message of body.messages ?? []) {
234
270
  if (await acceptMessage(message) && mode === '--next') return true
@@ -291,6 +327,7 @@ for (;;) {
291
327
  if (eventName === 'ping') {
292
328
  const head = Number(data)
293
329
  if (await acceptLatticeState(readLatticeState(state.lattice)) && mode === '--next') process.exit(0)
330
+ if (await checkStall() && mode === '--next') process.exit(0)
294
331
  if (Number.isSafeInteger(head) && head > state.last_seq && await catchUp()) process.exit(0)
295
332
  continue
296
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`)
@@ -0,0 +1,97 @@
1
+ #!/usr/bin/env node
2
+ // 台帳(room member 行)の argv_digest を、Lattice attach と同じ /bin/ps -o command= 観測へ揃える。
3
+ // pid / lstart が台帳と違うときは書き換えず終わる(pid 推定も再利用も禁止)。
4
+ import { execFileSync, execFile } from 'node:child_process'
5
+ import { readFileSync } from 'node:fs'
6
+ import { dirname, join, resolve } from 'node:path'
7
+ import { fileURLToPath } from 'node:url'
8
+ import { promisify } from 'node:util'
9
+
10
+ // ps 観測と digest 計算は seat-identity.mjs(OS観測ライブラリ)が唯一の所有者。再実装しない。
11
+ export { hashArgv, observePidCommand } from './seat-identity.mjs'
12
+ import { observePidCommand } from './seat-identity.mjs'
13
+
14
+ export function refreshSeatRecord(raw, observed, recordedAt) {
15
+ if (!Number.isSafeInteger(raw?.pid) || raw.pid < 1) {
16
+ const error = new Error('台帳の member 行に pid が無い')
17
+ error.code = 'SEAT_IDENTITY_NO_PID'
18
+ throw error
19
+ }
20
+ if (observed.pid !== raw.pid) {
21
+ const error = new Error(`live pid ${observed.pid} は台帳の pid ${raw.pid} と違う`)
22
+ error.code = 'SEAT_IDENTITY_PID_CHANGED'
23
+ throw error
24
+ }
25
+ if (observed.started_identity !== raw.started_identity) {
26
+ const error = new Error('pid の lstart が台帳と一致しない')
27
+ error.code = 'SEAT_IDENTITY_LSTART_CHANGED'
28
+ throw error
29
+ }
30
+ return {
31
+ argv_digest: observed.argv_digest,
32
+ name: raw.name,
33
+ pid: raw.pid,
34
+ recorded_at: recordedAt,
35
+ started_identity: raw.started_identity,
36
+ }
37
+ }
38
+
39
+ function utcRecordedAt() {
40
+ return execFileSync('date', ['-u', '+%Y-%m-%dT%H:%M:%S.000Z'], { encoding: 'utf8' }).trim()
41
+ }
42
+
43
+ const isCli = process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])
44
+ if (isCli) {
45
+ const [project, name] = process.argv.slice(2)
46
+ if (!project || !name) {
47
+ process.stderr.write('usage: refresh-seat-identity.mjs <project> <name>\n')
48
+ process.exit(2)
49
+ }
50
+ let setup
51
+ try {
52
+ setup = JSON.parse(readFileSync(join(resolve(project), '.team', 'setup-state.json'), 'utf8'))
53
+ } catch (error) {
54
+ process.stderr.write(`SEAT_IDENTITY_STATE_UNREADABLE: ${error.message}\n`)
55
+ process.exit(2)
56
+ }
57
+ const api = `${setup.server_url.replace(/\/$/u, '')}/api/${encodeURIComponent(setup.room)}`
58
+ const memberResponse = await fetch(`${api}/members/${encodeURIComponent(name)}`)
59
+ if (!memberResponse.ok) {
60
+ process.stderr.write(`SEAT_IDENTITY_UNREADABLE: 台帳に ${name} が無い(HTTP ${memberResponse.status})\n`)
61
+ process.exit(2)
62
+ }
63
+ const raw = (await memberResponse.json()).member
64
+ let observed
65
+ try {
66
+ observed = observePidCommand(raw.pid)
67
+ } catch (error) {
68
+ process.stderr.write(`${error.code || 'SEAT_IDENTITY_UNOBSERVABLE'}: ${error.message}\n`)
69
+ process.exit(2)
70
+ }
71
+ let next
72
+ try {
73
+ next = refreshSeatRecord(raw, observed, utcRecordedAt())
74
+ } catch (error) {
75
+ process.stderr.write(`${error.code}: ${error.message}\n`)
76
+ process.exit(2)
77
+ }
78
+ if (next.argv_digest === raw.argv_digest) {
79
+ process.stdout.write(`${JSON.stringify({ outcome: 'unchanged', name, pid: raw.pid, argv_digest: raw.argv_digest })}\n`)
80
+ process.exit(0)
81
+ }
82
+ // 書込は席別 credential 経由(秘密値を argv/env に載せない既存境界を使う)
83
+ const credentialHelper = join(dirname(fileURLToPath(import.meta.url)), 'seat-credential.mjs')
84
+ const run = promisify(execFile)
85
+ const credential = (await run('node', [credentialHelper, 'prepare', resolve(project), setup.room, name],
86
+ { env: { ...process.env, PEERTABLE_POST_TOKEN: undefined } })).stdout.trim()
87
+ const body = JSON.stringify({ name, argv_digest: next.argv_digest, identity_recorded_at: next.recorded_at })
88
+ await run('node', [credentialHelper, 'request', credential, 'POST', `${api}/members`, body],
89
+ { env: { ...process.env, PEERTABLE_POST_TOKEN: undefined } })
90
+ process.stdout.write(`${JSON.stringify({
91
+ outcome: 'updated',
92
+ name,
93
+ pid: next.pid,
94
+ argv_digest: next.argv_digest,
95
+ previous_argv_digest: raw.argv_digest,
96
+ })}\n`)
97
+ }
@@ -1,10 +1,37 @@
1
1
  #!/usr/bin/env node
2
- // pane_pid から Lattice attach 用の pid / lstart / argv を1件に決める。
2
+ // Peertable スクリプト群の OS プロセス観測ライブラリ(唯一の所有者)。
3
+ // pane_pid から Lattice attach 用の pid / lstart / argv を1件に決める CLI を兼ねる。
3
4
  // POSIX は ps。Windows は Win32_Process(ps -Ao は Git Bash で unknown option -- A)。
5
+ // ps の観測規約(LC_ALL=C・/bin/ps 絶対パス・trim)と argv digest の計算はこのファイルだけが持つ。
6
+ // 他スクリプトはここから import し、ps を手書きしない(locale 罠の再発防止。caveat:
7
+ // ps-locale-lstart-lc-time-ascii-argv-lc-ctype-digest)。
8
+ import { createHash } from 'node:crypto'
4
9
  import { execFileSync } from 'node:child_process'
5
10
  import { resolve } from 'node:path'
6
11
  import { fileURLToPath } from 'node:url'
7
12
 
13
+ export function hashArgv(argv) {
14
+ return createHash('sha256').update(String(argv), 'utf8').digest('hex')
15
+ }
16
+
17
+ // 記録済み pid そのものの lstart / argv を観測する(pane からの席特定はしない)。
18
+ // refresh-seat-identity・doctor が使う。
19
+ export function observePidCommand(pid) {
20
+ if (!Number.isSafeInteger(pid) || pid < 1) {
21
+ const error = new Error('pid が正整数でない')
22
+ error.code = 'SEAT_IDENTITY_NO_PID'
23
+ throw error
24
+ }
25
+ const started = execFileSync('/bin/ps', ['-o', 'lstart=', '-p', String(pid)], { encoding: 'utf8', env: psEnv() }).trim()
26
+ const argv = execFileSync('/bin/ps', ['-o', 'command=', '-p', String(pid)], { encoding: 'utf8', env: psEnv() }).trim()
27
+ if (!started || !argv) {
28
+ const error = new Error('pid の lstart/command を観測できない')
29
+ error.code = 'SEAT_IDENTITY_UNOBSERVABLE'
30
+ throw error
31
+ }
32
+ return { pid, started_identity: started, argv, argv_digest: hashArgv(argv) }
33
+ }
34
+
8
35
  const AGENT = /(?:claude|codex|grok|composer)(?:\.cmd|\.exe)?(?:\s|$)/iu
9
36
 
10
37
  export function parseWinCreationDate(value) {
@@ -25,8 +52,12 @@ export function parseWinCreationDate(value) {
25
52
  return parsed.toISOString()
26
53
  }
27
54
 
55
+ // ps の lstart 書式(LC_TIME)と非ASCII argv のエスケープ(LC_CTYPE)は locale で変わり、
56
+ // 観測者ごとに digest が割れる(2026-08-22 実測)。Lattice 側の観測も LC_ALL=C に固定している。
57
+ const psEnv = () => ({ ...process.env, LC_ALL: 'C' })
58
+
28
59
  function posixIdentity(pid) {
29
- const table = execFileSync('ps', ['-Ao', 'pid=,ppid=,pgid='], { encoding: 'utf8' })
60
+ const table = execFileSync('/bin/ps', ['-Ao', 'pid=,ppid=,pgid='], { encoding: 'utf8', env: psEnv() })
30
61
  const leaders = table.split('\n').flatMap((line) => {
31
62
  const match = /^\s*(\d+)\s+(\d+)\s+(\d+)\s*$/u.exec(line)
32
63
  if (!match) return []
@@ -34,10 +65,10 @@ function posixIdentity(pid) {
34
65
  return ppid === String(pid) && child === pgid ? [child] : []
35
66
  })
36
67
  const chosen = leaders.length === 1 ? leaders[0] : String(pid)
37
- const started = execFileSync('/bin/ps', ['-o', 'lstart=', '-p', chosen], { encoding: 'utf8' }).trim()
38
- const argv = execFileSync('/bin/ps', ['-o', 'args=', '-p', chosen], { encoding: 'utf8' }).trim()
68
+ const started = execFileSync('/bin/ps', ['-o', 'lstart=', '-p', chosen], { encoding: 'utf8', env: psEnv() }).trim()
69
+ const argv = execFileSync('/bin/ps', ['-o', 'command=', '-p', chosen], { encoding: 'utf8', env: psEnv() }).trim()
39
70
  if (!started || !argv) throw new Error('pid の lstart/args を観測できない')
40
- return { pid: Number(chosen), started_identity: started, argv }
71
+ return { pid: Number(chosen), started_identity: started, argv, argv_digest: hashArgv(argv) }
41
72
  }
42
73
 
43
74
  function winQuery(filter) {
@@ -60,7 +91,7 @@ function winIdentity(pid) {
60
91
  const started = chosen.CreationDate ? parseWinCreationDate(chosen.CreationDate) : ''
61
92
  const argv = String(chosen.CommandLine || '').trim()
62
93
  if (!started || !argv) throw new Error('pid の CreationDate/CommandLine を観測できない')
63
- return { pid: Number(chosen.ProcessId), started_identity: started, argv }
94
+ return { pid: Number(chosen.ProcessId), started_identity: started, argv, argv_digest: hashArgv(argv) }
64
95
  }
65
96
 
66
97
  const isCli = process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])
@@ -189,6 +189,11 @@ export const BLOCKED_MARKERS = [
189
189
  '1. I am using this for local development',
190
190
  '1. Yes, continue',
191
191
  'Do you want to proceed?',
192
+ // Codex v0.148 のコマンド承認ダイアログ(2026-08-22 実測: 承認待ちの席が idle と
193
+ // 誤表示され、オーナーが「動いているのに表示が変わらない」と気づいた)
194
+ 'Would you like to run the following command?',
195
+ '1. Yes, proceed',
196
+ 'Press enter to confirm or esc to cancel',
192
197
  ]
193
198
 
194
199
  /** Grok TUI の SpaceXAI coding-data バナー(2026-08-21 実測。入力は通るが席が死んだように見える)。 */
@@ -114,16 +114,18 @@ else
114
114
  skip "seat-status-bridge(起動記録なし)"
115
115
  fi
116
116
 
117
- # run 可視化ブリッジ(Lattice の実行層を使う卓だけ立っている)。同じ理由で `.team/` を消す前に止める。
118
- # 止め残すと、卓が無いのに run の進行と介入を投稿し続ける常駐が残る
117
+ # run-bridge は退役済み(2026-08-22)。旧卓の常駐が残っていれば記録の pid を照合して止める。
119
118
  if [ -f "$proj/.team/run-bridge.json" ]; then
120
- if node "$(dirname "$0")/run-bridge.mjs" "$proj" --stop; then
121
- did "run-bridge 停止"
119
+ rb_pid=$(python3 -c "import json;print(json.load(open('$proj/.team/run-bridge.json')).get('pid',''))" 2>/dev/null || true)
120
+ if [ -n "$rb_pid" ] && LC_ALL=C /bin/ps -o command= -p "$rb_pid" 2>/dev/null | grep -q "run-bridge.mjs"; then
121
+ kill "$rb_pid" 2>/dev/null || true
122
+ did "run-bridge(退役済み・旧常駐 pid ${rb_pid} を停止)"
122
123
  else
123
- miss "run-bridge 停止に失敗(常駐が残る)— 上の _STOP_FAILED を見て手で止める"
124
+ skip "run-bridge(退役済み・生きた常駐なし)"
124
125
  fi
126
+ rm -f "$proj/.team/run-bridge.json"
125
127
  else
126
- skip "run-bridge(起動記録なし)"
128
+ skip "run-bridge(退役済み)"
127
129
  fi
128
130
 
129
131
  # run の close は成果が既定branchへ着地した証拠ではない。bridgeを止めてから、`run list`が挙げる
@@ -17,6 +17,7 @@ import { existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from
17
17
  import { join } from 'node:path'
18
18
  import { promisify } from 'node:util'
19
19
  import { resolveSeatObservation, tmuxArgv } from './seat-usage.mjs'
20
+ import { keysForCodexPane } from './codex-dialog.mjs'
20
21
  import { BROADCAST_RECIPIENT, formatWakeNotice, isIdleSelfWake, isWakeupBridgeTarget, shouldDeferGrokWake } from './wakeup-delivery.mjs'
21
22
  import { bridgeRecordLive } from './bridge-record-live.mjs'
22
23
 
@@ -271,9 +272,31 @@ async function wake(seat, msgs) {
271
272
  error.code = code
272
273
  throw error
273
274
  }
275
+ // **agent CLI が死んで pane が素の shell に戻っていたら、1バイトも打たない。**
276
+ // shell へ room の本文を send-keys すると、本文がそのまま shell コマンドとして実行される
277
+ // (実被弾 2026-08-22: codex 終了後の bash へ配達され `command not found` が走った。
278
+ // 本文次第では席の権限で任意コマンドになる)。配達は止め、毎周期 typed log で叫ぶ。
279
+ const fg = await run('tmux', tmuxArgv(['display-message', '-p', '-t', observation.target, '#{pane_current_command}'], { socket: observation.socket }))
280
+ const fgCommand = String(fg.stdout ?? '').trim()
281
+ if (['bash', 'zsh', 'sh', 'dash', 'fish', 'tcsh', 'csh', 'ksh'].includes(fgCommand)) {
282
+ log(`SEAT_TUI_GONE: ${seat} の pane は shell(${fgCommand})に戻っている=agent CLI が終了済み。`
283
+ + 'shell へのコマンド実行を防ぐため配達しない。席を立て直すか leave-seat で畳むこと')
284
+ return 'deferred'
285
+ }
286
+ const pane = await run('tmux', tmuxArgv(['capture-pane', '-t', observation.target, '-p'], { socket: observation.socket }))
287
+ const screen = String(pane.stdout ?? '')
288
+ if (member.vendor === 'codex') {
289
+ const dialog = keysForCodexPane(screen)
290
+ if (dialog) {
291
+ for (const key of dialog.keys) {
292
+ await run('tmux', tmuxArgv(['send-keys', '-t', observation.target, key], { socket: observation.socket }))
293
+ }
294
+ log(`Codex ${dialog.kind} を通した: ${seat}`)
295
+ return 'deferred'
296
+ }
297
+ }
274
298
  if (member.vendor === 'grok') {
275
- const pane = await run('tmux', tmuxArgv(['capture-pane', '-t', observation.target, '-p'], { socket: observation.socket }))
276
- const tail = String(pane.stdout).split('\n').slice(-14).join('\n')
299
+ const tail = screen.split('\n').slice(-14).join('\n')
277
300
  if (shouldDeferGrokWake(member.vendor, tail)) {
278
301
  if (!deferredBusy.has(seat)) {
279
302
  log(`Grok席が実行中なのでidleまで待つ: ${seat} ← ${msgs.length} 件`)
@@ -291,6 +314,33 @@ async function wake(seat, msgs) {
291
314
  await run('tmux', tmuxArgv(['send-keys', '-l', '-t', observation.target, text], { socket: observation.socket }))
292
315
  await sleep(750)
293
316
  await run('tmux', tmuxArgv(['send-keys', '-t', observation.target, 'Enter'], { socket: observation.socket }))
317
+ // 送信検証。Codex v0.148 は長文入力後に「Create a plan?」popup が出て Enter を呑むことがあり、
318
+ // 本文が入力欄に残ったまま席が沈黙する(2026-08-22 実測: 2席が無音停止し、人が画面を見るまで
319
+ // 誰も気づけなかった)。打鍵成功=配達成功とみなさず、画面で送信成立を確認する。
320
+ // 残っていれば Escape(popup解除)→ Enter で再送信し、それでも残るなら受領を確定せず
321
+ // 次周期に再試行する(毎回ログに残るので沈黙しない)。
322
+ const markerSource = text.replace(/\s+/gu, '')
323
+ const marker = markerSource.slice(-24)
324
+ for (let attempt = 0; attempt < 3; attempt++) {
325
+ await sleep(1200)
326
+ const after = await run('tmux', tmuxArgv(['capture-pane', '-t', observation.target, '-p'], { socket: observation.socket }))
327
+ const tailLines = String(after.stdout ?? '').split('\n').slice(-14)
328
+ const tailJoined = tailLines.join(' ')
329
+ // 折返しはcapture上で行分割されるだけで空白は挟まらないため、空白除去で復元して照合する
330
+ const tailSquashed = tailLines.join('').replace(/\s+/gu, '')
331
+ const queuedOrRunning = tailJoined.includes('esc to interrupt') || tailJoined.includes('to queue message')
332
+ const stuck = tailJoined.includes('esc dismiss')
333
+ || (marker.length >= 8 && tailSquashed.includes(marker) && !queuedOrRunning)
334
+ if (!stuck) break
335
+ if (attempt === 2) {
336
+ log(`DELIVERY_STUCK: ${seat} の入力欄に本文が残ったまま送信できない(popup/入力詰まり)。受領を確定せず次周期に再試行する`)
337
+ return 'deferred'
338
+ }
339
+ log(`配達が送信されていない(popup/入力詰まり): ${seat}。Escape→Enter で再送信する`)
340
+ await run('tmux', tmuxArgv(['send-keys', '-t', observation.target, 'Escape'], { socket: observation.socket }))
341
+ await sleep(300)
342
+ await run('tmux', tmuxArgv(['send-keys', '-t', observation.target, 'Enter'], { socket: observation.socket }))
343
+ }
294
344
  log(`TUIへ入れた: ${seat} ← ${msgs.length} 件(最新 seq ${last.seq})`)
295
345
  }
296
346
 
@@ -36,7 +36,7 @@ export function isWakeupBridgeTarget(member, options = {}) {
36
36
  && observe.tmux_target,
37
37
  )
38
38
  if (hasPane) return true
39
- const vendor = member.vendor || member.settings?.vendor
39
+ const vendor = member.vendor
40
40
  return vendor === 'codex' || vendor === 'grok'
41
41
  }
42
42
 
@@ -26,7 +26,7 @@
26
26
  5. 証跡ファイル `evidence/{{PLAN_KEY}}/<task_id>.md` に、最終的な試験内容と試験結果を含めて「何を作り、どう確認したか」を書き、変更ファイルと証跡だけをcommitする
27
27
  6. その証跡と同じ最終試験内容・結果を監査担当へ渡す。作業者自身は `.team/scripts/done.sh` や `lattice todo done` を実行しない
28
28
  7. 監査担当として結果を受け取った場合は、提出された試験内容と試験結果が元PLAN・工程正本・受入条件に照らして妥当か判断する。試験を再実行せず、個人の思想や計画外の改善を完了条件へ加えない
29
- 8. 妥当なら監査担当が `.team/scripts/done.sh <task_id> --plan <plan_key>` で工程をクローズする。未着地の feat は `done.sh` が canonical main へ merge して push する。親へ着地を依頼しない。`done.sh` は証跡と同じ本文を Lattice の `test_result` へ記録し、remaining の並列記録も更新してから戻る。done を読返してから `post(to: "all", message: "次の工程に着手してください")` とだけ指示し、具体的な次工程は指示しない
29
+ 8. 妥当なら監査担当が `.team/scripts/done.sh <task_id> --plan <plan_key>` で工程をクローズする。クローズに親・オーナーの裁定は要らず、待たない——親が卓上で別の手順を言っていても、監査担当のクローズ権限が優先する。親は campaign 終端の最終監査で全量を見る(オーナー裁定 2026-08-22)。未着地の feat は `done.sh` が canonical main へ merge して push する。親へ着地を依頼しない。`done.sh` は証跡と同じ本文を Lattice の `test_result` へ記録し、remaining の並列記録も更新してから戻る。done を読返してから `post(to: "all", message: "次の工程に着手してください")` とだけ指示し、具体的な次工程は指示しない
30
30
  9. 不合格なら、現在モデルでの修正機会は1回だけとする。再び不合格になったら親へmodel変更を依頼し、`Luna → Terra → Sol`の順で一段昇格する。自分で席設定を変えない
31
31
  10. 作業者は監査担当によるクローズを確認し、工程正本から次のreadyを選ぶ
32
32
  11. **claimできるToDoが無いなら仕事を発明しない(決定68)。** 依頼されていない監査・他席への状況照会・正典の自主レビューを暇つぶしに始めない。縮退の打診が来たらWIP棚卸しを正直に返す
@@ -85,17 +85,20 @@ lattice run intake --run .lattice/runs/<run-id> --task <id>
85
85
  空のまま**である。作った席が他の席の仕事を決めたことにはならない。
86
86
 
87
87
  1. **`todo start` を先に済ませてから intake する。** 装置は Todo 正本の start event へ束縛するので、start していない task は intake できない。**逆順にしない。**
88
- 2. **`intervention.state` を読む。** `none` なら worktree を使ってそのまま進める。`hold` の理由が `record_stale` または `artifact_binding_mismatch`(並列記録の失効・再 compile との交差)なら、親へ退席も待ちも出さない。未受理 intake を解放し、canonical で `.team/scripts/independence-refresh.sh {{PLAN_KEY}}` を通してから intake を打ち直す。**他の着手済み task と同じ書き込み資源でぶつかった hold** だけが退席対象である。その場合、新しく起きた席または次工程を探した席で既存WIPが無ければ、競合理由と未着手を room へ一度記録し、未受理 intake を解放して `leave-seat.sh` で直ちに退席する。既存WIPがある席だけは工程正本へhandoffを残してから畳む。**hold を無視して進めず、競合解除 poll のために席を温存しない。**
88
+ 2. **`intervention.state` を読む。** `none` なら worktree を使ってそのまま進める。`hold` の理由が `record_stale` または `artifact_binding_mismatch`(並列記録の失効・再 compile との交差)なら、未受理 intake を解放し、canonical で `.team/scripts/independence-refresh.sh {{PLAN_KEY}}` を通してから intake を打ち直す。他の着手済み task と同じ書き込み資源でぶつかった hold は、**担当 ToDo を手放す理由にならず、退席の理由にもならない**(旧規約の「hold=退席」は 2026-08-22 に廃止。卓を空にしただけだった)。未受理 intake を解放し、競合相手の task と理由を room へ一度記録して、その task のクローズを待つ。待つ間に自分の別の active WIP があればそれを進める。解消は `lattice run intake intervention --run <ref> --task <id>` の読み直しで確認してから intake を打ち直す。**hold を無視して進めない。**
89
89
  3. **worktree を受け取ったら、自分の pid を装置へ渡す(attach)。** これをしないと、装置は競合時に「留まれ」と言うことはできても、実際に止めることができない(協調 hold のまま)。
90
90
  ```
91
91
  lattice run intake attach --run <ref> --task <id> --input <file>
92
92
  ```
93
- input は **`.team/seats/<あなたの名前>.json` を読んで `schema` を足すだけ**である(変換も再計算も要らない)。
93
+ input は **`.team/scripts/pull-attach-input.mjs <project> <あなたの名前>` が room 台帳(member 行の本人性欄)から作る**(席file は 2026-08-22 廃止・変換も再計算も要らない)。
94
94
  ```json
95
95
  {"schema":"lattice.pull_worker_attach_input.v1","name":…,"session":…,"pid":…,
96
96
  "started_identity":…,"argv_digest":…,"recorded_at":…}
97
97
  ```
98
- **pid を自分で推定しない。** その file だけが正で、無ければ room で言う(黙って別の値を渡さない)。**他の席の seats file を読まない。**
98
+ **pid を自分で推定しない。** 台帳の member 行だけが正で、無ければ room で言う(黙って別の値を渡さない)。**他の席の本人性欄を使わない。**
99
+ **argv_digest も席が計算し直さない。** pid と lstart が台帳と一致するのに digest だけ live と違うときは実行層死亡ではない。room で親へ「digest 更新が要る」とだけ言い、親が `refresh-seat-identity.mjs` で Lattice と同じ `/bin/ps -o command=` 観測へ揃える。
100
+ `lattice run intake` 自体は pid を取らない。attach の前に CLI の `process.pid` や死んだ子 pid を渡すと `ADAPTER_CONTROLLER_UNAVAILABLE: process start identity観測失敗` になる。それは実行層の死亡ではない。台帳の pid で打ち直す。
101
+ pull run の `driver_state=stopped` かつ `intakes=[]` は、まだ intake していない平常である。親へ「可視性を復旧して」と求めない。
99
102
  4. **intake は1本ずつ。** 前の intake が accepted / closed / released になるまで次を取らない。**席は1つの process なので、2本 intake すると片方の hold がもう片方を巻き込む**——制御の粒度が壊れる。
100
103
  5. **worktree の中だけを、絶対パスで触る。** `cd` しない・env を書き換えない・別 project へ移らない。席の room 接続と MCP 解決は cwd と env に乗っているので、動かすと卓から落ちる。git は `git -C <worktree> …`、編集は絶対パスで開く
101
104
  6. **commit してよい。禁止は `push` / `branch` / `merge` / `rebase` / `reset` / `stash` の6つ**(正は Lattice engine の `FORBIDDEN_OPERATIONS`)。worktree は `base_sha` の detached HEAD なので、そこへ積む commit は base の子孫のままで canonical branch を動かさない。逆に6つは HEAD を base の子孫から外すか、外部へ効果を出す操作なので、観測の前提か公開契約のどちらかを壊す