peertable 0.3.4 → 0.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -20,6 +20,8 @@ import { execFileSync } from 'node:child_process'
20
20
  import { existsSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs'
21
21
  import { join } from 'node:path'
22
22
 
23
+ import { parsePaneTokenHint, supportsMemberObservation } from './seat-usage.mjs'
24
+
23
25
  const args = process.argv.slice(2)
24
26
  const proj = args[0]
25
27
  if (!proj) { console.error('usage: seat-status-bridge.mjs <project_dir> [--interval <sec>] [--once] | --stop'); process.exit(1) }
@@ -77,21 +79,35 @@ async function seats() {
77
79
  return members.map(m => m.name)
78
80
  }
79
81
 
80
- function readStatus(name) {
82
+ function readSeat(name, previous, observedAt) {
81
83
  const target = `peer-${name}`
82
84
  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
+ if (dead === null) return { status: 'dead', busySince: null, paneTokenHint: null }
86
+ if (dead.trim().split('\n')[0] === '1') {
87
+ return { status: 'dead', busySince: null, paneTokenHint: null }
88
+ }
85
89
  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'
90
+ if (pane === null) return { status: 'dead', busySince: null, paneTokenHint: null }
91
+ const tail = pane.split('\n').slice(-14).join('\n')
92
+ const status = tail.includes('esc to interrupt') ? 'busy' : 'idle'
93
+ const busySince = status === 'busy'
94
+ ? (previous?.status === 'busy' && previous.busySince ? previous.busySince : observedAt)
95
+ : null
96
+ return { status, busySince, paneTokenHint: parsePaneTokenHint(tail) }
88
97
  }
89
98
 
90
- async function send(name, status) {
99
+ async function send(name, observation, observedAt) {
91
100
  const res = await fetch(`${url}/api/${encodeURIComponent(room)}/members`, {
92
101
  method: 'POST',
93
102
  headers: { 'Content-Type': 'application/json', ...(token ? { 'X-Peertable-Token': token } : {}) },
94
- body: JSON.stringify({ name, status, status_at: new Date().toISOString() }),
103
+ body: JSON.stringify({
104
+ name,
105
+ status: observation.status,
106
+ status_at: observedAt,
107
+ busy_since: observation.busySince,
108
+ pane_token_hint: observation.paneTokenHint,
109
+ usage_source: 'pane_status',
110
+ }),
95
111
  })
96
112
  if (!res.ok) throw new Error(`HTTP ${res.status}`)
97
113
  }
@@ -100,12 +116,12 @@ async function send(name, status) {
100
116
  // 読み返して実際に載ったかを見る。載らない版なら、そう言って**黙って成功したふりをしない**
101
117
  async function serverKeepsStatus() {
102
118
  const res = await fetch(`${url}/api/${encodeURIComponent(room)}/members`)
103
- const { members } = await res.json()
104
- return members.some(m => 'status' in m)
119
+ return supportsMemberObservation(await res.json())
105
120
  }
106
121
 
107
122
  const last = new Map() // name -> { status, at }
108
123
  let supported = null // server が status を保持する版か(未判定は null)
124
+ const tokenBucket = value => value === null ? null : Math.floor(value / 1_000)
109
125
 
110
126
  async function tick() {
111
127
  let names
@@ -121,18 +137,22 @@ async function tick() {
121
137
  }
122
138
  if (!supported) { console.error(`seat-status-bridge: ${names.length} 席を見たが、server が未対応なので送っていない`); return }
123
139
  const now = Date.now()
140
+ const observedAt = new Date(now).toISOString()
124
141
  let sent = 0
125
142
  for (const name of names) {
126
- const status = readStatus(name)
127
143
  const prev = last.get(name)
128
- const changed = !prev || prev.status !== status
144
+ const observation = readSeat(name, prev, observedAt)
145
+ const changed = !prev || prev.status !== observation.status
146
+ || prev.busySince !== observation.busySince
147
+ // token表示は実行中に細かく増える。1k未満の差で8秒ごとにPOSTせず、表示精度に合う粒度で送る。
148
+ || tokenBucket(prev.paneTokenHint) !== tokenBucket(observation.paneTokenHint)
129
149
  const stale = prev && now - prev.at >= HEARTBEAT_MS
130
150
  if (!changed && !stale) continue
131
151
  try {
132
- await send(name, status)
133
- last.set(name, { status, at: now })
152
+ await send(name, observation, observedAt)
153
+ last.set(name, { ...observation, at: now })
134
154
  sent++
135
- if (changed) console.error(`seat-status-bridge: ${name} → ${status}${prev ? `(${prev.status} から)` : ''}`)
155
+ if (changed) console.error(`seat-status-bridge: ${name} → ${observation.status}${prev ? `(${prev.status} から)` : ''}`)
136
156
  } catch (e) {
137
157
  console.error(`seat-status-bridge: ${name} の送信に失敗: ${e.message}`)
138
158
  }
@@ -0,0 +1,26 @@
1
+ const TOKEN_HINT = /[↓↑]\s*([0-9]+(?:\.[0-9]+)?)\s*([kKmM]?)\s*tokens\b/gu
2
+
3
+ const TOKEN_MULTIPLIER = Object.freeze({
4
+ '': 1,
5
+ k: 1_000,
6
+ m: 1_000_000,
7
+ })
8
+
9
+ export function supportsMemberObservation(payload) {
10
+ return payload?.capabilities?.member_observation_v1 === true
11
+ }
12
+
13
+ /**
14
+ * paneのstatus行が公開しているtoken値だけを読む。
15
+ * vendor固有のログや課金単価は推測せず、表示が無い席はnullのままにする。
16
+ */
17
+ export function parsePaneTokenHint(pane) {
18
+ if (typeof pane !== 'string') return null
19
+ let latest = null
20
+ for (const match of pane.matchAll(TOKEN_HINT)) {
21
+ const multiplier = TOKEN_MULTIPLIER[match[2].toLowerCase()]
22
+ const value = Math.round(Number(match[1]) * multiplier)
23
+ if (Number.isSafeInteger(value) && value >= 0) latest = value
24
+ }
25
+ return latest
26
+ }
@@ -51,27 +51,17 @@ else
51
51
  mode=lattice
52
52
  fi
53
53
 
54
- # Lattice 併用モードは、登録に使う公開CLIと同梱work-order binaryを、projectへ
55
- # 何か置く前に確定する。通常はglobal installされた lattice の隣を使う。
56
- # release前のsource treeを実測する時だけ、2つのenvで同じtreeのbinを明示できる。
54
+ # Lattice 併用モードは、公開CLIをprojectへ何か置く前に確定する。通常はglobal installされた
55
+ # lattice を使い、release前のsource treeを実測する時だけ `LATTICE_CLI` で同じtreeのbinを明示する。
56
+ # **work-order adapter binary はもう要らない**(配車を撤去したので登録しない)。ここで見るのは
57
+ # CLI の存在だけで、席が `todo status` / `run intake` を叩ける前提の確認である。
57
58
  lattice_cli=""
58
- work_order_binary=""
59
- node_binary=""
60
59
  if [ "$mode" = "lattice" ]; then
61
60
  lattice_cli="${LATTICE_CLI:-$(command -v lattice 2>/dev/null || true)}"
62
61
  [ -n "$lattice_cli" ] || { echo "ERROR: lattice CLI が見つからない" >&2; exit 1; }
63
62
  [ -x "$lattice_cli" ] || { echo "ERROR: lattice CLI が実行可能fileでない: $lattice_cli" >&2; exit 1; }
64
63
  lattice_cli=$(node -e 'process.stdout.write(require("node:fs").realpathSync(process.argv[1]))' "$lattice_cli")
65
64
 
66
- work_order_binary="${LATTICE_WORK_ORDER_ADAPTER_BINARY:-$(dirname "$lattice_cli")/lattice-work-order-adapter.mjs}"
67
- [ -f "$work_order_binary" ] && [ -x "$work_order_binary" ] || {
68
- echo "ERROR: Lattice work-order adapter binary が見つからないか実行不能: $work_order_binary" >&2
69
- exit 1
70
- }
71
- work_order_binary=$(node -e 'process.stdout.write(require("node:fs").realpathSync(process.argv[1]))' "$work_order_binary")
72
- node_binary=$(node -e 'process.stdout.write(require("node:fs").realpathSync(process.execPath))')
73
- [ -x "$node_binary" ] || { echo "ERROR: Node executable が実行可能fileでない: $node_binary" >&2; exit 1; }
74
-
75
65
  # config_refはgit root相対の公開契約。subdirectoryをprojectとして受けると別の
76
66
  # `.lattice/` を作ってしまうので、黙って親repoへ登録せずtypedに止める。
77
67
  project_root=$(node -e 'process.stdout.write(require("node:fs").realpathSync(process.argv[1]))' "$proj")
@@ -138,54 +128,14 @@ if [ "$mode" = "lattice" ] && [ -d "$proj/.git" ] \
138
128
  added_runtime_exclude=true
139
129
  fi
140
130
 
141
- # managed run の仕事口をLattice runtime stateとして用意する。configを`.team/`
142
- # に置くとarchive teardownでregistryだけが残って壊れるため、registryと同じ
143
- # `.lattice/runtime/`の寿命へ揃える。席はこのspoolへ直接触れない。
131
+ # **機械配車の口はもう作らない。** 2026-08-09 のオーナー裁定(改・裁定1)で、Lattice が席へ
132
+ # 仕事を配る向きは撤回された。席は自分で `todo start` してから `run intake` するので、
133
+ # work-order adapter の登録も spool(orders/reports)も要らない。**setup がここで adapter を
134
+ # 必須化していると、adapter binary が無い環境で新しい卓が立たなくなる**(配車をしないのに)。
135
+ # 既存 project に前の卓が作った registry が残っていても触らない——他 adapter や進行中 run の
136
+ # 所有物を含みうるので、setup が消す対象ではない。
144
137
  work_order_adapter=false
145
138
  work_order_spool_ref=""
146
- if [ "$mode" = "lattice" ]; then
147
- work_order_root="$proj/.lattice/runtime/work-order-adapter"
148
- work_order_spool="$work_order_root/spool"
149
- work_order_config="$work_order_root/config.json"
150
- work_order_registration="$tdir/work-order-adapter-registration.json"
151
- work_order_config_ref=".lattice/runtime/work-order-adapter/config.json"
152
- work_order_spool_ref=".lattice/runtime/work-order-adapter/spool"
153
-
154
- mkdir -p "$work_order_spool/orders" "$work_order_spool/reports"
155
- chmod 700 "$work_order_root" "$work_order_spool" "$work_order_spool/orders" "$work_order_spool/reports"
156
- work_order_spool=$(node -e 'process.stdout.write(require("node:fs").realpathSync(process.argv[1]))' "$work_order_spool")
157
-
158
- node -e '
159
- const { writeFileSync } = require("node:fs");
160
- const [target, spool] = process.argv.slice(1);
161
- writeFileSync(target, `${JSON.stringify({
162
- schema: "lattice.work_order_adapter_config.v1",
163
- spool_dir: spool,
164
- })}\n`, { mode: 0o600 });
165
- ' "$work_order_config" "$work_order_spool"
166
- chmod 600 "$work_order_config"
167
- node -e '
168
- const { writeFileSync } = require("node:fs");
169
- const [target, binary, script, configRef] = process.argv.slice(1);
170
- writeFileSync(target, `${JSON.stringify({
171
- schema: "lattice.runtime_adapter_registration_input.v2",
172
- adapter_kind: "work-order",
173
- launch_kind: "host_binary",
174
- binary_path: binary,
175
- argv: [script],
176
- config_ref: configRef,
177
- host_driven_epoch: true,
178
- })}\n`, { mode: 0o600 });
179
- ' "$work_order_registration" "$node_binary" "$work_order_binary" "$work_order_config_ref"
180
- chmod 600 "$work_order_registration"
181
-
182
- (
183
- cd "$proj"
184
- "$lattice_cli" run adapter register --input "$work_order_registration"
185
- )
186
- work_order_adapter=true
187
- echo "work-order adapter: binary=$node_binary argv=$work_order_binary config=$work_order_config_ref spool=$work_order_spool_ref" >&2
188
- fi
189
139
 
190
140
  # Lattice 併用モードだけ、工程表の右ペインへ円卓を差す(決定53・明示的コネクタ)。
191
141
  # 公開URL基底は `PEERTABLE_PUBLIC_URL`(クオ環境: https://peertable.kitepon.dev)。
@@ -207,6 +157,9 @@ if [ ${#phases[@]} -gt 0 ]; then
207
157
  phases_json="[${phases_json%,}]"
208
158
  fi
209
159
 
210
- printf '{"room":"%s","server_url":"%s","public_url":"%s","mode":"%s","plan_key":"%s","phases":%s,"added_exclude":%s,"lattice_preexisting":%s,"runtime_preexisting":%s,"added_runtime_exclude":%s,"added_root_mcp":%s,"added_mcp_exclude":%s,"external_pane":%s,"project_json_preexisting":%s,"work_order_adapter":%s,"work_order_spool_ref":"%s"}\n' \
211
- "$room" "$url" "$public_url" "$mode" "$plan" "$phases_json" "$added_exclude" "$lattice_preexisting" "$runtime_preexisting" "$added_runtime_exclude" "$added_root_mcp" "$added_mcp_exclude" "$external_pane" "$project_json_preexisting" "$work_order_adapter" "$work_order_spool_ref" > "$tdir/setup-state.json"
160
+ # **解決した CLI の実 path を残す。** 残さないと、席も teardown も PATH の `lattice` へ逸れる。
161
+ # release 前の source tree を実測する卓では、それは**pull command を持たない古い install**で、
162
+ # 手順どおり打っても届かない(suzune の監査で実測・room [1037])。
163
+ printf '{"room":"%s","server_url":"%s","public_url":"%s","mode":"%s","plan_key":"%s","phases":%s,"added_exclude":%s,"lattice_preexisting":%s,"runtime_preexisting":%s,"added_runtime_exclude":%s,"added_root_mcp":%s,"added_mcp_exclude":%s,"external_pane":%s,"project_json_preexisting":%s,"work_order_adapter":%s,"work_order_spool_ref":"%s","lattice_cli":"%s"}\n' \
164
+ "$room" "$url" "$public_url" "$mode" "$plan" "$phases_json" "$added_exclude" "$lattice_preexisting" "$runtime_preexisting" "$added_runtime_exclude" "$added_root_mcp" "$added_mcp_exclude" "$external_pane" "$project_json_preexisting" "$work_order_adapter" "$work_order_spool_ref" "$lattice_cli" > "$tdir/setup-state.json"
212
165
  echo "scaffold done: $tdir"
@@ -29,6 +29,7 @@ lat_pre=$(python3 -c "import json;print(json.load(open('$state'))['lattice_preex
29
29
  runtime_pre=$(python3 -c "import json;d=json.load(open('$state'));print(d.get('runtime_preexisting', True))")
30
30
  added_runtime_ex=$(python3 -c "import json;d=json.load(open('$state'));print(d.get('added_runtime_exclude', False))")
31
31
  work_order_adapter=$(python3 -c "import json;d=json.load(open('$state'));print(d.get('work_order_adapter', False))")
32
+ work_order_spool_ref=$(python3 -c "import json;d=json.load(open('$state'));print(d.get('work_order_spool_ref', ''))")
32
33
  # 旧 state(added_root_mcp 不在・手動フォールバック時代の root_mcp_json_fallback)も読む
33
34
  added_mcp=$(python3 -c "import json;d=json.load(open('$state'));print(d.get('added_root_mcp', d.get('root_mcp_json_fallback', False)))")
34
35
  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)))")
@@ -103,8 +104,8 @@ else
103
104
  skip "seat-status-bridge(起動記録なし)"
104
105
  fi
105
106
 
106
- # 配車ブリッジ(managed run に載せた卓だけ立っている)。同じ理由で `.team/` を消す前に止める。
107
- # 止め残すと、spool を見張り続ける常駐が次の run の order を拾って**卓が無いのに配車を投稿する**
107
+ # run 可視化ブリッジ(Lattice の実行層を使う卓だけ立っている)。同じ理由で `.team/` を消す前に止める。
108
+ # 止め残すと、卓が無いのに run の進行と介入を投稿し続ける常駐が残る
108
109
  if [ -f "$proj/.team/run-bridge.json" ]; then
109
110
  if node "$(dirname "$0")/run-bridge.mjs" "$proj" --stop; then
110
111
  did "run-bridge 停止"
@@ -115,16 +116,118 @@ else
115
116
  skip "run-bridge(起動記録なし)"
116
117
  fi
117
118
 
119
+ # run の close は成果が既定branchへ着地した証拠ではない。bridgeを止めてから、`run list`が挙げる
120
+ # **active run** の landing report を読む。**旧版は spool の work order から run を逆算していた**が、
121
+ # 配車が無くなって order が出ないので、装置に直接聞く形へ変えた(改・裁定1)。
122
+ # **`run list` は closed run を返さない**(実装で除外される)ので、ここで見えるのは未 close の分だけ
123
+ # である。closed 済み run の着地は `run close` の返値が landing report を含むので**その時点で読む**。
124
+ # `.lattice/runs` を直に走査して補わない——consumer contract 違反で、旧 orders の保持は配車の復活になる。
125
+ # 全 closed の再列挙が要るなら Lattice 側の公開面を足す別課題であって、ここで迂回実装しない。
126
+ # 未着地・未pushは判断結果なので `lattice run landing` 自体はexit 0を返し、teardownも止めない。
127
+ # **CLI の出力と exit code を先に単独で取る。** `cmd | python3` にすると pipeline の rc は
128
+ # python のものになり、**CLI が rc≠0 で typed error JSON を返しても parser が `active_runs` 欠落を
129
+ # 空配列として飲んで「active run なし」に化ける**(suzune の監査で実測・room [948])。
130
+ # 沈黙する偽 green は、着地の見落としをそのまま「確認済み」に見せる——いちばん避けたい壊れ方である。
131
+ lattice_cli="${LATTICE_CLI:-$(command -v lattice 2>/dev/null || true)}"
132
+ run_list_json=""
133
+ run_list_rc=0
134
+ if [ -n "$lattice_cli" ] && [ -x "$lattice_cli" ]; then
135
+ run_list_json=$(cd "$proj" && "$lattice_cli" run list --json 2>&1) || run_list_rc=$?
136
+ fi
137
+ if [ -z "$lattice_cli" ] || [ ! -x "$lattice_cli" ]; then
138
+ miss "run landing — LATTICE_CLIが実行可能fileを指さず、着地状態を読めない: ${lattice_cli:-未設定}"
139
+ elif [ "$run_list_rc" != "0" ]; then
140
+ miss "run landing — run list が rc=${run_list_rc} で失敗: $(printf '%s' "$run_list_json" | head -c 200)"
141
+ elif ! run_refs=$(printf '%s' "$run_list_json" | python3 -c '
142
+ import json, sys
143
+
144
+ # schema と active_runs を要求する。**typed error JSON も別 schema も黙って空扱いにしない。**
145
+ # f-string の中でバックスラッシュ付きの引用符を使わない——shell の single-quoted `python3 -c`
146
+ # へ `\"` がそのまま届き、**Python が SyntaxError で落ちる**(kanade の監査で実測・room [957])。
147
+ # 値は先に変数へ取り出して、f-string には名前だけを置く。
148
+ raw = sys.stdin.read()
149
+ try:
150
+ listed = json.loads(raw)
151
+ except json.JSONDecodeError as error:
152
+ sys.exit(f"run list がJSONでない: {error}")
153
+ if not isinstance(listed, dict):
154
+ sys.exit(f"run list がobjectでない: {type(listed).__name__}")
155
+ actual_schema = listed.get("schema")
156
+ if actual_schema != "lattice.run_list.v1":
157
+ sys.exit(f"run list の schema が違う: {actual_schema}")
158
+ active = listed.get("active_runs")
159
+ if not isinstance(active, list):
160
+ sys.exit(f"run list に active_runs 配列が無い: {type(active).__name__}")
161
+ # **entry を filter で捨てない。** 1件でも読めない entry があれば、active な run を落として
162
+ # 「なし」に見せることになる(suzune の監査で実測・room [956])。全件を要求して、
163
+ # 満たさなければ非ゼロで落ちる。外部 versioned JSON の境界で fallback しない。
164
+ refs = []
165
+ for index, entry in enumerate(active):
166
+ if not isinstance(entry, dict):
167
+ sys.exit(f"active_runs[{index}] がobjectでない: {type(entry).__name__}")
168
+ ref = entry.get("run_ref")
169
+ if not isinstance(ref, str) or not ref:
170
+ sys.exit(f"active_runs[{index}] に run_ref 文字列が無い")
171
+ refs.append(ref)
172
+ print("\n".join(sorted(refs)))
173
+ ' 2>&1); then
174
+ miss "run landing — run listを解釈できない: $(printf '%s' "$run_refs" | head -c 200)"
175
+ elif [ -z "$run_refs" ]; then
176
+ skip "run landing(active runなし)"
177
+ else
178
+ while IFS= read -r run_ref; do
179
+ [ -n "$run_ref" ] || continue
180
+ if landing_report=$(cd "$proj" && "$lattice_cli" run landing --run "$run_ref" 2>&1); then
181
+ unlanded_count=""
182
+ if unlanded_count=$(printf '%s' "$landing_report" | python3 -c '
183
+ import json, sys
184
+
185
+ raw = sys.stdin.read()
186
+ try:
187
+ report = json.loads(raw)
188
+ except json.JSONDecodeError as error:
189
+ sys.exit(f"run landing がJSONでない: {error}")
190
+ if not isinstance(report, dict):
191
+ sys.exit(f"run landing がobjectでない: {type(report).__name__}")
192
+ actual_schema = report.get("schema")
193
+ if actual_schema != "lattice.run_landing_report.v1":
194
+ sys.exit(f"run landing の schema が違う: {actual_schema}")
195
+ receipts = report.get("accepted_receipts")
196
+ if not isinstance(receipts, list):
197
+ sys.exit("run landing に accepted_receipts 配列が無い")
198
+ for index, receipt in enumerate(receipts):
199
+ if not isinstance(receipt, dict) or not isinstance(receipt.get("landed"), bool):
200
+ sys.exit(f"accepted_receipts[{index}] の landed が真偽値でない")
201
+ print(sum(1 for receipt in receipts if not receipt["landed"]))
202
+ ' 2>&1); then
203
+ did "run landing ${landing_report}"
204
+ if [ "$unlanded_count" != 0 ]; then
205
+ echo "未着地 ${unlanded_count}本: run ${run_ref} の受理済み成果が canonical default branch へ着地していない" >&2
206
+ fi
207
+ else
208
+ miss "run landing $run_ref — reportを解釈できない: ${unlanded_count}"
209
+ fi
210
+ else
211
+ miss "run landing $run_ref — ${landing_report}"
212
+ fi
213
+ done <<EOF
214
+ $run_refs
215
+ EOF
216
+ fi
217
+
118
218
  # setupが新しく作ったhost固有runtimeだけを撤去する。既存runtimeは他adapterや進行中runの
119
219
  # 所有物を含み得るので触らない。runtimeを先に消してからexcludeを戻し、teardown後に
120
220
  # untracked stateが露出する順序逆転を防ぐ。
221
+ # **配車を撤去した後の setup は `.lattice/runtime/` を作らない**ので、新しい卓ではここは常に
222
+ # skip になる。判定を残してあるのは、**配車時代に立てた卓を畳む時**にだけ効くからである
223
+ # (その卓の setup-state は `work_order_adapter: true` を持つ)。
121
224
  if yes_ "$work_order_adapter" && ! yes_ "$runtime_pre"; then
122
225
  rm -rf "$proj/.lattice/runtime"
123
- did ".lattice/runtime/ 撤去(setup が新規作成したhost固有state)"
226
+ did ".lattice/runtime/ 撤去(配車時代の setup が新規作成したhost固有state)"
124
227
  elif yes_ "$work_order_adapter"; then
125
228
  skip ".lattice/runtime/(setup 以前から存在)"
126
229
  else
127
- skip ".lattice/runtime/(work-order adapter登録なし)"
230
+ skip ".lattice/runtime/(setup は runtime state を作っていない)"
128
231
  fi
129
232
 
130
233
  # 外部ペイン(決定53)。`.team/` を消す前に戻す——退避先が `.team/` の中にある
@@ -148,7 +251,7 @@ if [ "$mode" = archive ]; then
148
251
  python3 -c "
149
252
  import json,sys,urllib.request
150
253
  req=urllib.request.Request('$url/api/$room/messages', method='POST',
151
- data=json.dumps({'from':'system','to':'all','body':'''$body'''}).encode(),
254
+ data=json.dumps({'from':'system','to':'system','body':'''$body'''}).encode(),
152
255
  headers={'Content-Type':'application/json','X-Peertable-Token':'$PEERTABLE_POST_TOKEN'})
153
256
  urllib.request.urlopen(req, timeout=10).read()
154
257
  " 2>/dev/null && did "解散の区切りを履歴へ" || skip "解散の区切り(投稿できず・撤去は続行)"
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- // Codex 席の起床ブリッジ。room の SSE を購読し、その席宛(または全員宛)の新着が来たら
2
+ // Codex 席の起床ブリッジ。room の SSE を購読し、明示的にその席宛の新着が来たら
3
3
  // tmux の席へ素送信して起こす。Claude 席は channels が同じ役をするので対象外。
4
4
  //
5
5
  // usage: wakeup-bridge.mjs <project_dir> <seat> [seat...] 起動(前面。nohup で常駐させる)
@@ -66,9 +66,10 @@ const pending = new Map(seats.map(s => [s, []]))
66
66
 
67
67
  async function wake(seat, msgs) {
68
68
  const last = msgs[msgs.length - 1]
69
+ const audience = Array.isArray(last.to_names) ? last.to_names.join(', ') : last.to
69
70
  const text = msgs.length === 1
70
- ? `room に新着あり(${last.from} → ${last.to})。read_unread で読むこと。`
71
- : `room に新着 ${msgs.length} 件(最新: ${last.from} → ${last.to})。read_unread で読むこと。`
71
+ ? `room に新着あり(${last.from} → ${audience})。read_unread で読むこと。`
72
+ : `room に新着 ${msgs.length} 件(最新: ${last.from} → ${audience})。read_unread で読むこと。`
72
73
  try {
73
74
  await run('tmux', ['-S', sock, 'send-keys', '-t', `peer-${seat}`, text])
74
75
  await sleep(400)
@@ -91,9 +92,7 @@ setInterval(async () => {
91
92
  function dispatch(msg) {
92
93
  for (const seat of seats) {
93
94
  if (msg.from === seat) continue
94
- // 複数人宛は `to_names` が実宛先を持つ(server は旧 client のために `to` 'all' へ倒す)。
95
- // ここで実宛先を見ないと、名指しされていない Codex 席まで起こしてしまう。
96
- if (Array.isArray(msg.to_names) ? !msg.to_names.includes(seat) : (msg.to !== 'all' && msg.to !== seat)) continue
95
+ if (Array.isArray(msg.to_names) ? !msg.to_names.includes(seat) : msg.to !== seat) continue
97
96
  pending.get(seat).push(msg)
98
97
  }
99
98
  }
@@ -2,12 +2,12 @@
2
2
 
3
3
  これはチーム作業である。自分が引き受けたタスクの完了はミッションの完了ではない。全タスク完了までチームは解散しない。
4
4
 
5
- 1. 拘束力を持つのは**工程正本への記録**と room での決定だけ(工程正本=Lattice 併用モードなら Lattice の todo 記録、単独円卓モードなら room の宣言そのもの)。DM(個別宛)で決まったことは決定ではない。部位を跨ぐ合意・設計判断は必ず room 全員宛で行う
6
- 2. 進捗は room に一行で報告する: managed run なら配車への受諾・辞退、その他の卓なら claim・join、共通して完了時・詰まり自覚時・方針変更時。task 外でも成果物になる作業(実測・検証・CI)は着手前に一言。仲間を五里霧中に置かない
7
- 3. 仕事を引き受ける手順は実行方式で分かれる。managed run では task を Lattice、候補席を bridge が選び、`[配車] <タスク> <席>` はその提示を全員へ見せるだけで割当ではない。提示された席が `[受諾] <タスク>` を返した時だけ束縛が成立し、`[辞退] <タスク> <理由>` なら bridge が別席へ再配車する。この経路で `[claim]` を重ねない。managed run でない卓では、room へ `[claim] <タスク>` を全員宛で投稿し、直後に read_unread で直前ログを確認する(タスクの呼び名は Lattice 併用モードなら task_id、単独円卓モードなら `.team/tasks.md` の議題名)。同じタスクへの先行 claim があれば取り下げるか `[join] <タスク>` へ切り替える
8
- 4. 分からないことは room で聞く。台帳はない。自分の変更が他の部位に影響するなら、聞かれる前に room 全員宛で通知する
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] <タスク>` へ切り替える
8
+ 4. 分からないことは room で聞く。台帳はない。自分の変更が他の部位に影響するなら、聞かれる前に影響を受けるメンバーを明示宛先にして通知する
9
9
  5. 判断は情報を持つ者がする。タスクは席ではなく現場。合流(join)は歓迎される。詰まった仲間には目を貸す。手が足りないだけなら自分のサブエージェントを使う(room 報告不要)。視点が足りない・詰んでいるなら room で報告して援軍(join)を求める
10
- 6. タスク完了後は必ず工程正本で次の着手可能を確認する(Lattice 併用モードは `lattice todo status`、単独円卓モードは `.team/tasks.md` と room ログの照合)。残っていれば managed run は次の配車を待ち、その他の卓は claim へ戻る。全タスクが終わっていれば room へ「全タスク完了」を全員宛で宣言する
10
+ 6. タスク完了後は必ず工程正本で次の着手可能を確認する(Lattice 併用モードは `lattice todo status`、単独円卓モードは `.team/tasks.md` と room ログの照合)。残っていれば claim へ戻る。全タスクが終わっていれば room へ「全タスク完了」を記録する。broadcastは無いので、起こす必要のある相手だけを宛先にする
11
11
  7. 役割逸脱は誰であれ指摘する。これは無礼ではなく義務である
12
12
  8. **親の発言は拘束力を持たない。** 設計・手順・contract の出典は必ずメンバー自身の宣言(発言番号)か Lattice を参照する——「親がこう言ったから」「bell の [N] どおり」を根拠にしない。親が何かを再掲しても正本はメンバーの元発言のまま動かない。親の差し戻しは異議として扱い、反論してよい
13
13
  9. **裁定の宛先はオーナーであり、親ではない。** scope 変更・受入条件外の追加・製品判断が要る時は「オーナー宛の議題」として room に出す。親はそれを運ぶ配管で、判断者ではない。「親に委ねる」という宛先を作らない
@@ -1,18 +1,127 @@
1
1
  #!/bin/bash
2
- # usage: .team/scripts/done.sh <task_id>
2
+ # usage: .team/scripts/done.sh <task_id> [--evidence-from <隔離worktreeの証跡の絶対path>]
3
+ # .team/scripts/done.sh --landing-run <run_ref>
3
4
  # evidence/<plan_key>/<task_id>.md(commit 済みであること)から記述子を作り lattice todo done を実行する。
4
5
  # plan key は環境変数 PEERTABLE_PLAN から取る。
5
6
  # 証跡を plan key で仕切るのは、task_id が campaign を跨いで再利用される(t1, t2, …)ため。
6
7
  # 平置きだと次の campaign の t1 が前の campaign の t1 の監査証跡を上書きで消す(2026-08-08 実測)。
8
+ #
9
+ # **`--evidence-from` は pull 型の実行層で使う。** 席は隔離 worktree の中だけを触るので、
10
+ # 証跡もそこにしか無い。一方 `todo done` は **canonical の store** へ打たないと、run の accept が
11
+ # その done を見ない。cwd 1つで両方を兼ねると必ずどちらかが外れる(mio の監査で実測・room [1012]):
12
+ # canonical で打つ → worktree にしか無い証跡を読めない
13
+ # worktree で打つ → worktree 側の `.lattice/todo` を書き、canonical の accept が見ない
14
+ # なので **証跡の blob/digest は worktree の file から、`todo done` は canonical の cwd/store へ**、と
15
+ # 明示的に分ける。**canonical へ証跡を別書きして通すのは禁止**——「worktree の中だけ」の契約を
16
+ # 破りながら green にする偽装になる。
17
+ #
18
+ # 成立する理由: linked worktree は canonical と object DB を共有するので、canonical の cwd から
19
+ # `git hash-object -w <worktree の絶対path>` で書いた blob はそのまま canonical で読める。
20
+ # evidence verifier は descriptor.path の working tree 実在を見ず、object DB の blob と digest、
21
+ # 読み出し時の `rev-list --all` 到達性を見る(mio が実 repo で確認・room [1016])。
7
22
  set -e
23
+ # `todo done` と run receipt の accept は別の正本を持つ。landing-only mode は accept の直後に
24
+ # 同じ run ref を受け取り、受理済み receipt の着地だけを表示する。accept 自体はここへ吸収しない。
25
+ if [ "${1:-}" = "--landing-run" ]; then
26
+ [ "$#" = 2 ] || {
27
+ echo "ERROR: --landing-run には run ref を1つ渡すこと(usage: done.sh --landing-run <run_ref>)" >&2
28
+ exit 1
29
+ }
30
+ run_ref="$2"
31
+ [ -n "$run_ref" ] || { echo "ERROR: --landing-run には run ref を渡すこと" >&2; exit 1; }
32
+ lattice_cli="${LATTICE_CLI:-$(command -v lattice 2>/dev/null || true)}"
33
+ if [ -z "$lattice_cli" ] || [ ! -x "$lattice_cli" ]; then
34
+ echo "着地状態を読めない: LATTICE_CLIが実行可能fileを指さない(${lattice_cli:-未設定})" >&2
35
+ exit 0
36
+ fi
37
+ landing_report=""
38
+ landing_rc=0
39
+ landing_report=$("$lattice_cli" run landing --run "$run_ref" 2>&1) || landing_rc=$?
40
+ if [ "$landing_rc" != 0 ]; then
41
+ echo "着地状態を読めない: run landing が rc=${landing_rc} で失敗: ${landing_report}" >&2
42
+ exit 0
43
+ fi
44
+ unlanded_count=""
45
+ if ! unlanded_count=$(printf '%s' "$landing_report" | python3 -c '
46
+ import json, sys
47
+ raw = sys.stdin.read()
48
+ try:
49
+ report = json.loads(raw)
50
+ except json.JSONDecodeError as error:
51
+ sys.exit(f"run landing がJSONでない: {error}")
52
+ if not isinstance(report, dict):
53
+ sys.exit(f"run landing がobjectでない: {type(report).__name__}")
54
+ actual_schema = report.get("schema")
55
+ if actual_schema != "lattice.run_landing_report.v1":
56
+ sys.exit(f"run landing の schema が違う: {actual_schema}")
57
+ receipts = report.get("accepted_receipts")
58
+ if not isinstance(receipts, list):
59
+ sys.exit("run landing に accepted_receipts 配列が無い")
60
+ for index, receipt in enumerate(receipts):
61
+ if not isinstance(receipt, dict) or not isinstance(receipt.get("landed"), bool):
62
+ sys.exit(f"accepted_receipts[{index}] の landed が真偽値でない")
63
+ print(sum(1 for receipt in receipts if not receipt["landed"]))
64
+ ' 2>&1); then
65
+ echo "着地状態を読めない: ${unlanded_count}" >&2
66
+ exit 0
67
+ fi
68
+ if [ "$unlanded_count" != 0 ]; then
69
+ echo "未着地 ${unlanded_count}本: run ${run_ref} の受理済み成果が canonical default branch へ着地していない" >&2
70
+ fi
71
+ exit 0
72
+ fi
73
+ # **引数の形を exact に要求する。** 緩く受けると、`--evidnce-from` のような typo が
74
+ # 「option 無し」として通り、**canonical 側の同名証跡を黙って hash する**——別 file を
75
+ # 受理させておいて green に見える(kanade の監査で実測・room [1029])。
76
+ # 1引数(既定経路)か、3引数(`<task> --evidence-from <絶対path>`)だけを許す。
77
+ case $# in
78
+ 1) ;;
79
+ 3) [ "$2" = "--evidence-from" ] || {
80
+ echo "ERROR: 未知のoption: $2(使えるのは --evidence-from だけ)" >&2; exit 1; } ;;
81
+ *) echo "ERROR: 引数の形が違う(usage: done.sh <task_id> [--evidence-from <絶対path>] | done.sh --landing-run <run_ref>)" >&2; exit 1 ;;
82
+ esac
8
83
  t="$1"
84
+ [ -n "$t" ] || { echo "ERROR: task_id が空" >&2; exit 1; }
85
+ evidence_from=""
86
+ if [ "$#" = 3 ]; then
87
+ evidence_from="$3"
88
+ [ -n "$evidence_from" ] || { echo "ERROR: --evidence-from には証跡fileの絶対pathを渡すこと" >&2; exit 1; }
89
+ case "$evidence_from" in
90
+ /*) ;;
91
+ *) echo "ERROR: --evidence-from は絶対pathでなければならない: $evidence_from" >&2; exit 1 ;;
92
+ esac
93
+ # **黙って canonical の証跡へ落ちない。** 落ちると「worktree の成果を done した」と見えるのに
94
+ # 実際は別の file を hash することになり、受理された内容と成果物が食い違う
95
+ [ -f "$evidence_from" ] || { echo "ERROR: --evidence-from の証跡が存在しない: $evidence_from" >&2; exit 1; }
96
+ # **object DB を共有していない木の file は hash-object できても意味が無い。** 別 repo の
97
+ # 証跡を渡された時に「書けたから成立した」と読まないよう、common git dir の一致を要求する
98
+ # (kanade の設計指摘・room [1018])。linked worktree なら両者は同じ絶対 path を指す。
99
+ here_common=$(git rev-parse --path-format=absolute --git-common-dir)
100
+ from_common=$(git -C "$(dirname "$evidence_from")" rev-parse --path-format=absolute --git-common-dir 2>/dev/null || true)
101
+ [ -n "$from_common" ] && [ "$here_common" = "$from_common" ] || {
102
+ echo "ERROR: --evidence-from が同じrepoのworktreeでない(object DBを共有していない)" >&2
103
+ echo " canonical: ${here_common}" >&2
104
+ echo " evidence : ${from_common:-(git worktree ではない)}" >&2
105
+ exit 1
106
+ }
107
+ fi
108
+
109
+ # descriptor の path は repo 内の相対(repo 外の絶対 path は --evidence が INVALID_ARGUMENTS で弾く)。
110
+ # worktree でも canonical でも同じ相対 path に置く規約なので、この値は両者で一致する。
9
111
  f="evidence/$PEERTABLE_PLAN/$t.md"
10
- oid=$(git hash-object -w "$f")
11
- digest=$(shasum -a 256 "$f" | cut -d' ' -f1)
12
- # 記述子は repo 内の相対パスに置く(repo 外の絶対パスは --evidence が INVALID_ARGUMENTS で弾く)
112
+ src="${evidence_from:-$f}"
113
+ [ -f "$src" ] || { echo "ERROR: 証跡が見つからない: $src" >&2; exit 1; }
114
+ oid=$(git hash-object -w "$src")
115
+ digest=$(shasum -a 256 "$src" | cut -d' ' -f1)
13
116
  tmp=".ev-$t.json"
117
+ # **失敗しても記述子を残さない。** `set -e` の下で `todo done` が落ちると、後段の `rm` へ
118
+ # 到達せず repo に `.ev-<task>.json` が残る(自分の負側 test で実測。TASK_NOT_FOUND の後に
119
+ # untracked file が残った)。次に `git status` を撮った人が、それを誰かの作業中変更と読む。
120
+ trap 'rm -f "$tmp"' EXIT
14
121
  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
- lattice todo done --plan "$PEERTABLE_PLAN" --task "$t" --evidence "$tmp"
122
+ # **PATH の `lattice` へ黙って逸れない。** setup が解決した CLI を席 env `LATTICE_CLI` で受け、
123
+ # 無い時だけ PATH を使う(bridge の `--lattice` / teardown の `LATTICE_CLI` と同じ選択規律)。
124
+ "${LATTICE_CLI:-lattice}" todo done --plan "$PEERTABLE_PLAN" --task "$t" --evidence "$tmp"
16
125
  rm -f "$tmp"
17
126
 
18
127
  # 完了の定義は「repo 内の変更は push まで」。done を打つ瞬間はそれが成り立っていなければ
@@ -7,14 +7,18 @@
7
7
  ## 作業ループ
8
8
 
9
9
  1. `.team/tasks.md` で議題を見る。`read_log` で既存の claim と完了報告を照合し、まだ誰も持っていないものを選ぶ
10
- 2. 憲章の手順で room に `[claim] <タスク>` を全員宛で宣言し、直後に `read_unread` で先行 claim が無いか確かめる。**`[claim]` は独立した1発言で出す**——完了報告や他タスクの話と同じ発言に畳まない。宣言としては有効でも、後から機械的に追えなくなり、監査が「宣言が無い」と誤読する(2026-08-08 実測)。単独モードでは room ログが唯一の正本なので、より効く
11
- 3. 実装する。インターフェースなど他タスクに影響する決定は、決めた時点で room 全員宛に一行で共有する
10
+ 2. 憲章の手順で`read_log`から先行claimを確認し、roomに`[claim] <タスク>`を記録する。起こす必要のある相手だけを宛先へ入れ、記録だけなら自分宛でよい。**`[claim]` は独立した1発言で出す**——完了報告や他タスクの話と同じ発言に畳まない。宣言としては有効でも、後から機械的に追えなくなり、監査が「宣言が無い」と誤読する(2026-08-08 実測)。単独モードでは room ログが唯一の正本なので、より効く
11
+ 3. 実装する。インターフェースなど他タスクに影響する決定は、影響を受けるメンバーを明示宛先にして一行で共有する
12
12
  4. 完了手順:
13
13
  - 何を作り、どう確認したかを自分で確かめる(テスト・実行・実測。「たぶん動く」で閉じない)
14
14
  - 変更ファイルを `git add` して commit する(メッセージは日本語一行。対象ファイルを明示して他人の作業中変更を巻き込まない)
15
- 5. room へ `[done] <タスク>` を全員宛で報告する(何を作り、どう確認したかを一行で添える)。**この報告が完了の唯一の記録である**——書かなければ完了していない
15
+ 5. room へ `[done] <タスク>` を記録する(何を作り、どう確認したかを一行で添える)。監査を頼む相手だけを宛先にし、相手が未定なら自分宛でよい。**この報告が完了の唯一の記録である**——書かなければ完了していない
16
16
  6. 1 へ戻る
17
17
 
18
+ ## effortを変更してほしい時
19
+
20
+ 作業を安全に中断できる状態にしてから、親だけへ`[effort変更依頼] <level>`を明示DMする。親が変更すると席は再起動し、会話contextは引き継がれない。再起動後は下の再着任手順でrole・`.team/tasks.md`・roomログから現在地を取り直す。自分でCLI設定を変えたり、broadcastで依頼したりしない。
21
+
18
22
  ## 再着任(context が要約されたら)
19
23
 
20
24
  自分の context が要約された(=会話の前半が手元に無い)と気づいたら、実装を続ける前に `.team/roles/member.md` と `.team/CLAUDE.md` を読み直して着任し直し、room へ `[再着任] <名前>` を一行投稿する。進行中 claim の状態は自分の記憶でなく**工程正本で取り直す**——この卓の工程正本は room の宣言だけなので、`read_log` で自分の claim・他人の claim・完了報告を全部照合する(機械に問い合わせる先は無い)。記憶と正本が食い違ったら、正本を正として食い違いを room で報告する。