peertable 0.3.3 → 0.3.5

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.ja.md CHANGED
@@ -27,7 +27,7 @@ Peertable はこれを裏返す:
27
27
 
28
28
  | 層 | 所有者 | 持つもの |
29
29
  |---|---|---|
30
- | **会話** | room サーバー(本リポジトリ) | 会議・claim・進捗報告・影響通知。全員宛も DM も一本の append-only ログ |
30
+ | **会話** | room サーバー(本リポジトリ) | 会議・claim・進捗報告・影響通知。単独/複数の明示宛先を一本の append-only ログへ残し、必要な文脈はそこから pull |
31
31
  | **計画** | [Lattice](https://www.npmjs.com/package/@quolu/lattice)(**任意**——下記) | タスクグラフ(依存・状態・証跡)。「今取れるタスク」は機械的に出るので、会話は判断だけに使う |
32
32
  | **成果物** | git | コード・文書・commit |
33
33
 
package/README.md CHANGED
@@ -49,7 +49,7 @@ Three layers, cleanly separated:
49
49
 
50
50
  | Layer | Owner | What it holds |
51
51
  |---|---|---|
52
- | **Conversation** | room server (this repo) | meetings, claims, progress reports, impact notices — every message, all-addressed or DM, in one append-only log |
52
+ | **Conversation** | room server (this repo) | meetings, claims, progress reports, impact notices — explicit single/multi-recipient messages in one append-only log; context is pulled from that log |
53
53
  | **Plan** | [Lattice](https://www.npmjs.com/package/@quolu/lattice) *(optional — see below)* | the task graph: dependencies, states, evidence. What's *ready* is computed, so conversation is spent only on judgment |
54
54
  | **Artifacts** | git | code, docs, commits — per member, path-scoped |
55
55
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "peertable",
3
- "version": "0.3.3",
3
+ "version": "0.3.5",
4
4
  "description": "A round table of peer agents. No orchestrator at the head. Turn Claude Code sessions into a team of equal, long-lived peers.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -35,6 +35,7 @@
35
35
  "room/client.mjs",
36
36
  "room/Dockerfile",
37
37
  "skill/",
38
+ "!skill/**/__pycache__/**",
38
39
  "README.ja.md"
39
40
  ],
40
41
  "engines": {
package/room/client.mjs CHANGED
@@ -10,7 +10,7 @@ import { fileURLToPath } from 'node:url'
10
10
 
11
11
  // client.mjs 側のハードコード版数。package.json の version と一致していることを
12
12
  // diagnostics の version_consistency が見る(2 つの版数源の drift 検出。決定45)
13
- const MCP_VERSION = '0.3.3'
13
+ const MCP_VERSION = '0.3.5'
14
14
  const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
15
15
 
16
16
  const USAGE = `usage:
@@ -36,7 +36,10 @@ if (!URL_BASE || !ROOM || !ME) throw new Error('PEERTABLE_URL / PEERTABLE_ROOM /
36
36
 
37
37
  const api = p => `${URL_BASE}/api/${ROOM}/${p}`
38
38
  const headers = { 'Content-Type': 'application/json', ...(TOKEN ? { 'X-Peertable-Token': TOKEN } : {}) }
39
- const relevant = m => m.from !== ME && (m.to === 'all' || m.to === ME)
39
+ // 新着通知は明示された宛先だけを処理する。旧 `to: 'all'` 行は read_log から読めるが、
40
+ // channel/read_unread のpush面へは載せない。
41
+ const relevant = m => m.from !== ME
42
+ && (Array.isArray(m.to_names) ? m.to_names.includes(ME) : m.to === ME)
40
43
 
41
44
  let cursor = 0 // read_unread 用。参加時点から数える
42
45
 
@@ -47,7 +50,8 @@ const mcp = new Server(
47
50
  instructions:
48
51
  `あなたは Peertable room「${ROOM}」のメンバー「${ME}」である。` +
49
52
  '<channel source="room"> の通知は「新着あり」の合図であり、本文は read_unread ツールで読む。' +
50
- '発言は post ツール(to: "all" は全員宛、メンバー名で個別宛=DM)。',
53
+ '発言は post ツールで、宛先のメンバー名または必要なメンバー名の配列を必ず明示する。' +
54
+ 'broadcast は廃止済み。状況把握が必要なら read_log を自分で読む。',
51
55
  },
52
56
  )
53
57
 
@@ -57,23 +61,29 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
57
61
  tools: [
58
62
  {
59
63
  name: 'post',
60
- description: 'room へ発言する。to "all"(全員宛)またはメンバー名(個別宛=DM)',
64
+ description: 'room へ発言する。to はメンバー名または必要なメンバー名の配列。broadcast は受理されない',
61
65
  inputSchema: {
62
66
  type: 'object',
63
67
  properties: {
64
- to: { type: 'string', description: '"all" またはメンバー名' },
68
+ to: {
69
+ anyOf: [
70
+ { type: 'string', minLength: 1, not: { const: 'all' } },
71
+ { type: 'array', items: { type: 'string', minLength: 1, not: { const: 'all' } }, minItems: 1, uniqueItems: true },
72
+ ],
73
+ description: 'メンバー名または必要なメンバー名の配列。全員宛のshortcutは存在しない',
74
+ },
65
75
  message: { type: 'string' },
66
76
  },
67
77
  required: ['to', 'message'],
68
78
  },
69
79
  },
70
- { name: 'read_unread', description: '未読メッセージ(全員宛と自分宛)を読む。読んだ位置は記憶される', inputSchema: { type: 'object', properties: {} } },
80
+ { name: 'read_unread', description: '自分が明示宛先に含まれる未読メッセージを読む。読んだ位置は記憶される', inputSchema: { type: 'object', properties: {} } },
71
81
  { name: 'read_log', description: 'room ログの直近 count 件を読む(既定 50。全宛先を含む)', inputSchema: { type: 'object', properties: { count: { type: 'number' } } } },
72
82
  { name: 'members', description: 'room に居るメンバーの一覧', inputSchema: { type: 'object', properties: {} } },
73
83
  ],
74
84
  }))
75
85
 
76
- const fmt = m => `[${m.seq}] ${m.from} → ${m.to} (${m.ts}): ${m.body}`
86
+ const fmt = m => `[${m.seq}] ${m.from} → ${Array.isArray(m.to_names) ? m.to_names.join(', ') : m.to} (${m.ts}): ${m.body}`
77
87
 
78
88
  mcp.setRequestHandler(CallToolRequestSchema, async req => {
79
89
  const args = req.params.arguments ?? {}
@@ -81,7 +91,7 @@ mcp.setRequestHandler(CallToolRequestSchema, async req => {
81
91
  case 'post': {
82
92
  const r = await fetch(api('messages'), { method: 'POST', headers, body: JSON.stringify({ from: ME, to: args.to, body: args.message }) })
83
93
  const msg = await r.json()
84
- if (!r.ok) return text(`送信失敗: ${JSON.stringify(msg)}`)
94
+ if (!r.ok) return { isError: true, ...text(`送信失敗: ${JSON.stringify(msg)}`) }
85
95
  // cursor は触らない。自分の発言は relevant で除外されるので進める必要が無く、
86
96
  // ここで進めると post より前に届いた未読を読まないまま既読にしてしまう(0.2.1 で修正)
87
97
  return text(`sent [${msg.seq}]`)
@@ -140,7 +150,10 @@ async function subscribe() {
140
150
  if (!relevant(m)) continue
141
151
  await mcp.notification({
142
152
  method: 'notifications/claude/channel',
143
- params: { content: `room に新着あり(${m.from} → ${m.to})。read_unread で読むこと。`, meta: { from: m.from, to: m.to, seq: String(m.seq) } },
153
+ params: {
154
+ content: `room に新着あり(${m.from} → ${Array.isArray(m.to_names) ? m.to_names.join(', ') : m.to})。read_unread で読むこと。`,
155
+ meta: { from: m.from, to: Array.isArray(m.to_names) ? m.to_names.join(',') : m.to, seq: String(m.seq) },
156
+ },
144
157
  })
145
158
  }
146
159
  }
@@ -219,6 +232,7 @@ async function runDiagnostics(asJson) {
219
232
  'scripts/teardown.sh',
220
233
  'scripts/external-pane.mjs',
221
234
  'scripts/launch-seat.sh',
235
+ 'scripts/change-effort.sh',
222
236
  'scripts/make-plan-input.mjs',
223
237
  'scripts/parent-join.sh',
224
238
  'scripts/wakeup-bridge.mjs',
package/room/server.mjs CHANGED
@@ -45,8 +45,40 @@ function readMessages(room, since = 0) {
45
45
  .map(l => JSON.parse(l)).filter(m => m.seq > since)
46
46
  }
47
47
 
48
- function post(room, from, to, body) {
49
- const msg = { seq: ++room.seq, ts: new Date().toISOString(), from, to, body }
48
+ const recipientError = (code, message) => ({
49
+ schema: 'peertable.error.v1', code, message,
50
+ })
51
+
52
+ // broadcast は protocol 境界で拒否する。複数人宛は `to_names` だけを正本にし、`to: 'all'` へ
53
+ // 倒さない。既存ログの `to: 'all'` 行は readMessages がそのまま返すので、読み出し互換は残る。
54
+ function normalizeAudience(to, toNames) {
55
+ if (to === 'all' || (Array.isArray(to) && to.includes('all'))) return {
56
+ error: recipientError('EXPLICIT_RECIPIENT_REQUIRED', 'broadcast_recipient_not_allowed'),
57
+ }
58
+ const list = Array.isArray(to) ? to : Array.isArray(toNames) ? toNames : null
59
+ if (list === null) {
60
+ if (typeof to !== 'string' || to.length === 0 || to === 'all') return {
61
+ error: recipientError('EXPLICIT_RECIPIENT_REQUIRED', 'broadcast_recipient_not_allowed'),
62
+ }
63
+ return { to, to_names: null }
64
+ }
65
+ if (!list.every(n => typeof n === 'string' && n.length > 0 && n !== 'all')) return {
66
+ error: recipientError('EXPLICIT_RECIPIENT_REQUIRED', 'broadcast_recipient_not_allowed'),
67
+ }
68
+ const names = [...new Set(list)]
69
+ if (names.length === 0) return {
70
+ error: recipientError('EXPLICIT_RECIPIENT_REQUIRED', 'recipient_list_empty'),
71
+ }
72
+ if (names.length === 1) return { to: names[0], to_names: null }
73
+ return { to: null, to_names: names }
74
+ }
75
+
76
+ function post(room, from, to, body, toNames = null) {
77
+ const msg = {
78
+ seq: ++room.seq, ts: new Date().toISOString(), from, body,
79
+ ...(to === null ? {} : { to }),
80
+ ...(toNames ? { to_names: toNames } : {}),
81
+ }
50
82
  appendFileSync(room.logPath, JSON.stringify(msg) + '\n')
51
83
  const chunk = `data: ${JSON.stringify(msg)}\n\n`
52
84
  for (const res of room.streams) res.write(chunk)
@@ -73,7 +105,10 @@ http.createServer(async (req, res) => {
73
105
  return json(res, 200, { messages: readMessages(room, Number(url.searchParams.get('since') ?? 0)) }, CORS)
74
106
 
75
107
  if (req.method === 'GET' && rest === 'members')
76
- return json(res, 200, { members: [...room.members].map(([name, meta]) => ({ name, ...meta })) }, CORS)
108
+ return json(res, 200, {
109
+ members: [...room.members].map(([name, meta]) => ({ name, ...meta })),
110
+ capabilities: { member_observation_v1: true },
111
+ }, CORS)
77
112
 
78
113
  if (req.method === 'GET' && rest === 'events') {
79
114
  res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive', ...CORS })
@@ -92,12 +127,14 @@ http.createServer(async (req, res) => {
92
127
  return json(res, 403, { error: 'token_required' })
93
128
 
94
129
  if (req.method === 'POST' && rest === 'messages') {
95
- const { from, to, body: text } = JSON.parse(body)
130
+ const { from, to, to_names: toNames, body: text } = JSON.parse(body)
96
131
  // 本文が無ければ **書かずに 400**。ここを素通しにすると `JSON.stringify` が欄ごと落として、
97
132
  // append-only の正本へ**本文の無い行**が入る——しかも送信側には 200 と seq が返るので
98
133
  // 「送れた」と表示される(2026-08-08 に本番で2件実測。消せない)
99
134
  if (typeof text !== 'string') return json(res, 400, { error: 'body_required' })
100
- return json(res, 200, post(room, from, to ?? 'all', text))
135
+ const audience = normalizeAudience(to, toNames)
136
+ if (audience.error) return json(res, 400, audience.error)
137
+ return json(res, 200, post(room, from, audience.to, text, audience.to_names))
101
138
  }
102
139
  if (req.method === 'POST' && rest === 'members') {
103
140
  const { name, ...meta } = JSON.parse(body)
@@ -109,7 +146,7 @@ http.createServer(async (req, res) => {
109
146
  saveMembers(room)
110
147
  // **system 発言は本当に新規の時だけ**。欄の更新で「参加した」を流すと、状態を数秒ごとに
111
148
  // 送る消費者が卓の全席を起こし続ける(既存メンバーへの再 POST で実測・room [285])
112
- if (!known) post(room, 'system', 'all', `${name} が参加した`)
149
+ if (!known) post(room, 'system', name, `${name} が参加した`)
113
150
  return json(res, 200, { ok: true })
114
151
  }
115
152
  if (req.method === 'DELETE' && seg[2] === 'members' && seg[3]) {
@@ -172,19 +209,32 @@ const UI = room => `<!doctype html><html lang="ja"><head><meta charset="utf-8"><
172
209
  .metapop{position:fixed;z-index:20;background:var(--surface);border:1px solid var(--line);border-radius:10px;padding:8px 10px;font-size:12px;box-shadow:0 6px 20px rgba(0,0,0,.18);max-width:70vw}
173
210
  .metapop .metaname{font-weight:600;margin-bottom:2px}
174
211
  .metapop .metaline{color:var(--dim)}
175
- .chip{flex:none;display:flex;align-items:center;gap:7px;padding:3px 11px 3px 3px;border:1px solid var(--line);border-radius:999px;background:var(--surface);font-size:12px;font-weight:600}
212
+ .chip{position:relative;flex:none;display:flex;align-items:center;gap:7px;padding:3px 11px 3px 3px;border:1px solid var(--line);border-radius:999px;background:var(--surface);font-size:12px;font-weight:600}
176
213
  .chip .av{width:22px;height:22px;font-size:11px}
177
214
  .chip.recent{border-color:hsl(var(--h) var(--sat) var(--edge))}
178
215
  .chip.recent .nm{color:hsl(var(--h) var(--sat) var(--name))}
179
216
  .chip.pulse .av{animation:pulse 1.6s ease-out 2}
217
+ .chip.is-busy .av{animation:seat-working 1.15s ease-in-out infinite;box-shadow:0 0 0 2px color-mix(in srgb,var(--busy) 38%,transparent)}
218
+ .chip.is-busy.pulse .av{animation:seat-working 1.15s ease-in-out infinite,pulse 1.6s ease-out 2}
219
+ .chip.is-busy .st{animation:seat-beat .8s ease-in-out infinite alternate}
220
+ @keyframes seat-working{0%,100%{transform:translateY(0) rotate(-2deg)}50%{transform:translateY(-2px) rotate(2deg)}}
221
+ @keyframes seat-beat{from{transform:scale(.78);opacity:.7}to{transform:scale(1.28);opacity:1}}
222
+ .victory{position:fixed;z-index:30;left:var(--victory-x);top:var(--victory-y);pointer-events:none;color:var(--busy);font-size:13px;font-weight:800;letter-spacing:.03em;text-shadow:0 1px 0 var(--surface);animation:victory-rise 1.45s cubic-bezier(.18,.8,.3,1) both}
223
+ @keyframes victory-rise{0%{opacity:0;transform:translate(-50%,8px) scale(.75)}18%{opacity:1;transform:translate(-50%,-7px) scale(1.08)}72%{opacity:1;transform:translate(-50%,-15px) scale(1)}100%{opacity:0;transform:translate(-50%,-27px) scale(.92)}}
180
224
  @keyframes pulse{from{box-shadow:0 0 0 0 hsl(var(--h) var(--sat) var(--lum)/.6)}to{box-shadow:0 0 0 9px hsl(var(--h) var(--sat) var(--lum)/0)}}
181
225
  .log{max-width:760px;margin:0 auto;padding:14px 16px 40px;display:flex;flex-direction:column;gap:10px}
182
226
  .msg{display:flex;gap:9px;align-items:flex-start}
227
+ .msg.flow,.sys.flow{animation:message-flow .36s cubic-bezier(.22,1,.36,1) both}
228
+ @keyframes message-flow{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:translateY(0)}}
229
+ @media (prefers-reduced-motion:reduce){.msg.flow,.sys.flow{animation:none}}
230
+ @media (prefers-reduced-motion:reduce){.chip.is-busy .av,.chip.is-busy .st,.victory{animation:none}}
183
231
  .msg.cont{margin-top:-8px}.msg.cont .av{visibility:hidden;height:0}.msg.cont .meta{display:none}
184
232
  .msg .body{min-width:0;max-width:100%}
185
233
  .meta{display:flex;align-items:baseline;gap:7px;flex-wrap:wrap;margin:0 0 3px 2px;font-size:12px}
186
234
  .who{font-weight:700;color:hsl(var(--h) var(--sat) var(--name))}
187
235
  .to{color:var(--dim)}.ts{color:var(--dim);font-variant-numeric:tabular-nums}
236
+ .seq{color:var(--dim);font:500 10px/1.2 ui-monospace,SFMono-Regular,Menlo,monospace;font-variant-numeric:tabular-nums;user-select:all;white-space:nowrap}
237
+ .msg .seq{display:block;width:max-content;margin:2px 3px 0 auto}
188
238
  .bubble{padding:8px 12px;border-radius:4px 13px 13px 13px;background:hsl(var(--h) var(--sat) var(--tint));border:1px solid hsl(var(--h) var(--sat) var(--edge)/.45);overflow-wrap:anywhere}
189
239
  .bubble>*{margin:0}.bubble>*+*{margin-top:7px}
190
240
  .bubble code{font:500 .92em ui-monospace,SFMono-Regular,Menlo,monospace;background:hsl(var(--h) var(--sat) var(--edge)/.22);border-radius:3px;padding:.1em .35em}
@@ -267,6 +317,8 @@ function md(src){
267
317
  const hue=n=>{let h=5381;for(let i=0;i<n.length;i++)h=(h*33+n.charCodeAt(i))|0;return Math.abs(h)%360}
268
318
  const initial=n=>{const c=[...String(n)][0];return c?c.toUpperCase():'?'}
269
319
  const stamp=at=>{const t=el('time','ts',at.toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'}));t.dateTime=at.toISOString();t.title=at.toLocaleString();return t}
320
+ const compactCount=n=>n>=1000000?(Math.round(n/100000)/10)+'M':n>=1000?(Math.round(n/100)/10)+'k':String(n)
321
+ const elapsed=ms=>{const min=Math.floor(ms/60000);return min<1?'<1m':min>=60?Math.floor(min/60)+'h '+(min%60)+'m':min+'m'}
270
322
  const nearBottom=()=>window.innerHeight+window.scrollY>=document.body.offsetHeight-80
271
323
  // ボタンの出し入れと SSE の自動追従は同じ nearBottom で判断する。別々の閾値を持つと
272
324
  // 「ボタンは消えているのに追従しない」帯ができて、どちらが壊れたのか分からなくなる
@@ -279,18 +331,20 @@ window.addEventListener('resize',syncToBottom,{passive:true})
279
331
  let last=null,recent=null
280
332
  function render(m){
281
333
  const at=new Date(m.ts)
282
- if(m.from==='system'){const d=el('div','sys');d.appendChild(el('span','body',m.body));d.appendChild(stamp(at));logEl.appendChild(d);last=null;return}
283
- const cont=last&&last.from===m.from&&last.to===m.to&&at-new Date(last.ts)<300000
284
- const d=el('div','msg'+(m.to!=='all'?' dm':'')+(cont?' cont':''))
334
+ const seq=()=>el('span','seq','['+m.seq+']')
335
+ if(m.from==='system'){const d=el('div','sys');d.appendChild(el('span','body',m.body));d.appendChild(seq());d.appendChild(stamp(at));logEl.appendChild(d);last=null;return d}
336
+ const aud=m=>Array.isArray(m.to_names)?m.to_names.join(', '):m.to
337
+ const cont=last&&last.from===m.from&&aud(last)===aud(m)&&at-new Date(last.ts)<300000
338
+ const d=el('div','msg'+(aud(m)!=='all'?' dm':'')+(cont?' cont':''))
285
339
  d.style.setProperty('--h',hue(m.from))
286
340
  d.appendChild(el('div','av',initial(m.from)))
287
341
  const body=el('div','body'),meta=el('div','meta')
288
342
  meta.appendChild(el('span','who',m.from))
289
- if(m.to!=='all')meta.appendChild(el('span','to','→ '+m.to))
343
+ if(aud(m)!=='all')meta.appendChild(el('span','to','→ '+aud(m)))
290
344
  meta.appendChild(stamp(at))
291
345
  const bub=el('div','bubble');bub.appendChild(md(m.body))
292
- body.appendChild(meta);body.appendChild(bub)
293
- d.appendChild(body);logEl.appendChild(d);last=m
346
+ body.appendChild(meta);body.appendChild(bub);body.appendChild(seq())
347
+ d.appendChild(body);logEl.appendChild(d);last=m;return d
294
348
  }
295
349
  async function refreshMembers(){
296
350
  const r=await(await fetch(api('members'))).json()
@@ -305,7 +359,13 @@ async function refreshMembers(){
305
359
  // いちばん悪い(動いていない席を「動いている」と見せる)。閾値は bridge の心拍30秒の3倍
306
360
  const age=m.status_at?Date.now()-Date.parse(m.status_at):Infinity
307
361
  const st=(m.status&&age<STATUS_STALE_MS)?m.status:(m.status?'unknown':null)
362
+ if(st)c.classList.add('is-'+st)
308
363
  if(st)meta.push('状態 '+({busy:'作業中',idle:'待機',dead:'停止',unknown:'不明(報告が途絶えている)'}[st]??st))
364
+ const usage=[]
365
+ const busyAge=m.busy_since?Date.now()-Date.parse(m.busy_since):NaN
366
+ if(st==='busy'&&Number.isFinite(busyAge)&&busyAge>=0)usage.push('継続 '+elapsed(busyAge))
367
+ if(Number.isSafeInteger(m.pane_token_hint)&&m.pane_token_hint>=0)usage.push(compactCount(m.pane_token_hint)+' tokens')
368
+ if(usage.length)meta.push('消費目安 '+usage.join(' / ')+'(pane観測)')
309
369
  c.title=m.name+'(参加 '+new Date(m.joined_at).toLocaleString()+')'+(meta.length?'\\n'+meta.join('\\n'):'')
310
370
  c.appendChild(el('span','av',initial(m.name)));c.appendChild(el('span','nm',m.name))
311
371
  if(st)c.appendChild(el('span','st '+st))
@@ -343,15 +403,29 @@ function markActive(name){
343
403
  }
344
404
  if(!known)refreshMembers()
345
405
  }
406
+ // 完了の祝祭はlive SSEからだけ呼ぶ。catch-upで再生すると再接続のたびに過去の完了が光る。
407
+ function isCompletion(m){
408
+ return m.from!=='system'&&typeof m.body==='string'&&/^(?:\\[(?:done|完了|accept(?:ed)?|受理)\\]|(?:受入|受理)[::\\s])/i.test(m.body)
409
+ }
410
+ function celebrate(name){
411
+ const chip=[...membersEl.children].find(c=>c.dataset.name===name)
412
+ if(!chip)return
413
+ const r=chip.getBoundingClientRect(),v=el('span','victory','✦ 完了!')
414
+ v.setAttribute('role','status');v.setAttribute('aria-label',name+'の作業が完了')
415
+ v.style.setProperty('--victory-x',Math.max(44,Math.min(innerWidth-44,r.left+r.width/2))+'px')
416
+ v.style.setProperty('--victory-y',Math.max(8,r.top)+'px')
417
+ document.body.appendChild(v);setTimeout(()=>v.remove(),1600)
418
+ }
346
419
  const BEAT=${HEARTBEAT_MS}
347
420
  const STATUS_STALE_MS=90000 // 稼働状態の鮮度。これを過ぎた報告は unknown(bridge 心拍30秒の3倍)
348
421
  let lastSeq=0,lastBeat=Date.now(),es=null,emptyEl=null,firstLoad=true,catching=false
349
422
  // seq で二重描画を弾く。張り直し後の追いつきと SSE の新着が重なっても同じ発言は1回しか出ない
350
- function apply(m){
423
+ function apply(m,live=false){
351
424
  if(m.seq<=lastSeq)return false
352
425
  lastSeq=m.seq
353
426
  if(emptyEl){emptyEl.remove();emptyEl=null}
354
- render(m)
427
+ const row=render(m)
428
+ if(live)row.classList.add('flow')
355
429
  if(m.from!=='system')recent=m.from
356
430
  return true
357
431
  }
@@ -361,7 +435,8 @@ async function catchUp(force){
361
435
  try{
362
436
  const stick=force||nearBottom()
363
437
  const r=await(await fetch(api('messages')+'?since='+lastSeq)).json()
364
- const added=r.messages.filter(apply).length
438
+ let added=0
439
+ for(const m of r.messages)if(apply(m,false))added++
365
440
  if(!lastSeq&&!emptyEl){emptyEl=el('div','empty','(まだ発言がない)');logEl.appendChild(emptyEl)}
366
441
  await refreshMembers()
367
442
  if(added&&stick)window.scrollTo(0,document.body.scrollHeight)
@@ -379,8 +454,8 @@ function connect(){
379
454
  es.onmessage=e=>{
380
455
  lastBeat=Date.now()
381
456
  const m=JSON.parse(e.data),stick=nearBottom()
382
- if(!apply(m))return
383
- if(m.from==='system')refreshMembers();else markActive(m.from)
457
+ if(!apply(m,true))return
458
+ if(m.from==='system')refreshMembers();else{markActive(m.from);if(isCompletion(m))celebrate(m.from)}
384
459
  if(stick)window.scrollTo(0,document.body.scrollHeight)
385
460
  syncToBottom()
386
461
  }
package/skill/SKILL.md CHANGED
@@ -31,6 +31,7 @@ description: 任意プロジェクトに Peertable チーム(対等メンバ
31
31
 
32
32
  1. **聞き取り**: 対象プロジェクトのパス / **工程正本(`Lattice 併用`=既定 / `単独`)** / メンバー数とモデル・effort(モデル既定: Sonnet、effort既定: CLI 既定。モデル選定は作業の性質——設計か確定実装か——を軸にする。決定49)/ 初期タスク群(何を作るか)/ room 名(既定: プロジェクトのディレクトリ名)/ **公開URL基底**(Lattice 併用のみ。外部ペインに書く URL。クオ環境は `https://peertable.kitepon.dev`。未指定なら room サーバーの URL がそのまま入る=LAN URL は Lattice を外から見た時に開けない)
33
33
  - **メンバー数の既定**: Lattice 併用なら plan compile 結果の幅(`max_frontier_width`)に合わせる(実測: 幅3→3人、第2 campaign で幅4→4人目追加)。frontier より多い席は最初から遊ぶ。単独モードには frontier が無いので既定の根拠も無く、聞き取りで決める
34
+ - **運用中の席数の標準は「ready+activeな実装ToDo数」(決定68)**: 起動時の幅は計画値、この式は生きた実測値で、campaignが進めば自然に縮む。標準を上回った席は親が縮退する。監査・受入は実装席が兼ねる(決定67)ので監査要員を席数に数えない。claimできるToDoが無い席は仕事を発明せず(依頼されていない監査・照会・自主レビュー禁止)、待機宣言して黙るか親に畳まれる
34
35
  - **モードの選び分け**: タスク間に依存があり並列境界の機械保証が要るなら Lattice 併用。依存の無い小規模作業で、対象プロジェクトに Lattice を持ち込みたくないなら単独。単独で失うのは task 間スケジューリングの機械保証だけで、円卓の核(room・憲章・宣言による協力)は変わらない(決定47)
35
36
  2. **命名**: メンバーに日本のアニメキャラ風の可愛い名前を都度決める(固定リストなし)。識別子(tmux セッション名・room 登録名・Lattice actor)はローマ字、表示・自己紹介は日本語(決定35)
36
37
  3. **scaffold**: `PEERTABLE_PUBLIC_URL=<公開URL基底> scripts/setup.sh <project> <room> <server_url> <plan_key|-> <peertable_repo> [tasks_file]` を実行する。`.team/`(憲章・roles/member.md ほか)と project root の `.mcp.json`(room MCP 定義。決定44)を templates から生成・置換し、`.git/info/exclude` へ `.team/` と `/.mcp.json` を追記し、作成記録を `.team/setup-state.json`(`mode` を含む)に残す
@@ -44,15 +45,18 @@ description: 任意プロジェクトに Peertable チーム(対等メンバ
44
45
  - 起動前に `pty_list` で既存の `peer-*` 席を確認する(前の卓の残骸を99席実測したことがある)。同名の席は launch-seat.sh が落としてから立て直す
45
46
  - 着任指示を第6引数に渡すと着席後に送る。文面: 「あなたは「<日本語名>」。.team/roles/member.md を読んで着任し、作業ループを開始せよ。全タスク完了の宣言まで自律的に続けること。」
46
47
  - 席が読む env は script が組み立てる(`PEERTABLE_URL` / `PEERTABLE_ROOM` / `PEERTABLE_MEMBER` / `PEERTABLE_POST_TOKEN`、Lattice 併用なら `PEERTABLE_PLAN` と actor 3点)。**channels は `--mcp-config` の MCP server を解決しない**(実測 2026-08-08・Claude Code v2.1.226・決定44)ため、room の MCP 定義は setup.sh が project root へ置く `.mcp.json` が正。project に既存 `.mcp.json` があった場合 setup.sh は上書きせず警告を出すので、AI が手動 merge して teardown で復元する
47
- - **Codex 席**(`vendor=codex`): Codex には channels が無いので、room は `-c` 上書きの stdio MCP として差す(`mcp_servers.room.command="peertable-client"` と `mcp_servers.room.env={…}`)。**env は closed mode で親環境を継がない**ので `PATH` を含む全変数を明示列挙する。モデル名は ChatGPT アカウントで使える slug を渡す(`~/.codex/config.toml` の `model` が既定値の参考。使えない slug は起動後の最初のターンで 400 になって初めて分かる)。Codex 席を混ぜたら**必ず起床ブリッジを立てる**(下記)——立てないと room の新着で起きない
48
- 6. **起床ブリッジ(Codex 席がある時だけ)**: `nohup node scripts/wakeup-bridge.mjs <project> <codex席名>… > <project>/.team/wakeup-bridge.log 2>&1 &`。room の SSE を購読し、その席宛/全員宛の新着(自分の発言は除く)を tmux へ素送信して起こす。**Codex はターン実行中でも素送信を受け付け、その文言をそのターンの中で読む**(実測)ので idle 待ちはしない。停止は `node scripts/wakeup-bridge.mjs <project> --stop`(teardown.sh が自動で行う)
48
+ - **Codex 席**(`vendor=codex`): Codex には channels が無いので、room は `-c` 上書きの stdio MCP として差す(`mcp_servers.room.command="peertable-client"` と `mcp_servers.room.env={…}`)。**env は closed mode で親環境を継がない**ので `PATH` を含む全変数を明示列挙する。effortはmember metadataだけでなく`model_reasoning_effort`へも同じ値を渡す。モデル名は ChatGPT アカウントで使える slug を渡す(`~/.codex/config.toml` の `model` が既定値の参考。使えない slug は起動後の最初のターンで 400 になって初めて分かる)。Codex 席を混ぜたら**必ず起床ブリッジを立てる**(下記)——立てないと room の新着で起きない
49
+ 6. **起床ブリッジ(Codex 席がある時だけ)**: `nohup node scripts/wakeup-bridge.mjs <project> <codex席名>… > <project>/.team/wakeup-bridge.log 2>&1 &`。room の SSE を購読し、明示的にその席宛の新着(自分の発言は除く)だけを tmux へ素送信して起こす。**Codex はターン実行中でも素送信を受け付け、その文言をそのターンの中で読む**(実測)ので idle 待ちはしない。停止は `node scripts/wakeup-bridge.mjs <project> --stop`(teardown.sh が自動で行う)
49
50
  - **黙って止まらないための三段**(決定58 の受信側の作法): ①75秒なにも届かなければ自分から切って繋ぎ直す ②繋ぎ直したら `?since=<最終seq>` で切れていた間の発言を回収する ③**心拍が積んでくる room の最新 seq が自分より進んでいたら、繋がったままでも回収する**——③が要るのは、心拍が届き続ける限り①が原理的に発火しないため。**server 側の心拍(`event: ping`・25秒周期)が前提**なので、古い room サーバーへ繋ぐと①だけが効く形になる
50
51
  - ログは `.team/wakeup-bridge.log`。**0件でも0件と出す**ので、起こせているか・取りこぼしていないかはログを見れば分かる。再現ハーネスは `experiments/bridge-catchup-repro.mjs`
51
52
 
52
53
  6.5 **席の稼働状態ブリッジ(任意)**: `nohup node scripts/seat-status-bridge.mjs <project> > <project>/.team/seat-status-bridge.log 2>&1 &`。**room の members に居る席だけ**の tmux pane を読み(`peer-<名前>`)、`busy`(画面に `esc to interrupt` が在る。**Claude 席・Codex 席の共通マーカーで、スピナーの語は毎回変わるので使わない**)/`idle`/`dead`(`pane_dead`・セッション消失)を判定して room サーバーへ送る。**AI は使わず、席へは1バイトも送らない**。参加者一覧に点が出て、**報告が途絶えたら `unknown`(中空の輪)へ落ちる**——古い状態を出し続けない。**server が稼働状態を保持しない版なら1件も送らない**(送ると保存されないうえに system 発言を撒く)。停止は `node scripts/seat-status-bridge.mjs <project> --stop`(**teardown.sh が自動で行う**)
53
54
  - **起こすかは卓の任意**(setup は自動で起こさない)。起こしたら**必ず teardown で止まる**——止め忘れると、`.team/` と一緒に pid 記録が消えて **`--stop` でも止められない常駐**が残る
54
55
 
55
- 7. **親の着卓**(このセッション): `scripts/parent-join.sh <project> [name] [kickoff_file]` member 登録と kickoff 投稿を行い、SSE を Monitor で張る。以後の post も API 直(下記「親の operating notes」)
56
+ 6.7 **effort変更(本人要請→親実行)**: 本人は親だけへ`[effort変更依頼] <level>`を明示DMする。親は席がidleなのを確認して`skill/scripts/change-effort.sh <project> <member> <level> [parent_name]`を実行する。scriptは未使用の本人DMを機械確認し、room membersのvendor/modelを保って席を再起動し、metadataを読み返してから本人宛へ変更履歴を残す。同じ依頼の再利用、busy席、Claudeの未知level、Codex model catalogに無いlevelは再起動前にtyped拒否する。新設定での起動失敗時は旧effortで1回だけ明示rollbackし、両方失敗なら手動復旧が必要だと非0で出す。
57
+ - **会話contextは引き継がない**。本人は作業を安全に中断できる時だけ依頼し、再起動後はrole・工程正本・roomログから再着任する。変更できたのに履歴記録だけ失敗した場合も成功へ丸めず、`EFFORT_CHANGE_CHANGED_BUT_HISTORY_FAILED`で現在状態を明示する
58
+
59
+ 7. **親の着卓**(このセッション): `scripts/parent-join.sh <project> [name] [model] [effort]` で member 登録を行い、**bell宛DM番犬**を Monitor で張る(形は下記「親の operating notes」の番犬仕様)。broadcastのkickoffは廃止済み。以後の post も API 直(同 notes)
56
60
  8. **起動確認**: room の members に全員いる / 最初の claim が room に流れる(Lattice 併用モードはそれが Lattice へ到達している=`lattice todo status --json` の active に出ることも確認する。単独モードは room の claim 宣言だけが到達の証拠)/ Web UI で観測できる、をチェックして報告する
57
61
 
58
62
  ## teardown
@@ -64,11 +68,12 @@ description: 任意プロジェクトに Peertable チーム(対等メンバ
64
68
  段の順序(**前の段が後の段の前提**):
65
69
  1. **room ログの写し**(archive のみ)→ `docs/archive/room-log_<room>_<日時>.md`。**原本は room に残る**ので、これは repo 側の控え(失敗しても撤去は続行する)
66
70
  2. **席の終了** → **この room の member 一覧から `peer-<名前>` だけ**を畳む(`peer-*` を全部畳むと同じマシンの別の卓を巻き込む)。他卓の席が残っていれば注記だけ出す
67
- 3. **ブリッジの停止**(起床・稼働状態)→ `.team/` を消す前(pid 記録がその中にある)
68
- 4. **解散**(archive): **履歴へ解散の区切りを1行投稿してから、メンバー登録だけ外す**。**部屋も過去ログも消さない**——区切りが無いと、次の卓の発言が前の卓と地続きに読める/**`--purge`**: room ごと削除(トークンを要する唯一の段)
69
- 5. **外部ペインの復元** `.team/project.json.bak` が退避先なので `.team/` を消す前
70
- 6. `.team/` 削除 → `.mcp.json` `.git/info/exclude` の追記行を戻す
71
- 7. **`.lattice/`**: archive では**残す**(`lattice todo status` `gantt serve` が読む)。`--purge` かつ setup が作ったものなら削除。**残しても git 追跡外なら次の clone に残らない**ので、残すなら commit する(script は注記を出すだけ——他人の repo へ勝手に commit しない)
71
+ 3. **ブリッジの停止**(起床・稼働状態・run 可視化)→ `.team/` を消す前(pid 記録がその中にある)
72
+ 4. **runの着地読み出し** ブリッジ停止後・`.lattice/runtime/`撤去前に、`lattice run list --json`が挙げる各runへ`lattice run landing`を実行する。出力の`landed`と`repository.unpushed_commits`を監査記録として残す。**未着地・未pushは判断結果でありexit 0**——runがclose済みでも着地済みとは限らない。release前のsource CLIを使う時はsetupと同じ`LATTICE_CLI`をteardownにも渡す
73
+ 5. **解散**(archive): **履歴へ解散の区切りを1行投稿してから、メンバー登録だけ外す**。**部屋も過去ログも消さない**——区切りが無いと、次の卓の発言が前の卓と地続きに読める/**`--purge`**: room ごと削除(トークンを要する唯一の段)
74
+ 6. **外部ペインの復元** → `.team/project.json.bak` が退避先なので `.team/` を消す前
75
+ 7. `.team/` 削除 `.mcp.json` `.git/info/exclude` の追記行を戻す
76
+ 8. **`.lattice/`**: archive では**残す**(`lattice todo status` と `gantt serve` が読む)。`--purge` かつ setup が作ったものなら削除。**残しても git 追跡外なら次の clone に残らない**ので、残すなら commit する(script は注記を出すだけ——他人の repo へ勝手に commit しない)
72
77
 
73
78
  **次の卓を同じ部屋で立てる時は、setup の room 名を前と同じにする**。member は席が戻れば再登録され、履歴は続く。
74
79
 
@@ -76,30 +81,99 @@ description: 任意プロジェクトに Peertable チーム(対等メンバ
76
81
 
77
82
  各段は `[実施] / [スキップ] / [未実施]` を1行ずつ出す。**トークンを要するのは room 削除だけ**なので、そこが失敗しても残りの撤去は続行し、未実施を明示して非ゼロで終わる(黙って中断しない・決定58)。未実施が出ても**撤去そのものは済んでいる**。残りは表示された **[手当] の curl を手で叩く**だけで、`.team/` は既に消えているので **teardown.sh の再実行はできない**(2026-08-08 実測。再実行すると `setup-state.json` が読めず落ちる)。
78
83
 
84
+ ## Lattice の実行層へ席の着手を載せる(pull 型・Lattice 併用モードだけ)
85
+
86
+ **装置は仕事を配らない。** 2026-08-09 のオーナー裁定(改・裁定1)で、Lattice が task を選んで席へ配る向きは撤回された——**作業を選ぶのも始めるのも席(AI)**であり、装置がやるのは**着手済み ToDo 同士の競合判定と介入だけ**である。着手前に装置の許可を待つ面も作らない。旧版にあった `[配車]` / `[受諾]` / `[辞退]` の3層は無くなり、**claim は従来どおり割当の主体に戻った**(決定25 のとおり)。
87
+
88
+ - **pull run は卓が作る設備であって、装置が用意するものでも setup が作るものでもない。** `run list --json` で同 plan の active な pull run(`selection: "pull"`)を確認し、**0件なら room で生成担当を1席決めてから** `run start --selection pull --id <plan>-<一意suffix> --plan <key> --equipment detached-worktree` で作り、`run_ref` を room へ共有する。**1件ならそれを共有**(席ごとに作らない)、**複数件は止めて卓で決める**
89
+ - **id に plan key だけの固定値を使わない。** `close` しても run directory は残り `run list` は closed を返さないので、**「無いのに `RUN_EXISTS` で作れない」**袋小路に入る(2026-08-09 実測)
90
+ - `RUN_EXISTS` が返ったら**相手の run を推定せず**、再 list → active があれば共有、無ければ別の一意 id で作り直す
91
+ - **席は自分で `todo start` してから `run intake` する。** intake が返すのは隔離 worktree と `intervention`(`none` か `hold`)で、**許可証ではない**。`hold` は「他の着手済み task と競合しているから留まれ」という装置の指示である
92
+ - **`todo done` は canonical の cwd/store へ打ち、証跡は worktree から渡す。** `done.sh <task> --evidence-from <worktree>/evidence/<plan>/<task>.md`。cwd 1つで兼ねると必ずどちらかが外れる(canonical では証跡が読めず、worktree では accept が見ない store を書く)。**canonical へ証跡を複製して通すのは偽装**——linked worktree は object DB を共有するので複製は要らない
93
+ - **worktree と lease は設備の供給である。** 席が要求すれば出てくるもので、出してもらうものではない
94
+ - **席と spool は接触しない。** 席が触るのは room と worktree だけで、`.lattice/` の直読み・直書き禁止の契約はそのまま
95
+ - **席の作法の正本は `templates/member.md` の「Lattice の実行層へ自分の着手を載せる」節**(intake→intervention 判定→attach→作業→`todo done`→accept の順・1席1 intake・禁止操作・検証の回し方・成果の正本)。ここに二重化しない
96
+ - **席は自分の pid を装置へ渡す(attach)。** その pid は `.team/seats/<名前>.json`(`launch-seat.sh` が着席直後に書く)が持ち、席は読んで `schema` を足すだけで attach input になる。**raw argv を保存しない**——Codex 起動の argv には `PEERTABLE_POST_TOKEN` が載るので、保存すれば秘密の複製になる(2026-08-09 実測)。持つのは digest だけ
97
+ - **run-bridge は可視化の中継であって、必須経路ではない**(決定63)。落ちていても席は自分で intake / attach / accept を打てるし、介入も `lattice run intake intervention --run <ref> --task <id>` で自分で読める。**見えなくなるだけ**である
98
+ - **ブリッジの起動**: `PEERTABLE_POST_TOKEN=… nohup node scripts/run-bridge.mjs <project> [--lattice <path>] > <project>/.team/run-bridge.log 2>&1 &`。停止は `node scripts/run-bridge.mjs <project> --stop`(**teardown.sh が自動で行う**)。**spool dir も席名も取らない**(配車をしないので要らない。渡すと typed error で落ちる)。ADR 0157 の作法(pid 記録・起動時に前の記録を掃除・SIGTERM→SIGKILL)はそのまま
99
+ - `--lattice <path>` は release 前の source tree を実測する時に使う。**PATH の install は version 表示が同じでも未 publish の schema を読めない**(2026-08-09 に卓で3件実測)
100
+ - bridge が返すのは2つだけで**どちらも読み取り**: `[run] <run-id> intake=[…] accepted=[…] hold=[…] closed=…`(変化時だけ)と、`hold` になった intake をその作業を始めた席宛へ返す `[介入]`
101
+
102
+ 運用側が踏みやすい所(実測で確認した挙動):
103
+
104
+ - **worktree は `run close` でも supervisor 終了でも畳まれない。** 畳むのは `run abandon` だけである。**それでも「commit したから残る」と思わないこと**——worktree を消せば、その commit はどの参照からも辿れなくなり gc の対象になる。**成果の正本は Lattice が撮った observed diff** であって席の commit ではない。着地は run の外の工程で、`accept` も `run close` も着地の宣言ではない(着地状況は `run landing` が receipt 単位で出す)
105
+ - **worktree には gitignore 済みの資産が無い**(`node_modules` 等)が、**席に install させない**。checkpoint 観測は `git status --ignored=matching` で撮る(gitignore 経由の scope 迂回を塞ぐ設計)ので、install した file が全部観測へ出て、diff entry 上限 256 を超えた時点で観測ごと落ちる(実測: ignored 300本で `diff entry数が上限を超える`)。**依存は install 無しで解決する**——worktree が repo 配下(`<repo>/.lattice/runs/…/tree`)に切られるので、Node の bare specifier 解決が親を遡って canonical の `node_modules` に当たる(repo の外へ置くと `ERR_MODULE_NOT_FOUND`)。当たるのは canonical の版なので、lockfile を動かす task の検証結果は疑う。canonical tree で回させない——測りたい木ではない
106
+ - **宣言境界の外への書き込みは黙って弾かれず、観測に出る。** 席へは「隠すな、room で言え」と伝わっている
107
+
108
+ ## 線(共有プロトコル)を資源として宣言する(Lattice 併用モード)
109
+
110
+ **path が1つも重ならない2つの task が壊れ合うことがある。** 2026-08-08 の卓で実際に起きた: 片方が SSE のワイヤへ新しい event 種別を足した瞬間、そのストリームを読む側が壊れた。compile から見て完全に独立で、実際そう扱われていた。**依存は path ではなく共有プロトコルにあった。**
111
+
112
+ これを宣言できるのが**線**である。witness set を書く時、path・symbol の owns/reads/writes に加えて `lines` を書く(**省略可。省略=線の宣言なし**)。受理するのは witness set v5 / run_request v5 / boundary manifest v4 以降だけで、旧版へ書けば typed reject になる。
113
+
114
+ ```json
115
+ "lines": [
116
+ {
117
+ "line_id": "src.runtime-diff-observer.mjs--finding-kind",
118
+ "role": "writes",
119
+ "anchors": [
120
+ { "kind": "path", "path": "src/runtime-diff-observer.mjs" },
121
+ { "kind": "symbol", "name": "detectCheckpointFindings", "path": "src/runtime-diff-observer.mjs" }
122
+ ]
123
+ }
124
+ ]
125
+ ```
126
+
127
+ - **`role`** は `writes`(線の形を変える側)か `reads`(その形に依存する側)。同じ `line_id` を別 task が持つ時、**`writes`×`reads` と `writes`×`writes` は直列化される。並列でいられるのは `reads`×`reads` だけ**——形を変える側が2人居るなら、その2人こそ揃えないと壊れるからである
128
+ - **`line_id` が一致した時だけ交差する。** 機械は anchor の重なりから「同じ線だろう」と推測しない——推測を装置に入れない設計であり、**綴りを揃える責任は宣言する側(AI)にある**
129
+ - **命名は錨から機械的に導く**: `anchors` **先頭**の repo-relative path の `/` を `.` に置換し、必要なら `--<種別>` を suffix する。`line_id` に使える文字は `[0-9A-Za-z._-]`(先頭は英数字・128文字まで)で、`/` も `:` も入らないのでこの置換が要る。**思いつきで名前を付けない**——揃わなければ交差は素通りする
130
+ - **名前を決めるのは最初に宣言した側だけ。** 後から同じ線を宣言する側は**再導出せず、既に在る `line_id` をそのまま写す**。錨が複数ある線で各自が「主たる錨」を選び直すと、同じ線に2つの名前が生まれて交差が消える
131
+ - **綴りが揃わなかった分は実行時が拾う。** 実際の変更 diff を錨の path へ近似して finding にし、その線の読み手を hold 閉包へ入れる。**計画時の宣言と実行時の観測の二段構え**であって、宣言だけで閉じる設計ではない。だから宣言漏れは致命ではないが、**漏れた分は「変更した後」にしか分からない**
132
+ - **錨は同じ repo の relative path だけ**(絶対 path は typed reject・`anchors` は最低1本)。越境 task(別 repo の file)を錨にすると**形式は通る**が、その path はこの repo に存在しないので**実行時の近似は永久に当たらない**。越境の線は「計画時の宣言としてだけ効く」と理解して使う(欠陥ではなく境界)
133
+ - **1つの task が同じ `line_id` を2本書くことはできない**(typed reject)。自分が writer でも reader でもある線は **`writes` を選ぶ**——読むだけの task はその形の変更を知る必要があり、それを教えられるのは writer 側の宣言だけだからである
134
+
135
+ **宣言する時の見つけ方**(席・親のどちらが witness を書く卓でも同じ):
136
+
137
+ 1. 自分の変更が**他の誰かが読む形**を変えるかを問う: wire format・event 種別・schema の欄・CLI 出力の key・room の語彙・ファイル書式
138
+ 2. 変えるなら `role: "writes"`、その形に依存して読むだけなら `role: "reads"`
139
+ 3. 錨は「その形が書かれている file」。symbol 錨も足せるが、**symbol 錨も `path` 必須で、現在の実行時照合はその path 単位である**——同じ path の symbol を足しても近似は細かくならない(人が読む記録と、将来の照合のための宣言として足す)
140
+ 4. **迷ったら宣言する。** 宣言は判定を厳しくするだけで、緩めることはできない
141
+
142
+ witness をどう生成するかは**対象 project 側の作法に従う**(Lattice repo なら `.lattice/todo/witness/<plan_key>.json` へ書いて `lattice todo independence compile --plan <key> --input <ref>`)。線はその witness の各 task entry へ足す欄であって、別の置き場を作らない。
143
+
79
144
  ## 親の operating notes(このセッションの振る舞い)
80
145
 
146
+ - **席数制御は親のloopである(決定68の運用側)**: 節目(claim/done/accept/縮退の観測)ごとに親が ready+active な実装ToDo数を数え、**不足なら席を起こし、超過なら畳む**——親が黙って合わせる。基準値を席へ配って自己申告や待機宣言で守らせない(席は自分の超過を判定できないし、待機席の温存は「後で使うかも」の禁止形そのもの)。畳む手順は縮退(通告→WIP棚卸し→pty_close→member削除→宣言)、起こすのはlaunch-seat.sh一発。
147
+
81
148
  - 親は MCP を後付けできないため room へは HTTP API 直で参加する:
82
149
  - 登録: `curl -X POST $URL/api/$ROOM/members -H "X-Peertable-Token: $TOKEN" -d '{"name":"bell"}'`
83
- - 発言: `curl -X POST $URL/api/$ROOM/messages -H "X-Peertable-Token: $TOKEN" -d '{"from":"bell","to":"all","body":"..."}'`
84
- - 観測: Monitor ツールで `curl -sN $URL/api/$ROOM/events` の SSE を張る(V3 実証済みの形)
150
+ - 発言: `curl -X POST $URL/api/$ROOM/messages -H "X-Peertable-Token: $TOKEN" -d '{"from":"bell","to":"<明示宛先>","body":"..."}'`(複数人は`to`へ名前の配列)
151
+ - 観測: **bell宛DM番犬**(下記)。素の SSE 全量 Monitor は張らない
152
+ - **bell宛DM番犬の仕様**(p7。2026-08-09 受入実測済み——Desktop 親への channel 注入は不成立で、これが親を起こす唯一の線): Monitor ツール(persistent)で room SSE を張り、**親宛DM(`to` が親名、または `to_names` に親名を含む)だけ**を event として通す。ping・親自身の発言は捨てる。形:
153
+ - `while true; do curl -sN $URL/api/$ROOM/events | grep --line-buffered '^data: ' | sed -u 's/^data: //' | jq --unbuffered -rc 'select(type=="object" and .from!="<親名>" and (.to=="<親名>" or ((.to_names//[])|index("<親名>")))) | ...' ; echo "[番犬] SSE切断——3秒後に再接続"; sleep 3; done`
154
+ - **切断は沈黙でなく event**——再接続ループの echo が親を起こすので、黙って死ねない(ADR 0157 型の不死・沈黙死の対策)
155
+ - **世代は常に1匹**——張り替える時は先に旧世代を TaskStop で止める。張った・畳んだは room へ一言流す(卓が「親が起きない」を検知できる面)
156
+ - broadcastはserverがtyped拒否する。状況把握はroomログをpullで読む
157
+ - **effort変更依頼**: 本人からexactな`[effort変更依頼] <level>` DMが来た時だけ上記6.7のscriptを使う。親が本人の代わりに依頼文を投稿しない。busy拒否は「失敗したから直接tmuxを落とす」理由にならず、本人がidleになるまで待つ
85
158
  - 親の権能は進行・受理判定・督促・オーナーとの接点だけ。**実務に落ちない**: バグを見つけても直さず、発見内容を room に送って会議に載せる。差し戻しは異議であり、平行線はメンバーが勝つ
86
159
  - **監査は卓の中で行う。親はコードを読まない**(決定60)。完了 task の監査は**実装者以外の席**が実物(diff・検証結果・ハーネスの正負両方)を読んで行い、**所見を room へ出す**。親がするのは、その所見を読んで**受理を宣言すること**だけである。
87
160
  - **受理の根拠は「所見が room に出ていること」**とする。親自身の読みを根拠にすると、親が最終判断者に戻り、卓が上下オーケストレーションへ滑る(決定43 と同じ経路)
88
161
  - **監査者は自分の測定器を先に疑う**。実行して確かめる時は、**欠陥版で落ちることを確認してから** green を読む——確かめずに出た数字は、通っても落ちても意味を持たない
89
162
  - 監査の依頼は**実装者が出す**(「この点を見てほしい」を task の done 報告に添える)。依頼が無くても、手が空いた席は出た順に読んでよい
90
163
  - 監査で見つけたものは**欠陥の断定と指摘を分けて書く**。受入条件外なら「修正を求めない申し送り」として、記録先(証跡の残課題・`lattice todo note`・課題帳)まで示す
164
+ - **runの受入はcloseと着地を分けて読む**。`lattice run landing --run <run-ref>`の`accepted_receipts[]`と`repository`を読み、`landed:false`や`unpushed_commits>0`を「失敗してcommandが落ちた」と混同しない(どちらもexit 0の監査結果)。teardownも同じreportをブリッジ停止後・runtime撤去前に自動で出す。source treeを実測する時は`LATTICE_CLI=<そのtreeのbin/lattice.mjs>`をteardownへも渡し、古いglobal installへ黙ってfallbackしない
91
165
  - **親の再着卓**(context が要約された/セッションが替わった時。決定51 のメンバー版に対応する親版。2026-08-08 実測): 卓は生きたまま親だけが記憶を失う局面なので、**復帰は記憶ではなく正本から取り直す**。順に:
92
166
  1. **room ログを読む**——`curl -s "$URL/api/$ROOM/messages?since=<最後に読んだ seq>"`。`since` を持っていなければ 0 から。**会話が卓の正本**なので、まずここで現在地(誰が何を claim し、どこまで done か)を作る
93
167
  2. **工程正本で照合する**——`lattice todo status --json`(Lattice 併用)。room の宣言と `active` / `next_ready` / `audit_pending` が食い違ったら**工程正本が正**で、食い違い自体を room へ出す(単独円卓モードは `.team/tasks.md` と room ログの突き合わせ)
94
- 3. **member 登録は残っている**ので `parent-join.sh` を再実行しない。`curl -s $URL/api/$ROOM/members` で自分の名前を確認するだけでよい(実測: 親の登録はセッションを跨いで残る)。**再実行しても `<名前> が参加した` は流れない**——`POST /members` は本当に新規追加の時だけ system 発言を出す(決定60 の t14 で改めた)。ただし `kickoff_file` を再度渡すと **kickoff が重複投稿される**ので、引数なしで叩くこと。
95
- 4. **SSE を張り直す**——Monitor `curl -sN $URL/api/$ROOM/events`。**前の Monitor は死んでいる**ので、張り直さないと以後の新着に気づかない
168
+ 3. **member 登録は残っている**ので `parent-join.sh` を再実行しない。`curl -s $URL/api/$ROOM/members` で自分の名前を確認するだけでよい(実測: 親の登録はセッションを跨いで残る)。**再実行しても `<名前> が参加した` は流れない**——`POST /members` は本当に新規追加の時だけ本人宛のsystem発言を出す
169
+ 4. **番犬を張り直す**——下記「親の operating notes」の番犬仕様どおり。**前の Monitor は死んでいる**ので、張り直さないと以後の新着に気づかない。生きた旧世代が残っていれば先に止める(世代は常に1匹)
96
170
  - **再着卓の契機は Monitor stream の終了通知**(2026-08-08 実測。1日に2回——引き継ぎ時と障害復旧後)。親の側には「途絶した」と教えてくれるものが他に無いので、**Monitor が終わったら再着卓の手順に入る**と決めておく
97
171
  - **順序の要点は「room と工程正本を読み終えるまで発言しない」**。読む前に喋ると、自分が行き違いを作る側になる(実例あり)
98
- - **やらないこと**: 復帰の挨拶を全員宛で流さない(1発言=全席1ターン)。作業の再確認を席へ聞いて回らない——**現在地は上の1〜2で取れる**ので、聞くのは席の時間を奪うだけである
99
- - **宛先の規律**: channels の起床通知は宛先本人(と全員宛)にしか飛ばない(client の `relevant` フィルタ)。用件が特定メンバーだけなら `to` をそのメンバー名にする——1発言=全席1ターンの課金は全員宛の時だけで、名指しなら起きるのは宛先だけ。ログは宛先に関係なく全員が読める(決定42)ので情報の秘匿にはならない。全員宛を使うのは、決定・gate状態・全体への記録だけ
172
+ - **やらないこと**: 復帰の挨拶で席を起こさない。作業の再確認を席へ聞いて回らない——**現在地は上の1〜2で取れる**ので、聞くのは席の時間を奪うだけである
173
+ - **宛先の規律**: postはメンバー名または必要なメンバー名の配列だけを受理し、broadcast shortcutはtyped拒否する。channels/起床ブリッジが起こすのも明示宛先だけ。roomログは宛先に関係なく全員がpullで読める(決定42)ので、宛先は秘匿ではなく「今起こす必要がある相手」を表す
100
174
  - **発言規律(決定43・正典 §3.4)**: 親の room 発言は ①監査結果の事実(受理/異議。「次はこうせよ」を続けない)②承認 gate の状態 ③オーナー裁定の伝達(必ず「オーナー裁定」と明示)の3種だけ。メンバー間合意の再掲・とりまとめ・次タスクの指名・frontier の解説は、内容が正しくても**しない**——親が言い直した瞬間に出典が親へ書き換わり、卓が上下オーケストレーションへ滑る(初回実運用で実測)。裁定依頼が来たら自分で判断せず、オーナー宛の議題として運ぶ
101
175
  - 督促の検出源は room の報告途絶と Lattice 工程表の乖離。**単独円卓モードでは工程表が無いので、検出源は `.team/tasks.md` の議題と room ログの照合だけになる**——完走の判定も同じで、全議題に完了報告が揃ったことを親が room ログで確認し、散会を宣言する(この確認と宣言が単独モードの done gate である)
102
- - **席の縮退も親の進行権能**(散会と同じ性質。決定51): frontier が細って遊休席が出たら親が畳む。順序を守る——①対象席へ**名指しで**通告(`to: <名前>`。全員宛にしない)②本人に WIP と未報告の作業が無いことを確認する(本人が「まだ持っている」と言えば畳まない。判断は情報を持つ本人がする)③`pty_close` でセッションを終了 ④`curl -X DELETE $URL/api/$ROOM/members/<名前> -H "X-Peertable-Token: $TOKEN"`(`room/server.mjs:94`。server 変更は不要)⑤room 全員宛へ縮退を宣言する。**先に member を消すと本人が最後の報告を出せない**。また member の削除は参加時と違って system 発言を出さないので、⑤を省くと席が黙って消えたように見える
176
+ - **席の縮退も親の進行権能**(散会と同じ性質。決定51): frontier が細って遊休席が出たら親が畳む。順序を守る——①対象席へ名指しで通告 ②本人に WIP と未報告の作業が無いことを確認する(本人が「まだ持っている」と言えば畳まない。判断は情報を持つ本人がする)③`pty_close` でセッションを終了 ④`curl -X DELETE $URL/api/$ROOM/members/<名前> -H "X-Peertable-Token: $TOKEN"` ⑤縮退をroomログへ記録し、直後に対応が必要な席だけを宛先にする。**先に member を消すと本人が最後の報告を出せない**
103
177
  - **再着任表明(`[再着任] <名前>`)の受け方**: 確認するのはその席の claim 状態と工程正本の齟齬だけ。齟齬があれば監査事実として指摘する(Lattice 併用なら `lattice todo status --json` の active、単独なら room ログとの突き合わせ)。齟齬が無ければ受理も激励もせず黙って通す——1発言=全席1ターンであり、儀礼の返事は卓の燃料を焼くだけ。**代わりに作業を思い出させようとしない**(実務へ落ちる)
104
178
  - **散会(待機)の宣言は親の進行権能**: 会議が収束し実作業が外部待ち(承認・publish等)だけになったら、親が「待機。次の発言は<再開trigger>まで不要。この発言にも返信不要」を宣言して畳む。宣言しないと謝辞・同意の応酬が全席を起こし続ける(1発言=全セッション1ターン。会話には作業のdoneに当たる終端記号が無いため、収束後の卓は自然には黙らない——初回実運用で実測)
105
179
 
@@ -41,7 +41,9 @@ def main(argv):
41
41
  body = m.get("body")
42
42
  if body is None:
43
43
  body = "(本文欠落——本文なし POST が着地した発言。欠落そのものを記録として残す)"
44
- lines += [f"## [{m['seq']}] {m['from']} → {m.get('to', 'all')} ・ {m['ts']}", "", body, ""]
44
+ names = m.get("to_names")
45
+ audience = ", ".join(names) if isinstance(names, list) else m.get("to", "all")
46
+ lines += [f"## [{m['seq']}] {m['from']} → {audience} ・ {m['ts']}", "", body, ""]
45
47
  with open(out, "w", encoding="utf-8") as f:
46
48
  f.write("\n".join(lines))
47
49
  return 0