peertable 0.6.0 → 0.7.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.
package/README.md CHANGED
@@ -131,7 +131,7 @@ claude --dangerously-load-development-channels server:room
131
131
 
132
132
  **Do not pass it via `--mcp-config`.** Channels do not resolve MCP servers given that way: the banner prints `server:room · no MCP server configured with that name` and room delivery goes silent while everything else looks fine (measured on Claude Code v2.1.226; decision 44 in [docs/plan.md](docs/plan.md)). The skill handles this for you and reverts the file on teardown.
133
133
 
134
- The member gets four tools — `post`, `read_unread`, `read_log`, `members` — and a channel that wakes it whenever teammates address it. (`--dangerously-load-development-channels` is required while channels are in research preview; custom channels aren't on the allowlist yet.)
134
+ The member gets five tools — `post`, `read_unread`, `read_log`, `members`, `delivery_status` — and a channel that wakes it whenever teammates address it. `post` returns `room_saved` plus a per-recipient `delivery` breakdown (delivered / pending / seat_unavailable / bridge_unavailable / failed): saving to the room is not the same fact as reaching a seat's TUI, and `delivered` is only ever written by the wakeup bridge after the injection actually lands. `members` includes each seat's server-computed effective status (fresh / stale / bridge down / auth failed) and bridge health. (`--dangerously-load-development-channels` is required while channels are in research preview; custom channels aren't on the allowlist yet.)
135
135
 
136
136
  For Codex, the skill instead installs its owned room MCP block in the project's `.codex/config.toml`; `.mcp.json` alone is not a Codex configuration path. Grok Build reads the project-root `.mcp.json`. Aiterm's `grok_agent` supplies its model, reasoning effort, and seat-specific environment; Codex and Grok receive arrivals through the same wake-up bridge. The bridge sends Codex immediately (mid-turn steering). Grok's TUI queues mid-turn paste as the *next* user turn, so the bridge waits until that seat is idle. The parent hat is never a wake-up target — Claude and Grok parents use `parent-watch --follow`; Codex parents poll.
137
137
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "peertable",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "A round table of peer agents. No orchestrator at the head. Turn Claude Code, Codex, and Grok sessions into a team of equal, long-lived peers.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/room/client.mjs CHANGED
@@ -13,7 +13,7 @@ import { findModelsDoc, resolveSeatIdentity } from '../skill/scripts/resolve-sea
13
13
 
14
14
  // client.mjs 側のハードコード版数。package.json の version と一致していることを
15
15
  // diagnostics の version_consistency が見る(2 つの版数源の drift 検出。決定45)
16
- const MCP_VERSION = '0.6.0'
16
+ const MCP_VERSION = '0.7.0'
17
17
  const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
18
18
 
19
19
  const USAGE = `usage:
@@ -73,6 +73,7 @@ const mcp = new Server(
73
73
  'メンバー一覧の名前・役割・設定・使命を見て連携せよ。members と read_unread 先頭に同じ欄がある。' +
74
74
  '<channel source="room"> の通知は「新着あり」の合図であり、本文は read_unread ツールで読む。' +
75
75
  '発言は post ツールを使う。to: "all" はroom全体、メンバー名はDM、配列は複数人宛である。' +
76
+ 'post 応答の room_saved は room 保存だけの事実で、配達成功ではない。相手に届いた前提で進む前に delivery が delivered であることを確認せよ。' +
76
77
  `次にやる仕事があるターンを終える直前に post(to: "${ME}", message: "[次の行動] ...") を1回送れ。` +
77
78
  'この自己DMは席の TUI へ次ターンの入力として入る。仕事があるのに出さないと席は止まる。' +
78
79
  '手番が無く待機に入るときは自己DMを出すな。親へ [待機] を一度だけ送り沈黙せよ。空の終了通知は使うな。',
@@ -105,11 +106,24 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
105
106
  },
106
107
  { name: 'read_unread', description: 'room全体宛と自分宛の未読メッセージを読む。読んだ位置は記憶される', inputSchema: { type: 'object', properties: {} } },
107
108
  { name: 'read_log', description: 'room ログの直近 count 件を読む(既定 50。全宛先を含む)', inputSchema: { type: 'object', properties: { count: { type: 'number' } } } },
108
- { name: 'members', description: 'room に居るメンバーの一覧(名前・役割・設定・使命)', inputSchema: { type: 'object', properties: {} } },
109
+ { name: 'members', description: 'room に居るメンバーの一覧(名前・役割・設定・使命・実効稼働状態)と bridge 健全性。状態が unknown の席と bridge 障害はここで分かる', inputSchema: { type: 'object', properties: {} } },
110
+ { name: 'delivery_status', description: '発言 seq の宛先別配達状態を照会する。delivered / pending / seat_unavailable / bridge_unavailable / failed。room_saved は配達成功ではない', inputSchema: { type: 'object', properties: { seq: { type: 'number' } }, required: ['seq'] } },
109
111
  ],
110
112
  }))
111
113
 
112
114
  const fmt = m => `[${m.seq}] ${m.from} → ${Array.isArray(m.to_names) ? m.to_names.join(', ') : m.to} (${m.ts}): ${m.body}`
115
+ const elapsedText = ms => {
116
+ if (!Number.isFinite(ms)) return null
117
+ const min = Math.floor(ms / 60000)
118
+ return min < 1 ? `${Math.floor(ms / 1000)}s前` : min >= 60 ? `${Math.floor(min / 60)}h${min % 60}m前` : `${min}m前`
119
+ }
120
+ // 実効稼働状態は server 生成の欄だけを読む(決定101)。MCP 側で鮮度判定を持たない
121
+ const statusText = (m) => {
122
+ if (m.status_effective === undefined) return '状態=不明(旧server・実効状態なし)'
123
+ const age = elapsedText(m.status_age_ms)
124
+ if (m.status_reason === 'fresh') return `状態=${m.status_effective}(最終報告 ${age})`
125
+ return `状態=${m.status_effective}(理由=${m.status_reason}${age ? `・最終報告 ${age}` : '・報告なし'})`
126
+ }
113
127
  const memberLine = (m) => {
114
128
  const rolesText = Array.isArray(m.roles) && m.roles.length ? m.roles.join('・') : ''
115
129
  const settingsText = [m.model, m.effort].filter(Boolean).join('×')
@@ -117,12 +131,28 @@ const memberLine = (m) => {
117
131
  if (rolesText) parts.push(`役割=${rolesText}`)
118
132
  if (settingsText) parts.push(`設定=${settingsText}`)
119
133
  if (m.mission) parts.push(`使命=${m.mission}`)
134
+ parts.push(statusText(m))
120
135
  return parts.join(' ')
121
136
  }
137
+ const bridgeText = (bridges) => {
138
+ if (!bridges) return '⚠ bridge健全性: 不明(旧serverはbridge台帳を持たない)'
139
+ const label = { seat_status: '稼働状態bridge', wakeup: '配達bridge' }
140
+ const lines = ['seat_status', 'wakeup'].map((kind) => {
141
+ const state = bridges[kind]?.state ?? 'unknown'
142
+ return `${label[kind]}=${state}`
143
+ })
144
+ const broken = ['seat_status', 'wakeup'].some(k => bridges[k]?.state !== 'up')
145
+ return `${broken ? '⚠ ' : ''}bridge: ${lines.join(' / ')}`
146
+ }
122
147
  const rosterText = async () => {
123
- const { members } = await (await fetch(api('members'))).json()
124
- if (!members?.length) return '席: (まだ誰も居ない)'
125
- return `席: ${members.map(memberLine).join(' / ')}`
148
+ const { members, bridges } = await (await fetch(api('members'))).json()
149
+ if (!members?.length) return `席: (まだ誰も居ない)\n${bridgeText(bridges)}`
150
+ return `席: ${members.map(memberLine).join(' / ')}\n${bridgeText(bridges)}`
151
+ }
152
+ const deliveryText = (delivery) => {
153
+ const entries = Object.entries(delivery ?? {})
154
+ if (!entries.length) return 'delivery: (TUI配達対象の宛先なし)'
155
+ return `delivery: ${entries.map(([name, d]) => `${name}=${d.state}${d.reason ? `(${d.reason})` : ''}`).join(' / ')}`
126
156
  }
127
157
 
128
158
  mcp.setRequestHandler(CallToolRequestSchema, async req => {
@@ -134,7 +164,15 @@ mcp.setRequestHandler(CallToolRequestSchema, async req => {
134
164
  if (!r.ok) return { isError: true, ...text(`送信失敗: ${JSON.stringify(msg)}`) }
135
165
  // cursor は触らない。自分の発言は relevant で除外されるので進める必要が無く、
136
166
  // ここで進めると post より前に届いた未読を読まないまま既読にしてしまう(0.2.1 で修正)
137
- return text(`sent [${msg.seq}]`)
167
+ // 保存と配達は別の事実(決定102)。sent の一語で両者を混ぜず、宛先別の配達状態を返す
168
+ if (msg.room_saved !== true) return text(`sent [${msg.seq}](旧server: 配達状態は取得できない。room保存を配達成功と扱うな)`)
169
+ return text(`room_saved [${msg.seq}](room保存のみ。配達成功ではない)\n${deliveryText(msg.delivery)}\n配達の確定は delivery_status ツール(seq=${msg.seq})で delivered を確認すること`)
170
+ }
171
+ case 'delivery_status': {
172
+ const r = await fetch(api(`deliveries?seq=${Number(args.seq)}`))
173
+ const body = await r.json()
174
+ if (!r.ok) return { isError: true, ...text(`照会失敗: ${JSON.stringify(body)}`) }
175
+ return text(`seq ${body.seq}: room_saved=${body.room_saved}\n${deliveryText(body.delivery)}`)
138
176
  }
139
177
  case 'read_unread': {
140
178
  const { messages } = await (await fetch(api(`messages?since=${cursor}`))).json()
@@ -148,8 +186,8 @@ mcp.setRequestHandler(CallToolRequestSchema, async req => {
148
186
  return text(messages.slice(-(args.count ?? 50)).map(fmt).join('\n') || '(ログなし)')
149
187
  }
150
188
  case 'members': {
151
- const { members } = await (await fetch(api('members'))).json()
152
- return text(members.map(memberLine).join('\n') || '(誰も居ない)')
189
+ const { members, bridges } = await (await fetch(api('members'))).json()
190
+ return text(`${members.map(memberLine).join('\n') || '(誰も居ない)'}\n${bridgeText(bridges)}`)
153
191
  }
154
192
  default:
155
193
  throw new Error(`unknown tool: ${req.params.name}`)
@@ -357,6 +395,9 @@ async function runDiagnostics(asJson) {
357
395
  'scripts/wakeup-bridge.mjs',
358
396
  'scripts/wakeup-delivery.mjs',
359
397
  'scripts/seat-status-bridge.mjs',
398
+ // 円卓開始ゲートと既存 room の正規 resume 入口(決定104・105)。欠けると親の依頼確定と再稼働が手作業へ戻る
399
+ 'scripts/kickoff-gate.mjs',
400
+ 'scripts/resume.sh',
360
401
  // teardown の archive(=解散・既定)が呼ぶ。欠けるとログの写しが取れない
361
402
  'scripts/archive-room-log.py',
362
403
  'templates/gen-plan.mjs',
package/room/server.mjs CHANGED
@@ -53,6 +53,20 @@ db.exec(`
53
53
  )
54
54
  `)
55
55
 
56
+ // bridge 台帳と配送 receipt 台帳(決定102・103)。bridge の心拍と wakeup-bridge の TUI 投入成立だけが書く。
57
+ db.exec(`
58
+ CREATE TABLE IF NOT EXISTS bridges (
59
+ room TEXT NOT NULL, kind TEXT NOT NULL,
60
+ pid INTEGER, state TEXT, detail TEXT, beat_at TEXT NOT NULL,
61
+ PRIMARY KEY (room, kind)
62
+ );
63
+ CREATE TABLE IF NOT EXISTS deliveries (
64
+ room TEXT NOT NULL, seq INTEGER NOT NULL, recipient TEXT NOT NULL,
65
+ result TEXT NOT NULL, reason TEXT, at TEXT NOT NULL,
66
+ PRIMARY KEY (room, seq, recipient)
67
+ )
68
+ `)
69
+
56
70
  // 既存 DB(旧 vendor 列世代)は一度だけ harness 列を足して値を引き継ぐ。旧列は rollback 用に残す。
57
71
  {
58
72
  const columns = db.prepare('PRAGMA table_info(members)').all().map((c) => c.name)
@@ -156,6 +170,104 @@ const recipientError = (code, message) => ({
156
170
  schema: 'peertable.error.v1', code, message,
157
171
  })
158
172
 
173
+ // ---- 実効状態の生成(決定101〜103)------------------------------------------------
174
+ // 実効稼働状態・bridge 健全性の判定は server がここで一度だけ行い、Web UI / MCP / script は
175
+ // 生成された欄を読むだけにする。閲覧面ごとの独自判定は同じ誤認(保存=配達、静的一覧=稼働)を再生産する。
176
+ // 既定 90 秒(bridge 心拍 30 秒の3倍)。env は repro が時間を縮めて実測するための入口(experiments/ 参照)
177
+ const STATUS_STALE_MS = Number(process.env.PEERTABLE_STATUS_STALE_MS ?? 90_000)
178
+ const AUTH_FAIL_FRESH_MS = 600_000 // 403 観測の表示保持。以後の正常心拍で即座に消える
179
+
180
+ // 書込 403 の観測記録。403 を返した server だけが「bridge が認証で書けていない」を確実に知る
181
+ // (bridge 側は書けないので自己申告できない)。`${room}/${kind}` -> 最終観測 epoch ms。
182
+ const authFailures = new Map()
183
+
184
+ // 403 になった書込がどの bridge のものかを request の形から分類する。分類できない 403 は記録しない
185
+ // (member 以外の一般書込の 403 を bridge 障害として表示しない)。
186
+ function classifyDeniedWrite(rest, body) {
187
+ if (rest === 'bridges') { try { return JSON.parse(body).kind ?? null } catch { return null } }
188
+ if (rest === 'deliveries') return 'wakeup'
189
+ if (rest === 'members') {
190
+ try { return JSON.parse(body).usage_source === 'pane_status' ? 'seat_status' : null } catch { return null }
191
+ }
192
+ return null
193
+ }
194
+
195
+ const BRIDGE_KINDS = ['seat_status', 'wakeup']
196
+ // 表示語彙はオーナー報告の障害名(status_bridge_down / wakeup_bridge_down / bridge_auth_failed)
197
+ const BRIDGE_LABEL = { seat_status: 'status', wakeup: 'wakeup' }
198
+ function bridgeHealth(roomName, now = Date.now()) {
199
+ const out = {}
200
+ for (const kind of BRIDGE_KINDS) {
201
+ const row = db.prepare('SELECT * FROM bridges WHERE room = ? AND kind = ?').get(roomName, kind)
202
+ const beat = row ? Date.parse(row.beat_at) : null
203
+ const authAt = authFailures.get(`${roomName}/${kind}`)
204
+ if (authAt !== undefined && now - authAt < AUTH_FAIL_FRESH_MS && (beat === null || authAt > beat)) {
205
+ out[kind] = { state: 'bridge_auth_failed', beat_at: row?.beat_at ?? null }
206
+ continue
207
+ }
208
+ if (!row) { out[kind] = { state: `${BRIDGE_LABEL[kind]}_bridge_unreported`, beat_at: null }; continue }
209
+ out[kind] = now - beat < STATUS_STALE_MS
210
+ ? { state: 'up', beat_at: row.beat_at, pid: row.pid }
211
+ : { state: `${BRIDGE_LABEL[kind]}_bridge_down`, beat_at: row.beat_at }
212
+ }
213
+ return out
214
+ }
215
+
216
+ // member 1件の実効稼働状態。報告が新しい時だけ status を採り、途絶・未報告は unknown へ落として
217
+ // 理由(bridge 停止 / 認証失敗 / 心拍途絶)を必ず併記する——状態点が黙って消えるだけの面を作らない。
218
+ function effectiveStatus(member, bridges, now = Date.now()) {
219
+ const statusBridge = bridges.seat_status.state
220
+ if (!member.status_at) {
221
+ return {
222
+ status_effective: 'unknown',
223
+ status_reason: statusBridge === 'up' ? 'status_unreported' : statusBridge,
224
+ status_age_ms: null,
225
+ }
226
+ }
227
+ const age = now - Date.parse(member.status_at)
228
+ if (age < STATUS_STALE_MS) return { status_effective: member.status, status_reason: 'fresh', status_age_ms: age }
229
+ return {
230
+ status_effective: 'unknown',
231
+ status_reason: statusBridge === 'up' ? 'status_heartbeat_stale' : statusBridge,
232
+ status_age_ms: age,
233
+ }
234
+ }
235
+
236
+ // ---- 配送状態の導出(決定102)-----------------------------------------------------
237
+ // receipt(wakeup-bridge の実投入記録)が正。無い宛先は member 台帳と bridge 台帳から
238
+ // pending / seat_unavailable / bridge_unavailable を導出する。room_saved だけでは配達と言わない。
239
+ const isParentMember = member => member?.delivery?.kind === 'parent_watch'
240
+ // wakeup-bridge の配送対象判定(skill/scripts/wakeup-delivery.mjs の isWakeupBridgeTarget と同じ規則)
241
+ function isTuiDeliveryTarget(member) {
242
+ if (!member || isParentMember(member)) return false
243
+ const hasPane = typeof member.observe?.tmux_target === 'string' && member.observe.tmux_target.length > 0
244
+ const harness = member.harness ?? member.vendor
245
+ return hasPane || harness === 'codex' || harness === 'grok'
246
+ }
247
+
248
+ function deliveryPlanFor(roomName, msg, bridges) {
249
+ const members = new Map(listMembers(roomName).map(m => [m.name, m]))
250
+ const recipients = Array.isArray(msg.to_names) ? msg.to_names
251
+ : msg.to === 'all' ? [...members.keys()].filter(n => n !== msg.from)
252
+ : typeof msg.to === 'string' && msg.to.length > 0 ? [msg.to] : []
253
+ const out = {}
254
+ for (const name of recipients) {
255
+ const receipt = db.prepare('SELECT * FROM deliveries WHERE room = ? AND seq = ? AND recipient = ?')
256
+ .get(roomName, msg.seq, name)
257
+ if (receipt) {
258
+ out[name] = { state: receipt.result, ...(receipt.reason ? { reason: receipt.reason } : {}), at: receipt.at }
259
+ continue
260
+ }
261
+ const member = members.get(name)
262
+ if (!member) { out[name] = { state: 'seat_unavailable', reason: 'member_not_found' }; continue }
263
+ if (isParentMember(member)) { out[name] = { state: 'pending', reason: 'parent_watch経由' }; continue }
264
+ if (!isTuiDeliveryTarget(member)) { out[name] = { state: 'seat_unavailable', reason: 'no_delivery_route' }; continue }
265
+ if (bridges.wakeup.state !== 'up') { out[name] = { state: 'bridge_unavailable', reason: bridges.wakeup.state }; continue }
266
+ out[name] = { state: 'pending' }
267
+ }
268
+ return out
269
+ }
270
+
159
271
  const WAITING_WORDS = ['待機する', '待機します', '待機。']
160
272
 
161
273
  function parentName(room) {
@@ -236,11 +348,30 @@ http.createServer(async (req, res) => {
236
348
  if (req.method === 'GET' && rest === 'messages')
237
349
  return json(res, 200, { messages: readMessages(room, Number(url.searchParams.get('since') ?? 0)) }, CORS)
238
350
 
239
- if (req.method === 'GET' && rest === 'members')
351
+ if (req.method === 'GET' && rest === 'members') {
352
+ const now = Date.now()
353
+ const bridges = bridgeHealth(room.name, now)
354
+ return json(res, 200, {
355
+ members: listMembers(room.name).map(m => ({ ...m, ...effectiveStatus(m, bridges, now) })),
356
+ bridges,
357
+ capabilities: {
358
+ member_observation_v1: true, member_ledger_v1: true,
359
+ effective_status_v1: true, delivery_receipt_v1: true,
360
+ },
361
+ }, CORS)
362
+ }
363
+
364
+ // 配送状態の照会(決定102)。receipt が正・無い宛先は台帳から導出
365
+ if (req.method === 'GET' && rest === 'deliveries') {
366
+ const seqParam = Number(url.searchParams.get('seq'))
367
+ if (!Number.isSafeInteger(seqParam) || seqParam <= 0) return json(res, 400, { error: 'seq_required' }, CORS)
368
+ const msg = readMessages(room, seqParam - 1).find(m => m.seq === seqParam)
369
+ if (!msg) return json(res, 404, { error: 'message_not_found' }, CORS)
240
370
  return json(res, 200, {
241
- members: listMembers(room.name),
242
- capabilities: { member_observation_v1: true, member_ledger_v1: true },
371
+ schema: 'peertable.delivery.v1', seq: seqParam, room_saved: true,
372
+ delivery: deliveryPlanFor(room.name, msg, bridgeHealth(room.name)),
243
373
  }, CORS)
374
+ }
244
375
 
245
376
  // 単独 member の読み出し。席の attach 入力・診断はここから取る(席fileは廃止・台帳が唯一の正)
246
377
  if (req.method === 'GET' && seg[2] === 'members' && seg[3]) {
@@ -267,8 +398,13 @@ http.createServer(async (req, res) => {
267
398
 
268
399
  // 書込系。TOKEN 設定時は一致を要求する(外部公開設置のための最小ゲート)
269
400
  const body = await readBody(req)
270
- if (TOKEN !== null && (req.headers['x-peertable-token'] ?? url.searchParams.get('token')) !== TOKEN)
401
+ if (TOKEN !== null && (req.headers['x-peertable-token'] ?? url.searchParams.get('token')) !== TOKEN) {
402
+ // bridge 由来と分類できる 403 は観測として残し、実効表示 bridge_auth_failed の根拠にする(決定103)。
403
+ // 403 を返す server だけが「bridge が認証で書けていない」を確実に知る——状態点が黙って消える形にしない
404
+ const deniedKind = classifyDeniedWrite(rest, body)
405
+ if (deniedKind !== null && BRIDGE_KINDS.includes(deniedKind)) authFailures.set(`${room.name}/${deniedKind}`, Date.now())
271
406
  return json(res, 403, { error: 'token_required' })
407
+ }
272
408
 
273
409
  if (req.method === 'POST' && rest === 'messages') {
274
410
  const { from, to, to_names: toNames, body: text } = JSON.parse(body)
@@ -279,7 +415,31 @@ http.createServer(async (req, res) => {
279
415
  const waiting = WAITING_WORDS.some(word => text.includes(word))
280
416
  const audience = normalizeAudience(waiting ? parentName(room) : to, waiting ? null : toNames)
281
417
  if (audience.error) return json(res, 400, audience.error)
282
- return json(res, 200, post(room, from, audience.to, text, audience.to_names))
418
+ const saved = post(room, from, audience.to, text, audience.to_names)
419
+ // 保存と配達は別の事実(決定102)。応答は room_saved と宛先別 delivery の二段で返し、
420
+ // delivered は wakeup-bridge の投入成立 receipt だけが作る。sent の一語で両者を混ぜない
421
+ return json(res, 200, { ...saved, room_saved: true, delivery: deliveryPlanFor(room.name, saved, bridgeHealth(room.name)) })
422
+ }
423
+ // bridge 心拍(決定103)。seat-status / wakeup が 30 秒ごとに送る。正常心拍は 403 観測を消す
424
+ if (req.method === 'POST' && rest === 'bridges') {
425
+ const { kind, pid, state, detail } = JSON.parse(body)
426
+ if (typeof kind !== 'string' || kind.length === 0) return json(res, 400, { error: 'kind_required' })
427
+ db.prepare(`INSERT OR REPLACE INTO bridges (room, kind, pid, state, detail, beat_at)
428
+ VALUES (?, ?, ?, ?, ?, ?)`).run(room.name, kind, pid ?? null, state ?? 'running', detail ?? null, new Date().toISOString())
429
+ authFailures.delete(`${room.name}/${kind}`)
430
+ return json(res, 200, { ok: true })
431
+ }
432
+ // 配送 receipt(決定102)。書き手は wakeup-bridge だけ。同じ (seq, recipient) は最新で上書き
433
+ // (seat_unavailable → 再試行成功 delivered の遷移を残すため)
434
+ if (req.method === 'POST' && rest === 'deliveries') {
435
+ const { seq: recSeq, recipient, result, reason } = JSON.parse(body)
436
+ if (!Number.isSafeInteger(recSeq) || recSeq <= 0) return json(res, 400, { error: 'seq_required' })
437
+ if (typeof recipient !== 'string' || recipient.length === 0) return json(res, 400, { error: 'recipient_required' })
438
+ if (!['delivered', 'pending', 'seat_unavailable', 'bridge_unavailable', 'failed'].includes(result))
439
+ return json(res, 400, { error: 'result_invalid' })
440
+ db.prepare(`INSERT OR REPLACE INTO deliveries (room, seq, recipient, result, reason, at)
441
+ VALUES (?, ?, ?, ?, ?, ?)`).run(room.name, recSeq, recipient, result, reason ?? null, new Date().toISOString())
442
+ return json(res, 200, { ok: true })
283
443
  }
284
444
  if (req.method === 'POST' && rest === 'members') {
285
445
  const { name, ...meta } = JSON.parse(body)
@@ -310,6 +470,8 @@ http.createServer(async (req, res) => {
310
470
  for (const s of room.streams) s.end()
311
471
  rooms.delete(room.name); rmSync(room.dir, { recursive: true, force: true })
312
472
  db.prepare('DELETE FROM members WHERE room = ?').run(room.name)
473
+ db.prepare('DELETE FROM bridges WHERE room = ?').run(room.name)
474
+ db.prepare('DELETE FROM deliveries WHERE room = ?').run(room.name)
313
475
  return json(res, 200, { ok: true })
314
476
  }
315
477
  return json(res, 404, { error: 'not_found' })
@@ -353,6 +515,8 @@ const UI = room => `<!doctype html><html lang="ja"><head><meta charset="utf-8"><
353
515
  .top{position:sticky;top:0;z-index:2;background:var(--bg);border-bottom:1px solid var(--line);padding:12px 16px 0}
354
516
  .top>div{max-width:760px;margin:0 auto}
355
517
  .members{display:flex;gap:6px;overflow-x:auto;padding:10px 0;scrollbar-width:thin}
518
+ .bridgewarn{color:var(--busy);font-size:12px;font-weight:650;padding:0 0 8px}
519
+ .bridgewarn:empty{display:none}
356
520
  .chip.has-meta{cursor:pointer}
357
521
  /* 稼働状態の点。報告が途絶えたら unknown(中空)へ落として、古い状態を出し続けない */
358
522
  .chip .nm{display:inline-flex;align-items:center;gap:5px}
@@ -419,7 +583,7 @@ const UI = room => `<!doctype html><html lang="ja"><head><meta charset="utf-8"><
419
583
  .to-bottom[hidden]{display:none}
420
584
  .sys .body{padding:2px 12px;border-radius:999px;background:var(--surface);border:1px solid var(--line)}
421
585
  </style></head><body>
422
- <header class="top"><div class="brand">${MARK}${esc(room)} <small>· Peertable</small></div><div class="members" id="members"></div></header>
586
+ <header class="top"><div class="brand">${MARK}${esc(room)} <small>· Peertable</small></div><div class="members" id="members"></div><div class="bridgewarn" id="bridges"></div></header>
423
587
  <main class="log" id="log"></main>
424
588
  <button class="to-bottom" id="to-bottom" type="button" hidden aria-label="最新の発言へ" title="最新の発言へ">↓</button>
425
589
  <script>
@@ -552,8 +716,21 @@ function render(m){
552
716
  body.appendChild(meta);body.appendChild(bub);body.appendChild(seq())
553
717
  d.appendChild(body);logEl.appendChild(d);last=m;return d
554
718
  }
719
+ // bridge 障害の帯。unreported(bridge を立てない部屋・archive 部屋)は常時警告で汚さないため出さない
720
+ const bridgesEl=document.getElementById('bridges')
721
+ function renderBridges(bridges){
722
+ const label={seat_status:'稼働状態bridge',wakeup:'配達bridge'}
723
+ const warn=[]
724
+ for(const k of ['seat_status','wakeup']){
725
+ const s=bridges&&bridges[k]&&bridges[k].state
726
+ if(!s||s==='up'||s.endsWith('_unreported'))continue
727
+ warn.push(label[k]+':'+(s==='bridge_auth_failed'?'認証失敗(403・bridge_auth_failed)':'心拍途絶(停止)'))
728
+ }
729
+ bridgesEl.textContent=warn.length?'⚠ '+warn.join(' / '):''
730
+ }
555
731
  async function refreshMembers(){
556
732
  const r=await(await fetch(api('members'))).json()
733
+ renderBridges(r.bridges)
557
734
  syncAvatarAssignments(r.members.map(m=>m.name))
558
735
  membersEl.textContent=''
559
736
  if(!r.members.length){membersEl.appendChild(el('span','empty','(まだ誰も居ない)'));return}
@@ -564,12 +741,12 @@ async function refreshMembers(){
564
741
  const rolesText=Array.isArray(m.roles)&&m.roles.length?m.roles.join('・'):''
565
742
  const settingsText=[m.model,m.effort].filter(Boolean).join('×')
566
743
  const meta=[rolesText,settingsText,m.mission].filter(Boolean)
567
- // 稼働状態は**報告が新しい時だけ**採る。途絶えたら unknown へ落とす——古い状態を出し続けるのが
568
- // いちばん悪い(動いていない席を「動いている」と見せる)。閾値は bridge の心拍30秒の3倍
569
- const age=m.status_at?Date.now()-Date.parse(m.status_at):Infinity
570
- const st=(m.status&&age<STATUS_STALE_MS)?m.status:(m.status?'unknown':null)
744
+ // 稼働状態は server 生成の実効状態(status_effective / status_reason)だけを読む(決定101)。
745
+ // 閲覧面ごとに独自の鮮度判定を持つと、MCP Web UI で違う判定になり誤認が再発する
746
+ const st=m.status_effective??null
571
747
  if(st)c.classList.add('is-'+st)
572
- meta.push(st?'状態 '+({busy:'作業中',idle:'待機',dead:'停止',blocked:'承認待ち',unknown:'不明(報告が途絶えている)'}[st]??st):'状態 未報告(seat-status bridge が動いていない)')
748
+ const reason={status_unreported:'未報告(seat-status bridge が動いていない)',status_heartbeat_stale:'状態heartbeatが途絶えている',status_bridge_down:'状態bridgeが停止(status_bridge_down)',status_bridge_unreported:'状態bridgeが未登録',bridge_auth_failed:'bridge認証失敗(403)'}[m.status_reason]
749
+ meta.push(st?'状態 '+({busy:'作業中',idle:'待機',dead:'停止',blocked:'承認待ち',unknown:'不明'}[st]??st)+(reason?'('+reason+')':''):'状態 未取得(旧serverは実効状態を返さない)')
573
750
  const usage=[]
574
751
  const busyAge=m.busy_since?Date.now()-Date.parse(m.busy_since):NaN
575
752
  if((st==='busy'||st==='blocked')&&Number.isFinite(busyAge)&&busyAge>=0)usage.push('継続 '+elapsed(busyAge))
@@ -633,7 +810,6 @@ function celebrate(name){
633
810
  document.body.appendChild(v);setTimeout(()=>v.remove(),1600)
634
811
  }
635
812
  const BEAT=${HEARTBEAT_MS}
636
- const STATUS_STALE_MS=90000 // 稼働状態の鮮度。これを過ぎた報告は unknown(bridge 心拍30秒の3倍)
637
813
  let lastSeq=0,lastBeat=Date.now(),es=null,emptyEl=null,firstLoad=true,catching=false,memberDebounce=null
638
814
  // seq で二重描画を弾く。張り直し後の追いつきと SSE の新着が重なっても同じ発言は1回しか出ない
639
815
  function apply(m,live=false){
package/skill/SKILL.md CHANGED
@@ -78,6 +78,11 @@ script は内部で Aiterm の公開 `claude_agent` / `codex_agent` / `grok_agen
78
78
 
79
79
  7. **親の着卓**(このセッション): `scripts/parent-join.sh <project> [name] [model] [effort] [harness]` で member 登録とparent-watch cursorのprimeを行う。**`effort` は任意のまま据え置く**——席は `launch-seat.sh` が `--effort` で実際に設定するので「渡した値=実挙動」だが、親は既に走っているセッションで自分の effort を機械的に知る経路が無く、推測して載せると画面が嘘をつく。続けて、ClaudeとGrokはMonitor、Codexはyieldしたbackground tool taskとして**親宛DM番犬**を1世代だけ張る(形は下記「親の operating notes」の番犬仕様)。**着卓完了の条件は、parent-join が投稿する耳疎通probe(`EAR_PROBE_SENT` の nonce)を自分の監視イベントとして受信すること**——番犬プロセスの生存は耳の証拠にならない(前セッションの耳へ吠え続け親宛DMが全損した実被弾 2026-08-22)。受信できないなら監視を張り直す。通常席用wakeup-bridgeに親を載せない。broadcastのkickoffは廃止済み。以後の post も API 直(同 notes)
80
80
  8. **起動確認**: room の members に全員いる / 最初の claim が room に流れる(Lattice 併用モードはそれが Lattice へ到達している=`lattice todo status --json` の active に出ることも確認する。単独モードは room の claim 宣言だけが到達の証拠)/ Web UI で観測できる、をチェックして報告する
81
+ 9. **円卓開始ゲート(決定104)**: kickoff の依頼は、`node scripts/kickoff-gate.mjs <project> --seq <kickoff_seq> --seats <a,b,c>` が `active` を返すまで「依頼済み」と扱わない。3条件——①対象席の実効稼働状態が fresh ②kickoff message の delivered receipt ③対象席の引受発言(kickoff 本文に「引受を [引受] で返すこと」を含める)——を server の実効状態・配送 receipt から機械判定する。親の推測・待ち時間・room 保存成功による稼働判定は禁止
82
+
83
+ ## resume(既存 room の再稼働・決定105)
84
+
85
+ 過去ログを残した同じ room を現行工程へ接続し直す時は、setup.sh でなく `scripts/resume.sh <project> [--plan <plan_key>] [--phase <id>]... [--no-probe]` を打つ。既存 `.team/` と room を前提に、①room 確認 ②plan 再束縛(setup-state.json・roles/member.md・外部ペイン)③死んだ bridge 記録の除去 ④台帳の現行メンバー構成から死んでいる席だけ launch-seat.sh で再起動(roles の無い member は typed error で止まる)⑤3 bridge の再起動 ⑥全席の fresh heartbeat 読み戻し ⑦probe DM の delivered receipt 確認、までを一回で行う。手書きのメンバー一覧・個別再起動 script に依存しない。親の再着卓(parent-join / 番犬)は「親の再着卓」の手順で別途行う。軽い健全性確認だけなら従来どおり `doctor.sh` を使う。
81
86
 
82
87
  ## teardown
83
88
 
@@ -237,7 +242,7 @@ witness をどう生成するかは**対象 project 側の作法に従う**(La
237
242
  - `--parallel-frontier` が要るのは、**ready が複数あって誰も着手していない frontier の最初の start だけ**(無いと `PARALLEL_DISPATCH_REQUIRED / parallel_frontier_requires_declaration` で弾かれる)。ready が1件だけ、または既に誰かが着手している frontier へ後から乗る場合は素の `start` でよい。フラグが効くのは**取る task が `next_ready` に居る時だけ**で、他人が着手済みの task へ付けると `PARALLEL_DISPATCH_INVALID / parallel_frontier_not_applicable` になる——「フラグが使えない」ではなく「**その task はもう空いていない**」の意味である。記録があるのに対象工程が未宣言・失効なら `todo start` は `INDEPENDENCE_UNVERIFIED` で拒否する(Lattice ADR 0182)。記録が無い plan の start は従来どおり助言だけ
238
243
  - **`done.sh` は feat SHA が `origin/main` の祖先でなければ canonical main へ merge して push する。** 親は着地しない。続けて remaining の independence を compile してから戻る。lattice run receipt の未着地は別軸で、警告のまま
239
244
  - **independence compile は remaining A を含める。** 現在の ready だけを compile すると、次の frontier の start が拒否される。`next_ready` が witness に無い compile 自体も `INDEPENDENCE_READY_UNDECLARED` で拒否する。stale なら席が `.team/scripts/independence-refresh.sh` を打つ。campaign を起こす最初の compile は kickoff より前。H を最初の next_ready に並べると `MAX_TODOS=8` で compile できず、席は intake 待ちで止まる(2026-08-21 8B)。H は最初の frontier の外に置く。途中の stale 再 compile は席が打つ。
240
- - **部屋へ書いたことを配達成功としない。** `.team/wakeup-bridge-delivery.json` の `last_seq` がキックオフ seq 以上になるまで、席は起きていない。
245
+ - **部屋へ書いたことを配達成功としない。** `post` 応答の `room_saved` は保存だけの事実で、配達は宛先別 `delivery` が `delivered`(wakeup-bridge の TUI 投入成立 receipt)になった時だけ成立する(決定102)。照会は `GET /api/<room>/deliveries?seq=` か MCP `delivery_status`。席の稼働は server 生成の実効状態(`status_effective` / `status_reason`)だけで判定し、静的な member 一覧・経過時間で判定しない(決定101)。bridge の停止・403 は members 応答の `bridges`(`status_bridge_down` / `wakeup_bridge_down` / `bridge_auth_failed`)に出る(決定103)。
241
246
  - **mission が古くても親は書き換えない。** 工程が変わったら席が `set-mission.sh` を自分で打つ。チップと `[mission]` の1行が正本で、席の再起動はしない
242
247
  - 同時書込は `STORE_WRITE_CONFLICT` 等で明示的に負ける。1〜2 秒待って再実行すれば通る(正常系)
243
248
  - evidence は記述子 JSON。記述子ファイル自体も repo 内相対パスに置く(repo 外絶対パスは INVALID_ARGUMENTS)。`.team/scripts/done.sh` が正規経路。証跡の置き場は **`evidence/<plan_key>/<task_id>.md`**——task_id は campaign を跨いで再利用されるので、平置きにすると前の campaign の監査証跡を上書きで消す(2026-08-08 実測)
@@ -0,0 +1,82 @@
1
+ #!/usr/bin/env node
2
+ // 円卓開始ゲート(決定104)。kickoff を「依頼済み」と扱ってよいかを機械判定する。
3
+ // 3条件(①対象席の実効稼働状態が fresh ②kickoff message の delivered receipt ③対象席の引受発言)が
4
+ // 全席で成立するまで pending。親の推測・経過時間・room 保存成功による稼働判定を置き換える。
5
+ //
6
+ // usage: kickoff-gate.mjs <project_dir> --seq <kickoff_seq> --seats <a,b,c> [--ack-regex <re>] [--json]
7
+ // 終了コード: 0 = active / 3 = pending / 1 = 判定不能(room へ到達できない等)
8
+ import { readFileSync } from 'node:fs'
9
+ import { join } from 'node:path'
10
+
11
+ const args = process.argv.slice(2)
12
+ const proj = args[0]
13
+ const opt = (name) => {
14
+ const i = args.indexOf(name)
15
+ return i >= 0 ? args[i + 1] : undefined
16
+ }
17
+ const seq = Number(opt('--seq'))
18
+ const seats = (opt('--seats') ?? '').split(',').map(s => s.trim()).filter(Boolean)
19
+ const ackRe = new RegExp(opt('--ack-regex') ?? '引受|着手|claim', 'iu')
20
+ const asJson = args.includes('--json')
21
+ if (!proj || !Number.isSafeInteger(seq) || seq <= 0 || seats.length === 0) {
22
+ console.error('usage: kickoff-gate.mjs <project_dir> --seq <kickoff_seq> --seats <a,b,c> [--ack-regex <re>] [--json]')
23
+ process.exit(1)
24
+ }
25
+
26
+ const { room, server_url: url } = JSON.parse(readFileSync(join(proj, '.team', 'setup-state.json'), 'utf8'))
27
+ const api = p => `${url}/api/${encodeURIComponent(room)}/${p}`
28
+ const get = async (p) => {
29
+ const res = await fetch(api(p))
30
+ if (!res.ok) throw new Error(`GET ${p} -> HTTP ${res.status}`)
31
+ return res.json()
32
+ }
33
+
34
+ let membersBody, deliveryBody, messagesBody
35
+ try {
36
+ ;[membersBody, deliveryBody, messagesBody] = await Promise.all([
37
+ get('members'), get(`deliveries?seq=${seq}`), get(`messages?since=${seq}`),
38
+ ])
39
+ } catch (error) {
40
+ console.error(`KICKOFF_GATE_UNREACHABLE: ${error.message}`)
41
+ process.exit(1)
42
+ }
43
+ const members = new Map(membersBody.members.map(m => [m.name, m]))
44
+ if (membersBody.members.length && membersBody.members[0].status_effective === undefined) {
45
+ console.error('KICKOFF_GATE_SERVER_TOO_OLD: server が実効稼働状態を返さない版。判定できない')
46
+ process.exit(1)
47
+ }
48
+
49
+ const report = {}
50
+ let allPass = true
51
+ for (const seat of seats) {
52
+ const member = members.get(seat)
53
+ const fresh = member?.status_reason === 'fresh' && member.status_effective !== 'dead'
54
+ const delivery = deliveryBody.delivery?.[seat]
55
+ const delivered = delivery?.state === 'delivered'
56
+ const ack = messagesBody.messages.find(m => m.from === seat && m.seq > seq && ackRe.test(m.body ?? ''))
57
+ const pass = fresh && delivered && Boolean(ack)
58
+ allPass = allPass && pass
59
+ report[seat] = {
60
+ pass,
61
+ status: member ? { effective: member.status_effective, reason: member.status_reason } : { effective: null, reason: 'member_not_found' },
62
+ delivery: delivery ?? { state: 'unknown' },
63
+ ack: ack ? { seq: ack.seq, body: String(ack.body).slice(0, 80) } : null,
64
+ }
65
+ }
66
+
67
+ const state = allPass ? 'active' : 'pending'
68
+ if (asJson) {
69
+ console.log(JSON.stringify({ schema: 'peertable.kickoff_gate.v1', room, kickoff_seq: seq, state, seats: report }))
70
+ } else {
71
+ console.log(`kickoff-gate: ${state}(room=${room} kickoff seq=${seq})`)
72
+ for (const [seat, r] of Object.entries(report)) {
73
+ const parts = [
74
+ `状態=${r.status.effective ?? '不在'}(${r.status.reason})`,
75
+ `配達=${r.delivery.state}${r.delivery.reason ? `(${r.delivery.reason})` : ''}`,
76
+ `引受=${r.ack ? `seq ${r.ack.seq}` : 'なし'}`,
77
+ ]
78
+ console.log(` ${r.pass ? 'OK' : 'NG'} ${seat}: ${parts.join(' / ')}`)
79
+ }
80
+ if (state === 'pending') console.log(' 3条件が揃うまで円卓taskは依頼済みと扱わないこと(決定104)')
81
+ }
82
+ process.exit(state === 'active' ? 0 : 3)
@@ -0,0 +1,230 @@
1
+ #!/bin/bash
2
+ # 既存 room の正規 resume(決定105)。過去ログを残した同じ room を、現行工程へ接続し直して再稼働させる。
3
+ # usage: resume.sh <project_dir> [--plan <plan_key>] [--phase <id>]... [--no-probe]
4
+ #
5
+ # 一回で行うこと:
6
+ # 1. 既存 .team/ と room の確認(無ければ setup.sh を案内して止まる)
7
+ # 2. --plan 指定時、setup-state.json / roles/member.md / 外部ペインを現行 plan へ再束縛
8
+ # 3. 死んだ bridge 記録(pid 不一致・停止)の除去
9
+ # 4. 台帳(room member 行)から現行メンバー構成を読み、席が死んでいるものだけ launch-seat.sh で再起動
10
+ # (credential の再生成・本人性の再記録は launch-seat.sh が持つ)
11
+ # 5. alarm / seat-status / wakeup bridge の再起動(ensure-bridge.sh)
12
+ # 6. fresh heartbeat の読み戻し(server 実効状態が fresh になるまで待つ)
13
+ # 7. テスト DM の配送 receipt 確認(delivered になるまで待つ。--no-probe で省略)
14
+ #
15
+ # 手書きのメンバー一覧・個別再起動 script には依存しない。親の再着卓(parent-join / 番犬)は別手順
16
+ #(SKILL.md「親の再着卓」)——resume は席と bridge の面だけを持つ。
17
+ set -euo pipefail
18
+
19
+ proj="${1:-}"
20
+ [ -n "$proj" ] || { echo "usage: resume.sh <project_dir> [--plan <plan_key>] [--phase <id>]... [--no-probe]" >&2; exit 1; }
21
+ shift
22
+ plan=""
23
+ phases=()
24
+ probe=true
25
+ while [ $# -gt 0 ]; do
26
+ case "$1" in
27
+ --plan) plan="${2:-}"; shift 2 ;;
28
+ --phase) [ -n "${2:-}" ] || { echo "ERROR: --phase には phase id が要る" >&2; exit 1; }; phases+=("$2"); shift 2 ;;
29
+ --no-probe) probe=false; shift ;;
30
+ *) echo "ERROR: 未知の引数: $1" >&2; exit 1 ;;
31
+ esac
32
+ done
33
+ proj=$(cd "$proj" && pwd)
34
+ script_dir=$(cd "$(dirname "$0")" && pwd -P)
35
+ repo="${PEERTABLE_REPO:-$(cd "$script_dir/../.." && pwd -P)}"
36
+ [ -f "$repo/room/client.mjs" ] || { echo "RESUME_PEERTABLE_TREE_UNRESOLVED: $repo が peertable tree でない" >&2; exit 1; }
37
+ setup="$proj/.team/setup-state.json"
38
+ if [ ! -f "$setup" ]; then
39
+ echo "RESUME_NOT_SET_UP: $setup が無い。新規の卓は setup.sh で立てること" >&2
40
+ exit 1
41
+ fi
42
+
43
+ seats_file="$proj/.team/resume-seats.json"
44
+
45
+ # Phase A: 到達確認・plan 再束縛・死記録掃除・再起動対象の抽出(node が JSON を書き出す)
46
+ RESUME_PROJ="$proj" RESUME_REPO="$repo" RESUME_PLAN="$plan" RESUME_PHASES="${phases[*]:-}" \
47
+ RESUME_SEATS_FILE="$seats_file" node --input-type=module <<'NODE'
48
+ import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'
49
+ import { execFileSync } from 'node:child_process'
50
+ import { join } from 'node:path'
51
+ import { pathToFileURL } from 'node:url'
52
+
53
+ const proj = process.env.RESUME_PROJ
54
+ const repo = process.env.RESUME_REPO
55
+ const team = join(proj, '.team')
56
+ const scriptDir = join(repo, 'skill', 'scripts')
57
+ const { bridgeRecordLive } = await import(pathToFileURL(join(scriptDir, 'bridge-record-live.mjs')))
58
+ const { resolveSeatObservation, tmuxArgv } = await import(pathToFileURL(join(scriptDir, 'seat-usage.mjs')))
59
+
60
+ const setupPath = join(team, 'setup-state.json')
61
+ const state = JSON.parse(readFileSync(setupPath, 'utf8'))
62
+ const { room, server_url: url } = state
63
+
64
+ // 1. room の存在確認(読み取りは room を作らないので、summary が member 0・seq 0 でも部屋の有無は API 一覧で見る)
65
+ const roomsRes = await fetch(`${url}/api/rooms`).catch((e) => { throw new Error(`RESUME_ROOM_UNREACHABLE: ${url} へ到達できない: ${e.message}`) })
66
+ if (!roomsRes.ok) throw new Error(`RESUME_ROOM_UNREACHABLE: GET /api/rooms -> HTTP ${roomsRes.status}`)
67
+ const { rooms } = await roomsRes.json()
68
+ if (!rooms.includes(room)) throw new Error(`RESUME_ROOM_MISSING: room「${room}」が server に無い。新規は setup.sh で立てること`)
69
+ console.log(`[実施] room 確認: ${room} @ ${url}`)
70
+
71
+ // 2. plan 再束縛(--plan 指定時だけ)
72
+ const plan = process.env.RESUME_PLAN
73
+ const phases = (process.env.RESUME_PHASES ?? '').split(' ').filter(Boolean)
74
+ if (plan) {
75
+ state.mode = 'lattice'
76
+ state.plan_key = plan
77
+ state.phases = phases
78
+ writeFileSync(setupPath, JSON.stringify(state) + '\n')
79
+ const scope = phases.length === 0
80
+ ? 'この卓の claim 範囲は plan 全体(phase 指定なしで立っている)。'
81
+ : `**この卓の claim 範囲は phase ${phases.join(' ')} の task だけ**。範囲外の phase の task は、ready に見えていても取らない。範囲外に手を入れる必要が出たら room へ出して裁定を仰ぐ。`
82
+ const template = readFileSync(join(repo, 'skill', 'templates', 'member.md'), 'utf8')
83
+ writeFileSync(join(team, 'roles', 'member.md'), template.replaceAll('{{PLAN_KEY}}', plan).replaceAll('{{CLAIM_SCOPE}}', scope))
84
+ const publicUrl = process.env.PEERTABLE_PUBLIC_URL ?? state.public_url ?? url
85
+ execFileSync('node', [join(scriptDir, 'external-pane.mjs'), proj, room, publicUrl], { stdio: ['ignore', 'ignore', 'inherit'] })
86
+ console.log(`[実施] plan 再束縛: ${plan}${phases.length ? `(phase ${phases.join(',')})` : ''}`)
87
+ } else {
88
+ console.log(`[スキップ] plan 再束縛(--plan 未指定。現行 ${state.plan_key || state.mode} のまま)`)
89
+ }
90
+
91
+ // 3. 死んだ bridge 記録の除去(生きている記録は触らない。parent-watch.json は cursor 正本なので消さない)
92
+ for (const name of ['wakeup-bridge.json', 'seat-status-bridge.json', 'alarm-bridge.json']) {
93
+ const path = join(team, name)
94
+ if (!existsSync(path)) continue
95
+ let record = null
96
+ try { record = JSON.parse(readFileSync(path, 'utf8')) } catch {}
97
+ if (bridgeRecordLive(record)) { console.log(`[スキップ] ${name}: 生存中(pid ${record.pid})`); continue }
98
+ unlinkSync(path)
99
+ console.log(`[実施] ${name}: 死んだ記録を除去(pid ${record?.pid ?? '不明'})`)
100
+ }
101
+
102
+ // 4. 台帳から再起動対象を抽出。席の生死は tmux session の実在で判定する
103
+ const { members } = await (await fetch(`${url}/api/${encodeURIComponent(room)}/members`)).json()
104
+ const relaunch = []
105
+ const missingRoles = []
106
+ for (const member of members) {
107
+ if (member.delivery?.kind === 'parent_watch') continue
108
+ const harness = member.harness ?? member.vendor
109
+ if (!harness) continue // 素性の無い行(親の手動登録等)は席ではない
110
+ const observation = resolveSeatObservation(member, null)
111
+ let aliveSeat = false
112
+ if (observation) {
113
+ try {
114
+ execFileSync('tmux', tmuxArgv(['has-session', '-t', observation.target], { socket: observation.socket }), { stdio: 'ignore' })
115
+ aliveSeat = true
116
+ } catch {}
117
+ }
118
+ if (aliveSeat) { console.log(`[スキップ] 席 ${member.name}: tmux session 生存中`); continue }
119
+ if (!Array.isArray(member.roles) || member.roles.length === 0) { missingRoles.push(member.name); continue }
120
+ relaunch.push({
121
+ name: member.name, roles: member.roles.join(','), harness,
122
+ model: member.model ?? null, effort: member.effort ?? null, mission: member.mission ?? null,
123
+ })
124
+ }
125
+ if (missingRoles.length) {
126
+ throw new Error(`RESUME_MEMBER_ROLES_MISSING: 役割の無い member は再起動できない: ${missingRoles.join(', ')}。`
127
+ + 'launch-seat.sh --roles で個別に立て直すか、member 行を整備すること')
128
+ }
129
+ writeFileSync(process.env.RESUME_SEATS_FILE, JSON.stringify(relaunch) + '\n')
130
+ console.log(`[実施] 再起動対象の抽出: ${relaunch.length} 席(${relaunch.map(s => s.name).join(', ') || 'なし'})`)
131
+ NODE
132
+
133
+ # Phase B: 席の再起動(credential 再生成・本人性再記録は launch-seat.sh が持つ)。
134
+ # 受け渡しは TSV(mission に tab を含む member は台帳整備の対象であり、ここでは想定しない)
135
+ while IFS=$'\t' read -r name roles harness model effort mission; do
136
+ [ -n "$name" ] || continue
137
+ argv=("$proj" "$name" --roles "$roles" --harness "$harness")
138
+ [ -n "$model" ] && argv+=(--model "$model")
139
+ [ -n "$effort" ] && argv+=(--effort "$effort")
140
+ [ -n "$mission" ] && argv+=(--mission "$mission")
141
+ echo "[実施] 席の再起動: $name(roles=$roles harness=$harness${model:+ model=$model}${effort:+ effort=$effort})"
142
+ env -u PEERTABLE_POST_TOKEN "$script_dir/launch-seat.sh" "${argv[@]}"
143
+ done < <(node -e '
144
+ const seats = JSON.parse(require("node:fs").readFileSync(process.argv[1], "utf8"))
145
+ for (const s of seats) console.log([s.name, s.roles, s.harness, s.model ?? "", s.effort ?? "", s.mission ?? ""].join("\t"))
146
+ ' "$seats_file")
147
+ rm -f "$seats_file"
148
+
149
+ # Phase C: bridge の再起動
150
+ for kind in alarm seat-status wakeup; do
151
+ if "$script_dir/ensure-bridge.sh" "$proj" "$kind"; then
152
+ echo "[実施] ${kind}-bridge: ready"
153
+ else
154
+ echo "[未実施] ${kind}-bridge: 起動を確認できなかった(ログ $proj/.team/${kind}-bridge.log)" >&2
155
+ exit 1
156
+ fi
157
+ done
158
+
159
+ # Phase D: fresh heartbeat の読み戻しと probe DM の配送 receipt 確認
160
+ RESUME_PROJ="$proj" RESUME_PROBE="$probe" RESUME_SCRIPT_DIR="$script_dir" node --input-type=module <<'NODE'
161
+ import { readFileSync } from 'node:fs'
162
+ import { join } from 'node:path'
163
+ import { pathToFileURL } from 'node:url'
164
+
165
+ const proj = process.env.RESUME_PROJ
166
+ const { room, server_url: url } = JSON.parse(readFileSync(join(proj, '.team', 'setup-state.json'), 'utf8'))
167
+ const api = p => `${url}/api/${encodeURIComponent(room)}/${p}`
168
+ const sleep = ms => new Promise(r => setTimeout(r, ms))
169
+
170
+ // launch-seat.sh 直後の席(tmux session が居る非親 member)が対象
171
+ const { resolvePostToken } = await import(pathToFileURL(join(process.env.RESUME_SCRIPT_DIR, 'seat-usage.mjs')))
172
+
173
+ // 6. fresh heartbeat の読み戻し(seat-status bridge 心拍 30 秒 + 判定余裕)
174
+ const deadline = Date.now() + 90_000
175
+ let pendingSeats = []
176
+ for (;;) {
177
+ const { members, bridges } = await (await fetch(api('members'))).json()
178
+ const seats = members.filter(m => m.delivery?.kind !== 'parent_watch' && (m.harness ?? m.vendor) && m.observe?.tmux_target)
179
+ if (seats.length === 0) {
180
+ console.log('[スキップ] fresh heartbeat 読み戻し(観測対象の席なし)')
181
+ break
182
+ }
183
+ pendingSeats = seats.filter(m => m.status_reason !== 'fresh')
184
+ if (pendingSeats.length === 0) {
185
+ console.log(`[実施] fresh heartbeat 読み戻し: ${seats.length} 席すべて fresh`)
186
+ break
187
+ }
188
+ if (Date.now() > deadline) {
189
+ console.error(`RESUME_HEARTBEAT_STALE: fresh にならない席: ${pendingSeats.map(m => `${m.name}(${m.status_reason})`).join(', ') || '(席なし)'}`
190
+ + `(bridges: seat_status=${bridges?.seat_status?.state} wakeup=${bridges?.wakeup?.state})`)
191
+ process.exit(1)
192
+ }
193
+ await sleep(3000)
194
+ }
195
+
196
+ // 7. probe DM の配送 receipt 確認
197
+ if (process.env.RESUME_PROBE !== 'true') {
198
+ console.log('[スキップ] probe DM(--no-probe 指定)')
199
+ process.exit(0)
200
+ }
201
+ const token = resolvePostToken(process.env)
202
+ const headers = { 'Content-Type': 'application/json', ...(token ? { 'X-Peertable-Token': token } : {}) }
203
+ const { members } = await (await fetch(api('members'))).json()
204
+ const targets = members.filter(m => m.delivery?.kind !== 'parent_watch' && (m.harness ?? m.vendor) && m.observe?.tmux_target).map(m => m.name)
205
+ if (!targets.length) { console.log('[スキップ] probe DM(配達対象の席なし)'); process.exit(0) }
206
+ const res = await fetch(api('messages'), {
207
+ method: 'POST', headers,
208
+ body: JSON.stringify({ from: 'resume', to: targets.length === 1 ? targets[0] : targets,
209
+ body: '[resume-probe] 配達経路の確認。応答不要・読み流してよい。' }),
210
+ })
211
+ const saved = await res.json()
212
+ if (!res.ok) { console.error(`RESUME_PROBE_POST_FAILED: ${JSON.stringify(saved)}`); process.exit(1) }
213
+ console.log(`[実施] probe DM 投稿: seq ${saved.seq} → ${targets.join(', ')}`)
214
+ const probeDeadline = Date.now() + 120_000
215
+ for (;;) {
216
+ const { delivery } = await (await fetch(api(`deliveries?seq=${saved.seq}`))).json()
217
+ const undelivered = targets.filter(t => delivery?.[t]?.state !== 'delivered')
218
+ if (undelivered.length === 0) {
219
+ console.log('[実施] probe DM の配送 receipt: 全席 delivered')
220
+ break
221
+ }
222
+ if (Date.now() > probeDeadline) {
223
+ console.error(`RESUME_PROBE_UNDELIVERED: delivered にならない席: ${undelivered.map(t => `${t}=${delivery?.[t]?.state ?? 'unknown'}${delivery?.[t]?.reason ? `(${delivery[t].reason})` : ''}`).join(', ')}`
224
+ + '(Grok 席は idle 待ちで遅れることがある。.team/wakeup-bridge.log を確認すること)')
225
+ process.exit(1)
226
+ }
227
+ await sleep(3000)
228
+ }
229
+ console.log('resume 完了。親の再着卓(parent-join / 番犬)は SKILL.md「親の再着卓」の手順で別途行うこと')
230
+ NODE
@@ -179,6 +179,32 @@ async function nudgeIfDropped(name, busySince) {
179
179
  }
180
180
  }
