peertable 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "peertable",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
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",
package/room/client.mjs CHANGED
@@ -209,6 +209,11 @@ async function runDiagnostics(asJson) {
209
209
  'SKILL.md',
210
210
  'scripts/setup.sh',
211
211
  'scripts/teardown.sh',
212
+ 'scripts/external-pane.mjs',
213
+ 'scripts/launch-seat.sh',
214
+ 'scripts/make-plan-input.mjs',
215
+ 'scripts/parent-join.sh',
216
+ 'scripts/wakeup-bridge.mjs',
212
217
  'templates/gen-plan.mjs',
213
218
  'templates/done.sh',
214
219
  'templates/charter.md',
package/room/server.mjs CHANGED
@@ -8,6 +8,7 @@ import { join } from 'node:path'
8
8
  const PORT = Number(process.env.PEERTABLE_PORT ?? 8790)
9
9
  const DATA = process.env.PEERTABLE_DATA ?? './peertable-data'
10
10
  const TOKEN = process.env.PEERTABLE_POST_TOKEN ?? null // 設定時のみ書込に要求(公開設置用)
11
+ const HEARTBEAT_MS = 25000 // SSE 心拍。中間の proxy が落とす前・client の見張りが切る前の間隔
11
12
 
12
13
  mkdirSync(DATA, { recursive: true })
13
14
 
@@ -50,7 +51,11 @@ function post(room, from, to, body) {
50
51
  return msg
51
52
  }
52
53
 
53
- const json = (res, code, obj) => { res.writeHead(code, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)) }
54
+ // 読み取り系だけ越境許可(Lattice 工程表ページからの probe fetch 用)。書込系には付けず OPTIONS も持たないので、
55
+ // ブラウザからの越境書込は成立しない=読み取り専用の公開面(決定42)は変わらない
56
+ const CORS = { 'Access-Control-Allow-Origin': '*' }
57
+
58
+ const json = (res, code, obj, headers) => { res.writeHead(code, { 'Content-Type': 'application/json', ...headers }); res.end(JSON.stringify(obj)) }
54
59
  const readBody = req => new Promise(r => { let b = ''; req.on('data', c => (b += c)); req.on('end', () => r(b)) })
55
60
 
