peertable 0.3.1 → 0.3.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "peertable",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
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",
@@ -23,6 +23,9 @@
23
23
  "chatroom",
24
24
  "channels"
25
25
  ],
26
+ "scripts": {
27
+ "prepublishOnly": "PEERTABLE_URL= node room/client.mjs diagnostics"
28
+ },
26
29
  "bin": {
27
30
  "peertable-room": "room/server.mjs",
28
31
  "peertable-client": "room/client.mjs"
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.1'
13
+ const MCP_VERSION = '0.3.2'
14
14
  const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
15
15
 
16
16
  const USAGE = `usage:
@@ -107,8 +107,16 @@ mcp.setRequestHandler(CallToolRequestSchema, async req => {
107
107
 
108
108
  await mcp.connect(new StdioServerTransport())
109
109
 
110
- // 参加登録し、現在のログ末尾から未読を数え始める
111
- await fetch(api('members'), { method: 'POST', headers, body: JSON.stringify({ name: ME }) })
110
+ // 参加登録し、現在のログ末尾から未読を数え始める。
111
+ // 素性(vendor/model/effort)は launch-seat.sh env へ入れる。**登録のたびに載せる**——
112
+ // 登録は client の起動ごとに繰り返し起きるので、1回きりの経路に置くと
113
+ // member の状態が失われた時に二度と戻らない(server 側は渡された欄だけ更新する upsert)
114
+ const IDENTITY = Object.fromEntries(Object.entries({
115
+ vendor: process.env.PEERTABLE_VENDOR,
116
+ model: process.env.PEERTABLE_MODEL,
117
+ effort: process.env.PEERTABLE_EFFORT,
118
+ }).filter(([, v]) => v))
119
+ await fetch(api('members'), { method: 'POST', headers, body: JSON.stringify({ name: ME, ...IDENTITY }) })
112
120
  {
113
121
  const { messages } = await (await fetch(api('messages'))).json()
114
122
  cursor = messages.length ? messages[messages.length - 1].seq : 0
package/room/server.mjs CHANGED
@@ -27,7 +27,9 @@ function loadRoom(name, create = false) {
27
27
  const logPath = join(dir, 'log.jsonl')
28
28
  const seq = existsSync(logPath) ? readFileSync(logPath, 'utf8').split('\n').filter(Boolean).length : 0
29
29
  const membersPath = join(dir, 'members.json')
30
- const members = new Map(existsSync(membersPath) ? Object.entries(JSON.parse(readFileSync(membersPath, 'utf8'))) : [])
30
+ // members の値は `{ joined_at, …任意欄 }`。旧形式(値が ISO 文字列)もそのまま読める
31
+ const stored = existsSync(membersPath) ? Object.entries(JSON.parse(readFileSync(membersPath, 'utf8'))) : []
32
+ const members = new Map(stored.map(([n, v]) => [n, typeof v === 'string' ? { joined_at: v } : v]))
31
33
  const room = { name, dir, logPath, membersPath, seq, members, streams: new Set() }
32
34
  rooms.set(name, room)
33
35
  return room
@@ -71,7 +73,7 @@ http.createServer(async (req, res) => {
71
73
  return json(res, 200, { messages: readMessages(room, Number(url.searchParams.get('since') ?? 0)) }, CORS)
72
74
 
73
75
  if (req.method === 'GET' && rest === 'members')
74
- return json(res, 200, { members: [...room.members].map(([name, joined_at]) => ({ name, joined_at })) }, CORS)
76
+ return json(res, 200, { members: [...room.members].map(([name, meta]) => ({ name, ...meta })) }, CORS)
75
77
 
76
78
  if (req.method === 'GET' && rest === 'events') {
77
79
  res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive', ...CORS })
@@ -91,12 +93,23 @@ http.createServer(async (req, res) => {
91
93
 
92
94
  if (req.method === 'POST' && rest === 'messages') {
93
95
  const { from, to, body: text } = JSON.parse(body)
96
+ // 本文が無ければ **書かずに 400**。ここを素通しにすると `JSON.stringify` が欄ごと落として、
97
+ // append-only の正本へ**本文の無い行**が入る——しかも送信側には 200 と seq が返るので
98
+ // 「送れた」と表示される(2026-08-08 に本番で2件実測。消せない)
99
+ if (typeof text !== 'string') return json(res, 400, { error: 'body_required' })
94
100
  return json(res, 200, post(room, from, to ?? 'all', text))
95
101
  }
96
102
  if (req.method === 'POST' && rest === 'members') {
97
- const { name } = JSON.parse(body)
98
- if (!room.members.has(name)) { room.members.set(name, new Date().toISOString()); saveMembers(room) }
99
- post(room, 'system', 'all', `${name} が参加した`)
103
+ const { name, ...meta } = JSON.parse(body)
104
+ // 素性(vendor/model/effort)や稼働状態は、名前以外の欄をそのまま任意欄として持つ。
105
+ // **渡された欄だけ更新し、渡されなかった欄は既存を保つ**——席の client は `{name}` だけで
106
+ // 登録するので、これが無いと再接続のたびに素性が消える。`joined_at` は最初の登録を保つ。
107
+ const known = room.members.get(name)
108
+ room.members.set(name, { joined_at: known?.joined_at ?? new Date().toISOString(), ...known, ...meta })
109
+ saveMembers(room)
110
+ // **system 発言は本当に新規の時だけ**。欄の更新で「参加した」を流すと、状態を数秒ごとに
111
+ // 送る消費者が卓の全席を起こし続ける(既存メンバーへの再 POST で実測・room [285])
112
+ if (!known) post(room, 'system', 'all', `${name} が参加した`)
100
113
  return json(res, 200, { ok: true })
101
114
  }
102
115
  if (req.method === 'DELETE' && seg[2] === 'members' && seg[3]) {
@@ -134,8 +147,8 @@ const FAVICON = `<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://
134
147
  // 発言者ごとの色は名前ハッシュ(--h)から作る。彩度・明度だけテーマで持ち替えれば dark/light 両方が成立する
135
148
  const STYLE = `
136
149
  *{box-sizing:border-box}
137
- :root{color-scheme:light dark;--fg:#1a1a1a;--bg:#f7f6f3;--surface:#fff;--line:#e4e2dc;--accent:#1d4ed8;--dim:#8a877f;--sat:55%;--lum:45%;--name:32%;--tint:96%;--edge:82%}
138
- @media(prefers-color-scheme:dark){:root{--fg:#e8e6e0;--bg:#141418;--surface:#1e1e24;--line:#2c2c33;--accent:#7aa2ff;--dim:#8b8892;--sat:48%;--lum:56%;--name:75%;--tint:18%;--edge:32%}}
150
+ :root{color-scheme:light dark;--fg:#1a1a1a;--bg:#f7f6f3;--surface:#fff;--line:#e4e2dc;--accent:#1d4ed8;--dim:#8a877f;--sat:55%;--lum:45%;--name:32%;--tint:96%;--edge:82%;--busy:#1f9d55;--idle:#b9b6ae;--dead:#d03b3b}
151
+ @media(prefers-color-scheme:dark){:root{--fg:#e8e6e0;--bg:#141418;--surface:#1e1e24;--line:#2c2c33;--accent:#7aa2ff;--dim:#8b8892;--sat:48%;--lum:56%;--name:75%;--tint:18%;--edge:32%;--busy:#3ecf7e;--idle:#4d4b52;--dead:#ff6b6b}}
139
152
  body{margin:0;font-family:ui-sans-serif,system-ui,-apple-system,"Hiragino Kaku Gothic ProN","Noto Sans JP",sans-serif;font-size:14px;line-height:1.65;color:var(--fg);background:var(--bg)}
140
153
  a{color:var(--accent)}
141
154
  .brand{display:flex;align-items:center;gap:8px;font-size:15px;font-weight:650;letter-spacing:.01em}
@@ -149,6 +162,16 @@ const UI = room => `<!doctype html><html lang="ja"><head><meta charset="utf-8"><
149
162
  .top{position:sticky;top:0;z-index:2;background:var(--bg);border-bottom:1px solid var(--line);padding:12px 16px 0}
150
163
  .top>div{max-width:760px;margin:0 auto}
151
164
  .members{display:flex;gap:6px;overflow-x:auto;padding:10px 0;scrollbar-width:thin}
165
+ .chip.has-meta{cursor:pointer}
166
+ /* 稼働状態の点。報告が途絶えたら unknown(中空)へ落として、古い状態を出し続けない */
167
+ .chip .st{flex:none;width:7px;height:7px;border-radius:50%;margin-left:1px;background:var(--dim)}
168
+ .chip .st.busy{background:var(--busy)}
169
+ .chip .st.idle{background:var(--idle)}
170
+ .chip .st.dead{background:var(--dead)}
171
+ .chip .st.unknown{background:transparent;box-shadow:inset 0 0 0 1.5px var(--dim)}
172
+ .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
+ .metapop .metaname{font-weight:600;margin-bottom:2px}
174
+ .metapop .metaline{color:var(--dim)}
152
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}
153
176
  .chip .av{width:22px;height:22px;font-size:11px}
154
177
  .chip.recent{border-color:hsl(var(--h) var(--sat) var(--edge))}