181
181
 
182
+ // bridge 心拍(決定103)。server の bridge 台帳へ 30 秒ごとに送る。途絶=status_bridge_down、
183
+ // 403=server 側が bridge_auth_failed として観測する。旧 server(404)へは送り続けない
184
+ let lastBridgeBeatAt = 0
185
+ let bridgeBeatSupported = null
186
+ async function beatBridge() {
187
+ const now = Date.now()
188
+ if (bridgeBeatSupported === false || now - lastBridgeBeatAt < HEARTBEAT_MS) return
189
+ try {
190
+ const res = await fetch(`${url}/api/${encodeURIComponent(room)}/bridges`, {
191
+ method: 'POST',
192
+ headers: { 'Content-Type': 'application/json', ...(token ? { 'X-Peertable-Token': token } : {}) },
193
+ body: JSON.stringify({ kind: 'seat_status', pid: process.pid, state: 'running' }),
194
+ })
195
+ if (res.status === 404) {
196
+ bridgeBeatSupported = false
197
+ console.error('seat-status-bridge: server が bridge 台帳を持たない版(404)。心拍送信を止める')
198
+ return
199
+ }
200
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
201
+ bridgeBeatSupported = true
202
+ lastBridgeBeatAt = now
203
+ } catch (e) {
204
+ console.error(`seat-status-bridge: bridge心拍の送信に失敗: ${e.message}`)
205
+ }
206
+ }
207
+
182
208
  const last = new Map() // name -> { status, at }
