peertable 0.3.1 → 0.3.3

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
@@ -79,7 +79,9 @@ claude --mcp-config .team/mcp.json \
79
79
 
80
80
  > 円卓を立てて
81
81
 
82
- 聞き取り・命名・`.team/` の scaffold(プロジェクト本体を汚さない)・Lattice plan 投入(単独モードなら読み取り専用の `.team/tasks.md` 生成)・メンバー起動・親の着卓まで一続き。teardown で diff ゼロに戻る。
82
+ 聞き取り・命名・`.team/` の scaffold(プロジェクト本体を汚さない)・Lattice plan 投入(単独モードなら読み取り専用の `.team/tasks.md` 生成)・メンバー起動・親の着卓まで一続き。
83
+
84
+ **teardown は既定で「解散」**——席を畳んでメンバー登録を外し、`.team/` と `.mcp.json` を撤去する。**部屋と過去ログは残る**(部屋は場所であり、次の卓も同じ部屋で続く。過去ログはその部屋の履歴として繋がる)。工程正本 `.lattice/` も残す。**痕跡ゼロに戻したいなら `--purge`**——部屋ごと削除してプロジェクトを diff ゼロへ返す(ゲストのプロジェクトで試した時はこちら)。
83
85
 
84
86
  ## 状態
85
87
 
package/README.md CHANGED
@@ -76,7 +76,7 @@ Standalone gives up machine-guaranteed scheduling across tasks — nothing else.
76
76
 
77
77
  ```
78
78
  room/ room server (zero-dependency Node) + per-session MCP channel client
79
- skill/ "peertable" skill for Claude Code: setup / teardown of a full table
79
+ skill/ "peertable" skill for Claude Code: setup / disband (teardown) of a full table
80
80
  docs/ plan.md — the living design document & decision log (Japanese)
81
81
  experiments/ verification harnesses (V1 channels, V2 Lattice concurrency, V3 full loop)
82
82
  ```
@@ -111,7 +111,7 @@ The `room` entry in `.team/mcp.json` is just `{ "command": "peertable-client" }`
111
111
 
112
112
  > 円卓を立てて / "set up a peertable for this project"
113
113
 