@@ -162,24 +185,97 @@ const UI = room => `<!doctype html><html lang="ja"><head><meta charset="utf-8"><
162
185
  .meta{display:flex;align-items:baseline;gap:7px;flex-wrap:wrap;margin:0 0 3px 2px;font-size:12px}
163
186
  .who{font-weight:700;color:hsl(var(--h) var(--sat) var(--name))}
164
187
  .to{color:var(--dim)}.ts{color:var(--dim);font-variant-numeric:tabular-nums}
165
- .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);white-space:pre-wrap;overflow-wrap:anywhere}
188
+ .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
+ .bubble>*{margin:0}.bubble>*+*{margin-top:7px}
190
+ .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}
191
+ .bubble pre{background:hsl(var(--h) var(--sat) var(--edge)/.16);border:1px solid hsl(var(--h) var(--sat) var(--edge)/.4);border-radius:5px;padding:7px 10px;overflow-x:auto}
192
+ .bubble pre code{background:none;padding:0;white-space:pre}
193
+ .bubble ul{padding-left:1.25em}.bubble li{margin:1px 0}
194
+ .bubble table{border-collapse:collapse;display:block;overflow-x:auto;max-width:100%;font-size:.94em}
195
+ .bubble th,.bubble td{border:1px solid hsl(var(--h) var(--sat) var(--edge)/.5);padding:3px 8px;text-align:left;vertical-align:top}
196
+ .bubble th{background:hsl(var(--h) var(--sat) var(--edge)/.18);font-weight:650}
166
197
  .msg.cont .bubble{border-radius:13px}
167
198
  .msg.dm .bubble{border-style:dashed;border-color:hsl(var(--h) var(--sat) var(--edge))}
168
199
  .msg.dm .to{color:hsl(var(--h) var(--sat) var(--name));font-weight:600}
169
200
  .sys{display:flex;justify-content:center;align-items:baseline;gap:8px;color:var(--dim);font-size:12px;padding:2px 0}
201
+ /* 最新へ戻る円形ボタン。最下部に居る時は hidden で消える(表示条件は追従と同じ nearBottom) */
202
+ .to-bottom{position:fixed;left:50%;bottom:18px;transform:translateX(-50%);z-index:3;display:grid;place-items:center;width:40px;height:40px;padding:0;border:1px solid var(--line);border-radius:50%;background:var(--surface);color:var(--fg);font-size:17px;line-height:1;cursor:pointer;box-shadow:0 2px 10px rgba(0,0,0,.18)}
203
+ .to-bottom:hover{border-color:var(--accent);color:var(--accent)}
204
+ .to-bottom:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
205
+ .to-bottom[hidden]{display:none}
170
206
  .sys .body{padding:2px 12px;border-radius:999px;background:var(--surface);border:1px solid var(--line)}
171
207
  </style></head><body>
172
208
  <header class="top"><div class="brand">${MARK}${esc(room)} <small>· Peertable</small></div><div class="members" id="members"></div></header>
173
209
  <main class="log" id="log"></main>
210
+ <button class="to-bottom" id="to-bottom" type="button" hidden aria-label="最新の発言へ" title="最新の発言へ">↓</button>
174
211
  <script>
175
212
  const ROOM=${JSON.stringify(room)}
176
213
  const api=p=>'/api/'+encodeURIComponent(ROOM)+'/'+p
177
214
  const logEl=document.getElementById('log'),membersEl=document.getElementById('members')
178
215
  const el=(tag,cls,text)=>{const e=document.createElement(tag);if(cls)e.className=cls;if(text!=null)e.textContent=text;return e}
216
+ // Markdown サブセットを DOM で組む。文字列を連結して innerHTML へ入れる形は取らない——
217
+ // エスケープを1箇所忘れた瞬間に穴が開く構造を選ばない(本文は常に textContent 経由で入る)。
218
+ // 対応: fenced code / 表 / 箇条書き / インラインコード / **強調** / 改行。リンクは入れない。
219
+ const RE_FENCE=/^\\u0060{3}/,RE_ROW=/^\\s*\\|.*\\|\\s*$/,RE_SEP=/^\\s*\\|[-: |]+\\|\\s*$/,RE_LI=/^\\s*[-*]\\s+/
220
+ const RE_INLINE=/\\u0060([^\\u0060\\n]+)\\u0060|\\*\\*([^*\\n]+)\\*\\*/g
221
+ function inline(parent,text){
222
+ RE_INLINE.lastIndex=0
223
+ let i=0,m
224
+ while((m=RE_INLINE.exec(text))){
225
+ if(m.index>i)parent.appendChild(document.createTextNode(text.slice(i,m.index)))
226
+ parent.appendChild(m[1]!=null?el('code',null,m[1]):el('strong',null,m[2]))
227
+ i=RE_INLINE.lastIndex
228
+ }
229
+ if(i<text.length)parent.appendChild(document.createTextNode(text.slice(i)))
230
+ }
231
+ const isTable=(ls,i)=>RE_ROW.test(ls[i])&&i+1<ls.length&&RE_SEP.test(ls[i+1])
232
+ function md(src){
233
+ const frag=document.createDocumentFragment(),ls=String(src).split('\\n')
234
+ let i=0
235
+ while(i<ls.length){
236
+ if(RE_FENCE.test(ls[i])){
237
+ const buf=[];i++
238
+ while(i<ls.length&&!RE_FENCE.test(ls[i]))buf.push(ls[i++])
239
+ i++
240
+ const pre=el('pre');pre.appendChild(el('code',null,buf.join('\\n')));frag.appendChild(pre);continue
241
+ }
242
+ if(isTable(ls,i)){
243
+ const cells=r=>r.trim().replace(/^\\||\\|$/g,'').split('|').map(c=>c.trim())
244
+ const table=el('table'),head=el('tr')
245
+ cells(ls[i]).forEach(c=>{const th=el('th');inline(th,c);head.appendChild(th)})
246
+ const thead=el('thead');thead.appendChild(head);table.appendChild(thead)
247
+ const tbody=el('tbody');i+=2
248
+ while(i<ls.length&&RE_ROW.test(ls[i])){
249
+ const tr=el('tr');cells(ls[i++]).forEach(c=>{const td=el('td');inline(td,c);tr.appendChild(td)});tbody.appendChild(tr)
250
+ }
251
+ table.appendChild(tbody);frag.appendChild(table);continue
252
+ }
253
+ if(RE_LI.test(ls[i])){
254
+ const ul=el('ul')
255
+ while(i<ls.length&&RE_LI.test(ls[i])){const li=el('li');inline(li,ls[i++].replace(RE_LI,''));ul.appendChild(li)}
256
+ frag.appendChild(ul);continue
257
+ }
258
+ if(!ls[i].trim()){i++;continue}
259
+ const buf=[]
260
+ while(i<ls.length&&ls[i].trim()&&!RE_FENCE.test(ls[i])&&!RE_LI.test(ls[i])&&!isTable(ls,i))buf.push(ls[i++])
261
+ const p=el('p')
262
+ buf.forEach((l,k)=>{if(k)p.appendChild(el('br'));inline(p,l)})
263
+ frag.appendChild(p)
264
+ }
265
+ return frag
266
+ }
179
267
  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}
180
268
  const initial=n=>{const c=[...String(n)][0];return c?c.toUpperCase():'?'}
181
269
  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}
182
270
  const nearBottom=()=>window.innerHeight+window.scrollY>=document.body.offsetHeight-80
271
+ // ボタンの出し入れと SSE の自動追従は同じ nearBottom で判断する。別々の閾値を持つと
272
+ // 「ボタンは消えているのに追従しない」帯ができて、どちらが壊れたのか分からなくなる
273
+ const toBottomEl=document.getElementById('to-bottom')
274
+ const syncToBottom=()=>{toBottomEl.hidden=nearBottom()}
275
+ const toBottom=()=>{window.scrollTo(0,document.body.scrollHeight);syncToBottom()}
276
+ toBottomEl.addEventListener('click',toBottom)
277
+ window.addEventListener('scroll',syncToBottom,{passive:true})
278
+ window.addEventListener('resize',syncToBottom,{passive:true})
183
279
  let last=null,recent=null
184
280
  function render(m){
185
281
  const at=new Date(m.ts)
@@ -192,7 +288,8 @@ function render(m){
192
288
  meta.appendChild(el('span','who',m.from))
193
289
  if(m.to!=='all')meta.appendChild(el('span','to','→ '+m.to))
194
290
  meta.appendChild(stamp(at))
195
- body.appendChild(meta);body.appendChild(el('div','bubble',m.body))
291
+ const bub=el('div','bubble');bub.appendChild(md(m.body))
292
+ body.appendChild(meta);body.appendChild(bub)
196
293
  d.appendChild(body);logEl.appendChild(d);last=m
197
294
  }
198
295
  async function refreshMembers(){
@@ -202,11 +299,39 @@ async function refreshMembers(){
202
299
  for(const m of r.members){
203
300
  const c=el('span','chip'+(m.name===recent?' recent':''))
204
301
  c.style.setProperty('--h',hue(m.name));c.dataset.name=m.name
205
- c.title=m.name+'(参加 '+new Date(m.joined_at).toLocaleString()+')'
302
+ // 素性は任意欄。名乗っていない席は行ごと出ない(空欄を「不明」として見せない)
303
+ const meta=[m.model&&(m.vendor?m.vendor+' / '+m.model:m.model),m.effort&&('effort '+m.effort)].filter(Boolean)
304
+ // 稼働状態は**報告が新しい時だけ**採る。途絶えたら unknown へ落とす——古い状態を出し続けるのが
305
+ // いちばん悪い(動いていない席を「動いている」と見せる)。閾値は bridge の心拍30秒の3倍
306
+ const age=m.status_at?Date.now()-Date.parse(m.status_at):Infinity
307
+ const st=(m.status&&age<STATUS_STALE_MS)?m.status:(m.status?'unknown':null)
308
+ if(st)meta.push('状態 '+({busy:'作業中',idle:'待機',dead:'停止',unknown:'不明(報告が途絶えている)'}[st]??st))
309
+ c.title=m.name+'(参加 '+new Date(m.joined_at).toLocaleString()+')'+(meta.length?'\\n'+meta.join('\\n'):'')
206
310
  c.appendChild(el('span','av',initial(m.name)));c.appendChild(el('span','nm',m.name))
311
+ if(st)c.appendChild(el('span','st '+st))
312
+ // タップ環境には hover が無いので、押した時に同じ内容を出す(ホバーは title が担う)
313
+ if(meta.length){c.classList.add('has-meta');c.addEventListener('click',ev=>{ev.stopPropagation();showMeta(c,m,meta)})}
207
314
  membersEl.appendChild(c)
208
315
  }
209
316
  }
317
+ // タップ用の popover。hover が無い環境でも素性が読める。中身は title と同じ
318
+ let metaPop=null
319
+ function hideMeta(){if(metaPop){metaPop.remove();metaPop=null}}
320
+ function showMeta(chip,m,lines){
321
+ if(metaPop&&metaPop.dataset.name===m.name){hideMeta();return}
322
+ hideMeta()
323
+ const p=el('div','metapop');p.dataset.name=m.name
324
+ p.appendChild(el('div','metaname',m.name))
325
+ for(const t of lines)p.appendChild(el('div','metaline',t))
326
+ document.body.appendChild(p)
327
+ const r=chip.getBoundingClientRect()
328
+ p.style.left=Math.max(8,Math.min(r.left,innerWidth-p.offsetWidth-8))+'px'
329
+ p.style.top=(r.bottom+6)+'px'
330
+ metaPop=p
331
+ }
332
+ addEventListener('click',hideMeta)
333
+ addEventListener('scroll',hideMeta,true)
334
+
210
335
  // 直近の発言者を光らせる=いま手を動かしている子が一覧で見える
211
336
  function markActive(name){
212
337
  recent=name;let known=false
@@ -219,6 +344,7 @@ function markActive(name){
219
344
  if(!known)refreshMembers()
220
345
  }
221
346
  const BEAT=${HEARTBEAT_MS}
347
+ const STATUS_STALE_MS=90000 // 稼働状態の鮮度。これを過ぎた報告は unknown(bridge 心拍30秒の3倍)
222
348
  let lastSeq=0,lastBeat=Date.now(),es=null,emptyEl=null,firstLoad=true,catching=false
223
349
  // seq で二重描画を弾く。張り直し後の追いつきと SSE の新着が重なっても同じ発言は1回しか出ない
224
350
  function apply(m){
@@ -239,6 +365,8 @@ async function catchUp(force){
239
365
  if(!lastSeq&&!emptyEl){emptyEl=el('div','empty','(まだ発言がない)');logEl.appendChild(emptyEl)}
240
366
  await refreshMembers()
241
367
  if(added&&stick)window.scrollTo(0,document.body.scrollHeight)
368
+ // 発言が増えると body が伸びる=scroll イベントなしで「最下部か」が変わる。ここで取り直す
369
+ if(added)syncToBottom()
242
370
  }finally{catching=false}
243
371
  }
244
372
  function connect(){
@@ -254,6 +382,7 @@ function connect(){
254
382
  if(!apply(m))return
255
383
  if(m.from==='system')refreshMembers();else markActive(m.from)
256
384
  if(stick)window.scrollTo(0,document.body.scrollHeight)
385
+ syncToBottom()
257
386
  }
258
387
  }
259
388
  connect()
package/skill/SKILL.md CHANGED
@@ -10,7 +10,7 @@ description: 任意プロジェクトに Peertable チーム(対等メンバ
10
10
  ## 前提
11
11
 
12
12
  - `npm install -g peertable` 済みであること(メンバーの root `.mcp.json` は PATH 上の `peertable-client` を使う。サーバーも `peertable-room` で立てられる)
13
- - room サーバーが稼働していること(クオ環境: `http://192.168.1.2:18860`、公開閲覧 https://peertable.kitepon.dev)。書込トークンは `~/.config/peertable.env`(`PEERTABLE_POST_TOKEN=`)
13
+ - room サーバーが稼働していること(クオ環境: `http://192.168.1.2:18860`、公開閲覧 https://peertable.kitepon.dev)。書込トークンは `~/.config/peertable.env`(**`export PEERTABLE_POST_TOKEN=…`**。`export` を落とすと `source` した shell にしか載らず、**子 process の teardown.sh へ渡らない**——2026-08-08 の実測でこれが teardown の無言中断の起点だった)
14
14
  - `lattice` CLI が入っていること(**Lattice 併用モードのみ**。単独円卓モードは Lattice に依存しない。決定47)
15
15
  - aiterm-mcp(tmux)が使えること(メンバーの器)
16
16
  - このスキルを呼び出したセッション自身が**親**として着卓する(専用親セッションは作らない。決定40)
@@ -22,6 +22,8 @@ description: 任意プロジェクトに Peertable チーム(対等メンバ
22
22
  - teardown 後にプロジェクトの diff がゼロになること
23
23
  - 例外は Lattice store(`.lattice/`): Lattice 自身の作法に従う。setup が新規作成した場合だけ teardown で削除し、既存 store には plan の追加・削除とも Lattice の正規コマンド以外で触れない
24
24
  - もう1つの例外は `.lattice/project.json` の `external_pane` 欄(**Lattice 併用モードのみ**。決定53)。既存文書は `.team/project.json.bak` へ退避し、teardown が書き戻す。文書が無かった project では teardown が `project.json` ごと削除する
25
+ - **本番のコネクタを検証のために外さない。** 「外すと痕跡ゼロで戻る」ことの確認は**使い捨ての project** でやる。**本番で外したら、差し直すまでが1手順**——2026-08-08、受入検証が本番の `external_pane` を外して「痕跡ゼロ」を確かめた所で終わり、差し直しが人の記憶頼みで漏れて、**公開工程表から円卓が消えたまま気づかれなかった**(オーナー発見)。外した状態は**画面から何も言ってこない**(そういう仕様なので正しい)
26
+ - 気づく仕掛けとして、`done.sh` が **卓が Lattice 併用モードなのに `external_pane` が無い時に1行警告**する。出すだけで止めない
25
27
 
26
28
  ## setup
27
29
 
@@ -33,6 +35,7 @@ description: 任意プロジェクトに Peertable チーム(対等メンバ
33
35
  2. **命名**: メンバーに日本のアニメキャラ風の可愛い名前を都度決める(固定リストなし)。識別子(tmux セッション名・room 登録名・Lattice actor)はローマ字、表示・自己紹介は日本語(決定35)
34
36
  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` を含む)に残す
35
37
  - **Lattice 併用**: `plan_key` に plan key を渡す。`.team/scripts/done.sh` も配られる。加えて `scripts/external-pane.mjs` が対象 project の `.lattice/project.json` へ `external_pane`(工程表の右ペインに円卓を差す口。決定53)を書く
38
+ - **phase で卓の範囲を絞る**(決定59): 複数 phase の plan へ相乗りする時は `--phase <id>`(複数可・位置引数の後ろ)を渡す。`setup-state.json` へ記録され、席の役割文書へ「claim 範囲はこの phase の task だけ」が焼き込まれる。指定なしは plan 全体
36
39
  - **単独**: `plan_key` に `-` を渡し、第6引数へ聞き取ったタスクを書いた本文ファイル(`- タスク名: 何をどこまでやるか` の箇条書き。中間ファイルは scratchpad で可)を渡す。`.team/tasks.md`(読み取り専用の議題表)が生成され、`roles/member.md` は単独版になる。`done.sh` は配られない。**議題表を渡さないと setup.sh はエラーで止まる**(空の議題表を作らない)
37
40
  4. **Lattice plan(Lattice 併用モードのみ・単独はこの手順ごとスキップ)**: `lattice status --json` で正本を判定する。`uninitialized` なら聞き取ったタスクを JSON へ落として `scripts/make-plan-input.mjs <tasks.json> --project <project>` で `plan create` 入力を生成し、`lattice plan create --input .lattice/plan-create.json` を打つ。初期化済みなら `todo migrate` の作法(Lattice 正典)に従う。設計メモは各タスクに必ず書く
38
41
  - `make-plan-input.mjs` が digest 計算と `hard_dependencies` の `(from,to)` 昇順ソートを持つ(**手書きで2回踏んだ罠**。順序が崩れると `INPUT_INVALID / pointer:"/"` としか言われない)。`project_id` の既定は project ディレクトリ名で、`external-pane.mjs` が書く `project.json` の既定と一致させてある——**両者がずれると Lattice が identity 検証で落ちる**
@@ -45,6 +48,10 @@ description: 任意プロジェクトに Peertable チーム(対等メンバ
45
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 が自動で行う)
46
49
  - **黙って止まらないための三段**(決定58 の受信側の作法): ①75秒なにも届かなければ自分から切って繋ぎ直す ②繋ぎ直したら `?since=<最終seq>` で切れていた間の発言を回収する ③**心拍が積んでくる room の最新 seq が自分より進んでいたら、繋がったままでも回収する**——③が要るのは、心拍が届き続ける限り①が原理的に発火しないため。**server 側の心拍(`event: ping`・25秒周期)が前提**なので、古い room サーバーへ繋ぐと①だけが効く形になる
47
50
  - ログは `.team/wakeup-bridge.log`。**0件でも0件と出す**ので、起こせているか・取りこぼしていないかはログを見れば分かる。再現ハーネスは `experiments/bridge-catchup-repro.mjs`
51
+
52
+ 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
+ - **起こすかは卓の任意**(setup は自動で起こさない)。起こしたら**必ず teardown で止まる**——止め忘れると、`.team/` と一緒に pid 記録が消えて **`--stop` でも止められない常駐**が残る
54
+
48
55
  7. **親の着卓**(このセッション): `scripts/parent-join.sh <project> [name] [kickoff_file]` で member 登録と kickoff 投稿を行い、SSE を Monitor で張る。以後の post も API 直(下記「親の operating notes」)
49
56
  8. **起動確認**: room の members に全員いる / 最初の claim が room に流れる(Lattice 併用モードはそれが Lattice へ到達している=`lattice todo status --json` の active に出ることも確認する。単独モードは room の claim 宣言だけが到達の証拠)/ Web UI で観測できる、をチェックして報告する
50
57
 
@@ -52,13 +59,28 @@ description: 任意プロジェクトに Peertable チーム(対等メンバ
52
59
 
53
60
  `scripts/teardown.sh <project>` が機械部分を行う(room 名・server URL・作成記録は `.team/setup-state.json` から読むので引数は project だけ。書込トークンは環境変数 `PEERTABLE_POST_TOKEN`): tmux セッション終了(先に殺す。`.team/` 消失後の参照事故防止)→ サーバー room 削除 → **外部ペインの復元**(`.lattice/project.json` を setup 前へ戻す。既存文書があったなら `.team/project.json.bak` から書き戻し、無かったなら削除する。`.team/` を消す前にやる——退避先がその中にある)→ `.team/` 削除 → `.git/info/exclude` の追記行を戻す → setup が作った `.lattice/` なら削除。実行後 `git status` で diff ゼロを確認して報告する。
54
61
 
62
+ 各段は `[実施] / [スキップ] / [未実施]` を1行ずつ出す。**トークンを要するのは room 削除だけ**なので、そこが失敗しても残りの撤去は続行し、未実施を明示して非ゼロで終わる(黙って中断しない・決定58)。未実施が出ても**撤去そのものは済んでいる**。残りは表示された **[手当] の curl を手で叩く**だけで、`.team/` は既に消えているので **teardown.sh の再実行はできない**(2026-08-08 実測。再実行すると `setup-state.json` が読めず落ちる)。
63
+
55
64
  ## 親の operating notes(このセッションの振る舞い)
56
65
 
57
66
  - 親は MCP を後付けできないため room へは HTTP API 直で参加する:
58
67
  - 登録: `curl -X POST $URL/api/$ROOM/members -H "X-Peertable-Token: $TOKEN" -d '{"name":"bell"}'`
59
68
  - 発言: `curl -X POST $URL/api/$ROOM/messages -H "X-Peertable-Token: $TOKEN" -d '{"from":"bell","to":"all","body":"..."}'`
60
69
  - 観測: Monitor ツールで `curl -sN $URL/api/$ROOM/events` の SSE を張る(V3 実証済みの形)
61
- - 親の権能は進行・承認・監査・督促・オーナーとの接点だけ。**実務に落ちない**: バグを見つけても直さず、発見内容を room に送って会議に載せる。差し戻しは異議であり、平行線はメンバーが勝つ
70
+ - 親の権能は進行・受理判定・督促・オーナーとの接点だけ。**実務に落ちない**: バグを見つけても直さず、発見内容を room に送って会議に載せる。差し戻しは異議であり、平行線はメンバーが勝つ
71
+ - **監査は卓の中で行う。親はコードを読まない**(決定60)。完了 task の監査は**実装者以外の席**が実物(diff・検証結果・ハーネスの正負両方)を読んで行い、**所見を room へ出す**。親がするのは、その所見を読んで**受理を宣言すること**だけである。
72
+ - **受理の根拠は「所見が room に出ていること」**とする。親自身の読みを根拠にすると、親が最終判断者に戻り、卓が上下オーケストレーションへ滑る(決定43 と同じ経路)
73
+ - **監査者は自分の測定器を先に疑う**。実行して確かめる時は、**欠陥版で落ちることを確認してから** green を読む——確かめずに出た数字は、通っても落ちても意味を持たない
74
+ - 監査の依頼は**実装者が出す**(「この点を見てほしい」を task の done 報告に添える)。依頼が無くても、手が空いた席は出た順に読んでよい
75
+ - 監査で見つけたものは**欠陥の断定と指摘を分けて書く**。受入条件外なら「修正を求めない申し送り」として、記録先(証跡の残課題・`lattice todo note`・課題帳)まで示す
76
+ - **親の再着卓**(context が要約された/セッションが替わった時。決定51 のメンバー版に対応する親版。2026-08-08 実測): 卓は生きたまま親だけが記憶を失う局面なので、**復帰は記憶ではなく正本から取り直す**。順に:
77
+ 1. **room ログを読む**——`curl -s "$URL/api/$ROOM/messages?since=<最後に読んだ seq>"`。`since` を持っていなければ 0 から。**会話が卓の正本**なので、まずここで現在地(誰が何を claim し、どこまで done か)を作る
78
+ 2. **工程正本で照合する**——`lattice todo status --json`(Lattice 併用)。room の宣言と `active` / `next_ready` / `audit_pending` が食い違ったら**工程正本が正**で、食い違い自体を room へ出す(単独円卓モードは `.team/tasks.md` と room ログの突き合わせ)
79
+ 3. **member 登録は残っている**ので `parent-join.sh` を再実行しない。`curl -s $URL/api/$ROOM/members` で自分の名前を確認するだけでよい(実測: 親の登録はセッションを跨いで残る)。**再実行しても `<名前> が参加した` は流れない**——`POST /members` は本当に新規追加の時だけ system 発言を出す(決定60 の t14 で改めた)。ただし `kickoff_file` を再度渡すと **kickoff が重複投稿される**ので、引数なしで叩くこと。
80
+ 4. **SSE を張り直す**——Monitor で `curl -sN $URL/api/$ROOM/events`。**前の Monitor は死んでいる**ので、張り直さないと以後の新着に気づかない
81
+ - **再着卓の契機は Monitor stream の終了通知**(2026-08-08 実測。1日に2回——引き継ぎ時と障害復旧後)。親の側には「途絶した」と教えてくれるものが他に無いので、**Monitor が終わったら再着卓の手順に入る**と決めておく
82
+ - **順序の要点は「room と工程正本を読み終えるまで発言しない」**。読む前に喋ると、自分が行き違いを作る側になる(実例あり)
83
+ - **やらないこと**: 復帰の挨拶を全員宛で流さない(1発言=全席1ターン)。作業の再確認を席へ聞いて回らない——**現在地は上の1〜2で取れる**ので、聞くのは席の時間を奪うだけである
62
84
  - **宛先の規律**: channels の起床通知は宛先本人(と全員宛)にしか飛ばない(client の `relevant` フィルタ)。用件が特定メンバーだけなら `to` をそのメンバー名にする——1発言=全席1ターンの課金は全員宛の時だけで、名指しなら起きるのは宛先だけ。ログは宛先に関係なく全員が読める(決定42)ので情報の秘匿にはならない。全員宛を使うのは、決定・gate状態・全体への記録だけ
63
85
  - **発言規律(決定43・正典 §3.4)**: 親の room 発言は ①監査結果の事実(受理/異議。「次はこうせよ」を続けない)②承認 gate の状態 ③オーナー裁定の伝達(必ず「オーナー裁定」と明示)の3種だけ。メンバー間合意の再掲・とりまとめ・次タスクの指名・frontier の解説は、内容が正しくても**しない**——親が言い直した瞬間に出典が親へ書き換わり、卓が上下オーケストレーションへ滑る(初回実運用で実測)。裁定依頼が来たら自分で判断せず、オーナー宛の議題として運ぶ
64
86
  - 督促の検出源は room の報告途絶と Lattice 工程表の乖離。**単独円卓モードでは工程表が無いので、検出源は `.team/tasks.md` の議題と room ログの照合だけになる**——完走の判定も同じで、全議題に完了報告が揃ったことを親が room ログで確認し、散会を宣言する(この確認と宣言が単独モードの done gate である)
@@ -76,11 +98,13 @@ description: 任意プロジェクトに Peertable チーム(対等メンバ
76
98
  - **メンバー起動の既知ダイアログは2種だけ**(実測 2026-08-08): 未信頼ディレクトリの workspace trust(`1. Yes, I trust this folder`)と開発 channel 警告(`1. I am using this for local development`)。`--dangerously-skip-permissions` を付けているので MCP 同意ダイアログは出ない。信頼済みディレクトリでは trust も出ない
77
99
  - **Codex 席のダイアログは2種で、片方は既定が誤り**(Codex CLI v0.146.0・実測): ディレクトリ trust(`1. Yes, continue`=既定で正しい)と、**更新案内(`1. Update now`)は既定のまま Enter を押すと立卓の途中で `npm install -g @openai/codex` が走る**。1つ下の「2. Skip」を選ぶ。更新案内は毎回は出ないので、出た時だけ通す
78
100
  - **Codex はターン実行中でも素送信を受け付ける**(実測 2026-08-08)。busy 中に送った文言はそのターンの中で読まれ、指示どおりに動く(steering)。よって起床ブリッジは idle 待ちを持たない。busy の判定が要る場面では画面の `esc to interrupt` の有無が使える
79
- - **席の沈黙は「詰まり」と同義ではない。** 発言間隔やファイルの更新時刻から止まったと判定しない——実装が終わって検証に時間を使っているだけのことがある。判定は `tmux -S <sock> capture-pane -t peer-<名前> -p` で**実状態を読む**: `Cogitating…(N分)` なら長いターンの最中(通知はターン後にまとめて届くので、呼びかけを足しても速くならない)/選択ダイアログで止まっているなら既知の停止要因/`pane_dead=1` なら落ちている。**読み取りだけなら相手の作業を壊さない**ので、憶測を room へ流す前にこれを見る(2026-08-08 に2人が独立に踏み、先に憶測を流した側が訂正を出した)
101
+ - **席の沈黙は「詰まり」と同義ではない。** 発言間隔やファイルの更新時刻から止まったと判定しない——実装が終わって検証に時間を使っているだけのことがある。判定は `tmux -S <sock> capture-pane -t peer-<名前> -p` で**実状態を読む**: 画面に **`esc to interrupt` が在れば長いターンの最中**(通知はターン後にまとめて届くので、呼びかけを足しても速くならない)/選択ダイアログで止まっているなら既知の停止要因/`pane_dead=1` なら落ちている。**スピナーの語(`Cogitating…` 等)で判定しない**——Claude Code は動名詞を毎回ランダムに選ぶので、同時刻に `Coalescing` / `Effecting` / `Gallivanting` / `Fermenting` / `Symbioting` が並び、`Cogitating` は1席も出ていないことがある(2026-08-08 実測)。**`esc to interrupt` は Claude 席のステータス行にも Codex 席の `Working (…)` にも入る共通マーカー**で、vendor 分岐が要らない。**読み取りだけなら相手の作業を壊さない**ので、憶測を room へ流す前にこれを見る(2026-08-08 に2人が独立に踏み、先に憶測を流した側が訂正を出した)
80
102
  - **`claude-in-chrome` の呼び出しは返らないことがある**。原因は2種で、解き方が違う(2026-08-08 に席1つが9分半沈黙して実測):
81
103
  - **接続ブラウザが複数あって、拡張がどれを使うか選ばせている**——選択待ちのまま返らない。**AI 側から解ける**(オーナーに「どちらを使うか」を一言聞けば済む)。今回の実例はこちら。デバッグ接続が宙吊りのまま「Claude がこのブラウザのデバッグを開始しました[キャンセル]」バナーが残る形もあり、キャンセルを押せば呼び出しは即エラーで返る
82
104
  - **ブラウザに alert/confirm 等のモーダルが出ている**——拡張が以後のコマンドを受け取れない。**AI 側から解けない**ので、人がダイアログを閉じるしかない
83
105
  - 沈黙した席を見る側は、この2つを区別せずに「固着」と決めない。トークン受信が増え続けているなら止まっていない
106
+ - **無人の席はどちらの型も自力で解けない。** 選択待ちは「人に聞けば解ける」型だが、**席には聞く相手が居ない**——だから席の役割文書は `claude-in-chrome` を使わせず、自分で起こす headless(Chrome for Testing + CDP)へ寄せてある。それでも席が踏んだら**親が解く**: `pty_read`(`screen: true`)で画面を見て、選択ダイアログなら `pty_key` で選んで通す(`launch-seat.sh` が起動時の既知ダイアログを通すのと同じ手)。モーダル固着ならオーナーへ回す——**AI 側から解けないのはこちらだけ**
107
+ - **親の督促手順に組み込む**: 報告途絶を見つけたら ①`capture-pane` で実状態(`esc to interrupt` の有無・ダイアログか・`pane_dead`)②ダイアログなら `pty_key` で解除 ③解除できない型ならオーナーへ。**呼びかけを増やすのは①の前にやらない**——ターン中なら読まれないので遅くなるだけ
84
108
  - **共有リソースを占める作業は着手前に room へ一言**。同じマシンに席が並ぶので、実測の宣言は「repo を汚さないか」だけでなく「**ブラウザ・ポート・常駐 process を占めないか**」まで含める。ブラウザを起こす席が複数あると、拡張の接続先が増えて他席の呼び出しが選択待ちに入りうる(2026-08-08 の停止例では原因ではなかったが、成立しうる経路として置く)
85
109
  - **シェルスクリプトで `$var` の直後に全角括弧を書かない**。bash が高位バイトを変数名の一部として食い、変数が空のまま何も言わずに出力から消える(2026-08-08 実測)。`${var}(…)` と閉じる。同様に `python3 -c` へ `{...}` を含む式をインラインで渡さない——シェルのブレース展開が刻む。ヒアドキュメントで渡す
86
110
  - channels はリサーチプレビュー。構文が変わったら V0 の要領で公式ドキュメント(code.claude.com/docs/en/channels-reference.md)を再確認する
@@ -18,9 +18,29 @@ const preexisting = existsSync(identity)
18
18
  const current = preexisting ? JSON.parse(readFileSync(identity, 'utf8')) : {}
19
19
  if (preexisting) copyFileSync(identity, join(proj, '.team', 'project.json.bak'))
20
20
 
21
- // project_id は Lattice store が持つ値が正。setup 時点では store がまだ無いので、
22
- // 既存 identity プロジェクトディレクトリ名 の順で決める(make-plan-input.mjs の既定と同じ)。
23
- const projectId = current.project_id ?? basename(realpathSync(proj))
21
+ // project_id は Lattice store が持つ値が正。store があるのにディレクトリ名を書くと、
22
+ // identity 検証が落ちて `lattice todo status` ごと死ぬ(PascalCase ディレクトリ × kebab-case store で実測)。
23
+ // 順序: 既存 identity(人が書いた値は書き換えない)→ store manifest → ディレクトリ名。
24
+ function storeProjectId() {
25
+ const ref = join(proj, '.lattice', 'todo', 'manifest.json')
26
+ if (!existsSync(ref)) return null
27
+ // manifest が壊れていると、この `JSON.parse` が例外を投げて **setup の画面へ生の traceback が出る**。
28
+ // 直していない理由: 壊れた manifest を持つ project では `lattice status` が `state:"invalid"`、
29
+ // `todo status` が `STORE_INCONSISTENT` を返す=**Lattice 自体が既に死んでいる**ので、
30
+ // setup が併用モードへ進む前に止まる経路のはず。実害はほぼ無いと判断した(2026-08-08)。
31
+ // ただし**失敗の見え方は惜しい**——typed error(`MANIFEST_UNREADABLE` 等)へ変えるなら、ここ。
32
+ const id = JSON.parse(readFileSync(ref, 'utf8')).project_id
33
+ return typeof id === 'string' && id.length > 0 ? id : null
34
+ }
35
+
36
+ const stored = storeProjectId()
37
+ const projectId = current.project_id ?? stored ?? basename(realpathSync(proj))
38
+ // 食い違いは黙って通さない。書き換えはしない(人の書いた identity が正)が、
39
+ // このまま進むと `lattice todo status` が死ぬので、setup の画面へ出す。
40
+ if (current.project_id && stored && current.project_id !== stored) {
41
+ process.stderr.write(`警告: 既存 project.json の project_id="${current.project_id}" が Lattice store の "${stored}" と食い違っている。`
42
+ + `このままでは lattice todo status が PROJECT_IDENTITY_INVALID で落ちる。project.json 側を直してから setup し直すこと\n`)
43
+ }
24
44
  const root = base.replace(/\/+$/, '')
25
45
  const pane = { title: '円卓', url: `${root}/${room}`, probe_url: `${root}/api/${room}/members` }
26
46
 
@@ -26,7 +26,10 @@ fi
26
26
  tmux -S "$sock" kill-session -t "$sess" 2>/dev/null || true
27
27
  tmux -S "$sock" new-session -d -s "$sess" -x 200 -y 50 -c "$proj"
28
28
 
29
+ # 素性は席の env にも入れる。client が**登録のたびに**載せるので、member の状態が失われても戻る
29
30
  env_line="export PEERTABLE_URL=$url PEERTABLE_ROOM=$room PEERTABLE_MEMBER=$name PEERTABLE_POST_TOKEN=$PEERTABLE_POST_TOKEN"
31
+ env_line="$env_line PEERTABLE_VENDOR=$vendor PEERTABLE_MODEL=$model"
32
+ [ -n "$effort" ] && env_line="$env_line PEERTABLE_EFFORT=$effort"
30
33
  if [ "$mode" = "lattice" ]; then
31
34
  env_line="$env_line PEERTABLE_PLAN=$plan LATTICE_TODO_ACTOR_HOST=${LATTICE_TODO_ACTOR_HOST:-mac} LATTICE_TODO_ACTOR_SESSION=$name LATTICE_TODO_ACTOR_AGENT=$name"
32
35
  fi
@@ -91,6 +94,49 @@ fi
91
94
 
92
95
  echo "seated: ${sess}(${vendor} / ${model}${effort:+ / $effort} / room=${room} / mode=${mode})"
93
96
 
97
+ # 席の素性(vendor / model / effort)を room へ渡す。参加者一覧のホバー表示に使う。
98
+ # 席自身の client も起動時に `{name}` だけで登録するので、server 側は
99
+ # **欄が無い登録で既存の素性を消さない**(upsert)ことが前提である。
100
+ # effort は渡された時だけ入れる——欄が無い=「不明」ではなく「CLI 既定で走っている」。
101
+ # ここが失敗しても席は着席済みなので落とさない。ただし黙っては飲まない。
102
+ meta=$(python3 - "$name" "$vendor" "$model" "$effort" <<'PY'
103
+ import json, sys
104
+ name, vendor, model, effort = sys.argv[1:5]
105
+ body = {'name': name, 'vendor': vendor, 'model': model}
106
+ if effort:
107
+ body['effort'] = effort
108
+ print(json.dumps(body))
109
+ PY
110
+ )
111
+ if curl -sf -o /dev/null -X POST "$url/api/$room/members" \
112
+ -H "X-Peertable-Token: ${PEERTABLE_POST_TOKEN:-}" -H 'content-type: application/json' -d "$meta"; then
113
+ # **200 は保存の証拠にならない**。素性欄を知らない server も 200 {"ok":true} を返して黙って捨てる
114
+ # (登録が `if (!members.has(name))` の no-op になる経路もある)。読み返して実際に載ったかを見る。
115
+ # **パイプと heredoc を同じ stdin へ重ねない**。`curl | python3 - <<'PY'` は
116
+ # 「プログラムを stdin から読む」と「データを stdin から読む」が衝突して、必ず失敗する
117
+ # (そして try/except で包むと、失敗が「保存されていない」という**もっともらしい答え**に化ける。実測)
118
+ listing=$(curl -sf "$url/api/$room/members" || true)
119
+ stored=$(python3 - "$name" "$listing" <<'PY'
120
+ import json, sys
121
+ # 読み返しに失敗しても生の traceback を出さない。ここは「保存されたか」を見るだけの確認段で、
122
+ # 判定不能は「保存されていない」と同じ扱いでよい(席は既に着席している)
123
+ try:
124
+ members = json.loads(sys.argv[2])['members']
125
+ except Exception:
126
+ members = []
127
+ m = next((x for x in members if x.get('name') == sys.argv[1]), {})
128
+ print('yes' if m.get('model') else 'no')
129
+ PY
130
+ )
131
+ if [ "$stored" = yes ]; then
132
+ echo "metadata: ${vendor} / ${model}${effort:+ / $effort}"
133
+ else
134
+ echo "metadata は保存されなかった: この room サーバーは素性欄を持たない版(席は着席済み・一覧に素性が出ないだけ)" >&2
135
+ fi
136
+ else
137
+ echo "metadata の登録に失敗した: 席は着席済みで、参加者一覧に素性が出ないだけ(room の到達性とトークンを確認)" >&2
138
+ fi
139
+
94
140
  if [ -n "$brief" ]; then
95
141
  sleep 2
96
142
  tmux -S "$sock" send-keys -t "$sess" "$brief"
@@ -1,11 +1,14 @@
1
1
  #!/bin/bash
2
2
  # 親(ベル等)が room へ着卓し、必要なら kickoff を投げる。
3
- # usage: parent-join.sh <project_dir> [name] [kickoff_file]
3
+ # usage: parent-join.sh <project_dir> [name] [kickoff_file] [model] [effort]
4
4
  # name 既定は bell。kickoff_file を渡すとその本文を全員宛で1回投稿する
5
5
  # (複数行・記号を安全に運ぶためファイル渡しにしている。引数のインライン渡しはしない)。
6
+ # model / effort は任意。親はオーナーの対話セッション(決定40)なので、席と違って
7
+ # 起動時に確定した値を script が知らない——**渡された時だけ**参加者一覧の素性として登録する。
8
+ # 渡さなければ欄ごと出ない(「不明」ではなく「素性を名乗っていない」)。
6
9
  # 親は MCP を後付けできないので room へは HTTP API 直で入る(決定40 の operating notes)。
7
10
  set -e
8
- proj="$1"; name="${2:-bell}"; kickoff="$3"
11
+ proj="$1"; name="${2:-bell}"; kickoff="$3"; model="$4"; effort="$5"
9
12
  state="$proj/.team/setup-state.json"
10
13
  room=$(python3 -c "import json;print(json.load(open('$state'))['room'])")
11
14
  url=$(python3 -c "import json;print(json.load(open('$state'))['server_url'])")
@@ -14,9 +17,17 @@ if [ -z "${PEERTABLE_POST_TOKEN:-}" ] && [ -f "$HOME/.config/peertable.env" ]; t
14
17
  . "$HOME/.config/peertable.env"
15
18
  fi
16
19
 
17
- member=$(python3 - "$name" <<'PY'
20
+ member=$(python3 - "$name" "$model" "$effort" <<'PY'
18
21
  import json, sys
19
- print(json.dumps({'name': sys.argv[1]}))
22
+ name, model, effort = sys.argv[1:4]
23
+ body = {'name': name}
24
+ # 渡された欄だけ載せる。空欄を送ると、素性を持つ既存登録を空で上書きしうる
25
+ if model:
26
+ body['vendor'] = 'claude'
27
+ body['model'] = model
28
+ if effort:
29
+ body['effort'] = effort
30
+ print(json.dumps(body))
20
31
  PY
21
32
  )
22
33
  curl -sf -X POST "$url/api/$room/members" \
@@ -0,0 +1,148 @@
1
+ #!/usr/bin/env node
2
+ // 席の稼働状態ブリッジ(決定61 候補・t15)。tmux の pane を読んで busy/idle/dead を判定し、room サーバーへ送る。
3
+ // usage: seat-status-bridge.mjs <project_dir> [--interval <sec>] [--once]
4
+ // seat-status-bridge.mjs <project_dir> --stop
5
+ //
6
+ // AI は使わない。読むのは `tmux capture-pane -p` の末尾だけで、席へは1バイトも送らない。
7
+ //
8
+ // 判定(2026-08-08 実測。**推測でパターンを書かない**):
9
+ // busy = pane の末尾に `esc to interrupt` が在る。Claude 席のステータス行にも Codex 席の `Working (…)` にも
10
+ // 同じ文字列が入るので **vendor 分岐が要らない**。実行中の動名詞(Cogitating/Coalescing/Effecting/
11
+ // Gallivanting/Fermenting/Symbioting…)は**毎回変わる**ので判定に使わない——語で照合すると全席を
12
+ // idle と誤判定して、画面が嘘をつく
13
+ // dead = tmux セッションが無い/`pane_dead=1`
14
+ // idle = 生きていて busy でない
15
+ //
16
+ // 送信は「変化した時」+「変化が無くても心拍」の2本立て。変化時だけだと、**bridge が死んだのか状態が
17
+ // 変わっていないのかを server が区別できない**(決定58 の liveness と cursor の分離と同じ形)。
18
+ // server 側は最終受信からの経過で `unknown` へ落とす——古い状態を出し続けるのが最悪だから。
19
+ import { execFileSync } from 'node:child_process'
20
+ import { existsSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs'
21
+ import { join } from 'node:path'
22
+
23
+ const args = process.argv.slice(2)
24
+ const proj = args[0]
25
+ if (!proj) { console.error('usage: seat-status-bridge.mjs <project_dir> [--interval <sec>] [--once] | --stop'); process.exit(1) }
26
+ const stop = args.includes('--stop')
27
+ const once = args.includes('--once')
28
+ const interval = Number(args[args.indexOf('--interval') + 1]) || 8
29
+ const HEARTBEAT_MS = 30_000 // 変化が無くても最低この間隔で送る(server 側の減衰より短いこと)
30
+
31
+ const stateDir = join(proj, '.team')
32
+ const pidPath = join(stateDir, 'seat-status-bridge.json')
33
+ const setupPath = join(stateDir, 'setup-state.json')
34
+
35
+ const alive = pid => { try { process.kill(pid, 0); return true } catch { return false } }
36
+
37
+ // ADR 0157 の作法: pid を記録し、起動時に死んだ記録を掃除し、--stop で明示停止する
38
+ if (stop) {
39
+ if (!existsSync(pidPath)) { console.error('seat-status-bridge: 起動記録が無い(既に停止)'); process.exit(0) }
40
+ const { pid } = JSON.parse(readFileSync(pidPath, 'utf8'))
41
+ if (alive(pid)) {
42
+ // SIGTERM 5秒 → SIGKILL 3秒(wakeup-bridge.mjs と同じ形)。**昇格が無いと、SIGTERM を無視する
43
+ // 常駐が居た時に teardown が `set -e` の2段目で即死して、[未実施] も [手当] も要約も出ない**
44
+ process.kill(pid, 'SIGTERM')
45
+ for (let i = 0; i < 50 && alive(pid); i++) execFileSync('sleep', ['0.1'])
46
+ if (alive(pid)) {
47
+ process.kill(pid, 'SIGKILL')
48
+ for (let i = 0; i < 30 && alive(pid); i++) execFileSync('sleep', ['0.1'])
49
+ }
50
+ if (alive(pid)) { console.error(`SEAT_STATUS_BRIDGE_STOP_FAILED: pid ${pid} が SIGKILL でも止まらない`); process.exit(1) }
51
+ }
52
+ // 止めた側の SIGTERM handler が先に消していることがある。生の traceback を出さない(それ自体が
53
+ // 「何が起きたか分からない失敗」になる——今日 teardown で同じ形を叩いたばかり)
54
+ try { unlinkSync(pidPath) } catch { /* 既に消えている=目的は達成されている */ }
55
+ console.error(`seat-status-bridge: 停止した(pid ${pid})`)
56
+ process.exit(0)
57
+ }
58
+
59
+ if (existsSync(pidPath)) {
60
+ const { pid } = JSON.parse(readFileSync(pidPath, 'utf8'))
61
+ if (alive(pid)) { console.error(`seat-status-bridge: 既に動いている(pid ${pid})`); process.exit(1) }
62
+ unlinkSync(pidPath) // 死んだ記録は掃除する
63
+ }
64
+
65
+ const setup = JSON.parse(readFileSync(setupPath, 'utf8'))
66
+ const url = setup.server_url
67
+ const room = setup.room
68
+ const token = process.env.PEERTABLE_POST_TOKEN ?? ''
69
+ writeFileSync(pidPath, JSON.stringify({ pid: process.pid, started_at: new Date().toISOString() }) + '\n')
70
+
71
+ const tmux = (...a) => { try { return execFileSync('tmux', a, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }) } catch { return null } }
72
+
73
+ // 監視するのは room の members に居る席だけ。tmux の `peer-*` を全部拾うと、同じマシンで走る別の卓を晒す
74
+ async function seats() {
75
+ const res = await fetch(`${url}/api/${encodeURIComponent(room)}/members`)
76
+ const { members } = await res.json()
77
+ return members.map(m => m.name)
78
+ }
79
+
80
+ function readStatus(name) {
81
+ const target = `peer-${name}`
82
+ const dead = tmux('list-panes', '-t', target, '-F', '#{pane_dead}')
83
+ if (dead === null) return 'dead' // セッションが無い
84
+ if (dead.trim().split('\n')[0] === '1') return 'dead'
85
+ const pane = tmux('capture-pane', '-t', target, '-p')
86
+ if (pane === null) return 'dead'
87
+ return pane.split('\n').slice(-14).join('\n').includes('esc to interrupt') ? 'busy' : 'idle'
88
+ }
89
+
90
+ async function send(name, status) {
91
+ const res = await fetch(`${url}/api/${encodeURIComponent(room)}/members`, {
92
+ method: 'POST',
93
+ headers: { 'Content-Type': 'application/json', ...(token ? { 'X-Peertable-Token': token } : {}) },
94
+ body: JSON.stringify({ name, status, status_at: new Date().toISOString() }),
95
+ })
96
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
97
+ }
98
+
99
+ // 200 を保存の証拠にしない(haruka の t14 と同じ判断)。現行 server は知らない欄を黙って捨てて 200 を返すので、
100
+ // 読み返して実際に載ったかを見る。載らない版なら、そう言って**黙って成功したふりをしない**
101
+ async function serverKeepsStatus() {
102
+ const res = await fetch(`${url}/api/${encodeURIComponent(room)}/members`)
103
+ const { members } = await res.json()
104
+ return members.some(m => 'status' in m)
105
+ }
106
+
107
+ const last = new Map() // name -> { status, at }
108
+ let supported = null // server が status を保持する版か(未判定は null)
109
+
110
+ async function tick() {
111
+ let names
112
+ try { names = await seats() } catch (e) { console.error(`seat-status-bridge: members を読めない: ${e.message}`); return }
113
+ // **送る前に、server が status を持つ版かを確かめる。**
114
+ // 現行の `POST /members` は、既存メンバーでも `<名前> が参加した` を必ず room へ流す(`post()` が
115
+ // `if (!members.has(name))` の外にある)。status を保持しない版へ投げると、**保存されないうえに
116
+ // 席全員を起こす system 発言を撒く**——2026-08-08 に私がこれで6件撒いて全席を1ターン起こした。
117
+ // 保持する版かどうかは GET で分かるので、**分かるまで投げない**。
118
+ if (supported === null) {
119
+ try { supported = await serverKeepsStatus() } catch { return } // 判定できない間は送らない
120
+ if (!supported) console.error('seat-status-bridge: この room サーバーは稼働状態を保持しない版(GET /members に status が無い)。送信すると保存されないうえに system 発言を撒くので、送信しない')
121
+ }
122
+ if (!supported) { console.error(`seat-status-bridge: ${names.length} 席を見たが、server が未対応なので送っていない`); return }
123
+ const now = Date.now()
124
+ let sent = 0
125
+ for (const name of names) {
126
+ const status = readStatus(name)
127
+ const prev = last.get(name)
128
+ const changed = !prev || prev.status !== status
129
+ const stale = prev && now - prev.at >= HEARTBEAT_MS
130
+ if (!changed && !stale) continue
131
+ try {
132
+ await send(name, status)
133
+ last.set(name, { status, at: now })
134
+ sent++
135
+ if (changed) console.error(`seat-status-bridge: ${name} → ${status}${prev ? `(${prev.status} から)` : ''}`)
136
+ } catch (e) {
137
+ console.error(`seat-status-bridge: ${name} の送信に失敗: ${e.message}`)
138
+ }
139
+ }
140
+ // 0件でも0件と言う(条件付きログにしない。沈黙する失敗を作らない・決定58)
141
+ console.error(`seat-status-bridge: ${names.length} 席を見て ${sent} 件送った`)
142
+ }
143
+
144
+ process.on('SIGTERM', () => { try { unlinkSync(pidPath) } catch {} process.exit(0) })
145
+ process.on('SIGINT', () => { try { unlinkSync(pidPath) } catch {} process.exit(0) })
146
+
147
+ await tick()
148
+ if (!once) setInterval(tick, interval * 1000)
@@ -1,15 +1,47 @@
1
1
  #!/bin/bash
2
2
  # Peertable setup の機械部分: .team/ scaffold と git 除外。
3
- # usage: setup.sh <project_dir> <room> <server_url> <plan_key|-> <peertable_repo> [tasks_file]
3
+ # usage: setup.sh <project_dir> <room> <server_url> <plan_key|-> <peertable_repo> [tasks_file] [--phase <id>]...
4
4
  # plan_key に `-` を渡すと単独円卓モード(工程正本を持たない。決定47)。
5
5
  # 単独モードでは tasks_file(聞き取ったタスクを書いた本文)が必須で、議題表 .team/tasks.md になる。
6
+ # --phase は複数指定可。指定すると卓の claim 範囲がその phase の task に限られる。
7
+ # 指定なしは plan 全体。他 campaign と同じ plan へ相乗りする時に、範囲外 phase の越境を止めるためのもの。
6
8
  set -e
7
- proj="$1"; room="$2"; url="$3"; plan="$4"; repo="$5"; tasks="$6"
9
+ proj="$1"; room="$2"; url="$3"; plan="$4"; repo="$5"
10
+ [ $# -ge 5 ] && shift 5 || shift $#
11
+ # 第6引数の tasks_file は単独円卓モードだけが使う。`--` で始まるものはオプションなので
12
+ # 位置引数として食わない——食うと Lattice 併用モードの `… <repo> --phase p2` が
13
+ # 「未知の引数: p2」という原因を指さないエラーで落ちる(2026-08-08 実測・kotoha 監査)。
14
+ tasks=""
15
+ if [ $# -gt 0 ]; then
16
+ case "$1" in
17
+ -) shift ;; # 明示的な「tasks_file 無し」
18
+ -*) ;; # オプション(綴り誤りも含む)。下のループで typed に落とす
19
+ *) tasks="$1"; shift ;; # tasks_file
20
+ esac
21
+ fi
22
+
23
+ phases=()
24
+ while [ $# -gt 0 ]; do
25
+ case "$1" in
26
+ --phase)
27
+ shift
28
+ [ -n "$1" ] || { echo "ERROR: --phase には phase id が要る" >&2; exit 1; }
29
+ case "$1" in
30
+ *[!A-Za-z0-9._-]*) echo "ERROR: phase id に使えない文字がある: $1" >&2; exit 1 ;;
31
+ esac
32
+ phases+=("$1")
33
+ ;;
34
+ *) echo "ERROR: 未知の引数: $1(受けるのは --phase <id> だけ。tasks_file は単独円卓モード専用で、オプションより前に置く)" >&2; exit 1 ;;
35
+ esac
36
+ shift
37
+ done
8
38
  tpl="$repo/skill/templates"
9
39
  tdir="$proj/.team"
10
40
 
11
41
  if [ "$plan" = "-" ] || [ -z "$plan" ]; then
12
42
  mode=standalone; plan=""
43
+ # 単独円卓モードに phase は無い(工程正本を持たないため)。黙って無視せず止める
44
+ [ ${#phases[@]} -eq 0 ] || { echo "ERROR: 単独円卓モードに --phase は使えない(工程正本を持たないため)" >&2; exit 1; }
13
45
  # 引数の検証は project へ何か置く前に済ませる(不可侵原則: 半端な .team/ を残さない)
14
46
  if [ -z "$tasks" ] || [ ! -f "$tasks" ]; then
15
47
  echo "ERROR: 単独円卓モードは議題表の本文ファイル(第6引数)が必須: setup.sh ... - <peertable_repo> <tasks_file>" >&2
@@ -26,7 +58,13 @@ if [ "$mode" = "standalone" ]; then
26
58
  cat "$tpl/tasks.md" "$tasks" > "$tdir/tasks.md"
27
59
  else
28
60
  mkdir -p "$tdir/scripts"
29
- sed "s|{{PLAN_KEY}}|$plan|g" "$tpl/member.md" > "$tdir/roles/member.md"
61
+ # claim 範囲は席へ渡す文書に焼き込む。範囲の出典を「誰かの記憶」でなく role 文書にする
62
+ if [ ${#phases[@]} -eq 0 ]; then
63
+ scope="この卓の claim 範囲は plan 全体(phase 指定なしで立っている)。"
64
+ else
65
+ scope="**この卓の claim 範囲は phase ${phases[*]} の task だけ**。範囲外の phase の task は、ready に見えていても取らない——同じ plan へ別 campaign が相乗りしている時、範囲外を取ると他卓の工程を横取りする(越境が2回実測されたことへの対処)。範囲外に手を入れる必要が出たら room へ出して裁定を仰ぐ。"
66
+ fi
67
+ sed -e "s|{{PLAN_KEY}}|$plan|g" -e "s|{{CLAIM_SCOPE}}|$scope|g" "$tpl/member.md" > "$tdir/roles/member.md"
30
68
  cp "$tpl/done.sh" "$tdir/scripts/done.sh" && chmod +x "$tdir/scripts/done.sh"
31
69
  fi
32
70
 
@@ -68,6 +106,13 @@ if [ "$mode" = "lattice" ]; then
68
106
  external_pane=true
69
107
  fi
70
108
 
71
- printf '{"room":"%s","server_url":"%s","public_url":"%s","mode":"%s","plan_key":"%s","added_exclude":%s,"lattice_preexisting":%s,"added_root_mcp":%s,"added_mcp_exclude":%s,"external_pane":%s,"project_json_preexisting":%s}\n' \
72
- "$room" "$url" "$public_url" "$mode" "$plan" "$added_exclude" "$lattice_preexisting" "$added_root_mcp" "$added_mcp_exclude" "$external_pane" "$project_json_preexisting" > "$tdir/setup-state.json"
109
+ # phases は追加キー(既存の読み手は .get で読むので壊れない)。空配列=plan 全体
110
+ phases_json="[]"
111
+ if [ ${#phases[@]} -gt 0 ]; then
112
+ phases_json=$(printf '"%s",' "${phases[@]}")
113
+ phases_json="[${phases_json%,}]"
114
+ fi
115
+
116
+ printf '{"room":"%s","server_url":"%s","public_url":"%s","mode":"%s","plan_key":"%s","phases":%s,"added_exclude":%s,"lattice_preexisting":%s,"added_root_mcp":%s,"added_mcp_exclude":%s,"external_pane":%s,"project_json_preexisting":%s}\n' \
117
+ "$room" "$url" "$public_url" "$mode" "$plan" "$phases_json" "$added_exclude" "$lattice_preexisting" "$added_root_mcp" "$added_mcp_exclude" "$external_pane" "$project_json_preexisting" > "$tdir/setup-state.json"
73
118
  echo "scaffold done: $tdir"
@@ -1,7 +1,10 @@
1
1
  #!/bin/bash
2
2
  # Peertable teardown の機械部分。実行前にメンバーセッションを終了しておくこと(AI が pty_close で行う)。
3
3
  # usage: teardown.sh <project_dir>
4
- # 書込トークンは環境変数 PEERTABLE_POST_TOKEN から取る。
4
+ # 書込トークンは環境変数 PEERTABLE_POST_TOKEN から取る(`~/.config/peertable.env` は export 付きで定義すること)。
5
+ #
6
+ # 撤去は「何が実施され、何が実施されなかったか」を1行ずつ出す。room 削除だけがトークンを要するので、
7
+ # そこが失敗しても残りの撤去は続行し、未実施を明示して非ゼロで終わる(黙って中断しない・決定58)。
5
8
  set -e
6
9
  proj="$1"
7
10
  state="$proj/.team/setup-state.json"
@@ -13,36 +16,108 @@ lat_pre=$(python3 -c "import json;print(json.load(open('$state'))['lattice_preex
13
16
  added_mcp=$(python3 -c "import json;d=json.load(open('$state'));print(d.get('added_root_mcp', d.get('root_mcp_json_fallback', False)))")
14
17
  added_mcp_ex=$(python3 -c "import json;d=json.load(open('$state'));print(d.get('added_mcp_exclude', d.get('root_mcp_json_fallback', False)))")
15
18
 
19
+ fail=0
20
+ did() { echo "teardown: [実施] $*"; }
21
+ skip() { echo "teardown: [スキップ] $*"; }
22
+ miss() { echo "teardown: [未実施] $*" >&2; fail=1; }
23
+ yes_() { [ "$1" = "True" ] || [ "$1" = "true" ]; }
24
+
16
25
  # Codex 席の起床ブリッジ(決定54)。常駐 process なので、`.team/` を消す前に確実に止める
17
26
  if [ -f "$proj/.team/wakeup-bridge.json" ]; then
18
- node "$(dirname "$0")/wakeup-bridge.mjs" "$proj" --stop
27
+ # 停止に失敗しても **ここで止まらない**。`set -e` で落ちると、t6 の契約(各段の実施・未実施を
28
+ # 1行ずつ出す/黙って中断しない)が丸ごと破れる——[未実施] も [手当] も要約も出ずに撤去が全部残る
29
+ if node "$(dirname "$0")/wakeup-bridge.mjs" "$proj" --stop; then
30
+ did "wakeup-bridge 停止"
31
+ else
32
+ miss "wakeup-bridge 停止に失敗(常駐が残る)— 上の _STOP_FAILED を見て手で止める"
33
+ fi
34
+ else
35
+ skip "wakeup-bridge(起動記録なし)"
36
+ fi
37
+
38
+ # 席の稼働状態ブリッジ。同じく常駐 process なので `.team/` を消す前に止める。
39
+ # ここで止めないと、pid 記録が `.team/` ごと消えて **`--stop` でも止められなくなる**——しかも
40
+ # 「起動記録が無い(既に停止)」と rc=0 で報告する=**止めたと嘘をつく残骸**になる(実測)
41
+ if [ -f "$proj/.team/seat-status-bridge.json" ]; then
42
+ # 停止に失敗しても **ここで止まらない**。`set -e` で落ちると、t6 の契約(各段の実施・未実施を
43
+ # 1行ずつ出す/黙って中断しない)が丸ごと破れる——[未実施] も [手当] も要約も出ずに撤去が全部残る
44
+ if node "$(dirname "$0")/seat-status-bridge.mjs" "$proj" --stop; then
45
+ did "seat-status-bridge 停止"
46
+ else
47
+ miss "seat-status-bridge 停止に失敗(常駐が残る)— 上の _STOP_FAILED を見て手で止める"
48
+ fi
49
+ else
50
+ skip "seat-status-bridge(起動記録なし)"
19
51
  fi
20
52
 
21
53
  # 外部ペイン(決定53)。`.team/` を消す前に戻す——退避先が `.team/` の中にある
22
54
  ext=$(python3 -c "import json;print(json.load(open('$state')).get('external_pane', False))")
23
55
  pj_pre=$(python3 -c "import json;print(json.load(open('$state')).get('project_json_preexisting', False))")
24
56
 
25
- curl -sf -X DELETE "$url/api/$room" -H "X-Peertable-Token: ${PEERTABLE_POST_TOKEN:-}" > /dev/null
26
- if [ "$ext" = "True" ] || [ "$ext" = "true" ]; then
27
- if [ "$pj_pre" = "True" ] || [ "$pj_pre" = "true" ]; then
57
+ # room 削除。トークンを要する唯一の段で、ここだけが外部サービスへの依存境界
58
+ if [ -z "${PEERTABLE_POST_TOKEN:-}" ]; then
59
+ miss "room 削除 $room TOKEN_MISSING: PEERTABLE_POST_TOKEN が空。\`~/.config/peertable.env\` の定義が \`export\` 付きでないと子 process へ渡らない"
60
+ else
61
+ code=$(curl -s -o /dev/null -w '%{http_code}' -X DELETE "$url/api/$room" -H "X-Peertable-Token: $PEERTABLE_POST_TOKEN" || true)
62
+ [ -n "$code" ] || code=000
63
+ if [ "$code" = 200 ]; then
64
+ did "room 削除 $room (HTTP 200)"
65
+ else
66
+ miss "room 削除 $room (HTTP $code) — 403/401 はトークン不一致、000 は server 不達"
67
+ fi
68
+ fi
69
+ # 未実施なら `.team/` と一緒に room 名も消えるので、後から手で消せる形を先に出す
70
+ [ "$fail" -eq 0 ] || echo "teardown: [手当] room は次で消せる: curl -X DELETE \"$url/api/$room\" -H \"X-Peertable-Token: \$PEERTABLE_POST_TOKEN\"" >&2
71
+
72
+ if yes_ "$ext"; then
73
+ if yes_ "$pj_pre"; then
28
74
  cp "$proj/.team/project.json.bak" "$proj/.lattice/project.json"
75
+ did "外部ペイン復元(既存 project.json を書き戻し)"
29
76
  else
30
77
  rm -f "$proj/.lattice/project.json"
78
+ did "外部ペイン撤去(project.json 削除)"
31
79
  fi
80
+ else
81
+ skip "外部ペイン(登録なし)"
32
82
  fi
83
+
33
84
  rm -rf "$proj/.team"
34
- if [ "$added_mcp" = "True" ] || [ "$added_mcp" = "true" ]; then
85
+ did ".team/ 削除"
86
+
87
+ if yes_ "$added_mcp"; then
35
88
  rm -f "$proj/.mcp.json"
89
+ did ".mcp.json 削除"
90
+ else
91
+ skip ".mcp.json(setup が作っていない)"
36
92
  fi
37
- if [ "$added_mcp_ex" = "True" ] || [ "$added_mcp_ex" = "true" ]; then
93
+
94
+ if yes_ "$added_mcp_ex"; then
38
95
  grep -vx '/\.mcp\.json' "$proj/.git/info/exclude" > "$proj/.git/info/exclude.tmp" || true
39
96
  mv "$proj/.git/info/exclude.tmp" "$proj/.git/info/exclude"
97
+ did "exclude から /.mcp.json を撤去"
98
+ else
99
+ skip "exclude の /.mcp.json(setup が足していない)"
40
100
  fi
41
- if [ "$added" = "True" ] || [ "$added" = "true" ]; then
101
+
102
+ if yes_ "$added"; then
42
103
  grep -vx '\.team/' "$proj/.git/info/exclude" > "$proj/.git/info/exclude.tmp" || true
43
104
  mv "$proj/.git/info/exclude.tmp" "$proj/.git/info/exclude"
105
+ did "exclude から .team/ を撤去"
106
+ else
107
+ skip "exclude の .team/(setup が足していない)"
44
108
  fi
109
+
45
110
  if [ "$lat_pre" = "False" ] || [ "$lat_pre" = "false" ]; then
46
111
  rm -rf "$proj/.lattice"
112
+ did ".lattice/ 削除(setup が作ったもの)"
113
+ else
114
+ skip ".lattice/(setup 以前から存在)"
115
+ fi
116
+
117
+ if [ "$fail" -ne 0 ]; then
118
+ # 「再実行すればいい」と書かないこと。`.team/` は上で消えているので、2回目は setup-state.json が
119
+ # 読めずに落ちる=**再実行の経路は存在しない**。残っている道は上の [手当] の curl だけ(2026-08-08 実測)
120
+ echo "teardown: 未完了 — 撤去は上のとおり済んでいる。残りは上の [手当] を手で叩くこと(.team/ は削除済みなので teardown.sh の再実行はできない)" >&2
121
+ exit 1
47
122
  fi
48
123
  echo "teardown done: $proj"
@@ -14,3 +14,39 @@ tmp=".ev-$t.json"
14
14
  printf '{"evidence_id":"ev-%s","repo_id":"self","path":"%s","git_blob_oid":"%s","content_digest":"%s","media_type":"text/markdown","anchor_digest":null}\n' "$t" "$f" "$oid" "$digest" > "$tmp"
15
15
  lattice todo done --plan "$PEERTABLE_PLAN" --task "$t" --evidence "$tmp"
16
16
  rm -f "$tmp"
17
+
18
+ # 完了の定義は「repo 内の変更は push まで」。done を打つ瞬間はそれが成り立っていなければ
19
+ # ならない唯一の時点で、かつ全員が必ず通る場所である。publish 経路の機械 gate は tarball
20
+ # しか見ないので、docs・証跡・experiments はその外側にある——黙ると誰も見ていない場所へ
21
+ # 成果物が取り残される(2026-08-08 実測。卓の全員が立っていた穴で、親の監査が見つけた)。
22
+ # 出すだけで止めない: push 既定でない repo も、まとめて push する運用も壊さないため。
23
+ # upstream 未設定・git 管理外でも done.sh 自体は死なせない(set -e の下なので必ずガードする)。
24
+ unpushed=$(git rev-list --count '@{u}..HEAD' 2>/dev/null || true)
25
+ if [ -n "$unpushed" ] && [ "$unpushed" != 0 ]; then
26
+ echo "未push ${unpushed}本: この done の成果物はまだ upstream へ着地していない" >&2
27
+ fi
28
+
29
+ # 外部ペインの喪失検出。Lattice 併用モードの卓は、公開工程表の右ペインに円卓が出ているのが正常。
30
+ # 2026-08-08、受入検証が本番のコネクタを「外して痕跡ゼロ」まで確かめて終わり、差し直しが人の記憶
31
+ # 頼みで漏れて、公開工程表から円卓が消えたままになった(オーナー発見)。**外したことは誰も間違えて
32
+ # いない——戻し忘れを誰も見ていなかった**ので、全員が必ず通る done の一点で見る。
33
+ # 未push 警告と同じ作法: 出すだけで止めない・読めない時は黙って継続する(この警告のために done.sh を殺さない)。
34
+ pane_missing=$(python3 - <<'PY' 2>/dev/null || true
35
+ import json
36
+ try:
37
+ state = json.load(open('.team/setup-state.json'))
38
+ except Exception:
39
+ raise SystemExit # 卓が無い/読めない=判定しない
40
+ if state.get('mode') != 'lattice':
41
+ raise SystemExit # 単独円卓モードには工程表が無い
42
+ try:
43
+ identity = json.load(open('.lattice/project.json'))
44
+ except Exception:
45
+ identity = {}
46
+ if not identity.get('external_pane'):
47
+ print('yes')
48
+ PY
49
+ )
50
+ if [ "$pane_missing" = yes ]; then
51
+ echo "外部ペインが未設置か読めない: 公開工程表に円卓が出ていない(差し直す: node <skill>/scripts/external-pane.mjs . <room> <public_base>)" >&2
52
+ fi
@@ -4,7 +4,7 @@
4
4
 
5
5
  ## 作業ループ
6
6
 
7
- 1. `lattice todo status --json` で ready なタスクを見る
7
+ 1. `lattice todo status --json` で ready なタスクを見る。{{CLAIM_SCOPE}}
8
8
  2. 憲章の手順で room に claim を宣言する。**`[claim]` は独立した1発言で出す**——完了報告や他タスクの話と同じ発言に畳まない。宣言としては有効でも、後から機械的に追えなくなり、監査が「宣言が無い」と誤読する(2026-08-08 実測)
9
9
  3. `lattice todo start --plan {{PLAN_KEY}} --task <id>` で着手を記録する。**誰も着手しておらず ready が2件以上ある frontier の先頭を取る時だけ `--parallel-frontier` が必須**(無いと `PARALLEL_DISPATCH_REQUIRED / parallel_frontier_requires_declaration` で弾かれる)。ready が1件だけ、または既に誰かが着手している frontier へ後から乗る時は素の start でよい
10
10
  4. 実装する。インターフェースなど他タスクに影響する決定は、決めた時点で room 全員宛に一行で共有する
@@ -12,7 +12,9 @@
12
12
  - 証跡ファイル `evidence/{{PLAN_KEY}}/<task_id>.md` に「何を作り、どう確認したか」を書く(ディレクトリが無ければ作る。task_id は campaign を跨いで再利用されるので、平置きにすると前の campaign の証跡を上書きで消す)
13
13
  - 変更ファイルと証跡を `git add` して commit する(メッセージは日本語一行。対象ファイルを明示して他人の作業中変更を巻き込まない)
14
14
  - `.team/scripts/done.sh <task_id>` を実行する(evidence 記述子の生成と `lattice todo done` をやってくれる)
15
- 6. room に完了を一行報告し、1 へ戻る
15
+ 6. room に完了を一行報告する。**その時「この点を見てほしい」を添える**——完了 task の監査は実装者以外の席が行い(決定60)、依頼が具体的なほど監査は速く深くなる
16
+ 7. **手が空いていて ready が無いなら、他の席の done を監査する。** 実装者以外なら誰でもよい。実物(diff・検証結果・ハーネス)を自分で走らせて所見を room へ出す——**報告を読むだけでは監査にならない**。親は所見を読んで受理を宣言するだけで、コードは読まない
17
+ 8. 1 へ戻る
16
18
 
17
19
  ## 再着任(context が要約されたら)
18
20
 
@@ -26,4 +28,7 @@
26
28
  - **note が持つものを room の散文へ二重化しない**。設計メモ・タスク固有の経緯は `lattice todo note` に置き、room には決定と進捗だけを流す
27
29
  - room の新着通知が来たら read_unread で読む。返事が要るものには post で応える
28
30
  - **Codex 席の場合**: 起床は channels ではなく wakeup-bridge が担う。`room に新着あり(<誰> → <宛先>)。read_unread で読むこと。` が端末へ直接届くので、Claude 席と同じく read_unread で読む。**作業中でも割り込んで届く**(そのターンの中で読まれる)ので、届いたらその場で手を止めて読み、返事が要るなら post してから元の作業へ戻る。自分の発言では起きない
31
+ - **ブラウザ検証に `claude-in-chrome` を使わない。** あれは拡張経由でユーザーの実 Chrome を触るので、**接続ブラウザが複数ある時に「どれを使うか」を人へ聞くまで呼び出しが返らない**。席には聞く相手が居ないので、**無人の席が踏むと自力で復帰できない**(2026-08-08 実測。オーナーが見ていたから10分で解けたが、見ていなければ親が気づくまで卓ごと止まる)。使うのは**自分で起こした headless の Chrome for Testing + CDP**(`--headless=new --remote-debugging-port=<port> --user-data-dir=<temp>` で起こし、playwright MCP や CDP を直に繋ぐ)——**拡張に触らないので、選択待ちもモーダル固着も起きない**。`chrome-devtools` MCP が空いていればそれでもよいが、**他の席が同じ profile を掴んでいると起動できない**(`browser is already running` で落ちる・実測)ので、確実なのは自分で起こす経路
32
+ - **ブラウザ・ポート・常駐 process を占める前に room へ一言**。上の経路でも 9222 等は共有資源で、終わったら **pid 直指定で止める**(`pkill -f` は他席の同名 process を巻き込む)
33
+ - **監査する時は、自分の測定器を先に疑う**(決定60)。欠陥版で落ちることを確かめてから green を読む。隔離の仕方で欠陥そのものが消えることがあるし、`cmd | tail` の終了コードは **tail のもの**で自分が測りたいものではない——**確かめずに出た数字は、通っても落ちても意味を持たない**
29
34
  - 憲章(.team/CLAUDE.md)が全ての基底である