peertable 0.3.8 → 0.3.10

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.
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
- // Codex 席の起床ブリッジ。room の SSE を購読し、明示的にその席宛の新着が来たら
3
- // tmux の席へ素送信して起こす。Claude 席は channels が同じ役をするので対象外。
2
+ // room のSSEを購読し、明示宛先の新着を current member descriptor の通常席へ
3
+ // 素送信して起こす。親セッションは parent-watch が所有し、このbridgeでは扱わない。
4
4
  //
5
- // usage: wakeup-bridge.mjs <project_dir> <seat> [seat...] 起動(前面。nohup で常駐させる)
5
+ // usage: wakeup-bridge.mjs <project_dir> [legacy-seat...] 起動(前面。nohup で常駐させる)
6
6
  // wakeup-bridge.mjs <project_dir> --stop 停止
7
7
  //
8
8
  // 生死の作法は Lattice ADR 0157 に倣う: 自分の pid を記録に置き、起動時に前の記録を掃除し、
@@ -15,21 +15,52 @@ import { execFile } from 'node:child_process'
15
15
  import { existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
16
16
  import { join } from 'node:path'
17
17
  import { promisify } from 'node:util'
18
- import { resolveSeatObservation, resolveTmuxSocket } from './seat-usage.mjs'
18
+ import { resolveSeatObservation } from './seat-usage.mjs'
19
19
 
20
20
  const run = promisify(execFile)
21
21
  const [proj, ...rest] = process.argv.slice(2)
22
- if (!proj || rest.length === 0) {
22
+ if (!proj) {
23
23
  console.error('usage: wakeup-bridge.mjs <project_dir> <seat> [seat...] | <project_dir> --stop')
24
24
  process.exit(1)
25
25
  }
26
26
 
27
27
  const record = join(proj, '.team', 'wakeup-bridge.json')
28
- const socketResult = resolveTmuxSocket(process.env)
29
- const sock = socketResult.socket
28
+ const deliveryStatePath = join(proj, '.team', 'wakeup-bridge-delivery.json')
29
+ const startupLock = `${record}.lock`
30
30
  const alive = pid => { try { process.kill(pid, 0); return true } catch { return false } }
31
31
  const sleep = ms => new Promise(r => setTimeout(r, ms))
32
32
  const log = line => console.log(`[${new Date().toISOString()}] ${line}`)
33
+ let lockHeld = false
34
+ const releaseStartupLock = () => {
35
+ if (!lockHeld) return
36
+ try { if (existsSync(startupLock)) unlinkSync(startupLock) } catch {}
37
+ lockHeld = false
38
+ }
39
+ process.on('exit', releaseStartupLock)
40
+
41
+ async function acquireStartupLock() {
42
+ const deadline = Date.now() + 10_000
43
+ for (;;) {
44
+ try {
45
+ writeFileSync(startupLock, `${process.pid}\n`, { flag: 'wx', mode: 0o600 })
46
+ lockHeld = true
47
+ return
48
+ } catch (error) {
49
+ if (error.code !== 'EEXIST') throw error
50
+ let owner = null
51
+ try { owner = Number(readFileSync(startupLock, 'utf8').trim()) } catch {}
52
+ if (owner !== null && Number.isInteger(owner) && !alive(owner)) {
53
+ try { unlinkSync(startupLock) } catch {}
54
+ continue
55
+ }
56
+ if (Date.now() >= deadline) {
57
+ console.error(`WAKEUP_BRIDGE_START_LOCKED: 起動処理中のbridge(pid ${owner ?? '不明'})がlockを保持している`)
58
+ process.exit(1)
59
+ }
60
+ await sleep(200)
61
+ }
62
+ }
63
+ }
33
64
 
34
65
  async function stopRecorded() {
35
66
  if (!existsSync(record)) return
@@ -49,37 +80,62 @@ async function stopRecorded() {
49
80
  log(`前のブリッジを停止した(pid ${pid})`)
50
81
  }
51
82
 
83
+ await acquireStartupLock()
52
84
  await stopRecorded()
53
85
  if (rest[0] === '--stop') process.exit(0)
54
86
 
55
- const seats = rest
87
+ // 起動引数は supervisor の後方互換として記録するだけで、配送対象の正本にはしない。
88
+ // 宛先は常に room の現在 member name から descriptor を解決する。
89
+ const requestedSeats = rest
56
90
  const state = JSON.parse(readFileSync(join(proj, '.team', 'setup-state.json'), 'utf8'))
57
91
  const { room, server_url: url } = state
58
92
  writeFileSync(record, JSON.stringify({
59
- pid: process.pid, room, server_url: url, seats, started_at: new Date().toISOString(),
93
+ pid: process.pid, room, server_url: url, requested_seats: requestedSeats, started_at: new Date().toISOString(),
60
94
  }) + '\n')
95
+ releaseStartupLock()
61
96
 
62
97
  const cleanup = () => { if (existsSync(record)) unlinkSync(record); process.exit(0) }
63
98
  process.on('SIGTERM', cleanup)
64
99
  process.on('SIGINT', cleanup)
65
100
 
66
- // 席ごとに未配達を溜めて、2秒ごとにまとめて1回起こす(連投で席を何度も起こさない)
67
- const pending = new Map(seats.map(s => [s, []]))
101
+ // 席ごとに未配達を溜めて、2秒ごとにまとめて1回起こす(連投で席を何度も起こさない)。
102
+ // pending room message の宛先名から作る。起動引数や vendor は候補集合を決めない。
103
+ const pending = new Map() // name -> Map<seq, message>
104
+ const deliveryStates = new Map() // seq -> { message, targets, delivered }
105
+ let seats = []
68
106
  let members = new Map()
107
+ let membersObserved = false
108
+ function reconcileSeats() {
109
+ const next = new Set()
110
+ if (membersObserved) {
111
+ for (const member of members.values()) {
112
+ if (typeof member.name !== 'string' || member.name.length === 0) continue
113
+ if (member.delivery?.kind === 'parent_watch') continue
114
+ next.add(member.name)
115
+ if (!pending.has(member.name)) pending.set(member.name, new Map())
116
+ }
117
+ }
118
+ const previous = seats.join(',')
119
+ seats = [...next]
120
+ if (previous !== seats.join(',')) log(`監視席を更新: ${seats.join(',') || 'なし'}`)
121
+ }
69
122
  async function refreshMembers() {
70
123
  try {
71
124
  const res = await fetch(`${url}/api/${encodeURIComponent(room)}/members`)
72
125
  if (!res.ok) throw new Error(`members ${res.status}`)
73
126
  const body = await res.json()
127
+ if (!Array.isArray(body.members)) throw new Error('members response is not an array')
74
128
  members = new Map(body.members.map(member => [member.name, member]))
129
+ membersObserved = true
130
+ reconcileSeats()
75
131
  return true
76
132
  } catch (error) {
77
- // 記述子の更新が読めないことは起床停止の理由にしない。直前の map を保ち、
78
- // 未登録席は wake() の legacy target へ戻す。縮退を必ずログに残す。
79
- log(`member 記述子を更新できないので直前の観測先で続ける: ${error.message}`)
133
+ // 直前の member map は保持するが、起動引数や既定 socketへ縮退しない。
134
+ log(`member 記述子を更新できないので直前の観測を保持する: ${error.message}`)
80
135
  return false
81
136
  }
82
137
  }
138
+ reconcileSeats()
83
139
  await refreshMembers()
84
140
  function markReady() {
85
141
  const next = { ...JSON.parse(readFileSync(record, 'utf8')), ready_at: new Date().toISOString() }
@@ -88,58 +144,189 @@ function markReady() {
88
144
  renameSync(temp, record)
89
145
  }
90
146
 
91
- async function wake(seat, msgs) {
92
- const last = msgs[msgs.length - 1]
93
- const audience = Array.isArray(last.to_names) ? last.to_names.join(', ') : last.to
94
- const text = msgs.length === 1
95
- ? `room に新着あり(${last.from} → ${audience})。read_unread で読むこと。`
96
- : `room に新着 ${msgs.length} 件(最新: ${last.from} → ${audience})。read_unread で読むこと。`
147
+ function loadDeliveryState() {
97
148
  try {
98
- // bridge 起動後の着席も次の配達で取り直す。初回だけの snapshot にすると
99
- // 新席を「member 不明」として永久に起こせず、旧 peer- 互換より後退する。
100
- await refreshMembers()
101
- const observation = resolveSeatObservation(members.get(seat) ?? { name: seat }, sock)
102
- if (observation === null) throw new Error(`観測記述子も既定 socket も無い: ${seat}`)
103
- await run('tmux', ['-S', observation.socket, 'send-keys', '-t', observation.target, text])
104
- await sleep(400)
105
- await run('tmux', ['-S', observation.socket, 'send-keys', '-t', observation.target, 'Enter'])
106
- log(`起こした: ${seat} ${msgs.length} 件(最新 seq ${last.seq})`)
107
- } catch (error) {
108
- // 席が畳まれていれば tmux が落ちる。黙って飲まず、毎回出す(何件落としたかも出す)
109
- log(`起こせなかった: ${seat} ← ${msgs.length} 件(最新 seq ${last.seq}): ${error.message.split('\n')[0]}`)
149
+ const saved = JSON.parse(readFileSync(deliveryStatePath, 'utf8'))
150
+ if (saved.room !== room || saved.server_url !== url) return { primed: false, lastSeq: 0, delivered: new Set() }
151
+ return {
152
+ primed: saved.primed === true,
153
+ lastSeq: Number.isSafeInteger(saved.last_seq) && saved.last_seq >= 0 ? saved.last_seq : 0,
154
+ delivered: new Set(Array.isArray(saved.delivered) ? saved.delivered.filter(key => typeof key === 'string') : []),
155
+ }
156
+ } catch {
157
+ return { primed: false, lastSeq: 0, delivered: new Set() }
110
158
  }
111
159
  }
112
160
 
113
- setInterval(async () => {
114
- for (const [seat, msgs] of pending) {
115
- if (msgs.length === 0) continue
116
- pending.set(seat, [])
117
- await wake(seat, msgs)
161
+ const deliveryState = loadDeliveryState()
162
+ let lastSeq = deliveryState.lastSeq
163
+ const delivered = deliveryState.delivered
164
+ let primed = deliveryState.primed
165
+ function saveDeliveryState() {
166
+ const temp = `${deliveryStatePath}.${process.pid}.tmp`
167
+ writeFileSync(temp, JSON.stringify({
168
+ room,
169
+ server_url: url,
170
+ primed,
171
+ last_seq: lastSeq,
172
+ delivered: [...delivered].slice(-10_000),
173
+ }) + '\n')
174
+ renameSync(temp, deliveryStatePath)
175
+ }
176
+
177
+ function deliveryKey(seq, seat) {
178
+ return `${seq}:${seat}`
179
+ }
180
+
181
+ const BROADCAST_RECIPIENT = 'all'
182
+ function recipientNames(msg) {
183
+ if (Array.isArray(msg.to_names)) {
184
+ if (msg.to_names.includes(BROADCAST_RECIPIENT)) return []
185
+ return [...new Set(msg.to_names.filter(name => typeof name === 'string' && name.length > 0))]
118
186
  }
119
- }, 2000)
187
+ if (msg.to === BROADCAST_RECIPIENT) return [...members.keys()].filter(name => name !== msg.from)
188
+ if (typeof msg.to === 'string' && msg.to.length > 0 && msg.to !== BROADCAST_RECIPIENT) return [msg.to]
189
+ return []
190
+ }
191
+
192
+ function advanceLastSeq() {
193
+ let advanced = false
194
+ for (;;) {
195
+ const state = deliveryStates.get(lastSeq + 1)
196
+ if (!state) break
197
+ if (![...state.targets].every(seat => state.delivered.has(seat))) break
198
+ deliveryStates.delete(lastSeq + 1)
199
+ lastSeq += 1
200
+ advanced = true
201
+ }
202
+ if (advanced) saveDeliveryState()
203
+ }
204
+
205
+ async function wake(seat, msgs) {
206
+ const last = msgs[msgs.length - 1]
207
+ const text = msgs.map(msg => {
208
+ const audience = Array.isArray(msg.to_names) ? msg.to_names.join(', ') : msg.to
209
+ if (msg.to === BROADCAST_RECIPIENT) {
210
+ return `[Peertable #${msg.seq}] room全体の状況が更新された。room.read_logで部屋を読み、状況を把握して次の行動を判断する。`
211
+ }
212
+ const body = String(msg.body).replace(/\s*\n+\s*/gu, ' / ')
213
+ return `[Peertable DM #${msg.seq}] ${msg.from} → ${audience}: ${body}`
214
+ }).join(' || ')
215
+ // 配送直前に member ledger を取り直し、current name -> descriptor の一経路だけを使う。
216
+ await refreshMembers()
217
+ const member = members.get(seat)
218
+ const delivery = member?.delivery
219
+ const observation = resolveSeatObservation(member, null)
220
+ if (observation === null) {
221
+ const code = members.has(seat) ? 'DESCRIPTOR_MISSING' : 'MEMBER_MISSING'
222
+ const error = new Error(`${code}: ${seat}`)
223
+ error.code = code
224
+ throw error
225
+ }
226
+ // Codexの入力欄は本文とEnterを同じtmux commandで送ると、初回turn完了後に
227
+ // 本文が入力欄へ残ることがある。再試行時の半入力も含め、正規のsubmitを分離する。
228
+ // 最後のEnterまで成功しない限りwakeは成功扱いにせず、flushSeatのreceiptも確定しない。
229
+ await run('tmux', ['-S', observation.socket, 'send-keys', '-t', observation.target, 'C-u'])
230
+ await sleep(100)
231
+ await run('tmux', ['-S', observation.socket, 'send-keys', '-l', '-t', observation.target, text])
232
+ await sleep(750)
233
+ await run('tmux', ['-S', observation.socket, 'send-keys', '-t', observation.target, 'Enter'])
234
+ log(`起こした: ${seat} ← ${msgs.length} 件(最新 seq ${last.seq})`)
235
+ }
120
236
 
121
237
  function dispatch(msg) {
122
- for (const seat of seats) {
123
- if (msg.from === seat) continue
124
- if (Array.isArray(msg.to_names) ? !msg.to_names.includes(seat) : msg.to !== seat) continue
125
- pending.get(seat).push(msg)
238
+ if (deliveryStates.has(msg.seq)) return
239
+ const targets = recipientNames(msg).filter(seat => members.has(seat)
240
+ && members.get(seat)?.delivery?.kind !== 'parent_watch')
241
+ const state = { message: msg, targets: new Set(targets), delivered: new Set() }
242
+ for (const seat of targets) {
243
+ if (delivered.has(deliveryKey(msg.seq, seat))) state.delivered.add(seat)
126
244
  }
245
+ deliveryStates.set(msg.seq, state)
246
+ for (const seat of targets) {
247
+ if (state.delivered.has(seat)) continue
248
+ if (!pending.has(seat)) pending.set(seat, new Map())
249
+ pending.get(seat).set(msg.seq, msg)
250
+ }
251
+ advanceLastSeq()
127
252
  }
128
253
 
129
- // 再接続で取りこぼさないために、配達済みの最大 seq を持つ。
130
- // SSE は切れている間の発言を後から届けてくれないので、繋ぎ直したら必ず穴を埋める。
131
- let lastSeq = 0
132
- function dispatchNew(msg) {
133
- // seq の無いイベントで lastSeq を汚さない。`undefined <= 数値` は false なので、
134
- // 素通しにすると lastSeq が undefined に化け、以後の比較が全部 false になって
135
- // 「取りこぼし回収が毎回 since=undefined 0 件」という静かな故障になる(実測で踏んだ)
136
- if (typeof msg.seq !== 'number') {
137
- log(`seq を持たないイベントを捨てた: ${JSON.stringify(msg).slice(0, 120)}`)
138
- return
254
+ const flushing = new Set()
255
+ async function flushSeat(seat) {
256
+ if (flushing.has(seat)) return
257
+ const queue = pending.get(seat)
258
+ if (!queue || queue.size === 0) return
259
+ flushing.add(seat)
260
+ const msgs = [...queue.values()].sort((a, b) => a.seq - b.seq)
261
+ try {
262
+ await wake(seat, msgs)
263
+ const receipts = []
264
+ for (const msg of msgs) {
265
+ if (queue.get(msg.seq) !== msg) continue
266
+ const state = deliveryStates.get(msg.seq)
267
+ if (!state) continue
268
+ state.delivered.add(seat)
269
+ delivered.add(deliveryKey(msg.seq, seat))
270
+ receipts.push({ msg, state })
271
+ }
272
+ // lastSeq が先行seqの別宛先失敗で止まっても、今回成功した宛先のreceiptは失わない。
273
+ // durable receiptを先に確定してからpendingを消すので、再起動後に成功席へ重複wakeしない。
274
+ if (receipts.length > 0) {
275
+ try {
276
+ saveDeliveryState()
277
+ } catch (error) {
278
+ for (const { msg, state } of receipts) {
279
+ state.delivered.delete(seat)
280
+ delivered.delete(deliveryKey(msg.seq, seat))
281
+ }
282
+ throw error
283
+ }
284
+ for (const { msg } of receipts) queue.delete(msg.seq)
285
+ }
286
+ advanceLastSeq()
287
+ } catch (error) {
288
+ // 失敗時はpendingもreceiptもcursorも動かさない。次のmember refreshで
289
+ // descriptor/PTYが復旧した時、同じseqを同じ宛先へ一度だけ再試行する。
290
+ log(`WAKEUP_BRIDGE_DELIVERY_FAILURE ${JSON.stringify({
291
+ recipient: seat,
292
+ code: typeof error.code === 'string' ? error.code : 'INJECTION_FAILED',
293
+ seqs: msgs.map(msg => msg.seq),
294
+ detail: error.message.split('\n')[0],
295
+ })}`)
296
+ } finally {
297
+ flushing.delete(seat)
298
+ }
299
+ }
300
+
301
+ setInterval(() => {
302
+ for (const [seat, queue] of pending) {
303
+ if (queue.size > 0) flushSeat(seat).catch(error => log(`WAKEUP_BRIDGE_FLUSH_FAILED: ${error.message}`))
139
304
  }
140
- if (msg.seq <= lastSeq) return
141
- lastSeq = msg.seq
142
- dispatch(msg)
305
+ }, 2000)
306
+
307
+ // 再接続で取りこぼさないために、全宛先の成功が揃った最大 seq を持つ。
308
+ // 失敗した宛先がある限り lastSeq は前進せず、GET since から同じseqを回収する。
309
+ let dispatchQueue = Promise.resolve()
310
+ function dispatchNew(msg) {
311
+ // 心拍由来のcatch-upとSSE本文は同時に到着しうる。refreshMembers() の await より
312
+ // 前でseqを判定すると、同じmsgが両方の経路から二重に積まれ、lastSeqも逆行する。
313
+ // 判定・members同期・カーソル更新・dispatchを一本の順序へ直列化する。
314
+ const queued = dispatchQueue.then(async () => {
315
+ // seq の無いイベントで lastSeq を汚さない。`undefined <= 数値` は false なので、
316
+ // 素通しにすると lastSeq が undefined に化け、取りこぼし回収が静かに壊れる。
317
+ if (typeof msg.seq !== 'number') {
318
+ log(`seq を持たないイベントを捨てた: ${JSON.stringify(msg).slice(0, 120)}`)
319
+ return
320
+ }
321
+ if (msg.seq <= lastSeq) return
322
+ // SSEのmessageだけでは席追加と競合する。DMを積む直前にmembersを取り直し、
323
+ // 配送時にはさらにdescriptorを解決する。refresh失敗でも静的席へ縮退しない。
324
+ await refreshMembers()
325
+ if (msg.seq <= lastSeq || deliveryStates.has(msg.seq)) return
326
+ dispatch(msg)
327
+ })
328
+ dispatchQueue = queued.catch(() => {})
329
+ return queued
143
330
  }
144
331
 
145
332
  // 繋がったまま黙って死ぬ接続を検出する。SSE は無音でも生きていられるので、
@@ -150,7 +337,6 @@ const IDLE_MS = 75_000
150
337
 
151
338
  // 起動直後の1回だけは配達しない。既に流れ終わった過去ログで席を起こしても意味がなく、
152
339
  // 卓が長いほど巨大な起床通知になる。初回は「ここまでは読んだこと」にして頭出しするだけ。
153
- let primed = false
154
340
  let catching = false
155
341
  async function catchUp(reason) {
156
342
  // 再接続直後と心拍由来の回収が重なると二重に取りにいく。取りこぼし回収は1本だけ走らせる
@@ -163,11 +349,12 @@ async function catchUp(reason) {
163
349
  if (!primed) {
164
350
  primed = true
165
351
  if (messages.length > 0) lastSeq = messages[messages.length - 1].seq
352
+ saveDeliveryState()
166
353
  log(`頭出し: seq ${lastSeq} まで既読として開始する`)
167
354
  return
168
355
  }
169
356
  log(`取りこぼし確認(${reason}・since ${lastSeq}): ${messages.length} 件`)
170
- for (const msg of messages) dispatchNew(msg)
357
+ for (const msg of messages) await dispatchNew(msg)
171
358
  } finally {
172
359
  catching = false
173
360
  }
@@ -218,7 +405,7 @@ for (;;) {
218
405
  const line = lines.find(l => l.startsWith('data: '))
219
406
  if (name === 'ping') { if (line) onHeartbeat(line.slice(6)); continue }
220
407
  if (name !== undefined && name !== 'message') continue
221
- if (line) dispatchNew(JSON.parse(line.slice(6)))
408
+ if (line) await dispatchNew(JSON.parse(line.slice(6)))
222
409
  }
223
410
  }
224
411
  log('SSE 切断(再接続する)')
@@ -2,13 +2,19 @@
2
2
 
3
3
  これはチーム作業である。自分が引き受けたタスクの完了はミッションの完了ではない。全タスク完了までチームは解散しない。
4
4
 
5
- 1. 拘束力を持つのは**工程正本への記録**と room ログに残った決定だけ(工程正本=Lattice 併用モードなら Lattice の todo 記録、単独円卓モードなら room の宣言そのもの)。宛先は影響を受けるメンバーだけを明示する。room ログは全員がpullで読めるので、broadcastによる周知を決定の成立条件にしない
6
- 2. 進捗は room に一行で報告する: claim・join、完了時・詰まり自覚時・方針変更時。task 外でも成果物になる作業(実測・検証・CI)は着手前に一言。仲間を五里霧中に置かない
7
- 3. **仕事を選ぶのも始めるのも自分である。** どのモードでも、`read_log`で先行claimを確認してからroomへ`[claim] <タスク>`を記録する。起こす必要のある相手だけを宛先へ入れ、記録だけなら自分宛でよい。**装置から仕事が降ってくることはないし、着手前に装置の許可を待つこともない**(オーナー裁定 2026-08-09)。Lattice の実行層を使う卓では、`todo start` の後に自分で `run intake` して隔離 worktree を受け取る——それは設備の供給であって許可証ではなく、装置が返すのは競合した時の「留まれ」だけである(タスクの呼び名は Lattice 併用モードなら task_id、単独円卓モードなら `.team/tasks.md` の議題名)。同じタスクへの先行 claim があれば取り下げるか `[join] <タスク>` へ切り替える
5
+ 1. 拘束力を持つのは**工程正本への記録**と room ログに残った決定だけ(工程正本=Lattice 併用モードなら Lattice の todo 記録、単独円卓モードなら room の宣言そのもの)。room の `to: "all"` は部屋全体への通常発言、名前指定はDMである
6
+ 2. room の宛先規則はこれだけである。claim は `post(to: "all")`、ターン終了時の次の行動は `post(to: "<自分の名前>")`、工程完了は `post(to: "all")`、誰かへの用事は `post(to: "<相手の名前>")` で送る。誰に聞けばよいか分からないことは `post(to: "all")` で聞く。実装・監査・readyが本当に無い時の待機宣言は最終手段として親だけへDMし、`all`へ送らない。別の通知機構は使わない
7
+ 3. **仕事を選ぶのも始めるのも自分である。** どのモードでも、`read_log`で先行claimを確認してから `post(to: "all", message: "[claim] <タスク>")` を送る。これが唯一の着手通知であり、別の着手通知は送らない。**装置から仕事が降ってくることはないし、着手前に装置の許可を待つこともない**(オーナー裁定 2026-08-09)。Lattice の実行層を使う卓では、その後 `todo start` し、自分で `run intake` して隔離 worktree を受け取る——それは設備の供給であって許可証ではなく、装置が返すのは競合した時の「留まれ」だけである(タスクの呼び名は Lattice 併用モードなら task_id、単独円卓モードなら `.team/tasks.md` の議題名)。同じタスクへの先行 claim があれば取り下げるか `[join] <タスク>` へ切り替える
8
8
  4. 分からないことは room で聞く。台帳はない。自分の変更が他の部位に影響するなら、聞かれる前に影響を受けるメンバーを明示宛先にして通知する
9
- 5. 判断は情報を持つ者がする。タスクは席ではなく現場。合流(join)は歓迎される。詰まった仲間には目を貸す。手が足りないだけなら自分のサブエージェントを使う(room 報告不要)。視点が足りない・詰んでいるなら room で報告して援軍(join)を求める
10
- 6. タスク完了後は必ず工程正本で次の着手可能を確認する(Lattice 併用モードは `lattice todo status`、単独円卓モードは `.team/tasks.md` と room ログの照合)。残っていれば claim へ戻る。全タスクが終わっていれば room へ「全タスク完了」を記録する。broadcastは無いので、起こす必要のある相手だけを宛先にする
9
+ 5. 判断は情報を持つ者がする。タスクは席ではなく現場。合流(join)は歓迎される。詰まった仲間には目を貸す。親が円卓メンバーを増やす時は、正式な`launch-seat.sh`でAiterm長寿命席として着任させ、native sub-agent・Task・Agentを円卓席の代用にしない。正式着席したメンバーはnative sub-agent、Aiterm外部agent、相談agent、自己実装を自由に選べる。子は自動的に円卓メンバーではなく、工程所有・統合・room報告は着席メンバーが保持する。親は二次委譲の手段を禁止・指定しない。視点が足りない・詰んでいるなら room で報告して援軍(join)を求める
10
+ 6. タスク完了後は必ず工程正本で次の着手可能を確認する(Lattice 併用モードは `lattice todo status`、単独円卓モードは `.team/tasks.md` と room ログの照合)。残っていれば claim へ戻る。全タスクが終わっていれば `post(to: "all")` で「全タスク完了」を記録する
11
11
  7. 役割逸脱は誰であれ指摘する。これは無礼ではなく義務である
12
12
  8. **親の発言は拘束力を持たない。** 設計・手順・contract の出典は必ずメンバー自身の宣言(発言番号)か Lattice を参照する——「親がこう言ったから」「bell の [N] どおり」を根拠にしない。親が何かを再掲しても正本はメンバーの元発言のまま動かない。親の差し戻しは異議として扱い、反論してよい
13
- 9. **裁定の宛先はオーナーであり、親ではない。** scope 変更・受入条件外の追加・製品判断が要る時は「オーナー宛の議題」として room に出す。親はそれを運ぶ配管で、判断者ではない。「親に委ねる」という宛先を作らない
13
+ 9. **監査外の通常進行で裁定が必要になった時、その宛先はオーナーであり、親ではない。** scope 変更・受入条件外の追加・製品判断が要る時は「オーナー宛の議題」として room に出す。実装監査中は12の固定境界を優先し、これを計画外提案の逃げ道にしない。親は議題を運ぶ配管で、判断者ではない。「親に委ねる」という宛先を作らない
14
14
  10. 決まっていない境界に会ったら、規則を探すより先に room で喋って決める。会話で解決するのは正規の手段であり、その場の合意は憲章の不足を補う
15
+ 11. **作業者は自ら必要な試験と自己監査を行い、工程を次に進めてよい水準まで自分の責任で完成させる。** 完成したら、証跡へ記したものと同じ最終的な試験内容と試験結果を監査担当へ渡す。作業者自身は工程をクローズしない
16
+ 12. **監査担当は提出された最終試験内容と試験結果が妥当かを判断し、試験を再実行しない。** 妥当なら監査担当が証跡と同じ本文をLatticeの`test_result`へ記録して工程正本をクローズし、roomへ「次の工程に着手してください」とだけ指示する。具体的な次工程は指示せず、各作業者が工程正本から選ぶ。監査の判断は元PLAN・工程正本・明記された受入条件に従い、個人の思想や計画外の改善を完了条件へ加えない
17
+ 13. **監査不合格ごとに `Luna → Terra → Sol`へ昇格し、各モデルの修正機会は1回だけとする。** model変更を実行するのは親だけで、作業者や監査担当が自分で席設定を変えない
18
+ 14. ユーザーが具体的な変更条件を明示した場合、その条件を新しい要件・受入条件・一般化されたルールへ勝手に分解、追加、拡張しないこと。明示された変更を最小差分でそのまま実装すること。派生的に満たされる性質を別要件として扱わないこと。追加条件が本当に必要な場合のみ、その理由を示して提案すること。提案を実装条件へ勝手に昇格させないこと。
19
+ 15. **通し試験は完成確認にだけ使い、原因調査には使わない。** 通し試験が失敗したら、担当者は失敗した機能を切り分け、focused testで原因を確定して修正し、最後に通し試験を確認する
20
+ 16. **後続工程へ着手した後に先行工程由来の不具合が判明しても、先行工程をreopenせず、前担当者へ戻さず、修正工程も追加しない。** 現在の工程担当者が、現在の工程を成立させる修正として自ら直し、必要なfocused testと自己監査を行い、最終試験結果へ「発見した不具合を含めて修正した」と記す