183
209
  let supported = null // server が status を保持する版か(未判定は null)
184
210
  const tokenBucket = value => value === null ? null : Math.floor(value / 1_000)
@@ -282,6 +308,7 @@ let failedTicks = 0
282
308
  let provenWritable = false
283
309
 
284
310
  async function guardedTick() {
311
+ await beatBridge()
285
312
  const { attempted, failed } = await tick() ?? NOTHING_ATTEMPTED
286
313
  const decided = decideBridgeContinuation({ attempted, failed, provenWritable, failedTicks, limit: FAILED_TICK_LIMIT })
287
314
  provenWritable = decided.provenWritable
@@ -16,9 +16,9 @@ import { execFile } from 'node:child_process'
16
16
  import { existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
17
17
  import { join } from 'node:path'
18
18
  import { promisify } from 'node:util'
19
- import { resolveSeatObservation, tmuxArgv } from './seat-usage.mjs'
19
+ import { resolvePostToken, resolveSeatObservation, tmuxArgv } from './seat-usage.mjs'
20
20
  import { keysForCodexPane } from './codex-dialog.mjs'
21
- import { BROADCAST_RECIPIENT, formatWakeNotice, isIdleSelfWake, isWakeupBridgeTarget, shouldDeferGrokWake } from './wakeup-delivery.mjs'
21
+ import { BROADCAST_RECIPIENT, formatWakeNotice, isIdleSelfWake, isWakeupBridgeTarget, memberHarness, shouldDeferGrokWake } from './wakeup-delivery.mjs'
22
22
  import { bridgeRecordLive } from './bridge-record-live.mjs'
23
23
 
24
24
  const run = promisify(execFile)
@@ -135,6 +135,61 @@ const cleanup = () => { if (existsSync(record)) unlinkSync(record); process.exit
135
135
  process.on('SIGTERM', cleanup)
136
136
  process.on('SIGINT', cleanup)
137
137
 
138
+ // ---- 心拍と配送 receipt(決定102・103)--------------------------------------------
139
+ // 心拍が 90 秒途絶えると server が wakeup_bridge_down、書込 403 は server が bridge_auth_failed
140
+ // として実効表示する。receipt は TUI 投入の成立確認後にだけ delivered を書き、席不在系は
141
+ // seat_unavailable を書く——room 保存を配達成功と見せる面を server 側から潰すための唯一の書き手。
142
+ const postToken = resolvePostToken(process.env)
143
+ const writeHeaders = { 'Content-Type': 'application/json', ...(postToken ? { 'X-Peertable-Token': postToken } : {}) }
144
+ const BEAT_MS = 30_000
145
+ let lastBeatAt = 0
146
+ let beatSupported = null
147
+ async function beatBridge() {
148
+ const now = Date.now()
149
+ if (beatSupported === false || now - lastBeatAt < BEAT_MS) return
150
+ try {
151
+ const res = await fetch(`${url}/api/${encodeURIComponent(room)}/bridges`, {
152
+ method: 'POST', headers: writeHeaders,
153
+ body: JSON.stringify({ kind: 'wakeup', pid: process.pid, state: 'running' }),
154
+ })
155
+ if (res.status === 404) {
156
+ beatSupported = false
157
+ log('server が bridge 台帳を持たない版(404)。心拍送信を止める')
158
+ return
159
+ }
160
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
161
+ beatSupported = true
162
+ lastBeatAt = now
163
+ } catch (error) {
164
+ log(`WAKEUP_BRIDGE_BEAT_FAILED: ${error.message}`)
165
+ }
166
+ }
167
+ let receiptSupported = null
168
+ const postedReceipts = new Map() // `${seq}:${recipient}` -> `${result}:${reason}`(同内容の再送を抑える)
169
+ async function postReceipt(seq, recipient, result, reason = null) {
170
+ if (receiptSupported === false) return
171
+ const key = `${seq}:${recipient}`
172
+ const value = `${result}:${reason ?? ''}`
173
+ if (postedReceipts.get(key) === value) return
174
+ try {
175
+ const res = await fetch(`${url}/api/${encodeURIComponent(room)}/deliveries`, {
176
+ method: 'POST', headers: writeHeaders,
177
+ body: JSON.stringify({ seq, recipient, result, ...(reason ? { reason } : {}) }),
178
+ })
179
+ if (res.status === 404) {
180
+ receiptSupported = false
181
+ log('server が配送 receipt 台帳を持たない版(404)。receipt 送信を止める')
182
+ return
183
+ }
184
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
185
+ postedReceipts.set(key, value)
186
+ } catch (error) {
187
+ log(`DELIVERY_RECEIPT_WRITE_FAILED ${JSON.stringify({ seq, recipient, result, detail: error.message.split('\n')[0] })}`)
188
+ }
189
+ }
190
+ beatBridge()
191
+ setInterval(beatBridge, 5_000) // 送信自体は BEAT_MS で間引く。失敗した心拍を次の周期で取り返すための駆動
192
+
138
193
  // 席ごとに未配達を溜めて、2秒ごとにまとめて1回起こす(連投で席を何度も起こさない)。
139
194
  // pending は room message の宛先名から作る。起動引数や harness は候補集合を決めない。
140
195
  const pending = new Map() // name -> Map<seq, message>
@@ -305,12 +360,14 @@ async function wake(seat, msgs) {
305
360
  if (descendants === 0) {
306
361
  log(`SEAT_TUI_GONE: ${seat} の pane は子孫プロセスの無い素の shell(${fgCommand})=agent CLI が終了済み。`
307
362
  + 'shell へのコマンド実行を防ぐため配達しない。席を立て直すか leave-seat で畳むこと')
308
- return 'deferred'
363
+ const error = new Error(`SEAT_TUI_GONE: ${seat}`)
364
+ error.code = 'SEAT_TUI_GONE'
365
+ throw error
309
366
  }
310
367
  }
311
368
  const pane = await run('tmux', tmuxArgv(['capture-pane', '-t', observation.target, '-p'], { socket: observation.socket }))
312
369
  const screen = String(pane.stdout ?? '')
313
- if ((member.harness ?? member.vendor) === 'codex') {
370
+ if (memberHarness(member) === 'codex') {
314
371
  const dialog = keysForCodexPane(screen)
315
372
  if (dialog) {
316
373
  for (const key of dialog.keys) {
@@ -320,9 +377,9 @@ async function wake(seat, msgs) {
320
377
  return 'deferred'
321
378
  }
322
379
  }
323
- if ((member.harness ?? member.vendor) === 'grok') {
380
+ if (memberHarness(member) === 'grok') {
324
381
  const tail = screen.split('\n').slice(-14).join('\n')
325
- if (shouldDeferGrokWake((member.harness ?? member.vendor), tail)) {
382
+ if (shouldDeferGrokWake(memberHarness(member), tail)) {
326
383
  if (!deferredBusy.has(seat)) {
327
384
  log(`Grok席が実行中なのでidleまで待つ: ${seat} ← ${msgs.length} 件`)
328
385
  deferredBusy.add(seat)
@@ -352,7 +409,7 @@ async function wake(seat, msgs) {
352
409
  // ターンを中断**していた(実被弾 2026-08-22: 監査席が2秒ごとに中断され、監査が進まなかった)。
353
410
  // Claude 席は「受理された兆候」を成功とみなし、回復キーに Escape を使わない。
354
411
  const CLAUDE_ACCEPTED = /paste again to expand|\[Pasted text|esc to interrupt|✻|✽|✳|·\s*\w+ing…/u
355
- const isClaude = (member.harness ?? member.vendor) === 'claude'
412
+ const isClaude = memberHarness(member) === 'claude'
356
413
  for (let attempt = 0; attempt < 3; attempt++) {
357
414
  await sleep(1200)
358
415
  const after = await run('tmux', tmuxArgv(['capture-pane', '-t', observation.target, '-p'], { socket: observation.socket }))
@@ -367,7 +424,9 @@ async function wake(seat, msgs) {
367
424
  if (!stuck) break
368
425
  if (attempt === 2) {
369
426
  log(`DELIVERY_STUCK: ${seat} の入力欄に本文が残ったまま送信できない(popup/入力詰まり)。受領を確定せず次周期に再試行する`)
370
- return 'deferred'
427
+ const error = new Error(`DELIVERY_STUCK: ${seat}`)
428
+ error.code = 'DELIVERY_STUCK'
429
+ throw error
371
430
  }
372
431
  if (isClaude) {
373
432
  // Escape は撃たない(実行中ターンを殺す)。Enter だけ打ち直す
@@ -415,6 +474,7 @@ async function flushSeat(seat) {
415
474
  const outcome = await wake(seat, msgs)
416
475
  if (outcome === 'deferred') return
417
476
  if (outcome === 'skipped') {
477
+ for (const msg of msgs) await postReceipt(msg.seq, seat, 'seat_unavailable', 'not_a_delivery_target')
418
478
  forgetSeat(seat)
419
479
  return
420
480
  }
@@ -440,17 +500,24 @@ async function flushSeat(seat) {
440
500
  throw error
441
501
  }
442
502
  for (const { msg } of receipts) queue.delete(msg.seq)
503
+ // TUI 投入が成立した時にだけ server の配送 receipt を delivered にする(決定102)
504
+ for (const { msg } of receipts) await postReceipt(msg.seq, seat, 'delivered')
443
505
  }
444
506
  advanceLastSeq()
445
507
  } catch (error) {
446
508
  // 失敗時はpendingもreceiptもcursorも動かさない。次のmember refreshで
447
509
  // descriptor/PTYが復旧した時、同じseqを同じ宛先へ一度だけ再試行する。
510
+ const code = typeof error.code === 'string' ? error.code : 'INJECTION_FAILED'
448
511
  log(`WAKEUP_BRIDGE_DELIVERY_FAILURE ${JSON.stringify({
449
512
  recipient: seat,
450
- code: typeof error.code === 'string' ? error.code : 'INJECTION_FAILED',
513
+ code,
451
514
  seqs: msgs.map(msg => msg.seq),
452
515
  detail: error.message.split('\n')[0],
453
516
  })}`)
517
+ // 席不在系は seat_unavailable、それ以外は failed として server へ現況を残す。
518
+ // 再試行が成立すれば同じ (seq, recipient) を delivered で上書きする
519
+ const result = ['SEAT_TUI_GONE', 'MEMBER_MISSING', 'DESCRIPTOR_MISSING'].includes(code) ? 'seat_unavailable' : 'failed'
520
+ for (const msg of msgs) await postReceipt(msg.seq, seat, result, code)
454
521
  } finally {
455
522
  flushing.delete(seat)
456
523
  }
@@ -4,6 +4,11 @@ export const BROADCAST_RECIPIENT = 'all'
4
4
  export const ROOM_UPDATE_FALLBACK =
5
5
  'room全体の状況が更新された。room.read_logで部屋を読み、状況を把握して次の行動を判断する。'
6
6
 
7
+ /** member素性の正本はharness。旧server応答(vendorだけ)も受ける。 */
8
+ export function memberHarness(member) {
9
+ return member?.harness ?? member?.vendor
10
+ }
11
+
7
12
  export function collapseWakeBody(body) {
8
13
  return String(body ?? '').replace(/\s*\n+\s*/gu, ' / ')
9
14
  }
@@ -36,7 +41,7 @@ export function isWakeupBridgeTarget(member, options = {}) {
36
41
  && observe.tmux_target,
37
42
  )
38
43
  if (hasPane) return true
39
- const harness = member.harness ?? member.vendor // 旧server互換
44
+ const harness = memberHarness(member)
40
45
  return harness === 'codex' || harness === 'grok'
41
46
  }
42
47
 
@@ -15,6 +15,8 @@
15
15
  **次にやる仕事があるターンを終える直前に `post(to: "<自分の名前>", message: "[次の行動] ...")` を1回送れ。** この自己DMは席の TUI へ次ターンの入力として入る。仕事があるのに出さないと席は止まる。空の終了通知は使うな。
16
16
  **手番が無く待機に入るときは `[次の行動]` 自己DMを出すな。** 親へ `[待機]` を一度だけ送り、沈黙する。再開は親または他人からの inbound だけ。
17
17
 
18
+ **kickoff・名指しの依頼DMには、まず `[引受] <要旨>` を room へ返してから着手する(決定104)。** 親はこの引受発言と配送 receipt が揃うまで依頼を未着手として扱う。
19
+
18
20
  **探索順は active → ready → 待機である。** まず自分の active 議題を完了させる。無ければ議題を選ぶ。実装も監査担当としての提出待ちも無い時だけ、最終手段として `[待機] ...` を親(bell 等、その卓の親名)だけへDMする。待機を `to: "all"` へ投稿しない。ターンを終える時は、次に行う作業または再確認条件を自分へDMする。
19
21
 
20
22
  1. `.team/tasks.md` で議題を見る。`read_log` で既存の claim と完了報告を照合し、まだ誰も持っていないものを選ぶ
@@ -19,6 +19,8 @@
19
19
 
20
20
  **作業を選ぶのも始めるのもあなたである。** 装置から仕事が降ってくることはないし、着手前に装置の許可を待つこともない(オーナー裁定 2026-08-09・改・裁定1)。Lattice が居る卓では、着手した後に装置が競合を見て介入してくることがある——それは次の「装置が介入してきた時」に従う。
21
21
 
22
+ **kickoff・名指しの依頼DMには、まず `[引受] <要旨>` を room へ返してから着手する(決定104)。** 親はこの引受発言と配送 receipt が揃うまで依頼を未着手として扱う——黙って作業を始めると、依頼が届いていないと判定され再送・再依頼が起きる。
23
+
22
24
  **探索順は active → ready → 待機である。** まず自分の active 工程を完了させる。無ければ全PLAN横断の ready を自律的に claim する。実装も監査担当としての提出待ちも無い時だけ、最終手段として `[待機] ...` を親(bell 等、その卓の親名)だけへDMする。待機を `to: "all"` へ投稿しない。待機のあと自分へ `[次の行動]` を送らない。次に行う作業があるターンだけ自己DMする。空の終了通知は使わない。
23
25
 
24
26
  1. `lattice todo status --json` で ready なタスクを見る。{{CLAIM_SCOPE}}
@@ -22,7 +22,8 @@
22
22
  - 作業者から監査担当への最終試験結果提出と、監査担当による工程クローズが正本へ記録されたことの観測
23
23
  - 着地と、途中の independence 再 compile は席の仕事である。親は代行しない
24
24
  - campaign を起こす最初の remaining A compile は kickoff より前。H を最初の next_ready に並べない(`MAX_TODOS=8`。並べると compile できず席の intake が止まる)
25
- - 部屋へ書いたことを配達成功としない。`.team/wakeup-bridge-delivery.json` `last_seq` が対象 seq 以上になるまで、席は起きていない。ensure-bridge の live 判定は `last_progress_at`。pid 生存だけでは本人ではない
25
+ - 部屋へ書いたことを配達成功としない。`post` 応答の `room_saved` は保存だけの事実で、配達は宛先別 `delivery` `delivered` になった時だけ成立する(決定102。照会は `GET /api/<room>/deliveries?seq=` か MCP の `delivery_status`)。ensure-bridge の live 判定は `last_progress_at`。pid 生存だけでは本人ではない
26
+ - **kickoff は kickoff-gate が active を返すまで「依頼済み」と扱わない**(決定104)。kickoff DM には「引受を [引受] で返すこと」を含め、`node skill/scripts/kickoff-gate.mjs <project> --seq <kickoff_seq> --seats <a,b,c>` で3条件(fresh 状態・delivered receipt・引受発言)の成立を機械確認する。pending のまま進めた依頼は未実施として扱う
26
27
  - mission の更新は席の仕事である。親は代行しない
27
28
  - 承認 gate・オーナーとの接点、裁定依頼の運搬(自分で判断せずオーナー宛の議題として運ぶ)
28
29
  - model / effort 変更依頼への対応(本人の自然文を親が判断し、確定したtargetだけを