114
- It interviews you, names the members, scaffolds `.team/` (charter + roles, isolated from your project, `.git/info/exclude`d), seeds the Lattice plan — or writes the read-only `.team/tasks.md` agenda if you chose standalone — launches the member sessions, and seats itself beside the table. `teardown` restores your project to a zero diff.
114
+ It interviews you, names the members, scaffolds `.team/` (charter + roles, isolated from your project, `.git/info/exclude`d), seeds the Lattice plan — or writes the read-only `.team/tasks.md` agenda if you chose standalone — launches the member sessions, and seats itself beside the table. `teardown` disbands by default: it closes the seats, removes the member registrations, and clears `.team/` — **the room and its history stay** (a room is a place; the next table continues in the same room, so past logs read as that room's history), and the `.lattice/` plan store is kept. Pass `--purge` to delete the room too and restore your project to a zero diff.
115
115
 
116
116
  ## Status
117
117
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "peertable",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
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.3'
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
@@ -214,6 +222,9 @@ async function runDiagnostics(asJson) {
214
222
  'scripts/make-plan-input.mjs',
215
223
  'scripts/parent-join.sh',
216
224
  'scripts/wakeup-bridge.mjs',
225
+ 'scripts/seat-status-bridge.mjs',
226
+ // teardown の archive(=解散・既定)が呼ぶ。欠けるとログの写しが取れない
227
+ 'scripts/archive-room-log.py',
217
228
  'templates/gen-plan.mjs',
218
229
  'templates/done.sh',
219
230
  'templates/charter.md',
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
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: peertable
3
- description: 任意プロジェクトに Peertable チーム(対等メンバー並列型のマルチエージェント作業システム)を導入・撤去する。setup でメンバーセッション群と room を立ち上げ、teardown diff ゼロに戻す。「チームで作業して」「円卓を立てて」「peertable setup / teardown」で使う。
3
+ description: 任意プロジェクトに Peertable チーム(対等メンバー並列型のマルチエージェント作業システム)を導入・解散する。setup でメンバーセッション群と room を立ち上げ、teardown で席と足場を撤去する(既定は解散——部屋と過去ログは残り、次の卓も同じ部屋で続く。痕跡ゼロにするなら --purge)。「チームで作業して」「円卓を立てて」「peertable setup / teardown」で使う。
4
4
  ---
5
5
 
6
6
  # Peertable — setup / teardown
@@ -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,12 +48,33 @@ 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
 
51
58
  ## teardown
52
59
 
53
- `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 ゼロを確認して報告する。
60
+ `scripts/teardown.sh <project> [--purge]` が機械部分を**全部**行う(room 名・server URL・作成記録は `.team/setup-state.json` から読むので引数は project だけ。書込トークンは環境変数 `PEERTABLE_POST_TOKEN`)。**席の終了も本 script が行う**——AI が事前に `pty_close` して回る必要はない。
61
+
62
+ **既定は archive(=解散)、`--purge` が痕跡ゼロ**(決定61・オーナー裁定 2026-08-09)。**円卓の解散は「部屋を畳む」ではなく「集まりが散る」**——**部屋は場所であって、次の卓も同じ部屋で続く**。過去ログはその部屋の履歴としてそのまま残り、**部屋は常に一つに見える**。ゲスト project を汚さない不可侵原則は `--purge` が担う。
63
+
64
+ 段の順序(**前の段が後の段の前提**):
65
+ 1. **room ログの写し**(archive のみ)→ `docs/archive/room-log_<room>_<日時>.md`。**原本は room に残る**ので、これは repo 側の控え(失敗しても撤去は続行する)
66
+ 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 しない)
72
+
73
+ **次の卓を同じ部屋で立てる時は、setup の room 名を前と同じにする**。member は席が戻れば再登録され、履歴は続く。
74
+
75
+ 実行後は **`git status`(archive なら `docs/archive/` と `.lattice/` が増えているのが正・`--purge` なら diff ゼロ)**、**公開 UI に部屋と過去ログが残っていること(archive)** と、**`tmux -S <socket> list-sessions | grep peer-`** の残存ゼロを確認して報告する。
76
+
77
+ 各段は `[実施] / [スキップ] / [未実施]` を1行ずつ出す。**トークンを要するのは room 削除だけ**なので、そこが失敗しても残りの撤去は続行し、未実施を明示して非ゼロで終わる(黙って中断しない・決定58)。未実施が出ても**撤去そのものは済んでいる**。残りは表示された **[手当] の curl を手で叩く**だけで、`.team/` は既に消えているので **teardown.sh の再実行はできない**(2026-08-08 実測。再実行すると `setup-state.json` が読めず落ちる)。
54
78
 
55
79
  ## 親の operating notes(このセッションの振る舞い)
56
80
 
@@ -58,7 +82,20 @@ description: 任意プロジェクトに Peertable チーム(対等メンバ
58
82
  - 登録: `curl -X POST $URL/api/$ROOM/members -H "X-Peertable-Token: $TOKEN" -d '{"name":"bell"}'`
59
83
  - 発言: `curl -X POST $URL/api/$ROOM/messages -H "X-Peertable-Token: $TOKEN" -d '{"from":"bell","to":"all","body":"..."}'`
60
84
  - 観測: Monitor ツールで `curl -sN $URL/api/$ROOM/events` の SSE を張る(V3 実証済みの形)
61
- - 親の権能は進行・承認・監査・督促・オーナーとの接点だけ。**実務に落ちない**: バグを見つけても直さず、発見内容を room に送って会議に載せる。差し戻しは異議であり、平行線はメンバーが勝つ
85
+ - 親の権能は進行・受理判定・督促・オーナーとの接点だけ。**実務に落ちない**: バグを見つけても直さず、発見内容を room に送って会議に載せる。差し戻しは異議であり、平行線はメンバーが勝つ
86
+ - **監査は卓の中で行う。親はコードを読まない**(決定60)。完了 task の監査は**実装者以外の席**が実物(diff・検証結果・ハーネスの正負両方)を読んで行い、**所見を room へ出す**。親がするのは、その所見を読んで**受理を宣言すること**だけである。
87
+ - **受理の根拠は「所見が room に出ていること」**とする。親自身の読みを根拠にすると、親が最終判断者に戻り、卓が上下オーケストレーションへ滑る(決定43 と同じ経路)
88
+ - **監査者は自分の測定器を先に疑う**。実行して確かめる時は、**欠陥版で落ちることを確認してから** green を読む——確かめずに出た数字は、通っても落ちても意味を持たない
89
+ - 監査の依頼は**実装者が出す**(「この点を見てほしい」を task の done 報告に添える)。依頼が無くても、手が空いた席は出た順に読んでよい
90
+ - 監査で見つけたものは**欠陥の断定と指摘を分けて書く**。受入条件外なら「修正を求めない申し送り」として、記録先(証跡の残課題・`lattice todo note`・課題帳)まで示す
91
+ - **親の再着卓**(context が要約された/セッションが替わった時。決定51 のメンバー版に対応する親版。2026-08-08 実測): 卓は生きたまま親だけが記憶を失う局面なので、**復帰は記憶ではなく正本から取り直す**。順に:
92
+ 1. **room ログを読む**——`curl -s "$URL/api/$ROOM/messages?since=<最後に読んだ seq>"`。`since` を持っていなければ 0 から。**会話が卓の正本**なので、まずここで現在地(誰が何を claim し、どこまで done か)を作る
93
+ 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 は死んでいる**ので、張り直さないと以後の新着に気づかない
96
+ - **再着卓の契機は Monitor stream の終了通知**(2026-08-08 実測。1日に2回——引き継ぎ時と障害復旧後)。親の側には「途絶した」と教えてくれるものが他に無いので、**Monitor が終わったら再着卓の手順に入る**と決めておく
97
+ - **順序の要点は「room と工程正本を読み終えるまで発言しない」**。読む前に喋ると、自分が行き違いを作る側になる(実例あり)
98
+ - **やらないこと**: 復帰の挨拶を全員宛で流さない(1発言=全席1ターン)。作業の再確認を席へ聞いて回らない——**現在地は上の1〜2で取れる**ので、聞くのは席の時間を奪うだけである
62
99
  - **宛先の規律**: channels の起床通知は宛先本人(と全員宛)にしか飛ばない(client の `relevant` フィルタ)。用件が特定メンバーだけなら `to` をそのメンバー名にする——1発言=全席1ターンの課金は全員宛の時だけで、名指しなら起きるのは宛先だけ。ログは宛先に関係なく全員が読める(決定42)ので情報の秘匿にはならない。全員宛を使うのは、決定・gate状態・全体への記録だけ
63
100
  - **発言規律(決定43・正典 §3.4)**: 親の room 発言は ①監査結果の事実(受理/異議。「次はこうせよ」を続けない)②承認 gate の状態 ③オーナー裁定の伝達(必ず「オーナー裁定」と明示)の3種だけ。メンバー間合意の再掲・とりまとめ・次タスクの指名・frontier の解説は、内容が正しくても**しない**——親が言い直した瞬間に出典が親へ書き換わり、卓が上下オーケストレーションへ滑る(初回実運用で実測)。裁定依頼が来たら自分で判断せず、オーナー宛の議題として運ぶ
64
101
  - 督促の検出源は room の報告途絶と Lattice 工程表の乖離。**単独円卓モードでは工程表が無いので、検出源は `.team/tasks.md` の議題と room ログの照合だけになる**——完走の判定も同じで、全議題に完了報告が揃ったことを親が room ログで確認し、散会を宣言する(この確認と宣言が単独モードの done gate である)
@@ -76,11 +113,13 @@ description: 任意プロジェクトに Peertable チーム(対等メンバ
76
113
  - **メンバー起動の既知ダイアログは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
114
  - **Codex 席のダイアログは2種で、片方は既定が誤り**(Codex CLI v0.146.0・実測): ディレクトリ trust(`1. Yes, continue`=既定で正しい)と、**更新案内(`1. Update now`)は既定のまま Enter を押すと立卓の途中で `npm install -g @openai/codex` が走る**。1つ下の「2. Skip」を選ぶ。更新案内は毎回は出ないので、出た時だけ通す
78
115
  - **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人が独立に踏み、先に憶測を流した側が訂正を出した)
116
+ - **席の沈黙は「詰まり」と同義ではない。** 発言間隔やファイルの更新時刻から止まったと判定しない——実装が終わって検証に時間を使っているだけのことがある。判定は `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
117
  - **`claude-in-chrome` の呼び出しは返らないことがある**。原因は2種で、解き方が違う(2026-08-08 に席1つが9分半沈黙して実測):
81
118
  - **接続ブラウザが複数あって、拡張がどれを使うか選ばせている**——選択待ちのまま返らない。**AI 側から解ける**(オーナーに「どちらを使うか」を一言聞けば済む)。今回の実例はこちら。デバッグ接続が宙吊りのまま「Claude がこのブラウザのデバッグを開始しました[キャンセル]」バナーが残る形もあり、キャンセルを押せば呼び出しは即エラーで返る
82
119
  - **ブラウザに alert/confirm 等のモーダルが出ている**——拡張が以後のコマンドを受け取れない。**AI 側から解けない**ので、人がダイアログを閉じるしかない
83
120
  - 沈黙した席を見る側は、この2つを区別せずに「固着」と決めない。トークン受信が増え続けているなら止まっていない
121
+ - **無人の席はどちらの型も自力で解けない。** 選択待ちは「人に聞けば解ける」型だが、**席には聞く相手が居ない**——だから席の役割文書は `claude-in-chrome` を使わせず、自分で起こす headless(Chrome for Testing + CDP)へ寄せてある。それでも席が踏んだら**親が解く**: `pty_read`(`screen: true`)で画面を見て、選択ダイアログなら `pty_key` で選んで通す(`launch-seat.sh` が起動時の既知ダイアログを通すのと同じ手)。モーダル固着ならオーナーへ回す——**AI 側から解けないのはこちらだけ**
122
+ - **親の督促手順に組み込む**: 報告途絶を見つけたら ①`capture-pane` で実状態(`esc to interrupt` の有無・ダイアログか・`pane_dead`)②ダイアログなら `pty_key` で解除 ③解除できない型ならオーナーへ。**呼びかけを増やすのは①の前にやらない**——ターン中なら読まれないので遅くなるだけ
84
123
  - **共有リソースを占める作業は着手前に room へ一言**。同じマシンに席が並ぶので、実測の宣言は「repo を汚さないか」だけでなく「**ブラウザ・ポート・常駐 process を占めないか**」まで含める。ブラウザを起こす席が複数あると、拡張の接続先が増えて他席の呼び出しが選択待ちに入りうる(2026-08-08 の停止例では原因ではなかったが、成立しうる経路として置く)
85
124
  - **シェルスクリプトで `$var` の直後に全角括弧を書かない**。bash が高位バイトを変数名の一部として食い、変数が空のまま何も言わずに出力から消える(2026-08-08 実測)。`${var}(…)` と閉じる。同様に `python3 -c` へ `{...}` を含む式をインラインで渡さない——シェルのブレース展開が刻む。ヒアドキュメントで渡す
86
125
  - channels はリサーチプレビュー。構文が変わったら V0 の要領で公式ドキュメント(code.claude.com/docs/en/channels-reference.md)を再確認する
@@ -0,0 +1,57 @@
1
+ #!/usr/bin/env python3
2
+ """room の会話ログを Markdown へ書き出す(teardown の archive モード用)。
3
+
4
+ usage:
5
+ archive-room-log.py <server_url> <room> <out.md> ログを書き出す
6
+ archive-room-log.py --members <server_url> <room> member 名を空白区切りで出す(席を畳む相手)
7
+
8
+ 本文は投稿時の Markdown をそのまま置く(引用ブロックで包むと表とコードが崩れる)。
9
+ 本文の無い発言(過去に本文なし POST が着地した分)は、欠落と分かる形で残す——
10
+ 消すと「そこに発言があった」ことまで消える。
11
+ """
12
+ import json
13
+ import sys
14
+ import urllib.request
15
+
16
+
17
+ def fetch(url, path):
18
+ with urllib.request.urlopen(f"{url.rstrip('/')}{path}", timeout=15) as r:
19
+ return json.load(r)
20
+
21
+
22
+ def main(argv):
23
+ if argv[:1] == ["--members"]:
24
+ url, room = argv[1], argv[2]
25
+ members = fetch(url, f"/api/{room}/members")["members"]
26
+ print(" ".join(m["name"] for m in members))
27
+ return 0
28
+
29
+ url, room, out = argv[0], argv[1], argv[2]
30
+ messages = fetch(url, f"/api/{room}/messages")["messages"]
31
+ lines = [
32
+ f"# 円卓ログ — room `{room}`(全{len(messages)}発言)",
33
+ "",
34
+ "teardown(archive モード)が書き出した正史。room サーバー側は削除済みなので、"
35
+ "この文書が唯一の記録である。",
36
+ "",
37
+ "---",
38
+ "",
39
+ ]
40
+ for m in messages:
41
+ body = m.get("body")
42
+ if body is None:
43
+ body = "(本文欠落——本文なし POST が着地した発言。欠落そのものを記録として残す)"
44
+ lines += [f"## [{m['seq']}] {m['from']} → {m.get('to', 'all')} ・ {m['ts']}", "", body, ""]
45
+ with open(out, "w", encoding="utf-8") as f:
46
+ f.write("\n".join(lines))
47
+ return 0
48
+
49
+
50
+ if __name__ == "__main__":
51
+ # 生の traceback を出さない。teardown の画面へ出るのは「何が起きたか」の1行であるべきで、
52
+ # stack trace は読む側に原因を伝えない(呼び出し側は非ゼロだけを見る)
53
+ try:
54
+ sys.exit(main(sys.argv[1:]))
55
+ except Exception as exc: # noqa: BLE001 — 入口なので型を絞らない
56
+ print(f"ARCHIVE_ROOM_LOG_FAILED: {type(exc).__name__}: {exc}", file=sys.stderr)
57
+ sys.exit(1)
@@ -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,9 +1,26 @@
1
1
  #!/bin/bash
2
- # Peertable teardown の機械部分。実行前にメンバーセッションを終了しておくこと(AI pty_close で行う)。
3
- # usage: teardown.sh <project_dir>
4
- # 書込トークンは環境変数 PEERTABLE_POST_TOKEN から取る。
2
+ # Peertable teardown の機械部分。席(tmux)も本 script が畳む。
3
+ # usage: teardown.sh <project_dir> [--purge]
4
+ # 既定(archive): room のログを `docs/archive/` へ書き出し、`.lattice/` は残して畳む
5
+ # --purge : 何も残さず全部消す(従来の痕跡ゼロ・ゲスト project 向け)
6
+ # 書込トークンは環境変数 PEERTABLE_POST_TOKEN から取る(`~/.config/peertable.env` は export 付きで定義すること)。
7
+ #
8
+ # 撤去は「何が実施され、何が実施されなかったか」を1行ずつ出す。room 削除だけがトークンを要するので、
9
+ # そこが失敗しても残りの撤去は続行し、未実施を明示して非ゼロで終わる(黙って中断しない・決定58)。
10
+ #
11
+ # **既定が archive なのはオーナー裁定(2026-08-09・決定61)**。痕跡ゼロを既定にしていた時代は、
12
+ # 畳んだ瞬間に room の会話ログ(server 側の正本)と Lattice store が消えていた——**卓の議論と
13
+ # 工程の記録は、卓そのものより寿命が長い**。ゲスト project を汚さない不可侵原則は `--purge` が持つ。
5
14
  set -e
6
15
  proj="$1"
16
+ mode=archive
17
+ for arg in "${@:2}"; do
18
+ case "$arg" in
19
+ --purge) mode=purge ;;
20
+ --archive) mode=archive ;;
21
+ *) echo "ERROR: 未知の引数: ${arg}(受けるのは --purge / --archive だけ)" >&2; exit 1 ;;
22
+ esac
23
+ done
7
24
  state="$proj/.team/setup-state.json"
