peertable 0.7.0 → 0.8.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.
Files changed (44) hide show
  1. package/LICENSE +21 -21
  2. package/README.ja.md +128 -124
  3. package/README.md +164 -160
  4. package/package.json +49 -49
  5. package/room/Dockerfile +7 -7
  6. package/room/client.mjs +14 -3
  7. package/room/server.mjs +13 -3
  8. package/skill/02_models.snapshot.md +103 -103
  9. package/skill/SKILL.md +264 -264
  10. package/skill/scripts/aiterm-launch.mjs +20 -1
  11. package/skill/scripts/alarm-set.sh +0 -0
  12. package/skill/scripts/archive-room-log.py +0 -0
  13. package/skill/scripts/bridge-record-live.mjs +0 -0
  14. package/skill/scripts/change-effort.sh +0 -0
  15. package/skill/scripts/change-seat.sh +0 -0
  16. package/skill/scripts/codex-parent-watch.sh +0 -0
  17. package/skill/scripts/doctor.sh +22 -0
  18. package/skill/scripts/ensure-bridge.sh +0 -0
  19. package/skill/scripts/ensure-codex-room-mcp.mjs +0 -0
  20. package/skill/scripts/ensure-room-mcp.mjs +3 -7
  21. package/skill/scripts/external-pane.mjs +0 -0
  22. package/skill/scripts/kickoff-gate.mjs +0 -0
  23. package/skill/scripts/launch-seat.sh +0 -0
  24. package/skill/scripts/leave-seat.sh +0 -0
  25. package/skill/scripts/make-plan-input.mjs +0 -0
  26. package/skill/scripts/parent-join.sh +0 -0
  27. package/skill/scripts/platform/windows/normalize-read-line.mjs +7 -0
  28. package/skill/scripts/remove-managed-room-mcp.mjs +70 -0
  29. package/skill/scripts/resolve-seat-placement.mjs +7 -2
  30. package/skill/scripts/resume.sh +4 -0
  31. package/skill/scripts/room-mcp-config.mjs +24 -0
  32. package/skill/scripts/seat-status-bridge.mjs +111 -2
  33. package/skill/scripts/seat-usage.mjs +72 -0
  34. package/skill/scripts/set-mission.sh +0 -0
  35. package/skill/scripts/setup.sh +0 -0
  36. package/skill/scripts/teardown.sh +16 -2
  37. package/skill/scripts/upgrade-team-assets.sh +59 -7
  38. package/skill/scripts/wakeup-bridge.mjs +35 -1
  39. package/skill/templates/charter.md +20 -20
  40. package/skill/templates/mcp.json +5 -5
  41. package/skill/templates/member-standalone.md +60 -60
  42. package/skill/templates/member.md +159 -149
  43. package/skill/templates/parent.md +143 -143
  44. package/skill/templates/tasks.md +8 -8
@@ -37,7 +37,26 @@ const result = await client.callTool({
37
37
  },
38
38
  })
39
39
  await client.close()
40
- if (result.isError) throw new Error(result.content?.[0]?.text ?? 'SEAT_AITERM_LAUNCH_FAILED')
40
+ if (result.isError) {
41
+ const text = result.content?.[0]?.text ?? ''
42
+ // aiterm v0.29.0からready gate失敗(起動時ダイアログ等でprompt未送信)は明示エラーになった。
43
+ // Peertableの席起動は「未送信→ダイアログ自動通過→着任指示を後段で貼る」を正規手順として
44
+ // 持っているので、sessionが残るこの失敗だけはnot_sent受領として続行する。
45
+ if (text.includes('initial_prompt=not_sent') && text.includes(`session_id: ${session_name}`)) {
46
+ console.log(JSON.stringify({
47
+ schema: 'aiterm.agent-launch-result.v1',
48
+ provider: harness,
49
+ harness: `${harness === 'claude' ? 'claude-code' : `${harness}-cli`}`,
50
+ session_id: session_name,
51
+ managed_completion: true,
52
+ event_cursor: null,
53
+ wait_command: null,
54
+ submit_residue: null,
55
+ }))
56
+ process.exit(0)
57
+ }
58
+ throw new Error(text || 'SEAT_AITERM_LAUNCH_FAILED')
59
+ }
41
60
  const receipt = result.structuredContent ?? JSON.parse(result.content?.[0]?.text ?? '{}')