56
61
  http.createServer(async (req, res) => {
@@ -63,16 +68,19 @@ http.createServer(async (req, res) => {
63
68
  const rest = seg.slice(2).join('/')
64
69
 
65
70
  if (req.method === 'GET' && rest === 'messages')
66
- return json(res, 200, { messages: readMessages(room, Number(url.searchParams.get('since') ?? 0)) })
71
+ return json(res, 200, { messages: readMessages(room, Number(url.searchParams.get('since') ?? 0)) }, CORS)
67
72
 
68
73
  if (req.method === 'GET' && rest === 'members')
69
- return json(res, 200, { members: [...room.members].map(([name, joined_at]) => ({ name, joined_at })) })
74
+ return json(res, 200, { members: [...room.members].map(([name, joined_at]) => ({ name, joined_at })) }, CORS)
70
75
 
71
76
  if (req.method === 'GET' && rest === 'events') {
72
- res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' })
77
+ res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive', ...CORS })
73
78
  res.write(`: connected seq=${room.seq}\n\n`)
74
79
  room.streams.add(res)
75
- req.on('close', () => room.streams.delete(res))
80
+ // 心拍。TCP が半開きで死ぬと onerror も発火しないので、client が「途絶」を検知できる signal を送り続ける。
81
+ // コメント行では EventSource から見えないため、名前付き event にする
82
+ const beat = setInterval(() => res.write(`event: ping\ndata: ${room.seq}\n\n`), HEARTBEAT_MS)
83
+ req.on('close', () => { clearInterval(beat); room.streams.delete(res) })
76
84
  return
77
85
  }
78
86
 
@@ -107,7 +115,7 @@ http.createServer(async (req, res) => {
107
115
  if (req.method === 'GET' && seg.length === 0) {
108
116
  const list = readdirSync(DATA, { withFileTypes: true }).filter(d => d.isDirectory()).map(d => d.name)
109
117
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
110
- return res.end(`<!doctype html><meta charset="utf-8"><title>Peertable</title><body style="font-family:sans-serif;max-width:640px;margin:40px auto"><h1>Peertable</h1><p>A round table of peer agents.</p><ul>${list.map(r => `<li><a href="/${r}">${r}</a></li>`).join('') || '<li>(no rooms yet)</li>'}</ul>`)
118
+ return res.end(INDEX(list))
111
119
  }
112
120
  if (req.method === 'GET' && seg.length === 1) {
113
121
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
@@ -116,28 +124,153 @@ http.createServer(async (req, res) => {
116
124
  json(res, 404, { error: 'not_found' })
117
125
  }).listen(PORT, () => console.error(`peertable room server on :${PORT} (data: ${DATA})`))
118
126
 
119
- const UI = room => `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
120
- <title>${room} · Peertable</title><style>
121
- :root{--fg:#1a1a1a;--bg:#fafaf8;--line:#e4e2dc;--accent:#1d4ed8;--dim:#8a877f}
122
- @media(prefers-color-scheme:dark){:root{--fg:#e8e6e0;--bg:#16161a;--line:#2c2c33;--accent:#7aa2ff;--dim:#77747c}}
123
- body{font-family:ui-sans-serif,system-ui,sans-serif;color:var(--fg);background:var(--bg);max-width:760px;margin:0 auto;padding:24px 16px}
124
- h1{font-size:18px;margin:0 0 4px}h1 small{color:var(--dim);font-weight:normal}
125
- #members{color:var(--dim);font-size:13px;margin-bottom:16px}
126
- #log{border-top:1px solid var(--line)}
127
- .m{padding:8px 0;border-bottom:1px solid var(--line);font-size:14px;line-height:1.6}
128
- .m .h{color:var(--dim);font-size:12px}.m .h b{color:var(--accent);font-weight:600}
129
- .m.dm .h::after{content:" · DM";color:var(--dim)}
130
- .m.system{color:var(--dim);font-style:italic}
127
+ // HTML へ差し込む値は room 名などブラウザ由来なので、表示は esc・URL encodeURIComponent・JS JSON.stringify で渡す
128
+ const esc = s => String(s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]))
129
+
130
+ // ブランド: 卓(円)を囲む4人(点)。header の印と favicon で同じ形を使う
131
+ const MARK = `<svg class="mark" width="17" height="17" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="4.6" fill="none" stroke="currentColor" stroke-width="2"/><circle cx="12" cy="3" r="2" fill="currentColor"/><circle cx="21" cy="12" r="2" fill="currentColor"/><circle cx="12" cy="21" r="2" fill="currentColor"/><circle cx="3" cy="12" r="2" fill="currentColor"/></svg>`
132
+ const FAVICON = `<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Ccircle cx='12' cy='12' r='4.6' fill='none' stroke='%231d4ed8' stroke-width='2'/%3E%3Ccircle cx='12' cy='3' r='2' fill='%231d4ed8'/%3E%3Ccircle cx='21' cy='12' r='2' fill='%231d4ed8'/%3E%3Ccircle cx='12' cy='21' r='2' fill='%231d4ed8'/%3E%3Ccircle cx='3' cy='12' r='2' fill='%231d4ed8'/%3E%3C/svg%3E">`
133
+
134
+ // 発言者ごとの色は名前ハッシュ(--h)から作る。彩度・明度だけテーマで持ち替えれば dark/light 両方が成立する
135
+ const STYLE = `
136
+ *{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%}}
139
+ 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
+ a{color:var(--accent)}
141
+ .brand{display:flex;align-items:center;gap:8px;font-size:15px;font-weight:650;letter-spacing:.01em}
142
+ .brand .mark{color:var(--accent);flex:none}
143
+ .brand small{color:var(--dim);font-weight:400}
144
+ .av{flex:none;display:grid;place-items:center;width:30px;height:30px;border-radius:50%;background:hsl(var(--h) var(--sat) var(--lum));color:#fff;font-size:13px;font-weight:700;line-height:1}
145
+ .empty{color:var(--dim)}`
146
+
147
+ const UI = room => `<!doctype html><html lang="ja"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
148
+ <title>${esc(room)} · Peertable</title>${FAVICON}<style>${STYLE}
149
+ .top{position:sticky;top:0;z-index:2;background:var(--bg);border-bottom:1px solid var(--line);padding:12px 16px 0}
150
+ .top>div{max-width:760px;margin:0 auto}
151
+ .members{display:flex;gap:6px;overflow-x:auto;padding:10px 0;scrollbar-width:thin}
152
+ .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
+ .chip .av{width:22px;height:22px;font-size:11px}
154
+ .chip.recent{border-color:hsl(var(--h) var(--sat) var(--edge))}
155
+ .chip.recent .nm{color:hsl(var(--h) var(--sat) var(--name))}
156
+ .chip.pulse .av{animation:pulse 1.6s ease-out 2}
157
+ @keyframes pulse{from{box-shadow:0 0 0 0 hsl(var(--h) var(--sat) var(--lum)/.6)}to{box-shadow:0 0 0 9px hsl(var(--h) var(--sat) var(--lum)/0)}}
158
+ .log{max-width:760px;margin:0 auto;padding:14px 16px 40px;display:flex;flex-direction:column;gap:10px}
159
+ .msg{display:flex;gap:9px;align-items:flex-start}
160
+ .msg.cont{margin-top:-8px}.msg.cont .av{visibility:hidden;height:0}.msg.cont .meta{display:none}
161
+ .msg .body{min-width:0;max-width:100%}
162
+ .meta{display:flex;align-items:baseline;gap:7px;flex-wrap:wrap;margin:0 0 3px 2px;font-size:12px}
163
+ .who{font-weight:700;color:hsl(var(--h) var(--sat) var(--name))}
164
+ .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}
166
+ .msg.cont .bubble{border-radius:13px}
167
+ .msg.dm .bubble{border-style:dashed;border-color:hsl(var(--h) var(--sat) var(--edge))}
168
+ .msg.dm .to{color:hsl(var(--h) var(--sat) var(--name));font-weight:600}
169
+ .sys{display:flex;justify-content:center;align-items:baseline;gap:8px;color:var(--dim);font-size:12px;padding:2px 0}
170
+ .sys .body{padding:2px 12px;border-radius:999px;background:var(--surface);border:1px solid var(--line)}
131
171
  </style></head><body>
132
- <h1>${room} <small>· Peertable</small></h1><div id="members"></div><div id="log"></div>
172
+ <header class="top"><div class="brand">${MARK}${esc(room)} <small>· Peertable</small></div><div class="members" id="members"></div></header>
173
+ <main class="log" id="log"></main>
133
174
  <script>
134
- const log=document.getElementById('log'),membersEl=document.getElementById('members')
135
- const render=m=>{const d=document.createElement('div');d.className='m'+(m.to!=='all'?' dm':'')+(m.from==='system'?' system':'')
136
- d.innerHTML='<div class="h"><b></b> → '+m.to+' · '+new Date(m.ts).toLocaleTimeString()+'</div><div class="b"></div>'
137
- d.querySelector('b').textContent=m.from;d.querySelector('.b').textContent=m.body;log.appendChild(d);window.scrollTo(0,document.body.scrollHeight)}
138
- const refreshMembers=async()=>{const r=await(await fetch('/api/${room}/members')).json()
139
- membersEl.textContent='卓に居る: '+(r.members.map(m=>m.name).join('、')||'(まだ誰も居ない)')}
140
- fetch('/api/${room}/messages').then(r=>r.json()).then(r=>r.messages.forEach(render))
141
- new EventSource('/api/${room}/events').onmessage=e=>{render(JSON.parse(e.data));refreshMembers()}
142
- refreshMembers()
175
+ const ROOM=${JSON.stringify(room)}
176
+ const api=p=>'/api/'+encodeURIComponent(ROOM)+'/'+p
177
+ const logEl=document.getElementById('log'),membersEl=document.getElementById('members')
178
+ const el=(tag,cls,text)=>{const e=document.createElement(tag);if(cls)e.className=cls;if(text!=null)e.textContent=text;return e}
179
+ 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
+ const initial=n=>{const c=[...String(n)][0];return c?c.toUpperCase():'?'}
181
+ 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
+ const nearBottom=()=>window.innerHeight+window.scrollY>=document.body.offsetHeight-80
183
+ let last=null,recent=null
184
+ function render(m){
185
+ const at=new Date(m.ts)
186
+ if(m.from==='system'){const d=el('div','sys');d.appendChild(el('span','body',m.body));d.appendChild(stamp(at));logEl.appendChild(d);last=null;return}
187
+ const cont=last&&last.from===m.from&&last.to===m.to&&at-new Date(last.ts)<300000
188
+ const d=el('div','msg'+(m.to!=='all'?' dm':'')+(cont?' cont':''))
189
+ d.style.setProperty('--h',hue(m.from))
190
+ d.appendChild(el('div','av',initial(m.from)))
191
+ const body=el('div','body'),meta=el('div','meta')
192
+ meta.appendChild(el('span','who',m.from))
193
+ if(m.to!=='all')meta.appendChild(el('span','to','→ '+m.to))
194
+ meta.appendChild(stamp(at))
195
+ body.appendChild(meta);body.appendChild(el('div','bubble',m.body))
196
+ d.appendChild(body);logEl.appendChild(d);last=m
197
+ }
198
+ async function refreshMembers(){
199
+ const r=await(await fetch(api('members'))).json()
200
+ membersEl.textContent=''
201
+ if(!r.members.length){membersEl.appendChild(el('span','empty','(まだ誰も居ない)'));return}
202
+ for(const m of r.members){
203
+ const c=el('span','chip'+(m.name===recent?' recent':''))
204
+ c.style.setProperty('--h',hue(m.name));c.dataset.name=m.name
205
+ c.title=m.name+'(参加 '+new Date(m.joined_at).toLocaleString()+')'
206
+ c.appendChild(el('span','av',initial(m.name)));c.appendChild(el('span','nm',m.name))
207
+ membersEl.appendChild(c)
208
+ }
209
+ }
210
+ // 直近の発言者を光らせる=いま手を動かしている子が一覧で見える
211
+ function markActive(name){
212
+ recent=name;let known=false
213
+ for(const c of membersEl.children){
214
+ if(!c.dataset.name)continue
215
+ const hit=c.dataset.name===name;known=known||hit
216
+ c.classList.toggle('recent',hit)
217
+ if(hit){c.classList.remove('pulse');void c.offsetWidth;c.classList.add('pulse')}
218
+ }
219
+ if(!known)refreshMembers()
220
+ }
221
+ const BEAT=${HEARTBEAT_MS}
222
+ let lastSeq=0,lastBeat=Date.now(),es=null,emptyEl=null,firstLoad=true,catching=false
223
+ // seq で二重描画を弾く。張り直し後の追いつきと SSE の新着が重なっても同じ発言は1回しか出ない
224
+ function apply(m){
225
+ if(m.seq<=lastSeq)return false
226
+ lastSeq=m.seq
227
+ if(emptyEl){emptyEl.remove();emptyEl=null}
228
+ render(m)
229
+ if(m.from!=='system')recent=m.from
230
+ return true
231
+ }
232
+ async function catchUp(force){
233
+ if(catching)return
234
+ catching=true
235
+ try{
236
+ const stick=force||nearBottom()
237
+ const r=await(await fetch(api('messages')+'?since='+lastSeq)).json()
238
+ const added=r.messages.filter(apply).length
239
+ if(!lastSeq&&!emptyEl){emptyEl=el('div','empty','(まだ発言がない)');logEl.appendChild(emptyEl)}
240
+ await refreshMembers()
241
+ if(added&&stick)window.scrollTo(0,document.body.scrollHeight)
242
+ }finally{catching=false}
243
+ }
244
+ function connect(){
245
+ if(es)es.close()
246
+ es=new EventSource(api('events'));lastBeat=Date.now()
247
+ es.onopen=()=>{lastBeat=Date.now();catchUp(firstLoad);firstLoad=false}
248
+ // 心拍は room の最新 seq を積んでくる。繋がったまま取りこぼした(=途絶しないので watchdog が気づけない)
249
+ // 場合は、この差分だけが手掛かりになる
250
+ es.addEventListener('ping',e=>{lastBeat=Date.now();if(Number(e.data)>lastSeq)catchUp()})
251
+ es.onmessage=e=>{
252
+ lastBeat=Date.now()
253
+ const m=JSON.parse(e.data),stick=nearBottom()
254
+ if(!apply(m))return
255
+ if(m.from==='system')refreshMembers();else markActive(m.from)
256
+ if(stick)window.scrollTo(0,document.body.scrollHeight)
257
+ }
258
+ }
259
+ connect()
260
+ // 半開きで死んだ接続は onerror を出さない=心拍の途絶だけが唯一の手掛かり。見つけたら黙って諦めず張り直す
261
+ setInterval(()=>{if(Date.now()-lastBeat>BEAT*2.5)connect()},BEAT/2)
262
+ setInterval(refreshMembers,30000) // 退席(member DELETE)は発言を出さないので定期に取り直す
143
263
  </script></body></html>`
264
+
265
+ const INDEX = list => `<!doctype html><html lang="ja"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
266
+ <title>Peertable</title>${FAVICON}<style>${STYLE}
267
+ .wrap{max-width:560px;margin:0 auto;padding:56px 16px}
268
+ .tag{color:var(--dim);margin:6px 0 22px}
269
+ .rooms{list-style:none;padding:0;margin:0;display:flex;flex-direction:column;gap:8px}
270
+ .rooms a{display:flex;align-items:center;gap:9px;padding:11px 14px;border:1px solid var(--line);border-radius:11px;background:var(--surface);text-decoration:none;color:var(--fg);font-weight:650}
271
+ .rooms a::before{content:"";width:7px;height:7px;border-radius:50%;background:var(--accent)}
272
+ </style></head><body><div class="wrap">
273
+ <div class="brand">${MARK}Peertable</div>
274
+ <p class="tag">A round table of peer agents. No orchestrator at the head.</p>
275
+ <ul class="rooms">${list.map(r => `<li><a href="/${encodeURIComponent(r)}">${esc(r)}</a></li>`).join('') || '<li class="empty">(まだ卓が無い)</li>'}</ul>
276
+ </div></body></html>`
package/skill/SKILL.md CHANGED
@@ -21,30 +21,36 @@ description: 任意プロジェクトに Peertable チーム(対等メンバ
21
21
  - git 除外は `.git/info/exclude` を使う(`.gitignore` には触れない。決定34)
22
22
  - teardown 後にプロジェクトの diff がゼロになること
23
23
  - 例外は Lattice store(`.lattice/`): Lattice 自身の作法に従う。setup が新規作成した場合だけ teardown で削除し、既存 store には plan の追加・削除とも Lattice の正規コマンド以外で触れない
24
+ - もう1つの例外は `.lattice/project.json` の `external_pane` 欄(**Lattice 併用モードのみ**。決定53)。既存文書は `.team/project.json.bak` へ退避し、teardown が書き戻す。文書が無かった project では teardown が `project.json` ごと削除する
24
25
 
25
26
  ## setup
26
27
 
27
- 1. **聞き取り**: 対象プロジェクトのパス / **工程正本(`Lattice 併用`=既定 / `単独`)** / メンバー数とモデル・effort(モデル既定: Sonnet、effort既定: CLI 既定。モデル選定は作業の性質——設計か確定実装か——を軸にする。決定49)/ 初期タスク群(何を作るか)/ room 名(既定: プロジェクトのディレクトリ名)
28
+ 手順は **聞き取り script 着任指示** の3段である。scripts が機械部分を全部持つので、AI が手で tmux を組み立てることはしない。
29
+
30
+ 1. **聞き取り**: 対象プロジェクトのパス / **工程正本(`Lattice 併用`=既定 / `単独`)** / メンバー数とモデル・effort(モデル既定: Sonnet、effort既定: CLI 既定。モデル選定は作業の性質——設計か確定実装か——を軸にする。決定49)/ 初期タスク群(何を作るか)/ room 名(既定: プロジェクトのディレクトリ名)/ **公開URL基底**(Lattice 併用のみ。外部ペインに書く URL。クオ環境は `https://peertable.kitepon.dev`。未指定なら room サーバーの URL がそのまま入る=LAN URL は Lattice を外から見た時に開けない)
28
31
  - **メンバー数の既定**: Lattice 併用なら plan compile 結果の幅(`max_frontier_width`)に合わせる(実測: 幅3→3人、第2 campaign で幅4→4人目追加)。frontier より多い席は最初から遊ぶ。単独モードには frontier が無いので既定の根拠も無く、聞き取りで決める
29
32
  - **モードの選び分け**: タスク間に依存があり並列境界の機械保証が要るなら Lattice 併用。依存の無い小規模作業で、対象プロジェクトに Lattice を持ち込みたくないなら単独。単独で失うのは task 間スケジューリングの機械保証だけで、円卓の核(room・憲章・宣言による協力)は変わらない(決定47)
30
33
  2. **命名**: メンバーに日本のアニメキャラ風の可愛い名前を都度決める(固定リストなし)。識別子(tmux セッション名・room 登録名・Lattice actor)はローマ字、表示・自己紹介は日本語(決定35)
31
- 3. **scaffold**: `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` を含む)に残す
32
- - **Lattice 併用**: `plan_key` に plan key を渡す。`.team/scripts/done.sh` も配られる
34
+ 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
+ - **Lattice 併用**: `plan_key` に plan key を渡す。`.team/scripts/done.sh` も配られる。加えて `scripts/external-pane.mjs` が対象 project の `.lattice/project.json` へ `external_pane`(工程表の右ペインに円卓を差す口。決定53)を書く
33
36
  - **単独**: `plan_key` に `-` を渡し、第6引数へ聞き取ったタスクを書いた本文ファイル(`- タスク名: 何をどこまでやるか` の箇条書き。中間ファイルは scratchpad で可)を渡す。`.team/tasks.md`(読み取り専用の議題表)が生成され、`roles/member.md` は単独版になる。`done.sh` は配られない。**議題表を渡さないと setup.sh はエラーで止まる**(空の議題表を作らない)
34
- 4. **Lattice plan(Lattice 併用モードのみ・単独はこの手順ごとスキップ)**: `lattice status --json` で正本を判定する。`uninitialized` なら templates/gen-plan.mjs を雛形に聞き取ったタスクを plan 化して `lattice plan create`。初期化済みなら `todo migrate` の作法(Lattice 正典)に従う。設計メモは各タスクに必ず書く
37
+ 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
+ - `make-plan-input.mjs` が digest 計算と `hard_dependencies` の `(from,to)` 昇順ソートを持つ(**手書きで2回踏んだ罠**。順序が崩れると `INPUT_INVALID / pointer:"/"` としか言われない)。`project_id` の既定は project ディレクトリ名で、`external-pane.mjs` が書く `project.json` の既定と一致させてある——**両者がずれると Lattice が identity 検証で落ちる**
35
39
  - 単独モードのタスク正本は手順3で生成した `.team/tasks.md` だけである。状態(誰が持っているか・何が終わったか)は持たせない——claim と完了は room の宣言だけが正(決定48 の延長)。ミニタスクトラッカーを別途作らない(決定36)
36
- 5. **メンバー起動**(メンバーごとに aiterm PTY で):
37
- - 起動前に `pty_list` で既存の `peer-*` 席を確認し、前の卓の残骸が残っていれば回収してから立てる(2026-08-08 に他 campaign 由来の残骸を99席実測)
38
- - export: `PEERTABLE_URL` / `PEERTABLE_ROOM` / `PEERTABLE_MEMBER=<romaji>` / `PEERTABLE_POST_TOKEN`(`~/.config/peertable.env` から)。**Lattice 併用モードは加えて** `PEERTABLE_PLAN=<plan_key>` / `LATTICE_TODO_ACTOR_HOST` / `LATTICE_TODO_ACTOR_SESSION=<romaji>` / `LATTICE_TODO_ACTOR_AGENT=<romaji>`(単独モードでは不要——渡さない)
39
- - 起動: `cd <project> && claude --model <model> [--effort <level>] --dangerously-skip-permissions --dangerously-load-development-channels server:room`(**channels は `--mcp-config` の MCP server を解決しない**(実測 2026-08-08・Claude Code v2.1.226・決定44)ため、room の MCP 定義は setup.sh が project root へ置く `.mcp.json` が正。`.git/info/exclude` 追加と teardown での撤去で不可侵原則を保つ。project に既存 `.mcp.json` があった場合 setup.sh は上書きせず警告を出すので、AI が手動 merge して teardown で復元する)
40
- - ダイアログを画面確認しながら通す(MCP 同意 外部 import は状況判断 bypass 承諾 開発 channel 警告)。バナーに `Channels (experimental) ... server:room` が出たら着席成立
41
- - 着任指示: 「あなたは「<日本語名>」。.team/roles/member.md を読んで着任し、作業ループを開始せよ。全タスク完了の宣言まで自律的に続けること。」
42
- 6. **親の着卓**(このセッション): room API member 登録(名は bell 等)し、SSE Monitor で張る。post API 直(下記「親の operating notes」)
43
- 7. **起動確認**: room の members に全員いる / 最初の claim が room に流れる(Lattice 併用モードはそれが Lattice へ到達している=`lattice todo status --json` の active に出ることも確認する。単独モードは room の claim 宣言だけが到達の証拠)/ Web UI で観測できる、をチェックして報告する
40
+ 5. **メンバー起動**: メンバーごとに `scripts/launch-seat.sh <project> <name> <model> [claude|codex] [effort] [着任指示]` を実行する。tmux 作成(aiterm と同じソケットなので、立った席はそのまま `pty_read`/`pty_send` で読める)→ env 注入 → 起動 → 既知ダイアログ通過 → 着席確認まで1回で行き、着席しなければ最後の画面を出して非ゼロで落ちる(黙って進まない)
41
+ - 起動前に `pty_list` で既存の `peer-*` 席を確認する(前の卓の残骸を99席実測したことがある)。同名の席は launch-seat.sh が落としてから立て直す
42
+ - 着任指示を第6引数に渡すと着席後に送る。文面: 「あなたは「<日本語名>」。.team/roles/member.md を読んで着任し、作業ループを開始せよ。全タスク完了の宣言まで自律的に続けること。」
43
+ - 席が読む env は script が組み立てる(`PEERTABLE_URL` / `PEERTABLE_ROOM` / `PEERTABLE_MEMBER` / `PEERTABLE_POST_TOKEN`、Lattice 併用なら `PEERTABLE_PLAN` actor 3点)。**channels は `--mcp-config` の MCP server を解決しない**(実測 2026-08-08・Claude Code v2.1.226・決定44)ため、room の MCP 定義は setup.sh が project root へ置く `.mcp.json` が正。project に既存 `.mcp.json` があった場合 setup.sh は上書きせず警告を出すので、AI が手動 merge して teardown で復元する
44
+ - **Codex 席**(`vendor=codex`): Codex には channels が無いので、room は `-c` 上書きの stdio MCP として差す(`mcp_servers.room.command="peertable-client"` `mcp_servers.room.env={…}`)。**env closed mode で親環境を継がない**ので `PATH` を含む全変数を明示列挙する。モデル名は ChatGPT アカウントで使える slug を渡す(`~/.codex/config.toml` `model` が既定値の参考。使えない slug は起動後の最初のターンで 400 になって初めて分かる)。Codex 席を混ぜたら**必ず起床ブリッジを立てる**(下記)——立てないと room の新着で起きない
45
+ 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
+ - **黙って止まらないための三段**(決定58 の受信側の作法): ①75秒なにも届かなければ自分から切って繋ぎ直す ②繋ぎ直したら `?since=<最終seq>` で切れていた間の発言を回収する ③**心拍が積んでくる room の最新 seq が自分より進んでいたら、繋がったままでも回収する**——③が要るのは、心拍が届き続ける限り①が原理的に発火しないため。**server 側の心拍(`event: ping`・25秒周期)が前提**なので、古い room サーバーへ繋ぐと①だけが効く形になる
47
+ - ログは `.team/wakeup-bridge.log`。**0件でも0件と出す**ので、起こせているか・取りこぼしていないかはログを見れば分かる。再現ハーネスは `experiments/bridge-catchup-repro.mjs`
48
+ 7. **親の着卓**(このセッション): `scripts/parent-join.sh <project> [name] [kickoff_file]` で member 登録と kickoff 投稿を行い、SSE を Monitor で張る。以後の post も API 直(下記「親の operating notes」)
49
+ 8. **起動確認**: room の members に全員いる / 最初の claim が room に流れる(Lattice 併用モードはそれが Lattice へ到達している=`lattice todo status --json` の active に出ることも確認する。単独モードは room の claim 宣言だけが到達の証拠)/ Web UI で観測できる、をチェックして報告する
44
50
 
45
51
  ## teardown
46
52
 
47
- `scripts/teardown.sh <project>` が機械部分を行う(room 名・server URL・作成記録は `.team/setup-state.json` から読むので引数は project だけ。書込トークンは環境変数 `PEERTABLE_POST_TOKEN`): tmux セッション終了(先に殺す。`.team/` 消失後の参照事故防止)→ サーバー room 削除 → `.team/` 削除 → `.git/info/exclude` の追記行を戻す → setup が作った `.lattice/` なら削除。実行後 `git status` で diff ゼロを確認して報告する。
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 ゼロを確認して報告する。
48
54
 
49
55
  ## 親の operating notes(このセッションの振る舞い)
50
56
 
@@ -65,5 +71,16 @@ description: 任意プロジェクトに Peertable チーム(対等メンバ
65
71
  - Lattice 書込には actor 環境変数 3 点が必須
66
72
  - `--parallel-frontier` が要るのは、**ready が複数あって誰も着手していない frontier の最初の start だけ**(無いと `PARALLEL_DISPATCH_REQUIRED / parallel_frontier_requires_declaration` で弾かれる)。ready が1件だけ、または既に誰かが着手している frontier へ後から乗る場合は素の `start` でよい。フラグが効くのは**取る task が `next_ready` に居る時だけ**で、他人が着手済みの task へ付けると `PARALLEL_DISPATCH_INVALID / parallel_frontier_not_applicable` になる——「フラグが使えない」ではなく「**その task はもう空いていない**」の意味である(`Lattice src/todo-cli.mjs:698-704` が唯一の発生条件。independence の compile 状態は無関係で、未 compile でもフラグ付き start は通る。2026-08-08 実測)
67
73
  - 同時書込は `STORE_WRITE_CONFLICT` 等で明示的に負ける。1〜2 秒待って再実行すれば通る(正常系)
68
- - evidence は記述子 JSON。記述子ファイル自体も repo 内相対パスに置く(repo 外絶対パスは INVALID_ARGUMENTS)。`.team/scripts/done.sh` が正規経路
74
+ - evidence は記述子 JSON。記述子ファイル自体も repo 内相対パスに置く(repo 外絶対パスは INVALID_ARGUMENTS)。`.team/scripts/done.sh` が正規経路。証跡の置き場は **`evidence/<plan_key>/<task_id>.md`**——task_id は campaign を跨いで再利用されるので、平置きにすると前の campaign の監査証跡を上書きで消す(2026-08-08 実測)
75
+ - **外部ペイン(決定53)は Lattice 0.50.0 以降が要る。** それ以前の Lattice に `external_pane` 入りの `project.json` を差すと、identity 検証が完全一致キーで落ちて `lattice todo status` ごと死ぬ(`PROJECT_IDENTITY_INVALID / identity_schema_invalid`・0.49.0 で実測)。工程正本が読めなくなる=卓が止まるので、Lattice が古い環境では Lattice 併用 setup を走らせない
76
+ - **メンバー起動の既知ダイアログは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
+ - **Codex 席のダイアログは2種で、片方は既定が誤り**(Codex CLI v0.146.0・実測): ディレクトリ trust(`1. Yes, continue`=既定で正しい)と、**更新案内(`1. Update now`)は既定のまま Enter を押すと立卓の途中で `npm install -g @openai/codex` が走る**。1つ下の「2. Skip」を選ぶ。更新案内は毎回は出ないので、出た時だけ通す
78
+ - **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人が独立に踏み、先に憶測を流した側が訂正を出した)
80
+ - **`claude-in-chrome` の呼び出しは返らないことがある**。原因は2種で、解き方が違う(2026-08-08 に席1つが9分半沈黙して実測):
81
+ - **接続ブラウザが複数あって、拡張がどれを使うか選ばせている**——選択待ちのまま返らない。**AI 側から解ける**(オーナーに「どちらを使うか」を一言聞けば済む)。今回の実例はこちら。デバッグ接続が宙吊りのまま「Claude がこのブラウザのデバッグを開始しました[キャンセル]」バナーが残る形もあり、キャンセルを押せば呼び出しは即エラーで返る
82
+ - **ブラウザに alert/confirm 等のモーダルが出ている**——拡張が以後のコマンドを受け取れない。**AI 側から解けない**ので、人がダイアログを閉じるしかない
83
+ - 沈黙した席を見る側は、この2つを区別せずに「固着」と決めない。トークン受信が増え続けているなら止まっていない
84
+ - **共有リソースを占める作業は着手前に room へ一言**。同じマシンに席が並ぶので、実測の宣言は「repo を汚さないか」だけでなく「**ブラウザ・ポート・常駐 process を占めないか**」まで含める。ブラウザを起こす席が複数あると、拡張の接続先が増えて他席の呼び出しが選択待ちに入りうる(2026-08-08 の停止例では原因ではなかったが、成立しうる経路として置く)
85
+ - **シェルスクリプトで `$var` の直後に全角括弧を書かない**。bash が高位バイトを変数名の一部として食い、変数が空のまま何も言わずに出力から消える(2026-08-08 実測)。`${var}(…)` と閉じる。同様に `python3 -c` へ `{...}` を含む式をインラインで渡さない——シェルのブレース展開が刻む。ヒアドキュメントで渡す
69
86
  - channels はリサーチプレビュー。構文が変わったら V0 の要領で公式ドキュメント(code.claude.com/docs/en/channels-reference.md)を再確認する
@@ -0,0 +1,36 @@
1
+ #!/usr/bin/env node
2
+ // Peertable → Lattice 外部ペインコネクタ(決定53)。
3
+ // usage: external-pane.mjs <project_dir> <room> <public_base>
4
+ //
5
+ // 対象プロジェクトの `.lattice/project.json` へ `external_pane`(title/url/probe_url)を書く。
6
+ // Lattice 側(project-identity)は identity 文書のキー集合を検証するので、既存欄
7
+ // (schema / project_id / display_name)を保ったまま完全な文書として書き直す。
8
+ // 既存文書は `.team/project.json.bak` へ退避し、teardown がそれを戻す。
9
+ //
10
+ // stdout: 既存 project.json があったかを `true` / `false` の1行で出す(setup.sh が state へ記録する)
11
+ // stderr: 人間向けの書き込み内容
12
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'
13
+ import { basename, join } from 'node:path'
14
+
15
+ const [proj, room, base] = process.argv.slice(2)
16
+ const identity = join(proj, '.lattice', 'project.json')
17
+ const preexisting = existsSync(identity)
18
+ const current = preexisting ? JSON.parse(readFileSync(identity, 'utf8')) : {}
19
+ if (preexisting) copyFileSync(identity, join(proj, '.team', 'project.json.bak'))
20
+
21
+ // project_id は Lattice store が持つ値が正。setup 時点では store がまだ無いので、
22
+ // 既存 identity → プロジェクトディレクトリ名 の順で決める(make-plan-input.mjs の既定と同じ)。
23
+ const projectId = current.project_id ?? basename(realpathSync(proj))
24
+ const root = base.replace(/\/+$/, '')
25
+ const pane = { title: '円卓', url: `${root}/${room}`, probe_url: `${root}/api/${room}/members` }
26
+
27
+ mkdirSync(join(proj, '.lattice'), { recursive: true })
28
+ writeFileSync(identity, JSON.stringify({
29
+ schema: 'lattice.project_identity.v1',
30
+ project_id: projectId,
31
+ display_name: current.display_name ?? projectId,
32
+ external_pane: pane,
33
+ }, null, 2) + '\n')
34
+
35
+ process.stderr.write(`external pane: ${pane.url}(probe: ${pane.probe_url}・project_id: ${projectId})\n`)
36
+ process.stdout.write(preexisting ? 'true\n' : 'false\n')
@@ -0,0 +1,100 @@
1
+ #!/bin/bash
2
+ # 席を1つ立てる(tmux 作成 → env 注入 → エージェント起動 → 既知ダイアログ通過 → 着席確認)。
3
+ # usage: launch-seat.sh <project_dir> <name> <model> [vendor] [effort] [brief]
4
+ # vendor: claude(既定)/ codex
5
+ # brief: 着席が成立したら送る着任指示(省略時は送らない)
6
+ #
7
+ # room 名・server URL・モード・plan key は <project>/.team/setup-state.json から読む(setup.sh の後に呼ぶ)。
8
+ # 書込トークンは環境変数 PEERTABLE_POST_TOKEN、無ければ ~/.config/peertable.env から取る。
9
+ # tmux は aiterm-mcp と同じソケットへ作るので、立てた席はそのまま pty_read / pty_send で読める。
10
+ set -e
11
+ proj="$1"; name="$2"; model="$3"; vendor="${4:-claude}"; effort="$5"; brief="$6"
12
+ [ -n "$proj" ] && [ -n "$name" ] && [ -n "$model" ] || { echo "usage: launch-seat.sh <project_dir> <name> <model> [vendor] [effort] [brief]" >&2; exit 1; }
13
+
14
+ sock="${PEERTABLE_TMUX_SOCKET:-${TMPDIR}claude-tmux-sockets/claude.sock}"
15
+ sess="peer-$name"
16
+ state="$proj/.team/setup-state.json"
17
+ read -r room url mode plan <<EOF
18
+ $(python3 -c "import json;d=json.load(open('$state'));print(d['room'],d['server_url'],d['mode'],d.get('plan_key') or '-')")
19
+ EOF
20
+
21
+ if [ -z "${PEERTABLE_POST_TOKEN:-}" ] && [ -f "$HOME/.config/peertable.env" ]; then
22
+ . "$HOME/.config/peertable.env"
23
+ fi
24
+
25
+ # 前の卓の残骸を回収してから立てる(同名セッションが残ると起動が黙って古い席に化ける)
26
+ tmux -S "$sock" kill-session -t "$sess" 2>/dev/null || true
27
+ tmux -S "$sock" new-session -d -s "$sess" -x 200 -y 50 -c "$proj"
28
+
29
+ env_line="export PEERTABLE_URL=$url PEERTABLE_ROOM=$room PEERTABLE_MEMBER=$name PEERTABLE_POST_TOKEN=$PEERTABLE_POST_TOKEN"
30
+ if [ "$mode" = "lattice" ]; then
31
+ 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
+ fi
33
+ tmux -S "$sock" send-keys -t "$sess" "$env_line" Enter
34
+ sleep 1
35
+
36
+ case "$vendor" in
37
+ claude)
38
+ cmd="claude --model $model"
39
+ [ -n "$effort" ] && cmd="$cmd --effort $effort"
40
+ cmd="$cmd --dangerously-skip-permissions --dangerously-load-development-channels server:room"
41
+ ;;
42
+ codex)
43
+ # Codex には channels が無いので room は stdio MCP として差す。
44
+ # `[mcp_servers.X.env]` は closed mode(親 env を継がない)ので全変数を明示列挙する
45
+ # (caveat `codex-cli-v0-130-0-mcp-servers-x-env-block-is-closed-mode-parent-env-not-inherited`)。
46
+ envtbl="PATH=\\\"$PATH\\\",PEERTABLE_URL=\\\"$url\\\",PEERTABLE_ROOM=\\\"$room\\\",PEERTABLE_MEMBER=\\\"$name\\\",PEERTABLE_POST_TOKEN=\\\"$PEERTABLE_POST_TOKEN\\\""
47
+ cmd="codex --model $model -C $proj --dangerously-bypass-approvals-and-sandbox"
48
+ cmd="$cmd -c 'mcp_servers.room.command=\"peertable-client\"'"
49
+ cmd="$cmd -c \"mcp_servers.room.env={$envtbl}\""
50
+ ;;
51
+ # 変数展開の直後に全角括弧を置かない(bash が高位バイトを変数名の一部として食い、
52
+ # 変数が空になって黙って情報が消える。2026-08-08 実測)。必ず ${var} で閉じる
53
+ *) echo "unknown vendor: ${vendor}(claude / codex)" >&2; exit 1 ;;
54
+ esac
55
+ tmux -S "$sock" send-keys -t "$sess" "$cmd" Enter
56
+
57
+ # 既知ダイアログ(実測 2026-08-08・Claude Code v2.1.226 / Codex CLI v0.146.0):
58
+ # claude ① 未信頼ディレクトリの workspace trust「1. Yes, I trust this folder」
59
+ # ② 開発 channel 警告「1. I am using this for local development」
60
+ # codex ① ディレクトリ trust「1. Yes, continue」
61
+ # いずれも既定の選択肢が正なので、文言を確認してから Enter を送る。信頼済みディレクトリでは
62
+ # trust が出ないので、出た時だけ通す(順不同・出ないものは待たない)。
63
+ # 着席の判定は claude=channels バナー / codex=セッションヘッダ。出なければ画面ごと出して落ちる。
64
+ deadline=$((SECONDS + 90))
65
+ seated=false
66
+ while [ $SECONDS -lt $deadline ]; do
67
+ screen=$(tmux -S "$sock" capture-pane -t "$sess" -p)
68
+ if [ "$vendor" = "claude" ]; then
69
+ case "$screen" in *"Channels (experimental)"*"server:room"*) seated=true; break ;; esac
70
+ else
71
+ case "$screen" in *"OpenAI Codex (v"*) seated=true; break ;; esac
72
+ fi
73
+ case "$screen" in
74
+ # Codex の更新案内だけは既定(1. Update now)が誤り——立卓の途中で
75
+ # `npm install -g @openai/codex` が走る。1つ下の「2. Skip」を選ぶ
76
+ *"1. Update now"*)
77
+ tmux -S "$sock" send-keys -t "$sess" Down
78
+ sleep 1
79
+ tmux -S "$sock" send-keys -t "$sess" Enter ;;
80
+ *"1. Yes, I trust this folder"*|*"1. I am using this for local development"*|*"1. Yes, continue"*)
81
+ tmux -S "$sock" send-keys -t "$sess" Enter ;;
82
+ esac
83
+ sleep 2
84
+ done
85
+
86
+ if [ "$seated" != "true" ]; then
87
+ echo "着席しなかった: ${sess}(最後の画面を出す)" >&2
88
+ tmux -S "$sock" capture-pane -t "$sess" -p >&2
89
+ exit 1
90
+ fi
91
+
92
+ echo "seated: ${sess}(${vendor} / ${model}${effort:+ / $effort} / room=${room} / mode=${mode})"
93
+
94
+ if [ -n "$brief" ]; then
95
+ sleep 2
96
+ tmux -S "$sock" send-keys -t "$sess" "$brief"
97
+ sleep 1
98
+ tmux -S "$sock" send-keys -t "$sess" Enter
99
+ echo "briefed: $sess"
100
+ fi
@@ -0,0 +1,101 @@
1
+ #!/usr/bin/env node
2
+ // タスク定義 JSON から `lattice plan create` の入力(lattice.plan_create_input.v4)を生成する。
3
+ // usage: make-plan-input.mjs <tasks.json> [--project <dir>] [--out <path>]
4
+ // 既定の出力先は <project>/.lattice/plan-create.json(`lattice status` が案内する場所)。
5
+ //
6
+ // 入力(tasks.json):
7
+ // {
8
+ // "plan_key": "lattice-integration",
9
+ // "project_id": "peertable", // 省略時は project ディレクトリ名
10
+ // "plan_version": "v1", // 省略時 v1
11
+ // "actor": { "host": "mac", "session": "setup", "agent": "bell" }, // 省略可
12
+ // "phase": { "id": "p1", "title": "…", "gate_policy": "audit" }, // 省略可(単一 Phase)
13
+ // "tasks": [
14
+ // { "id": "t1", "title": "…", "memo": "…", "deps": ["t0"] } // deps は「先に終わる方」
15
+ // ]
16
+ // }
17
+ //
18
+ // 手書きで2回踏んだ罠をここで潰す:
19
+ // 1. hard_dependencies は (from.task_id, to.task_id) の昇順ソートが必須。崩れると
20
+ // `INPUT_INVALID / plan_create_schema_invalid / pointer:"/"` としか言われない
21
+ // (caveat `lattice-plan-create-hard-dependencies-from-to-pointer`)
22
+ // 2. input_digest は input_digest 自身を除いたキー昇順の正規化 JSON の sha256
23
+ import { createHash } from 'node:crypto'
24
+ import { mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'
25
+ import { basename, dirname, join, relative, resolve } from 'node:path'
26
+
27
+ const argv = process.argv.slice(2)
28
+ let src = null
29
+ let projectArg = '.'
30
+ let outArg = null
31
+ for (let i = 0; i < argv.length; i++) {
32
+ if (argv[i] === '--project') projectArg = argv[++i]
33
+ else if (argv[i] === '--out') outArg = argv[++i]
34
+ else src ??= argv[i]
35
+ }
36
+ if (!src) {
37
+ console.error('usage: make-plan-input.mjs <tasks.json> [--project <dir>] [--out <path>]')
38
+ process.exit(1)
39
+ }
40
+
41
+ const spec = JSON.parse(readFileSync(src, 'utf8'))
42
+ const proj = realpathSync(resolve(projectArg))
43
+ const out = resolve(outArg ?? join(proj, '.lattice', 'plan-create.json'))
44
+
45
+ const projectId = spec.project_id ?? basename(proj)
46
+ const planKey = spec.plan_key
47
+ const phase = spec.phase ?? {}
48
+ const phaseId = phase.id ?? 'p1'
49
+ const ref = taskId => ({ project_id: projectId, plan_key: planKey, task_id: taskId })
50
+
51
+ const deps = []
52
+ for (const t of spec.tasks) for (const d of t.deps ?? []) deps.push({ from: ref(d), to: ref(t.id) })
53
+ deps.sort((a, b) => a.from.task_id.localeCompare(b.from.task_id) || a.to.task_id.localeCompare(b.to.task_id))
54
+
55
+ const input = {
56
+ schema: 'lattice.plan_create_input.v4',
57
+ project_id: projectId,
58
+ plan_key: planKey,
59
+ plan_version: spec.plan_version ?? 'v1',
60
+ actor: spec.actor ?? { host: 'mac', session: 'setup', agent: 'bell' },
61
+ // 記録時刻が未来だと弾かれうるので少し過去へ倒す(gen-plan.mjs から踏襲)
62
+ recorded_at: new Date(Date.now() - 60_000).toISOString(),
63
+ tasks: spec.tasks.map(t => ({
64
+ task_id: t.id,
65
+ title: t.title,
66
+ lane: t.lane ?? 'dev',
67
+ design_memo: t.memo,
68
+ narrative_ref: null,
69
+ narrative_anchor: null,
70
+ compile_binding: null,
71
+ parent_task_id: null,
72
+ phase_id: t.phase ?? phaseId,
73
+ })),
74
+ phases: [{
75
+ phase_id: phaseId,
76
+ title: phase.title ?? planKey,
77
+ gate_policy: phase.gate_policy ?? 'audit',
78
+ predecessor_phase_ids: [],
79
+ required_evidence_slots: phase.required_evidence_slots ?? ['result'],
80
+ }],
81
+ hard_dependencies: deps,
82
+ joins: [],
83
+ phase_accept_dependencies: [],
84
+ input_digest: '0'.repeat(64),
85
+ }
86
+
87
+ const canon = o => {
88
+ if (Array.isArray(o)) return o.map(canon)
89
+ if (o && typeof o === 'object') return Object.fromEntries(Object.keys(o).sort().map(k => [k, canon(o[k])]))
90
+ return o
91
+ }
92
+ const { input_digest, ...rest } = input
93
+ input.input_digest = createHash('sha256').update(JSON.stringify(canon(rest))).digest('hex')
94
+
95
+ mkdirSync(dirname(out), { recursive: true })
96
+ writeFileSync(out, JSON.stringify(canon(input)) + '\n')
97
+ // `lattice plan create --input` は repo root からの相対パスでないと `INPUT_INVALID / input_ref_invalid`
98
+ // で弾かれるので、案内も相対で出す(絶対パスを貼れる形にしない)
99
+ const rel = relative(proj, out)
100
+ console.log(`${out}(${input.tasks.length} tasks / ${deps.length} deps)`)
101
+ console.log(`次: cd ${proj} && lattice plan create --input ${rel}`)
@@ -0,0 +1,41 @@
1
+ #!/bin/bash
2
+ # 親(ベル等)が room へ着卓し、必要なら kickoff を投げる。
3
+ # usage: parent-join.sh <project_dir> [name] [kickoff_file]
4
+ # name 既定は bell。kickoff_file を渡すとその本文を全員宛で1回投稿する
5
+ # (複数行・記号を安全に運ぶためファイル渡しにしている。引数のインライン渡しはしない)。
6
+ # 親は MCP を後付けできないので room へは HTTP API 直で入る(決定40 の operating notes)。
7
+ set -e
8
+ proj="$1"; name="${2:-bell}"; kickoff="$3"
9
+ state="$proj/.team/setup-state.json"
10
+ room=$(python3 -c "import json;print(json.load(open('$state'))['room'])")
11
+ url=$(python3 -c "import json;print(json.load(open('$state'))['server_url'])")
12
+
13
+ if [ -z "${PEERTABLE_POST_TOKEN:-}" ] && [ -f "$HOME/.config/peertable.env" ]; then
14
+ . "$HOME/.config/peertable.env"
15
+ fi
16
+
17
+ member=$(python3 - "$name" <<'PY'
18
+ import json, sys
19
+ print(json.dumps({'name': sys.argv[1]}))
20
+ PY
21
+ )
22
+ curl -sf -X POST "$url/api/$room/members" \
23
+ -H "X-Peertable-Token: $PEERTABLE_POST_TOKEN" -H 'content-type: application/json' \
24
+ -d "$member" > /dev/null
25
+ echo "joined: ${name}(room=${room})"
26
+
27
+ if [ -n "$kickoff" ]; then
28
+ # JSON の組み立てはヒアドキュメントで行う。`-c` にインラインで書くと本文の `{...}`
29
+ # をシェルのブレース展開が刻んで壊す(2026-08-08 実測)
30
+ body=$(python3 - "$name" "$kickoff" <<'PY'
31
+ import json, sys
32
+ print(json.dumps({'from': sys.argv[1], 'to': 'all', 'body': open(sys.argv[2]).read().strip()}))
33
+ PY
34
+ )
35
+ curl -sf -X POST "$url/api/$room/messages" \
36
+ -H "X-Peertable-Token: $PEERTABLE_POST_TOKEN" -H 'content-type: application/json' \
37
+ -d "$body" > /dev/null
38
+ echo "kickoff posted: $kickoff"
39
+ fi
40
+
41
+ curl -sf "$url/api/$room/members" | python3 -c "import json,sys;print('members:', ', '.join(m['name'] for m in json.load(sys.stdin)['members']))"
@@ -55,6 +55,19 @@ fi
55
55
  lattice_preexisting=false
56
56
  [ -d "$proj/.lattice" ] && lattice_preexisting=true
57
57
 
58
- printf '{"room":"%s","server_url":"%s","mode":"%s","plan_key":"%s","added_exclude":%s,"lattice_preexisting":%s,"added_root_mcp":%s,"added_mcp_exclude":%s}\n' \
59
- "$room" "$url" "$mode" "$plan" "$added_exclude" "$lattice_preexisting" "$added_root_mcp" "$added_mcp_exclude" > "$tdir/setup-state.json"
58
+ # Lattice 併用モードだけ、工程表の右ペインへ円卓を差す(決定53・明示的コネクタ)。
59
+ # 公開URL基底は `PEERTABLE_PUBLIC_URL`(クオ環境: https://peertable.kitepon.dev)。
60
+ # 未設定なら room サーバーの URL をそのまま使う——LAN URL は Lattice を外から見た時に開けないので、
61
+ # 書いた URL は必ず標準エラーへ出す。
62
+ external_pane=false
63
+ project_json_preexisting=false
64
+ public_url=""
65
+ if [ "$mode" = "lattice" ]; then
66
+ public_url="${PEERTABLE_PUBLIC_URL:-$url}"
67
+ project_json_preexisting=$(node "$repo/skill/scripts/external-pane.mjs" "$proj" "$room" "$public_url")
68
+ external_pane=true
69
+ fi
70
+
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"
60
73
  echo "scaffold done: $tdir"
@@ -13,7 +13,23 @@ lat_pre=$(python3 -c "import json;print(json.load(open('$state'))['lattice_preex
13
13
  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
14
  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
15
 
16
+ # Codex 席の起床ブリッジ(決定54)。常駐 process なので、`.team/` を消す前に確実に止める
17
+ if [ -f "$proj/.team/wakeup-bridge.json" ]; then
18
+ node "$(dirname "$0")/wakeup-bridge.mjs" "$proj" --stop
19
+ fi
20
+
21
+ # 外部ペイン(決定53)。`.team/` を消す前に戻す——退避先が `.team/` の中にある
22
+ ext=$(python3 -c "import json;print(json.load(open('$state')).get('external_pane', False))")
23
+ pj_pre=$(python3 -c "import json;print(json.load(open('$state')).get('project_json_preexisting', False))")
24
+
16
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
28
+ cp "$proj/.team/project.json.bak" "$proj/.lattice/project.json"
29
+ else
30
+ rm -f "$proj/.lattice/project.json"
31
+ fi
32
+ fi
17
33
  rm -rf "$proj/.team"
18
34
  if [ "$added_mcp" = "True" ] || [ "$added_mcp" = "true" ]; then
19
35
  rm -f "$proj/.mcp.json"
@@ -0,0 +1,213 @@
1
+ #!/usr/bin/env node
2
+ // Codex 席の起床ブリッジ。room の SSE を購読し、その席宛(または全員宛)の新着が来たら
3
+ // tmux の席へ素送信して起こす。Claude 席は channels が同じ役をするので対象外。
4
+ //
5
+ // usage: wakeup-bridge.mjs <project_dir> <seat> [seat...] 起動(前面。nohup で常駐させる)
6
+ // wakeup-bridge.mjs <project_dir> --stop 停止
7
+ //
8
+ // 生死の作法は Lattice ADR 0157 に倣う: 自分の pid を記録に置き、起動時に前の記録を掃除し、
9
+ // 止まらなければ黙って諦めず typed error で落ちる。
10
+ //
11
+ // 実測(2026-08-08・Codex CLI v0.146.0): Codex は**ターン実行中でも素送信を受け付ける**。
12
+ // 送った文言はそのターンの中で読まれ、指示どおりに動いた(steering が効く)。したがって
13
+ // idle 待ちの経路は持たない——待ちを入れると、混んでいる席ほど起床が遅れる。
14
+ import { execFile } from 'node:child_process'
15
+ import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'
16
+ import { join } from 'node:path'
17
+ import { promisify } from 'node:util'
18
+
19
+ const run = promisify(execFile)
20
+ const [proj, ...rest] = process.argv.slice(2)
21
+ if (!proj || rest.length === 0) {
22
+ console.error('usage: wakeup-bridge.mjs <project_dir> <seat> [seat...] | <project_dir> --stop')
23
+ process.exit(1)
24
+ }
25
+
26
+ const record = join(proj, '.team', 'wakeup-bridge.json')
27
+ const sock = process.env.PEERTABLE_TMUX_SOCKET ?? `${process.env.TMPDIR}claude-tmux-sockets/claude.sock`
28
+ const alive = pid => { try { process.kill(pid, 0); return true } catch { return false } }
29
+ const sleep = ms => new Promise(r => setTimeout(r, ms))
30
+ const log = line => console.log(`[${new Date().toISOString()}] ${line}`)
31
+
32
+ async function stopRecorded() {
33
+ if (!existsSync(record)) return
34
+ const { pid } = JSON.parse(readFileSync(record, 'utf8'))
35
+ if (!alive(pid)) { unlinkSync(record); log(`死んだ記録を掃除した(pid ${pid})`); return }
36
+ process.kill(pid, 'SIGTERM')
37
+ for (let i = 0; i < 25 && alive(pid); i++) await sleep(200)
38
+ if (alive(pid)) {
39
+ process.kill(pid, 'SIGKILL')
40
+ for (let i = 0; i < 15 && alive(pid); i++) await sleep(200)
41
+ }
42
+ if (alive(pid)) {
43
+ console.error(`WAKEUP_BRIDGE_STOP_FAILED: pid ${pid} が SIGKILL でも止まらない`)
44
+ process.exit(1)
45
+ }
46
+ if (existsSync(record)) unlinkSync(record)
47
+ log(`前のブリッジを停止した(pid ${pid})`)
48
+ }
49
+
50
+ await stopRecorded()
51
+ if (rest[0] === '--stop') process.exit(0)
52
+
53
+ const seats = rest
54
+ const state = JSON.parse(readFileSync(join(proj, '.team', 'setup-state.json'), 'utf8'))
55
+ const { room, server_url: url } = state
56
+ writeFileSync(record, JSON.stringify({
57
+ pid: process.pid, room, server_url: url, seats, started_at: new Date().toISOString(),
58
+ }) + '\n')
59
+
60
+ const cleanup = () => { if (existsSync(record)) unlinkSync(record); process.exit(0) }
61
+ process.on('SIGTERM', cleanup)
62
+ process.on('SIGINT', cleanup)
63
+
64
+ // 席ごとに未配達を溜めて、2秒ごとにまとめて1回起こす(連投で席を何度も起こさない)
65
+ const pending = new Map(seats.map(s => [s, []]))
66
+
67
+ async function wake(seat, msgs) {
68
+ const last = msgs[msgs.length - 1]
69
+ const text = msgs.length === 1
70
+ ? `room に新着あり(${last.from} → ${last.to})。read_unread で読むこと。`
71
+ : `room に新着 ${msgs.length} 件(最新: ${last.from} → ${last.to})。read_unread で読むこと。`
72
+ try {
73
+ await run('tmux', ['-S', sock, 'send-keys', '-t', `peer-${seat}`, text])
74
+ await sleep(400)
75
+ await run('tmux', ['-S', sock, 'send-keys', '-t', `peer-${seat}`, 'Enter'])
76
+ log(`起こした: ${seat} ← ${msgs.length} 件(最新 seq ${last.seq})`)
77
+ } catch (error) {
78
+ // 席が畳まれていれば tmux が落ちる。黙って飲まず、毎回出す(何件落としたかも出す)
79
+ log(`起こせなかった: ${seat} ← ${msgs.length} 件(最新 seq ${last.seq}): ${error.message.split('\n')[0]}`)
80
+ }
81
+ }
82
+
83
+ setInterval(async () => {
84
+ for (const [seat, msgs] of pending) {
85
+ if (msgs.length === 0) continue
86
+ pending.set(seat, [])
87
+ await wake(seat, msgs)
88
+ }
89
+ }, 2000)
90
+
91
+ function dispatch(msg) {
92
+ for (const seat of seats) {
93
+ if (msg.from === seat) continue
94
+ if (msg.to !== 'all' && msg.to !== seat) continue
95
+ pending.get(seat).push(msg)
96
+ }
97
+ }
98
+
99
+ // 再接続で取りこぼさないために、配達済みの最大 seq を持つ。
100
+ // SSE は切れている間の発言を後から届けてくれないので、繋ぎ直したら必ず穴を埋める。
101
+ let lastSeq = 0
102
+ function dispatchNew(msg) {
103
+ // seq の無いイベントで lastSeq を汚さない。`undefined <= 数値` は false なので、
104
+ // 素通しにすると lastSeq が undefined に化け、以後の比較が全部 false になって
105
+ // 「取りこぼし回収が毎回 since=undefined で 0 件」という静かな故障になる(実測で踏んだ)
106
+ if (typeof msg.seq !== 'number') {
107
+ log(`seq を持たないイベントを捨てた: ${JSON.stringify(msg).slice(0, 120)}`)
108
+ return
109
+ }
110
+ if (msg.seq <= lastSeq) return
111
+ lastSeq = msg.seq
112
+ dispatch(msg)
113
+ }
114
+
115
+ // 繋がったまま黙って死ぬ接続を検出する。SSE は無音でも生きていられるので、
116
+ // 「切れた」ではなく「一定時間なにも届かない」を異常として扱い、自分から切って繋ぎ直す。
117
+ // 直後に since で追いつくので、無音が正常だった場合も取りこぼしは出ない。
118
+ // server の心拍が 25 秒周期(`room/server.mjs` の HEARTBEAT_MS)なので、その3倍を無音の閾値にする。
119
+ const IDLE_MS = 75_000
120
+
121
+ // 起動直後の1回だけは配達しない。既に流れ終わった過去ログで席を起こしても意味がなく、
122
+ // 卓が長いほど巨大な起床通知になる。初回は「ここまでは読んだこと」にして頭出しするだけ。
123
+ let primed = false
124
+ let catching = false
125
+ async function catchUp(reason) {
126
+ // 再接続直後と心拍由来の回収が重なると二重に取りにいく。取りこぼし回収は1本だけ走らせる
127
+ if (catching) return
128
+ catching = true
129
+ try {
130
+ const res = await fetch(`${url}/api/${room}/messages?since=${lastSeq}`)
131
+ if (!res.ok) throw new Error(`messages ${res.status}`)
132
+ const { messages } = await res.json()
133
+ if (!primed) {
134
+ primed = true
135
+ if (messages.length > 0) lastSeq = messages[messages.length - 1].seq
136
+ log(`頭出し: seq ${lastSeq} まで既読として開始する`)
137
+ return
138
+ }
139
+ log(`取りこぼし確認(${reason}・since ${lastSeq}): ${messages.length} 件`)
140
+ for (const msg of messages) dispatchNew(msg)
141
+ } finally {
142
+ catching = false
143
+ }
144
+ }
145
+
146
+ // 心拍の data は room の最新 seq である(kotoha の `859bc21`)。これが自分の lastSeq より
147
+ // 進んでいたら「繋がったままなのに取りこぼしている」証拠になる。**watchdog はこの穴を原理的に
148
+ // 見つけられない**——心拍が届き続ける限り最終受信時刻は更新され続けるので、途絶判定に一生
149
+ // 引っかからない。だから常時流れてくる心拍そのものを取りこぼし検出に使う。
150
+ function onHeartbeat(dataLine) {
151
+ const head = Number(dataLine)
152
+ if (!Number.isFinite(head) || head <= lastSeq) return
153
+ log(`心拍が示す最新 seq ${head} に追いついていない(手元 ${lastSeq})`)
154
+ catchUp('心拍の差分').catch(error => log(`心拍由来の回収に失敗: ${error.message}`))
155
+ }
156
+
157
+ let failures = 0
158
+ log(`bridge start: room=${room} seats=${seats.join(',')} pid=${process.pid}`)
159
+ for (;;) {
160
+ try {
161
+ const abort = new AbortController()
162
+ let lastByteAt = Date.now()
163
+ const watchdog = setInterval(() => {
164
+ if (Date.now() - lastByteAt > IDLE_MS) {
165
+ log(`受信途絶 ${Math.round(IDLE_MS / 1000)} 秒。接続が黙って死んだとみなして繋ぎ直す`)
166
+ abort.abort()
167
+ }
168
+ }, 5000)
169
+ try {
170
+ const res = await fetch(`${url}/api/${room}/events`, { signal: abort.signal })
171
+ if (!res.ok) throw new Error(`events ${res.status}`)
172
+ failures = 0
173
+ log('SSE 接続')
174
+ await catchUp('再接続')
175
+ let buf = ''
176
+ for await (const chunk of res.body) {
177
+ lastByteAt = Date.now()
178
+ buf += Buffer.from(chunk).toString('utf8')
179
+ const parts = buf.split('\n\n')
180
+ buf = parts.pop()
181
+ for (const part of parts) {
182
+ const lines = part.split('\n')
183
+ // SSE の1フレームは `event:` と `data:` の複数行で来る。名前付きイベント
184
+ // (server の心拍 `event: ping` / `data: 1` 等)は発言ではないので配達しない。
185
+ // `data:` だけ拾う実装だと心拍の `1` が発言として流れ込む(kotoha [106] の指摘)
186
+ const name = lines.find(l => l.startsWith('event: '))?.slice(7).trim()
187
+ const line = lines.find(l => l.startsWith('data: '))
188
+ if (name === 'ping') { if (line) onHeartbeat(line.slice(6)); continue }
189
+ if (name !== undefined && name !== 'message') continue
190
+ if (line) dispatchNew(JSON.parse(line.slice(6)))
191
+ }
192
+ }
193
+ log('SSE 切断(再接続する)')
194
+ } finally {
195
+ clearInterval(watchdog)
196
+ }
197
+ } catch (error) {
198
+ // 自分で切った時(watchdog の abort)は失敗ではない——数えると健全な再接続で落ちてしまう
199
+ if (error.name === 'AbortError') {
200
+ log('再接続する')
201
+ } else {
202
+ failures++
203
+ log(`SSE 失敗 ${failures} 回目: ${error.message}`)
204
+ // 落ちっぱなしを黙って再試行し続けない。連続失敗が続いたら記録を外して落ちる
205
+ if (failures >= 10) {
206
+ console.error('WAKEUP_BRIDGE_UNREACHABLE: room の SSE へ10回連続で繋げない')
207
+ if (existsSync(record)) unlinkSync(record)
208
+ process.exit(1)
209
+ }
210
+ }
211
+ }
212
+ await sleep(2000)
213
+ }
@@ -1,10 +1,12 @@
1
1
  #!/bin/bash
2
2
  # usage: .team/scripts/done.sh <task_id>
3
- # evidence/<task_id>.md(commit 済みであること)から記述子を作り lattice todo done を実行する。
3
+ # evidence/<plan_key>/<task_id>.md(commit 済みであること)から記述子を作り lattice todo done を実行する。
4
4
  # plan key は環境変数 PEERTABLE_PLAN から取る。
5
+ # 証跡を plan key で仕切るのは、task_id が campaign を跨いで再利用される(t1, t2, …)ため。
6
+ # 平置きだと次の campaign の t1 が前の campaign の t1 の監査証跡を上書きで消す(2026-08-08 実測)。
5
7
  set -e
6
8
  t="$1"
7
- f="evidence/$t.md"
9
+ f="evidence/$PEERTABLE_PLAN/$t.md"
8
10
  oid=$(git hash-object -w "$f")
9
11
  digest=$(shasum -a 256 "$f" | cut -d' ' -f1)
10
12
  # 記述子は repo 内の相対パスに置く(repo 外の絶対パスは --evidence が INVALID_ARGUMENTS で弾く)
@@ -7,7 +7,7 @@
7
7
  ## 作業ループ
8
8
 
9
9
  1. `.team/tasks.md` で議題を見る。`read_log` で既存の claim と完了報告を照合し、まだ誰も持っていないものを選ぶ
10
- 2. 憲章の手順で room に `[claim] <タスク>` を全員宛で宣言し、直後に `read_unread` で先行 claim が無いか確かめる
10
+ 2. 憲章の手順で room に `[claim] <タスク>` を全員宛で宣言し、直後に `read_unread` で先行 claim が無いか確かめる。**`[claim]` は独立した1発言で出す**——完了報告や他タスクの話と同じ発言に畳まない。宣言としては有効でも、後から機械的に追えなくなり、監査が「宣言が無い」と誤読する(2026-08-08 実測)。単独モードでは room ログが唯一の正本なので、より効く
11
11
  3. 実装する。インターフェースなど他タスクに影響する決定は、決めた時点で room 全員宛に一行で共有する
12
12
  4. 完了手順:
13
13
  - 何を作り、どう確認したかを自分で確かめる(テスト・実行・実測。「たぶん動く」で閉じない)
@@ -23,5 +23,6 @@
23
23
 
24
24
  - 誰が何を持っているかを機械に問い合わせられない卓である。claim と完了の宣言を落とした瞬間に重複作業になるので、宣言を省かない
25
25
  - room の新着通知が来たら read_unread で読む。返事が要るものには post で応える
26
+ - **Codex 席の場合**: 起床は channels ではなく wakeup-bridge が担う。`room に新着あり(<誰> → <宛先>)。read_unread で読むこと。` が端末へ直接届くので、Claude 席と同じく read_unread で読む。**作業中でも割り込んで届く**(そのターンの中で読まれる)ので、届いたらその場で手を止めて読み、返事が要るなら post してから元の作業へ戻る。自分の発言では起きない
26
27
  - 全タスクの完了報告が揃ったかの判定と散会の宣言は親が行う。自分の担当が終わっても散会宣言までは卓に残り、仲間の求めに応える
27
28
  - 憲章(.team/CLAUDE.md)が全ての基底である
@@ -5,11 +5,11 @@
5
5
  ## 作業ループ
6
6
 
7
7
  1. `lattice todo status --json` で ready なタスクを見る
8
- 2. 憲章の手順で room に claim を宣言する
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 全員宛に一行で共有する
11
11
  5. 完了手順:
12
- - 証跡ファイル `evidence/<task_id>.md` に「何を作り、どう確認したか」を書く
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
15
  6. room に完了を一行報告し、1 へ戻る
@@ -25,4 +25,5 @@
25
25
  - claim が衝突したら、Lattice の start 記録(誰が in-progress か)を機械の事実として使う。会話の言った言わないより先に工程正本を見る
26
26
  - **note が持つものを room の散文へ二重化しない**。設計メモ・タスク固有の経緯は `lattice todo note` に置き、room には決定と進捗だけを流す
27
27
  - room の新着通知が来たら read_unread で読む。返事が要るものには post で応える
28
+ - **Codex 席の場合**: 起床は channels ではなく wakeup-bridge が担う。`room に新着あり(<誰> → <宛先>)。read_unread で読むこと。` が端末へ直接届くので、Claude 席と同じく read_unread で読む。**作業中でも割り込んで届く**(そのターンの中で読まれる)ので、届いたらその場で手を止めて読み、返事が要るなら post してから元の作業へ戻る。自分の発言では起きない
28
29
  - 憲章(.team/CLAUDE.md)が全ての基底である