8
25
  room=$(python3 -c "import json;print(json.load(open('$state'))['room'])")
9
26
  url=$(python3 -c "import json;print(json.load(open('$state'))['server_url'])")
@@ -13,36 +30,178 @@ lat_pre=$(python3 -c "import json;print(json.load(open('$state'))['lattice_preex
13
30
  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
31
  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
32
 
33
+ fail=0
34
+ did() { echo "teardown: [実施] $*"; }
35
+ skip() { echo "teardown: [スキップ] $*"; }
36
+ miss() { echo "teardown: [未実施] $*" >&2; fail=1; }
37
+ yes_() { [ "$1" = "True" ] || [ "$1" = "true" ]; }
38
+
39
+ echo "teardown: mode=${mode}(archive=ログとstoreを残す/purge=痕跡ゼロ)"
40
+
41
+ # ---- ログの控え(archive だけ)。room 自体は残るので、これは repo 側の写し ----
42
+ # 失敗しても撤去は続ける(room に原本があるため)。--purge の時だけ「消す前の最後の機会」になる
43
+ log_saved=skip
44
+ if [ "$mode" = archive ]; then
45
+ arc="$proj/docs/archive"
46
+ out="$arc/room-log_${room}_$(date +%Y%m%d-%H%M%S).md"
47
+ mkdir -p "$arc"
48
+ if python3 "$(dirname "$0")/archive-room-log.py" "$url" "$room" "$out"; then
49
+ did "room ログの写しを ${out#"$proj/"} へ(原本は room に残る)"
50
+ log_saved=yes
51
+ else
52
+ miss "room ログの写しに失敗($url/api/$room が読めない)— **原本は room に残っている**ので撤去は続行する"
53
+ log_saved=no
54
+ fi
55
+ fi
56
+
57
+ # ---- 席(tmux)の終了。**`.team/` を消す前**に、この room の member だけを畳む ----
58
+ # `peer-*` を全部畳むと、同じマシンの別の卓を巻き込む(bridge が members 起点にしているのと同じ理由)
59
+ sock="${PEERTABLE_TMUX_SOCKET:-${TMPDIR}claude-tmux-sockets/claude.sock}"
60
+ seats=$(python3 "$(dirname "$0")/archive-room-log.py" --members "$url" "$room" 2>/dev/null || true)
61
+ if [ -z "$seats" ]; then
62
+ miss "席の終了 — member 一覧が取れず、畳む相手を特定できない。手で確認: tmux -S \"$sock\" list-sessions | grep peer-"
63
+ else
64
+ closed=0
65
+ for name in $seats; do
66
+ if tmux -S "$sock" has-session -t "peer-$name" 2>/dev/null; then
67
+ tmux -S "$sock" kill-session -t "peer-$name" && closed=$((closed + 1))
68
+ fi
69
+ done
70
+ if [ "$closed" -gt 0 ]; then did "席の終了(${closed}席)"; else skip "席の終了(生きている席なし)"; fi
71
+ left=$(tmux -S "$sock" list-sessions 2>/dev/null | grep -c '^peer-' || true)
72
+ [ "${left:-0}" -eq 0 ] || echo "teardown: [注記] 他の卓の peer-* が ${left}件 残っている(この卓のものではないので畳まない)"
73
+ fi
74
+
16
75
  # Codex 席の起床ブリッジ(決定54)。常駐 process なので、`.team/` を消す前に確実に止める
17
76
  if [ -f "$proj/.team/wakeup-bridge.json" ]; then
18
- node "$(dirname "$0")/wakeup-bridge.mjs" "$proj" --stop
77
+ # 停止に失敗しても **ここで止まらない**。`set -e` で落ちると、t6 の契約(各段の実施・未実施を
78
+ # 1行ずつ出す/黙って中断しない)が丸ごと破れる——[未実施] も [手当] も要約も出ずに撤去が全部残る
79
+ if node "$(dirname "$0")/wakeup-bridge.mjs" "$proj" --stop; then
80
+ did "wakeup-bridge 停止"
81
+ else
82
+ miss "wakeup-bridge 停止に失敗(常駐が残る)— 上の _STOP_FAILED を見て手で止める"
83
+ fi
84
+ else
85
+ skip "wakeup-bridge(起動記録なし)"
86
+ fi
87
+
88
+ # 席の稼働状態ブリッジ。同じく常駐 process なので `.team/` を消す前に止める。
89
+ # ここで止めないと、pid 記録が `.team/` ごと消えて **`--stop` でも止められなくなる**——しかも
90
+ # 「起動記録が無い(既に停止)」と rc=0 で報告する=**止めたと嘘をつく残骸**になる(実測)
91
+ if [ -f "$proj/.team/seat-status-bridge.json" ]; then
92
+ # 停止に失敗しても **ここで止まらない**。`set -e` で落ちると、t6 の契約(各段の実施・未実施を
93
+ # 1行ずつ出す/黙って中断しない)が丸ごと破れる——[未実施] も [手当] も要約も出ずに撤去が全部残る
94
+ if node "$(dirname "$0")/seat-status-bridge.mjs" "$proj" --stop; then
95
+ did "seat-status-bridge 停止"
96
+ else
97
+ miss "seat-status-bridge 停止に失敗(常駐が残る)— 上の _STOP_FAILED を見て手で止める"
98
+ fi
99
+ else
100
+ skip "seat-status-bridge(起動記録なし)"
19
101
  fi
20
102
 
21
103
  # 外部ペイン(決定53)。`.team/` を消す前に戻す——退避先が `.team/` の中にある
22
104
  ext=$(python3 -c "import json;print(json.load(open('$state')).get('external_pane', False))")
23
105
  pj_pre=$(python3 -c "import json;print(json.load(open('$state')).get('project_json_preexisting', False))")
24
106
 
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
107
+ # ---- 解散(archive): **部屋は残し、メンバー登録だけ外す** ----
108
+ # 円卓の解散は「部屋を畳む」ではなく「集まりが散る」。部屋は場所であって、次の campaign
109
+ # 同じ部屋で続く——**過去ログが同じ部屋の履歴として繋がり、部屋は常に一つに見える**
110
+ # (オーナー裁定 2026-08-09・決定61)。参加者一覧だけ空にして、席が戻れば再登録される
111
+ if [ "$mode" = archive ]; then
112
+ if [ -z "${PEERTABLE_POST_TOKEN:-}" ]; then
113
+ miss "メンバー登録の解除 — TOKEN_MISSING: \`~/.config/peertable.env\` の定義が \`export\` 付きでないと子 process へ渡らない"
114
+ echo "teardown: [手当] 参加者は次で外せる: curl -X DELETE \"$url/api/$room/members/<名前>\" -H \"X-Peertable-Token: \$PEERTABLE_POST_TOKEN\"" >&2
115
+ elif [ -z "$seats" ]; then
116
+ skip "メンバー登録の解除(一覧が取れていない)"
117
+ else
118
+ # 履歴に解散の区切りを残す。**部屋が続く以上、どこで卓が変わったかが読めないと
119
+ # 過去ログが一続きの会話に見えてしまう**(次の campaign の発言と地続きになる)
120
+ body="解散。この卓はここまで。参加者: ${seats}。部屋と過去ログはこのまま残り、次の卓も同じ部屋で続く。"
121
+ python3 -c "
122
+ import json,sys,urllib.request
123
+ req=urllib.request.Request('$url/api/$room/messages', method='POST',
124
+ data=json.dumps({'from':'system','to':'all','body':'''$body'''}).encode(),
125
+ headers={'Content-Type':'application/json','X-Peertable-Token':'$PEERTABLE_POST_TOKEN'})
126
+ urllib.request.urlopen(req, timeout=10).read()
127
+ " 2>/dev/null && did "解散の区切りを履歴へ" || skip "解散の区切り(投稿できず・撤去は続行)"
128
+ n=0
129
+ for name in $seats; do
130
+ c=$(curl -s -o /dev/null -w '%{http_code}' -X DELETE "$url/api/$room/members/$name" -H "X-Peertable-Token: $PEERTABLE_POST_TOKEN" || true)
131
+ [ "$c" = 200 ] && n=$((n + 1))
132
+ done
133
+ did "メンバー登録の解除(${n}名)— **部屋と過去ログは残す**($url/$room)"
134
+ fi
135
+ # room 削除は --purge だけ。トークンを要する唯一の段で、ここだけが外部サービスへの依存境界
136
+ elif [ "$log_saved" = no ]; then
137
+ miss "room 削除 $room — ログを保全できていないので消さない(保全より先に消すと会話は二度と戻らない)"
138
+ elif [ -z "${PEERTABLE_POST_TOKEN:-}" ]; then
139
+ miss "room 削除 $room — TOKEN_MISSING: PEERTABLE_POST_TOKEN が空。\`~/.config/peertable.env\` の定義が \`export\` 付きでないと子 process へ渡らない"
140
+ else
141
+ code=$(curl -s -o /dev/null -w '%{http_code}' -X DELETE "$url/api/$room" -H "X-Peertable-Token: $PEERTABLE_POST_TOKEN" || true)
142
+ [ -n "$code" ] || code=000
143
+ if [ "$code" = 200 ]; then
144
+ did "room 削除 $room (HTTP 200)"
145
+ else
146
+ miss "room 削除 $room (HTTP $code) — 403/401 はトークン不一致、000 は server 不達"
147
+ fi
148
+ fi
149
+ # 未実施なら `.team/` と一緒に room 名も消えるので、後から手で消せる形を先に出す
150
+ [ "$fail" -eq 0 ] || echo "teardown: [手当] room は次で消せる: curl -X DELETE \"$url/api/$room\" -H \"X-Peertable-Token: \$PEERTABLE_POST_TOKEN\"" >&2
151
+
152
+ if yes_ "$ext"; then
153
+ if yes_ "$pj_pre"; then
28
154
  cp "$proj/.team/project.json.bak" "$proj/.lattice/project.json"
155
+ did "外部ペイン復元(既存 project.json を書き戻し)"
29
156
  else
30
157
  rm -f "$proj/.lattice/project.json"
158
+ did "外部ペイン撤去(project.json 削除)"
31
159
  fi
160
+ else
161
+ skip "外部ペイン(登録なし)"
32
162
  fi
163
+
33
164
  rm -rf "$proj/.team"
34
- if [ "$added_mcp" = "True" ] || [ "$added_mcp" = "true" ]; then
165
+ did ".team/ 削除"
166
+
167
+ if yes_ "$added_mcp"; then
35
168
  rm -f "$proj/.mcp.json"
169
+ did ".mcp.json 削除"
170
+ else
171
+ skip ".mcp.json(setup が作っていない)"
36
172
  fi
37
- if [ "$added_mcp_ex" = "True" ] || [ "$added_mcp_ex" = "true" ]; then
173
+
174
+ if yes_ "$added_mcp_ex"; then
38
175
  grep -vx '/\.mcp\.json' "$proj/.git/info/exclude" > "$proj/.git/info/exclude.tmp" || true
39
176
  mv "$proj/.git/info/exclude.tmp" "$proj/.git/info/exclude"
177
+ did "exclude から /.mcp.json を撤去"
178
+ else
179
+ skip "exclude の /.mcp.json(setup が足していない)"
40
180
  fi
41
- if [ "$added" = "True" ] || [ "$added" = "true" ]; then
181
+
182
+ if yes_ "$added"; then
42
183
  grep -vx '\.team/' "$proj/.git/info/exclude" > "$proj/.git/info/exclude.tmp" || true
43
184
  mv "$proj/.git/info/exclude.tmp" "$proj/.git/info/exclude"
185
+ did "exclude から .team/ を撤去"
186
+ else
187
+ skip "exclude の .team/(setup が足していない)"
44
188
  fi
45
- if [ "$lat_pre" = "False" ] || [ "$lat_pre" = "false" ]; then
189
+
190
+ if [ "$lat_pre" = "True" ] || [ "$lat_pre" = "true" ]; then
191
+ skip ".lattice/(setup 以前から存在)"
192
+ elif [ "$mode" = archive ]; then
193
+ # 工程正本を残すのが archive の本体。**残すだけでは git が知らない**ので、追跡へ入れるのは人の判断
194
+ did ".lattice/ を残す(工程正本・\`lattice todo status\` と \`gantt serve\` が読む)"
195
+ echo "teardown: [注記] .lattice/ は git 追跡外のままなら次の clone に残らない。残すなら commit すること"
196
+ else
46
197
  rm -rf "$proj/.lattice"
198
+ did ".lattice/ 削除(setup が作ったもの・--purge)"
199
+ fi
200
+
201
+ if [ "$fail" -ne 0 ]; then
202
+ # 「再実行すればいい」と書かないこと。`.team/` は上で消えているので、2回目は setup-state.json が
203
+ # 読めずに落ちる=**再実行の経路は存在しない**。残っている道は上の [手当] の curl だけ(2026-08-08 実測)
204
+ echo "teardown: 未完了 — 撤去は上のとおり済んでいる。残りは上の [手当] を手で叩くこと(.team/ は削除済みなので teardown.sh の再実行はできない)" >&2
205
+ exit 1
47
206
  fi
48
207
  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)が全ての基底である