42
61
  if (receipt?.schema !== 'aiterm.agent-launch-result.v1' || receipt.provider !== harness || receipt.session_id !== session_name) {
43
62
  throw new Error('SEAT_AITERM_LAUNCH_RECEIPT_INVALID')
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
@@ -166,6 +166,28 @@ for (const [name, judge] of [['wakeup', judgeWakeup], ['seat-status', judgeSeatS
166
166
  line(result.level, result.text)
167
167
  }
168
168
 
169
+ // 4.5 端末別の装置可用性(silent absence禁止・2026-08-25 オーナー裁定):
170
+ // この端末で「動かない」なら動かないと表示する。黙ってMacだけで動く状態を作らない。
171
+ {
172
+ // job観測(アクティブランプへの預け仕事合成): tmux list-sessions がこの端末で成立するか
173
+ try {
174
+ const probe = spawnSync('tmux', ['list-sessions', '-F', '#{session_name}'], { encoding: 'utf8' })
175
+ if (probe.error) throw probe.error
176
+ line('OK', `job観測: tmux list-sessions 実行可(ランプ合成は有効。jobは peer-<name>-job* セッションで走らせること)`)
177
+ } catch (error) {
178
+ line('NG', `job観測: tmux list-sessions が失敗——預け仕事のランプ合成はこの端末で無効(${String(error.message ?? error).split('\n')[0]})`)
179
+ }
180
+ // 読了ack: server members に read_seq 欄が観測できるか(席が一度もackしていない間は判定できない)
181
+ try {
182
+ const res = JSON.parse(execFileSync('curl', ['-s', `${url}/api/${encodeURIComponent(room)}/members`], { encoding: 'utf8' }))
183
+ const withAck = (res.members ?? []).filter(m => m.read_seq !== undefined).length
184
+ if (withAck > 0) line('OK', `読了ack: server対応を確認(read_seq保持 ${withAck} 席)`)
185
+ else line('判定不能', '読了ack: read_seq を持つ席がまだ無い(旧serverか、席が一度も read_unread→post していないか区別できない)')
186
+ } catch (error) {
187
+ line('NG', `読了ack: members を読めない(${String(error.message ?? error).split('\n')[0]})`)
188
+ }
189
+ }
190
+
169
191
  // 5. Lattice 併用モードの工程正本
170
192
  if (mode === 'lattice') {
171
193
  const latticeCli = process.env.LATTICE_CLI || (typeof setup.lattice_cli === 'string' && setup.lattice_cli) || 'lattice'
File without changes
File without changes
@@ -4,6 +4,7 @@ import {
4
4
  closeSync, fsyncSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync,
5
5
  } from 'node:fs'
6
6
  import { join, resolve } from 'node:path'
7
+ import { expectedRoomMcp, isExpectedRoomMcp } from './room-mcp-config.mjs'
7
8
 
8
9
  const fail = (code, message) => {
9
10
  process.stderr.write(`${code}: ${message}\n`)
@@ -21,14 +22,9 @@ try { config = JSON.parse(readFileSync(file, 'utf8')) } catch {
21
22
  }
22
23
  if (!config || typeof config !== 'object' || Array.isArray(config))
23
24
  fail('SEAT_ROOM_MCP_INVALID', `${file} のrootがobjectでない`)
24
- const expected = { command: 'node', args: [resolve(peertableRepo, 'room', 'client.mjs')] }
25
+ const expected = expectedRoomMcp(peertableRepo)
25
26
  const current = config?.mcpServers?.room
26
- if (current && typeof current === 'object' && !Array.isArray(current)
27
- && Object.keys(current).sort().join(',') === 'args,command'
28
- && current.command === expected.command
29
- && Array.isArray(current.args)
30
- && current.args.length === 1
31
- && current.args[0] === expected.args[0]) process.exit(0)
27
+ if (isExpectedRoomMcp(current, expected)) process.exit(0)
32
28
 
33
29
  if (ownership !== 'managed')
34
30
  fail('SEAT_ROOM_MCP_STALE', '既存.mcp.jsonのroom serverをcurrent-tree clientへmergeする必要がある')
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ import process from 'node:process'
3
+
4
+ const chunks = []
5
+ for await (const chunk of process.stdin) chunks.push(chunk)
6
+ const value = Buffer.concat(chunks).toString('utf8')
7
+ process.stdout.write(value.endsWith('\r') ? value.slice(0, -1) : value)
@@ -0,0 +1,70 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ closeSync, fsyncSync, lstatSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync,
4
+ } from 'node:fs'
5
+ import { basename, dirname, join, resolve } from 'node:path'
6
+ import { randomBytes } from 'node:crypto'
7
+ import { isPeertableRoomMcp } from './room-mcp-config.mjs'
8
+
9
+ const fail = (code, detail) => {
10
+ process.stderr.write(`${JSON.stringify({ schema: 'peertable.managed_room_mcp_remove_result.v1',
11
+ result: 'rejected', code, detail })}\n`)
12
+ process.exit(1)
13
+ }
14
+
15
+ const [project] = process.argv.slice(2)
16
+ if (!project) fail('PEERTABLE_MANAGED_MCP_REMOVE_ARGS_INVALID', '<project>')
17
+ const file = resolve(project, '.mcp.json')
18
+ let stat
19
+ try { stat = lstatSync(file) } catch (error) {
20
+ if (error?.code === 'ENOENT') {
21
+ process.stdout.write(`${JSON.stringify({ schema: 'peertable.managed_room_mcp_remove_result.v1',
22
+ result: 'ok', action: 'already-absent' })}\n`)
23
+ process.exit(0)
24
+ }
25
+ fail('PEERTABLE_MANAGED_MCP_REMOVE_UNREADABLE', error.message)
26
+ }
27
+ if (stat.isSymbolicLink() || !stat.isFile())
28
+ fail('PEERTABLE_MANAGED_MCP_REMOVE_UNSAFE', `${file} がsymlinkまたはregular fileでない`)
29
+
30
+ let config
31
+ try { config = JSON.parse(readFileSync(file, 'utf8')) } catch (error) {
32
+ fail('PEERTABLE_MANAGED_MCP_REMOVE_INVALID', `${file}: JSONを読めない: ${error.message}`)
33
+ }
34
+ if (!config || typeof config !== 'object' || Array.isArray(config)
35
+ || !config.mcpServers || typeof config.mcpServers !== 'object' || Array.isArray(config.mcpServers)) {
36
+ fail('PEERTABLE_MANAGED_MCP_REMOVE_INVALID', `${file}: root/mcpServersがobjectでない`)
37
+ }
38
+ if (config.mcpServers.room === undefined) {
39
+ process.stdout.write(`${JSON.stringify({ schema: 'peertable.managed_room_mcp_remove_result.v1',
40
+ result: 'ok', action: 'room-already-absent' })}\n`)
41
+ process.exit(0)
42
+ }
43
+ if (!isPeertableRoomMcp(config.mcpServers.room))
44
+ fail('PEERTABLE_MANAGED_MCP_REMOVE_CONFLICT', `${file}: room blockがPeertable所有形でない`)
45
+
46
+ delete config.mcpServers.room
47
+ if (Object.keys(config.mcpServers).length === 0) delete config.mcpServers
48
+ if (Object.keys(config).length === 0) {
49
+ unlinkSync(file)
50
+ process.stdout.write(`${JSON.stringify({ schema: 'peertable.managed_room_mcp_remove_result.v1',
51
+ result: 'ok', action: 'file-deleted' })}\n`)
52
+ process.exit(0)
53
+ }
54
+
55
+ const temporary = join(dirname(file), `.${basename(file)}.teardown-${process.pid}-${randomBytes(6).toString('hex')}.tmp`)
56
+ let fd
57
+ try {
58
+ fd = openSync(temporary, 'wx', stat.mode & 0o777)
59
+ writeFileSync(fd, `${JSON.stringify(config, null, 2)}\n`, 'utf8')
60
+ fsyncSync(fd)
61
+ closeSync(fd)
62
+ fd = undefined
63
+ renameSync(temporary, file)
64
+ } catch (error) {
65
+ if (fd !== undefined) closeSync(fd)
66
+ try { unlinkSync(temporary) } catch {}
67
+ fail('PEERTABLE_MANAGED_MCP_REMOVE_FAILED', error.message)
68
+ }
69
+ process.stdout.write(`${JSON.stringify({ schema: 'peertable.managed_room_mcp_remove_result.v1',
70
+ result: 'ok', action: 'room-removed-file-preserved' })}\n`)
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  // 02_models.md の役割→1位〜3位から、着席可能な harness / model / effort を解決する。
3
3
  // 具体モデル名はここに持たない。台帳と順位表をその場で読む。
4
- import { existsSync, readFileSync } from 'node:fs'
4
+ import { existsSync, readFileSync, realpathSync } from 'node:fs'
5
5
  import { dirname, join, resolve } from 'node:path'
6
6
  import { fileURLToPath } from 'node:url'
7
7
 
@@ -245,7 +245,12 @@ export function resolveSeatIdentity({
245
245
  }
246
246
  }
247
247
 
248
- const isMain = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)
248
+ // realpath比較にする——symlink経由(例: ~/.claude/skills/peertable 実体tree)で呼ばれると
249
+ // パス文字列比較が外れ、mainが走らず「exit 0・出力なし」で沈黙する(実被弾 2026-08-25:
250
+ // launch-seat.sh が SEAT_LAUNCH_HARNESS_UNSUPPORTED: harness= で落ち、原因が見えなかった)。
251
+ const isMain = process.argv[1] && (() => {
252
+ try { return realpathSync(resolve(process.argv[1])) === realpathSync(fileURLToPath(import.meta.url)) } catch { return false }
253
+ })()
249
254
  if (isMain) {
250
255
  const args = process.argv.slice(2)
251
256
  let roles = ''
@@ -40,6 +40,10 @@ if [ ! -f "$setup" ]; then
40
40
  exit 1
41
41
  fi
42
42
 
43
+ # resumeは同じroomを現行Peertable treeへ再接続する入口である。席が全て生存していて再起動0件でも、
44
+ # Peertable所有のgenerated assetとroot room MCPを先に現行版へ更新する。
45
+ "$script_dir/upgrade-team-assets.sh" "$proj"
46
+
43
47
  seats_file="$proj/.team/resume-seats.json"
44
48
 
45
49
  # Phase A: 到達確認・plan 再束縛・死記録掃除・再起動対象の抽出(node が JSON を書き出す)
@@ -0,0 +1,24 @@
1
+ import { resolve } from 'node:path'
2
+
3
+ export function expectedRoomMcp(peertableRepo) {
4
+ return { command: 'node', args: [resolve(peertableRepo, 'room', 'client.mjs')] }
5
+ }
6
+
7
+ export function isExpectedRoomMcp(current, expected) {
8
+ return Boolean(current && typeof current === 'object' && !Array.isArray(current)
9
+ && Object.keys(current).sort().join(',') === 'args,command'
10
+ && current.command === expected.command
11
+ && Array.isArray(current.args)
12
+ && current.args.length === 1
13
+ && current.args[0] === expected.args[0])
14
+ }
15
+
16
+ export function isPeertableRoomMcp(current) {
17
+ return Boolean(current && typeof current === 'object' && !Array.isArray(current)
18
+ && Object.keys(current).sort().join(',') === 'args,command'
19
+ && current.command === 'node'
20
+ && Array.isArray(current.args)
21
+ && current.args.length === 1
22
+ && typeof current.args[0] === 'string'
23
+ && /(?:^|[\\/])room[\\/]client\.mjs$/u.test(current.args[0]))
24
+ }
@@ -27,7 +27,7 @@ import { execFileSync } from 'node:child_process'
27
27
  import { existsSync, readFileSync, renameSync, writeFileSync, unlinkSync } from 'node:fs'
28
28
  import { join } from 'node:path'
29
29
 
30
- import { classifyPaneTail, decideBridgeContinuation, deriveMissingSession, isPaneProcessStopped, parsePaneTokenHint, resolvePostToken, resolveSeatObservation, resolveTmuxSocket, supportsMemberObservation, tmuxArgv, tmuxPanePid } from './seat-usage.mjs'
30
+ import { STOP_DECLARATION, combineSeatLamp, hasActiveDescendant, patrolTargets, classifyPaneTail, decideBridgeContinuation, deriveMissingSession, isPaneProcessStopped, parsePaneTokenHint, resolvePostToken, resolveSeatObservation, resolveTmuxSocket, supportsMemberObservation, tmuxArgv, tmuxPanePid } from './seat-usage.mjs'
31
31
 
32
32
  const args = process.argv.slice(2)
33
33
  const proj = args[0]
@@ -123,6 +123,39 @@ function readSeat(member, previous, observedAt) {
123
123
  return { status, busySince, paneTokenHint: parsePaneTokenHint(tail) }
124
124
  }
125
125
 
126
+ // ---- 預け仕事(jobセッション)の観測(2026-08-25 オーナー裁定)----
127
+ // 席が常駐・長時間ジョブを `peer-<名前>-job*` の名前のtmuxセッションで走らせる取り決めを前提に、
128
+ // その実物を毎周期観測してランプへ合成する。登録・宣言は一切見ない——観測だけ。
129
+ // active判定は「画面内容が前周期から変わった」こと。出力を出さない計算中はidle表示になるが、
130
+ // それは観測できる事実の正直な表示であり、推測で点滅させない。
131
+ const jobPaneHash = new Map() // `${name}:${session}` -> { hash, changedAt }
132
+ let jobObserveFailureLogged = false
133
+ function observeJob(socket, name) {
134
+ const listed = tmux(socket, 'list-sessions', '-F', '#{session_name}')
135
+ if (listed === null) {
136
+ // 「jobなし」と「観測手段が壊れている」を同じ顔にしない(silent absence禁止・2026-08-25 オーナー裁定)。
137
+ // この端末でjob観測が成立しない場合、ランプ合成は席paneのみで動いていることを明示的に吠える。
138
+ if (!jobObserveFailureLogged) {
139
+ jobObserveFailureLogged = true
140
+ console.error('JOB_OBSERVE_UNAVAILABLE: tmux list-sessions が失敗するため、預け仕事のランプ合成はこの端末で無効(席paneのみで表示)。以後この警告は繰り返さない')
141
+ }
142
+ return { alive: false, active: false }
143
+ }
144
+ jobObserveFailureLogged = false
145
+ const sessions = listed.split('\n').filter(s => s.startsWith(`peer-${name}-job`))
146
+ if (sessions.length === 0) return { alive: false, active: false }
147
+ let active = false
148
+ for (const session of sessions) {
149
+ const pane = tmux(socket, 'capture-pane', '-t', session, '-p')
150
+ if (pane === null) continue
151
+ const hash = String(pane.length) + ':' + pane.slice(-256)
152
+ const key = `${name}:${session}`
153
+ const prev = jobPaneHash.get(key)
154
+ if (!prev || prev.hash !== hash) { jobPaneHash.set(key, { hash }); active = true }
155
+ }
156
+ return { alive: true, active }
157
+ }
158
+
126
159
  async function send(name, observation, observedAt) {
127
160
  const res = await fetch(`${url}/api/${encodeURIComponent(room)}/members`, {
128
161
  method: 'POST',
@@ -152,7 +185,6 @@ async function serverKeepsStatus() {
152
185
  // 自己DM(席の自己規律)は保険として残るが、継続の保証はこの番犬が持つ。
153
186
  // busy 2分未満は情報読取だけの正当な無宣言ターンがあるため起こさない(誤爆の代償は
154
187
  // 待機宣言1ターンで、宣言後の episode は declared 判定で再起こしされない)。
155
- const STOP_DECLARATION = /\[待機\]|\[監査提出\]|待機します|散会/u
156
188
  const NUDGE_MIN_BUSY_MS = 120_000
157
189
  const nudgedEpisodes = new Map() // name -> busySince(同一エピソード1回だけ)
158
190
  async function nudgeIfDropped(name, busySince) {
@@ -179,6 +211,64 @@ async function nudgeIfDropped(name, busySince) {
179
211
  }
180
212
  }
181
213
 
214
+ // ---- claim巡回番犬(2026-08-25 オーナー設計)----
215
+ // 「activeなclaimを保有する席が、有効な待機宣言なしにidleでいる」ことを**周期的に**検査して起こす。
216
+ // 上のbusy→idle遷移番犬は2分未満のターンで作業を落とした席を構造的に見ない(実被弾 2026-08-25:
217
+ // 目覚ましで起床→1分未満で「再開します」と宣言だけしてidle化。以後どの装置も起こさなかった)。
218
+ // 待機宣言の有効性: 宣言より後にその席宛の名指しメッセージ(目覚まし・DM)が届いたら失効。
219
+ // 目覚ましで起こされた席の古い宣言文面を、寝ている根拠にしない(同実被弾: #105宣言→#106起床→就寝)。
220
+ // to:"all" は失効させない——親の待機宣言運用(起きても返信不要)と両立させるため。
221
+ const PATROL_INTERVAL_MS = 30_000
222
+ const PATROL_NAG_INTERVAL_MS = 300_000 // 条件が続く席へは5分間隔で再吠えする(1回きりにしない)
223
+ const patrolLastNag = new Map() // seat -> epoch_ms
224
+ const busyStartedAt = new Map() // seat -> epoch_ms(このbridgeプロセスが観測した最後のターン開始)
225
+ let lastPatrolAt = 0
226
+ async function patrolClaims() {
227
+ if (setup.mode !== 'lattice' || !setup.plan_key) return
228
+ const now = Date.now()
229
+ if (now - lastPatrolAt < PATROL_INTERVAL_MS) return
230
+ lastPatrolAt = now
231
+ let activeTasks
232
+ try {
233
+ // doctor.sh と同じ解決規則(LATTICE_CLI env → setup記録 → 既定 'lattice'、Windowsは .cmd 解決)
234
+ const latticeCli = process.env.LATTICE_CLI || (typeof setup.lattice_cli === 'string' && setup.lattice_cli) || 'lattice'
235
+ const latticeCommand = process.platform === 'win32' && !/\.(cmd|bat|exe)$/i.test(latticeCli) && existsSync(`${latticeCli}.cmd`)
236
+ ? `${latticeCli}.cmd`
237
+ : latticeCli
238
+ const out = execFileSync(latticeCommand, ['todo', 'status', '--json'], { cwd: proj, encoding: 'utf8' })
239
+ activeTasks = (JSON.parse(out).active_set ?? []).map(t => t.task_id)
240
+ } catch (e) {
241
+ console.error(`seat-status-bridge: claim巡回がLatticeを読めない(今周期は検査しない): ${e.message.split('\n')[0]}`)
242
+ return
243
+ }
244
+ if (!activeTasks.length) return
245
+ let messages
246
+ try {
247
+ messages = (await (await fetch(`${url}/api/${encodeURIComponent(room)}/messages`)).json()).messages ?? []
248
+ } catch { return }
249
+ const targets = patrolTargets({
250
+ activeTasks, messages, statusOf: seat => last.get(seat)?.status ?? null,
251
+ lastBusyStartAt: seat => busyStartedAt.get(seat) ?? null,
252
+ now, lastNag: patrolLastNag, nagIntervalMs: PATROL_NAG_INTERVAL_MS,
253
+ })
254
+ for (const { seat, task } of targets) {
255
+ patrolLastNag.set(seat, now)
256
+ try {
257
+ const res = await fetch(`${url}/api/${encodeURIComponent(room)}/messages`, {
258
+ method: 'POST',
259
+ headers: { 'Content-Type': 'application/json', ...(token ? { 'X-Peertable-Token': token } : {}) },
260
+ body: JSON.stringify({ from: 'alarm', to: seat,
261
+ body: `[継続] あなたがclaim中の工程 ${task} が着手中のまま、ターン終了後の待機宣言なしに席が停止している。作業を続行するか、外部待ちなら目覚まし条件を登録して [待機] を宣言してから終えること。` }),
262
+ })
263
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
264
+ console.error(`seat-status-bridge: claim巡回が ${seat} を起こした(${task} を保有したまま無宣言idle)`)
265
+ } catch (e) {
266
+ patrolLastNag.delete(seat)
267
+ console.error(`seat-status-bridge: claim巡回の起こしに失敗: ${e.message}`)
268
+ }
269
+ }
270
+ }
271
+
182
272
  // bridge 心拍(決定103)。server の bridge 台帳へ 30 秒ごとに送る。途絶=status_bridge_down、
183
273
  // 403=server 側が bridge_auth_failed として観測する。旧 server(404)へは送り続けない
184
274
  let lastBridgeBeatAt = 0
@@ -270,6 +360,23 @@ async function tick() {
270
360
  const observation = readSeat(member, prev, observedAt)
271
361
  // tmux 席を持たない member(親など)は一度も観測できないので送らない(deriveMissingSession が null を返す)
272
362
  if (observation === null) { skipped++; continue }
363
+ {
364
+ const target = resolveSeatObservation(member, null) ?? resolveSeatObservation(member, defaultSocket())
365
+ if (target !== null) {
366
+ const job = observeJob(target.socket, member.name)
367
+ // 席paneの子孫プロセス(Codex内蔵background terminal等)の実働もjob稼働として合成する
368
+ if (!job.active && observation.status === 'idle') {
369
+ const panePid = tmuxPanePid(target.socket, target.target)
370
+ if (panePid) {
371
+ try {
372
+ const rows = execFileSync('ps', ['-axo', 'pid=,ppid=,pcpu='], { encoding: 'utf8' }).split('\n')
373
+ if (hasActiveDescendant(rows, Number(panePid))) job.active = true
374
+ } catch { /* psが失敗する端末では子孫観測なしで続行(named session観測は生きている) */ }
375
+ }
376
+ }
377
+ observation.status = combineSeatLamp(observation.status, job)
378
+ }
379
+ }
273
380
  const changed = !prev || prev.status !== observation.status
274
381
  || prev.busySince !== observation.busySince
275
382
  // token表示は実行中に細かく増える。1k未満の差で8秒ごとにPOSTせず、表示精度に合う粒度で送る。
@@ -280,6 +387,7 @@ async function tick() {
280
387
  await send(name, observation, observedAt)
281
388
  last.set(name, { ...observation, at: now })
282
389
  sent++
390
+ if ((observation.status === 'busy' || observation.status === 'blocked') && prev?.status !== 'busy' && prev?.status !== 'blocked') busyStartedAt.set(name, now)
283
391
  if (prev?.status === 'busy' && observation.status === 'idle') await nudgeIfDropped(name, prev.busySince)
284
392
  if (changed) console.error(`seat-status-bridge: ${name} → ${observation.status}${prev ? `(${prev.status} から)` : ''}`)
285
393
  } catch (e) {
@@ -289,6 +397,7 @@ async function tick() {
289
397
  }
290
398
  // 0件でも0件と言う(条件付きログにしない。沈黙する失敗を作らない・決定58)
291
399
  console.error(`seat-status-bridge: ${members.length} 席を見て ${sent} 件送った(tmux席を持たず観測対象外: ${skipped})`)
400
+ await patrolClaims()
292
401
  return { attempted: sent + failed, failed }
293
402
  }
294
403
 
@@ -280,3 +280,75 @@ export function parsePaneTokenHint(pane) {
280
280
  }
281
281
  return latest
282
282
  }
283
+
284
+ // ---- claim巡回番犬の純関数(seat-status-bridge.mjsが使う。testはここからimportする)----
285
+ export const STOP_DECLARATION = /\[待機\]|\[監査提出\]|待機します|散会/u
286
+
287
+ export function patrolTargets({ activeTasks, messages, statusOf, lastBusyStartAt, now, lastNag, nagIntervalMs }) {
288
+ const owner = new Map() // task_id -> 最新のclaim発言者
289
+ for (const m of messages) {
290
+ const match = /^\[claim\]\s+(\S+)/u.exec(m.body ?? '')
291
+ if (match) owner.set(match[1], m.from)
292
+ }
293
+ const targets = []
294
+ for (const task of activeTasks) {
295
+ const seat = owner.get(task)
296
+ if (!seat || statusOf(seat) !== 'idle') continue
297
+ // 待機の判定は観測ベース(2026-08-25 オーナー裁定): 「最後のターン終了より後に待機宣言があるか」
298
+ // だけを見る。宛先による失効・TTLのようなメッセージ解釈の条件は持たない。
299
+ // ターンを終えたのに宣言せず黙った席(実被弾: 起こされて→落として→古い宣言の裏で就寝)は
300
+ // 宣言がターン終了より古いので確実に引っかかる。
301
+ let declTs = 0
302
+ for (const m of messages) {
303
+ if (m.from === seat && STOP_DECLARATION.test(m.body ?? '')) declTs = Date.parse(m.ts ?? '') || declTs
304
+ }
305
+ const busyStart = lastBusyStartAt(seat)
306
+ // 錨はターン「開始」(2026-08-25 実被弾 #120: 終了錨だと「宣言→数秒後にターン終了」の正常系が
307
+ // 常に古い判定を食らい誤爆する)。「最後のターンの開始より後に宣言した」=そのターンが宣言を
308
+ // 残したこと。起床ターンが無宣言で終わると、手持ちの宣言は前ターン開始より古くなり必ず捕まる。
309
+ // bridge再起動等でターン履歴が無い席は、宣言の存在だけで正当とみなす(誤爆しない安全側)
310
+ const declared = busyStart == null ? declTs > 0 : declTs >= busyStart - 10_000
311
+ if (declared) continue
312
+ if (now - (lastNag.get(seat) ?? 0) < nagIntervalMs) continue
313
+ if (!targets.some(t => t.seat === seat)) targets.push({ seat, task })
314
+ }
315
+ return targets
316
+ }
317
+
318
+
319
+ // ---- ランプ合成(2026-08-25 オーナー裁定: ドットは1個、席と預け仕事を合成する)----
320
+ // 点滅(busy) = 席のターン実行中 または jobセッションの画面が動いている
321
+ // 点灯(idle) = どのプロセスかは生きているが、何も動いていない
322
+ // 白抜き(dead/unknown相当) = 何も無い
323
+ // blockedは「存在するが承認待ちで詰まっている」の特殊表示としてそのまま通す。
324
+ export function combineSeatLamp(paneStatus, job) {
325
+ if (job?.active) return 'busy'
326
+ if (paneStatus === 'busy' || paneStatus === 'blocked') return paneStatus
327
+ if (paneStatus === 'idle' || job?.alive) return 'idle'
328
+ return paneStatus // 'dead' 等はそのまま
329
+ }
330
+
331
+ // paneプロセスの子孫に実働(CPU消費)が居るかの判定素材。psの `pid ppid pcpu` 出力を受け取り、
332
+ // root配下の子孫(root自身は除く)に pcpu >= 閾値 が居れば true。席がCodex内蔵background terminal等、
333
+ // tmuxセッションを作らずに走らせた預け仕事を、プロセスツリーの実観測で拾う(2026-08-25 オーナー裁定
334
+ //「子プロセスも見る」)。nohup等でツリーから切り離された仕事はここでは見えない——それは
335
+ // peer-<name>-job* セッション慣例の側が受け持つ(二本立て)。
336
+ export function hasActiveDescendant(psRows, rootPid, { minCpu = 1.0 } = {}) {
337
+ const children = new Map()
338
+ const cpu = new Map()
339
+ for (const row of psRows) {
340
+ const m = /^\s*(\d+)\s+(\d+)\s+([\d.]+)/.exec(row)
341
+ if (!m) continue
342
+ const [pid, ppid, pcpu] = [Number(m[1]), Number(m[2]), Number(m[3])]
343
+ if (!children.has(ppid)) children.set(ppid, [])
344
+ children.get(ppid).push(pid)
345
+ cpu.set(pid, pcpu)
346
+ }
347
+ const queue = [...(children.get(rootPid) ?? [])]
348
+ while (queue.length) {
349
+ const pid = queue.pop()
350
+ if ((cpu.get(pid) ?? 0) >= minCpu) return true
351
+ queue.push(...(children.get(pid) ?? []))
352
+ }
353
+ return false
354
+ }
File without changes
File without changes
@@ -43,6 +43,20 @@ did() { echo "teardown: [実施] $*"; }
43
43
  skip() { echo "teardown: [スキップ] $*"; }
44
44
  miss() { echo "teardown: [未実施] $*" >&2; fail=1; }
45
45
  yes_() { [ "$1" = "True" ] || [ "$1" = "true" ]; }
46
+ platform=$(node -p 'process.platform')
47
+ normalize_read_line() {
48
+ if [ "$platform" = win32 ]; then
49
+ printf '%s' "$1" | node "$script_dir/platform/windows/normalize-read-line.mjs"
50
+ else
51
+ printf '%s' "$1"
52
+ fi
53
+ }
54
+
55
+ mcp_remove_action=not-managed
56
+ if yes_ "$added_mcp"; then
57
+ mcp_remove_result=$(node "$script_dir/remove-managed-room-mcp.mjs" "$proj") || exit "$?"
58
+ mcp_remove_action=$(python3 -c 'import json,sys; print(json.loads(sys.argv[1])["action"])' "$mcp_remove_result")
59
+ fi
46
60
 
47
61
  echo "teardown: mode=${mode}(archive=ログとstoreを残す/purge=痕跡ゼロ)"
48
62
 
@@ -188,6 +202,7 @@ elif [ -z "$run_refs" ]; then
188
202
  skip "run landing(active runなし)"
189
203
  else
190
204
  while IFS= read -r run_ref; do
205
+ run_ref=$(normalize_read_line "$run_ref")
191
206
  [ -n "$run_ref" ] || continue
192
207
  if landing_report=$(cd "$proj" && "$lattice_cli" run landing --run "$run_ref" 2>&1); then
193
208
  unlanded_count=""
@@ -316,8 +331,7 @@ rm -rf "$proj/.team"
316
331
  did ".team/ 削除"
317
332
 
318
333
  if yes_ "$added_mcp"; then
319
- rm -f "$proj/.mcp.json"
320
- did ".mcp.json 削除"
334
+ did ".mcp.json のPeertable room MCP撤去(${mcp_remove_action})"
321
335
  else
322
336
  skip ".mcp.json(setup が作っていない)"
323
337
  fi