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 +1 -1
- package/package.json +1 -1
- package/room/client.mjs +49 -8
- package/room/server.mjs +188 -12
- package/skill/SKILL.md +6 -1
- package/skill/scripts/kickoff-gate.mjs +82 -0
- package/skill/scripts/resume.sh +230 -0
- package/skill/scripts/seat-status-bridge.mjs +27 -0
- package/skill/scripts/wakeup-bridge.mjs +76 -9
- package/skill/scripts/wakeup-delivery.mjs +6 -1
- package/skill/templates/member-standalone.md +2 -0
- package/skill/templates/member.md +2 -0
- package/skill/templates/parent.md +2 -1
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
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
242
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
568
|
-
//
|
|
569
|
-
const
|
|
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
|
-
|
|
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
|
-
- **部屋へ書いたことを配達成功としない。**
|
|
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
|
-
|
|
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
|
|
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
|
|
380
|
+
if (memberHarness(member) === 'grok') {
|
|
324
381
|
const tail = screen.split('\n').slice(-14).join('\n')
|
|
325
|
-
if (shouldDeferGrokWake((member
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
-
|
|
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だけを
|