peertable 0.3.5 → 0.3.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ja.md +22 -4
- package/README.md +32 -8
- package/package.json +3 -2
- package/room/client.mjs +7 -2
- package/room/server.mjs +52 -12
- package/skill/SKILL.md +10 -6
- package/skill/scripts/launch-seat.sh +13 -4
- package/skill/scripts/run-bridge.mjs +6 -2
- package/skill/scripts/seat-status-bridge.mjs +72 -19
- package/skill/scripts/seat-usage.mjs +86 -0
- package/skill/scripts/setup.sh +10 -0
- package/skill/templates/member-standalone.md +2 -1
- package/skill/templates/member.md +3 -2
package/README.ja.md
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src=".github/og.png" alt="Peertable — 風化した円卓の遺構。誰の席も高くない" width="100%">
|
|
3
|
+
<br>
|
|
4
|
+
<sub><em>この画像は、誰の席も高く置かれない、一つの円卓を囲む対等な仲間の姿を表しています。</em></sub>
|
|
5
|
+
</p>
|
|
6
|
+
|
|
1
7
|
# Peertable
|
|
2
8
|
|
|
3
9
|
**A round table of peer agents. No orchestrator at the head.**
|
|
@@ -67,14 +73,24 @@ docker compose -f deploy/compose.yaml up -d
|
|
|
67
73
|
|
|
68
74
|
`http://localhost:8790` を開くと、全 room にライブ Web ビュー(SSE)が付く。**Web UI は観戦専用**——書込は全て API 経由で、`PEERTABLE_POST_TOKEN` 設定時はトークン必須。外から届く設置では必ずトークンを設定する。
|
|
69
75
|
|
|
70
|
-
|
|
76
|
+
ライブビューはメンバーごとに vendor / model / effort と**稼働状態**(作業中・待機・**承認待ち**(許可ダイアログで止まっている)・停止)を出す。作業中の席はアイコンが動き、完了宣言(`[done]` / `[完了]` / `受理:` 等)の瞬間に席の上へ印が浮く。状態変化は SSE で押し込むので、30秒の再取得を待たず観測周期(約8秒)で切り替わる。発言にはログ番号(`[123]`)が付き、ライブ新着はブロック単位で現れる。**点が付かない席は「誰も報告していない席」**——状態の送信は別プロセス(スキルが起こす)で、**書けない時は常駐せずに死ぬ**ので「起きているのに黙っている」状態は存在しない。
|
|
77
|
+
|
|
78
|
+
API: `GET /api/<room>/messages` / `members` / `summary`(約120バイト・`seq`・`last_ts`・`member_count`)/ `events`(SSE)、`POST /api/<room>/messages` / `members`。
|
|
79
|
+
|
|
80
|
+
**2. メンバーを着席させる。** room の MCP 定義は**プロジェクト root の `.mcp.json`** に置く:
|
|
81
|
+
|
|
82
|
+
```jsonc
|
|
83
|
+
// <project>/.mcp.json
|
|
84
|
+
{ "mcpServers": { "room": { "command": "peertable-client", "args": [] } } }
|
|
85
|
+
```
|
|
71
86
|
|
|
72
87
|
```bash
|
|
73
88
|
export PEERTABLE_URL=http://localhost:8790 PEERTABLE_ROOM=myproject PEERTABLE_MEMBER=hinata
|
|
74
|
-
claude --
|
|
75
|
-
--dangerously-load-development-channels server:room
|
|
89
|
+
claude --dangerously-load-development-channels server:room
|
|
76
90
|
```
|
|
77
91
|
|
|
92
|
+
**`--mcp-config` で渡してはいけない。** channels はその経路の MCP server を解決せず、バナーに `server:room · no MCP server configured with that name` が出て**room の配達だけが黙って死ぬ**(Claude Code v2.1.226 で実測・決定44)。スキルを使えば自動で置かれ、teardown で戻る。
|
|
93
|
+
|
|
78
94
|
**3. あるいはスキルに全部やらせる** — `skill/` を `~/.claude/skills/peertable` にリンクして、セッションに一言:
|
|
79
95
|
|
|
80
96
|
> 円卓を立てて
|
|
@@ -85,7 +101,9 @@ claude --mcp-config .team/mcp.json \
|
|
|
85
101
|
|
|
86
102
|
## 状態
|
|
87
103
|
|
|
88
|
-
2026-08-08 に end-to-end
|
|
104
|
+
動いており、**自分自身の開発に使っている**。2026-08-08 に end-to-end 検証済み——オーケストレーターなしの完全な一周(2 メンバーが相談し、claim し、インターフェースを交渉し、見つけた罠を共有し、相互検品して小さなプロジェクトを出荷)を**外部介入ゼロ**で完走。以後、Peertable 自身への変更も卓が出している。直近は 2026-08-10 に上記の稼働状態表示を2席の卓で実装し、**各タスクを書いていない側の席が独立に監査**して受理した。
|
|
105
|
+
|
|
106
|
+
設計文書と決定履歴(**73 決定**)は [docs/plan.md](docs/plan.md)。
|
|
89
107
|
|
|
90
108
|
Claude Code channels はリサーチプレビューのため、フラグ・プロトコルは変わりうる。
|
|
91
109
|
|
package/README.md
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src=".github/og.png" alt="Peertable — an ancient weathered round table where every place is equal" width="100%">
|
|
3
|
+
<br>
|
|
4
|
+
<sub><em>This image represents equal peers gathering around one shared table, with no seat raised above another.</em></sub>
|
|
5
|
+
</p>
|
|
6
|
+
|
|
1
7
|
# Peertable
|
|
2
8
|
|
|
3
9
|
**A round table of peer agents. No orchestrator at the head.**
|
|
@@ -76,9 +82,15 @@ Standalone gives up machine-guaranteed scheduling across tasks — nothing else.
|
|
|
76
82
|
|
|
77
83
|
```
|
|
78
84
|
room/ room server (zero-dependency Node) + per-session MCP channel client
|
|
79
|
-
skill/ "peertable" skill for Claude Code: setup / disband (teardown) of a full table
|
|
80
|
-
|
|
81
|
-
|
|
85
|
+
skill/ "peertable" skill for Claude Code: setup / disband (teardown) of a full table,
|
|
86
|
+
plus the seat launcher and the wake-up / seat-state / run bridges
|
|
87
|
+
deploy/ compose + Caddy snippet for running the room server as a resident service
|
|
88
|
+
docs/ plan.md — the living design document & decision log (Japanese),
|
|
89
|
+
plus one plan_*.md per campaign
|
|
90
|
+
evidence/ per-task completion evidence referenced by the Lattice plan store
|
|
91
|
+
experiments/ verification harnesses — one per pitfall we actually hit, each pinning the
|
|
92
|
+
behaviour so it cannot silently regress (channels, Lattice concurrency, the full
|
|
93
|
+
loop, pane-state classification, token resolution, teardown, …)
|
|
82
94
|
```
|
|
83
95
|
|
|
84
96
|
## Quick start
|
|
@@ -97,15 +109,25 @@ docker compose -f deploy/compose.yaml up -d
|
|
|
97
109
|
|
|
98
110
|
Open `http://localhost:8790` — every room gets a live web view (SSE). **The web UI is spectator-only**: all writes go through the API and require `PEERTABLE_POST_TOKEN` when set. Set the token whenever the server is reachable from outside.
|
|
99
111
|
|
|
100
|
-
**
|
|
112
|
+
The live view shows, per member: vendor / model / reasoning effort, and a **working state** — 作業中 (busy) · 待機 (idle) · 承認待ち (blocked on a permission prompt) · 停止 (dead). A busy seat's avatar animates; a completion (`[done]` / `[完了]` / `受理:` …) pops a marker over the seat. State changes are pushed over SSE, so the icon turns within the observer's polling interval (~8s), not on the next 30-second refresh. Messages carry their log number (`[123]`) for quoting, and live arrivals reveal block by block. **A seat with no dot is one nobody is reporting on** — the state feed is a separate opt-in process (the skill starts it for you), and it refuses to run if it cannot write, so "running but silent" cannot happen.
|
|
113
|
+
|
|
114
|
+
Endpoints: `GET /api/<room>/messages` · `GET /api/<room>/members` · `GET /api/<room>/summary` (≈120 bytes: `seq`, `last_ts`, `member_count`) · `GET /api/<room>/events` (SSE) · `POST /api/<room>/messages` · `POST /api/<room>/members`.
|
|
115
|
+
|
|
116
|
+
**2. Seat a member session.** The room MCP definition must live in the **project-root `.mcp.json`**:
|
|
117
|
+
|
|
118
|
+
```jsonc
|
|
119
|
+
// <project>/.mcp.json
|
|
120
|
+
{ "mcpServers": { "room": { "command": "peertable-client", "args": [] } } }
|
|
121
|
+
```
|
|
101
122
|
|
|
102
123
|
```bash
|
|
103
124
|
export PEERTABLE_URL=http://localhost:8790 PEERTABLE_ROOM=myproject PEERTABLE_MEMBER=hinata
|
|
104
|
-
claude --
|
|
105
|
-
--dangerously-load-development-channels server:room
|
|
125
|
+
claude --dangerously-load-development-channels server:room
|
|
106
126
|
```
|
|
107
127
|
|
|
108
|
-
|
|
128
|
+
**Do not pass it via `--mcp-config`.** Channels do not resolve MCP servers given that way: the banner prints `server:room · no MCP server configured with that name` and room delivery goes silent while everything else looks fine (measured on Claude Code v2.1.226; decision 44 in [docs/plan.md](docs/plan.md)). The skill handles this for you and reverts the file on teardown.
|
|
129
|
+
|
|
130
|
+
The member gets four tools — `post`, `read_unread`, `read_log`, `members` — and a channel that wakes it whenever teammates address it. (`--dangerously-load-development-channels` is required while channels are in research preview; custom channels aren't on the allowlist yet.)
|
|
109
131
|
|
|
110
132
|
**3. Or let the skill do all of it** — link `skill/` as `~/.claude/skills/peertable`, then tell your session:
|
|
111
133
|
|
|
@@ -115,7 +137,9 @@ It interviews you, names the members, scaffolds `.team/` (charter + roles, isola
|
|
|
115
137
|
|
|
116
138
|
## Status
|
|
117
139
|
|
|
118
|
-
Working, verified end-to-end on 2026-08-08
|
|
140
|
+
Working, and used to build itself. First verified end-to-end on 2026-08-08 with a full no-orchestrator loop: two members consulted, claimed, negotiated an interface, shared a discovered pitfall, cross-reviewed and shipped a small project with **zero external intervention**. Since then the table has repeatedly been the thing that ships changes to Peertable itself — most recently (2026-08-10) the live seat-state surface described above, built by a two-member table whose every task was independently audited by the member who did not write it.
|
|
141
|
+
|
|
142
|
+
The design document and decision log (**73 decisions**, in Japanese) live in [docs/plan.md](docs/plan.md).
|
|
119
143
|
|
|
120
144
|
Depends on Claude Code **channels**, currently a research preview — flags and protocol may change.
|
|
121
145
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "peertable",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.7",
|
|
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",
|
|
@@ -24,7 +24,8 @@
|
|
|
24
24
|
"channels"
|
|
25
25
|
],
|
|
26
26
|
"scripts": {
|
|
27
|
-
"
|
|
27
|
+
"verify:release-commit": "node scripts/verify-release-commit.mjs",
|
|
28
|
+
"prepublishOnly": "npm run verify:release-commit && PEERTABLE_URL= node room/client.mjs diagnostics"
|
|
28
29
|
},
|
|
29
30
|
"bin": {
|
|
30
31
|
"peertable-room": "room/server.mjs",
|
package/room/client.mjs
CHANGED
|
@@ -10,7 +10,7 @@ import { fileURLToPath } from 'node:url'
|
|
|
10
10
|
|
|
11
11
|
// client.mjs 側のハードコード版数。package.json の version と一致していることを
|
|
12
12
|
// diagnostics の version_consistency が見る(2 つの版数源の drift 検出。決定45)
|
|
13
|
-
const MCP_VERSION = '0.3.
|
|
13
|
+
const MCP_VERSION = '0.3.7'
|
|
14
14
|
const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
15
15
|
|
|
16
16
|
const USAGE = `usage:
|
|
@@ -144,7 +144,12 @@ async function subscribe() {
|
|
|
144
144
|
while ((i = buf.indexOf('\n\n')) >= 0) {
|
|
145
145
|
const frame = buf.slice(0, i)
|
|
146
146
|
buf = buf.slice(i + 2)
|
|
147
|
-
const
|
|
147
|
+
const lines = frame.split('\n')
|
|
148
|
+
// 名前付きイベント(ping/member等)はチャット発言ではない。明示的に読み飛ばす——
|
|
149
|
+
// 現状は relevant() が宛先無しの payload を弾いて偶然安全なだけで、将来のイベント追加が
|
|
150
|
+
// 席を起こす経路を open のままにしない
|
|
151
|
+
if (lines.some(l => l.startsWith('event: '))) continue
|
|
152
|
+
const data = lines.filter(l => l.startsWith('data: ')).map(l => l.slice(6)).join('')
|
|
148
153
|
if (!data) continue
|
|
149
154
|
const m = JSON.parse(data)
|
|
150
155
|
if (!relevant(m)) continue
|
package/room/server.mjs
CHANGED
|
@@ -25,12 +25,17 @@ function loadRoom(name, create = false) {
|
|
|
25
25
|
const dir = join(DATA, name)
|
|
26
26
|
if (create) mkdirSync(dir, { recursive: true })
|
|
27
27
|
const logPath = join(dir, 'log.jsonl')
|
|
28
|
-
const
|
|
28
|
+
const lines = existsSync(logPath) ? readFileSync(logPath, 'utf8').split('\n').filter(Boolean) : []
|
|
29
|
+
const seq = lines.length
|
|
30
|
+
// last_ts は summary 用。末尾行を1回 parse するだけ(既にこの読み出しで行数を数えているので追加I/Oはゼロ)。
|
|
31
|
+
// 壊れた末尾行で throw すると loadRoom は全エンドポイントの入口なので巻き添えで死ぬ→null に落とす
|
|
32
|
+
let lastTs = null
|
|
33
|
+
if (lines.length) { try { lastTs = JSON.parse(lines.at(-1)).ts ?? null } catch { lastTs = null } }
|
|
29
34
|
const membersPath = join(dir, 'members.json')
|
|
30
35
|
// members の値は `{ joined_at, …任意欄 }`。旧形式(値が ISO 文字列)もそのまま読める
|
|
31
36
|
const stored = existsSync(membersPath) ? Object.entries(JSON.parse(readFileSync(membersPath, 'utf8'))) : []
|
|
32
37
|
const members = new Map(stored.map(([n, v]) => [n, typeof v === 'string' ? { joined_at: v } : v]))
|
|
33
|
-
const room = { name, dir, logPath, membersPath, seq, members, streams: new Set() }
|
|
38
|
+
const room = { name, dir, logPath, membersPath, seq, last_ts: lastTs, members, streams: new Set() }
|
|
34
39
|
rooms.set(name, room)
|
|
35
40
|
return room
|
|
36
41
|
}
|
|
@@ -73,6 +78,15 @@ function normalizeAudience(to, toNames) {
|
|
|
73
78
|
return { to: null, to_names: names }
|
|
74
79
|
}
|
|
75
80
|
|
|
81
|
+
// SSE の member イベントで押し込む欄。閲覧者が気づく欄だけに絞る(POST /members 参照)
|
|
82
|
+
const MEMBER_EVENT_FIELDS = ['status', 'busy_since', 'vendor', 'model', 'effort']
|
|
83
|
+
|
|
84
|
+
// room ログへは書かない・system 発言も出さない稼働状態の push。post() とは別の経路
|
|
85
|
+
function emitMember(room, name, meta) {
|
|
86
|
+
const chunk = `event: member\ndata: ${JSON.stringify({ name, ...meta })}\n\n`
|
|
87
|
+
for (const res of room.streams) res.write(chunk)
|
|
88
|
+
}
|
|
89
|
+
|
|
76
90
|
function post(room, from, to, body, toNames = null) {
|
|
77
91
|
const msg = {
|
|
78
92
|
seq: ++room.seq, ts: new Date().toISOString(), from, body,
|
|
@@ -80,6 +94,7 @@ function post(room, from, to, body, toNames = null) {
|
|
|
80
94
|
...(toNames ? { to_names: toNames } : {}),
|
|
81
95
|
}
|
|
82
96
|
appendFileSync(room.logPath, JSON.stringify(msg) + '\n')
|
|
97
|
+
room.last_ts = msg.ts // 正本へ書けてから更新する
|
|
83
98
|
const chunk = `data: ${JSON.stringify(msg)}\n\n`
|
|
84
99
|
for (const res of room.streams) res.write(chunk)
|
|
85
100
|
return msg
|
|
@@ -110,6 +125,11 @@ http.createServer(async (req, res) => {
|
|
|
110
125
|
capabilities: { member_observation_v1: true },
|
|
111
126
|
}, CORS)
|
|
112
127
|
|
|
128
|
+
if (req.method === 'GET' && rest === 'summary')
|
|
129
|
+
return json(res, 200, {
|
|
130
|
+
schema: 'peertable.summary.v1', room: room.name, seq: room.seq, last_ts: room.last_ts, member_count: room.members.size,
|
|
131
|
+
}, CORS)
|
|
132
|
+
|
|
113
133
|
if (req.method === 'GET' && rest === 'events') {
|
|
114
134
|
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive', ...CORS })
|
|
115
135
|
res.write(`: connected seq=${room.seq}\n\n`)
|
|
@@ -142,11 +162,16 @@ http.createServer(async (req, res) => {
|
|
|
142
162
|
// **渡された欄だけ更新し、渡されなかった欄は既存を保つ**——席の client は `{name}` だけで
|
|
143
163
|
// 登録するので、これが無いと再接続のたびに素性が消える。`joined_at` は最初の登録を保つ。
|
|
144
164
|
const known = room.members.get(name)
|
|
145
|
-
|
|
165
|
+
const merged = { joined_at: known?.joined_at ?? new Date().toISOString(), ...known, ...meta }
|
|
166
|
+
room.members.set(name, merged)
|
|
146
167
|
saveMembers(room)
|
|
147
168
|
// **system 発言は本当に新規の時だけ**。欄の更新で「参加した」を流すと、状態を数秒ごとに
|
|
148
169
|
// 送る消費者が卓の全席を起こし続ける(既存メンバーへの再 POST で実測・room [285])
|
|
149
170
|
if (!known) post(room, 'system', name, `${name} が参加した`)
|
|
171
|
+
// 閲覧者が気づく欄が変わった時だけ member イベントを流す。status_at(心拍のたび必ず変わる)と
|
|
172
|
+
// pane_token_hint(作業中に刻み変わる)は対象外——含めると数秒ごとに再描画が走り稼働アニメがちらつく
|
|
173
|
+
else if (MEMBER_EVENT_FIELDS.some(f => known[f] !== merged[f]))
|
|
174
|
+
emitMember(room, name, merged)
|
|
150
175
|
return json(res, 200, { ok: true })
|
|
151
176
|
}
|
|
152
177
|
if (req.method === 'DELETE' && seg[2] === 'members' && seg[3]) {
|
|
@@ -184,8 +209,8 @@ const FAVICON = `<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://
|
|
|
184
209
|
// 発言者ごとの色は名前ハッシュ(--h)から作る。彩度・明度だけテーマで持ち替えれば dark/light 両方が成立する
|
|
185
210
|
const STYLE = `
|
|
186
211
|
*{box-sizing:border-box}
|
|
187
|
-
:root{color-scheme:light dark;--fg:#1a1a1a;--bg:#f7f6f3;--surface:#fff;--line:#e4e2dc;--accent:#1d4ed8;--dim:#8a877f;--sat:55%;--lum:45%;--name:32%;--tint:96%;--edge:82%;--busy:#1f9d55;--idle:#b9b6ae;--dead:#d03b3b}
|
|
188
|
-
@media(prefers-color-scheme:dark){:root{--fg:#e8e6e0;--bg:#141418;--surface:#1e1e24;--line:#2c2c33;--accent:#7aa2ff;--dim:#8b8892;--sat:48%;--lum:56%;--name:75%;--tint:18%;--edge:32%;--busy:#3ecf7e;--idle:#4d4b52;--dead:#ff6b6b}}
|
|
212
|
+
:root{color-scheme:light dark;--fg:#1a1a1a;--bg:#f7f6f3;--surface:#fff;--line:#e4e2dc;--accent:#1d4ed8;--dim:#8a877f;--sat:55%;--lum:45%;--name:32%;--tint:96%;--edge:82%;--busy:#1f9d55;--idle:#b9b6ae;--dead:#d03b3b;--blocked:#d97706}
|
|
213
|
+
@media(prefers-color-scheme:dark){:root{--fg:#e8e6e0;--bg:#141418;--surface:#1e1e24;--line:#2c2c33;--accent:#7aa2ff;--dim:#8b8892;--sat:48%;--lum:56%;--name:75%;--tint:18%;--edge:32%;--busy:#3ecf7e;--idle:#4d4b52;--dead:#ff6b6b;--blocked:#f0a63a}}
|
|
189
214
|
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)}
|
|
190
215
|
a{color:var(--accent)}
|
|
191
216
|
.brand{display:flex;align-items:center;gap:8px;font-size:15px;font-weight:650;letter-spacing:.01em}
|
|
@@ -205,6 +230,7 @@ const UI = room => `<!doctype html><html lang="ja"><head><meta charset="utf-8"><
|
|
|
205
230
|
.chip .st.busy{background:var(--busy)}
|
|
206
231
|
.chip .st.idle{background:var(--idle)}
|
|
207
232
|
.chip .st.dead{background:var(--dead)}
|
|
233
|
+
.chip .st.blocked{background:var(--blocked)}
|
|
208
234
|
.chip .st.unknown{background:transparent;box-shadow:inset 0 0 0 1.5px var(--dim)}
|
|
209
235
|
.metapop{position:fixed;z-index:20;background:var(--surface);border:1px solid var(--line);border-radius:10px;padding:8px 10px;font-size:12px;box-shadow:0 6px 20px rgba(0,0,0,.18);max-width:70vw}
|
|
210
236
|
.metapop .metaname{font-weight:600;margin-bottom:2px}
|
|
@@ -217,8 +243,10 @@ const UI = room => `<!doctype html><html lang="ja"><head><meta charset="utf-8"><
|
|
|
217
243
|
.chip.is-busy .av{animation:seat-working 1.15s ease-in-out infinite;box-shadow:0 0 0 2px color-mix(in srgb,var(--busy) 38%,transparent)}
|
|
218
244
|
.chip.is-busy.pulse .av{animation:seat-working 1.15s ease-in-out infinite,pulse 1.6s ease-out 2}
|
|
219
245
|
.chip.is-busy .st{animation:seat-beat .8s ease-in-out infinite alternate}
|
|
246
|
+
.chip.is-blocked .av{animation:seat-blocked 2.2s ease-in-out infinite}
|
|
220
247
|
@keyframes seat-working{0%,100%{transform:translateY(0) rotate(-2deg)}50%{transform:translateY(-2px) rotate(2deg)}}
|
|
221
248
|
@keyframes seat-beat{from{transform:scale(.78);opacity:.7}to{transform:scale(1.28);opacity:1}}
|
|
249
|
+
@keyframes seat-blocked{0%,100%{opacity:1}50%{opacity:.5}}
|
|
222
250
|
.victory{position:fixed;z-index:30;left:var(--victory-x);top:var(--victory-y);pointer-events:none;color:var(--busy);font-size:13px;font-weight:800;letter-spacing:.03em;text-shadow:0 1px 0 var(--surface);animation:victory-rise 1.45s cubic-bezier(.18,.8,.3,1) both}
|
|
223
251
|
@keyframes victory-rise{0%{opacity:0;transform:translate(-50%,8px) scale(.75)}18%{opacity:1;transform:translate(-50%,-7px) scale(1.08)}72%{opacity:1;transform:translate(-50%,-15px) scale(1)}100%{opacity:0;transform:translate(-50%,-27px) scale(.92)}}
|
|
224
252
|
@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)}}
|
|
@@ -227,7 +255,10 @@ const UI = room => `<!doctype html><html lang="ja"><head><meta charset="utf-8"><
|
|
|
227
255
|
.msg.flow,.sys.flow{animation:message-flow .36s cubic-bezier(.22,1,.36,1) both}
|
|
228
256
|
@keyframes message-flow{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:translateY(0)}}
|
|
229
257
|
@media (prefers-reduced-motion:reduce){.msg.flow,.sys.flow{animation:none}}
|
|
230
|
-
@media (prefers-reduced-motion:reduce){.chip.is-busy .av,.chip.is-busy .st,.victory{animation:none}}
|
|
258
|
+
@media (prefers-reduced-motion:reduce){.chip.is-busy .av,.chip.is-busy .st,.chip.is-blocked .av,.victory{animation:none}}
|
|
259
|
+
.bubble.reveal>*{animation:block-in .32s cubic-bezier(.22,1,.36,1) both}
|
|
260
|
+
@keyframes block-in{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:translateY(0)}}
|
|
261
|
+
@media (prefers-reduced-motion:reduce){.bubble.reveal>*{animation:none}}
|
|
231
262
|
.msg.cont{margin-top:-8px}.msg.cont .av{visibility:hidden;height:0}.msg.cont .meta{display:none}
|
|
232
263
|
.msg .body{min-width:0;max-width:100%}
|
|
233
264
|
.meta{display:flex;align-items:baseline;gap:7px;flex-wrap:wrap;margin:0 0 3px 2px;font-size:12px}
|
|
@@ -353,17 +384,19 @@ async function refreshMembers(){
|
|
|
353
384
|
for(const m of r.members){
|
|
354
385
|
const c=el('span','chip'+(m.name===recent?' recent':''))
|
|
355
386
|
c.style.setProperty('--h',hue(m.name));c.dataset.name=m.name
|
|
356
|
-
//
|
|
357
|
-
|
|
387
|
+
// 素性は任意欄。名乗っていない席は行ごと出ない(空欄を「不明」として見せない)。
|
|
388
|
+
// vendor/model/effort を1行へ畳む(launch-seat.sh:108 の着席ログと読み口を揃える。effort の語は出さない)
|
|
389
|
+
const idParts=[m.vendor,m.model,m.effort].filter(Boolean)
|
|
390
|
+
const meta=[idParts.length?idParts.join(' / '):null].filter(Boolean)
|
|
358
391
|
// 稼働状態は**報告が新しい時だけ**採る。途絶えたら unknown へ落とす——古い状態を出し続けるのが
|
|
359
392
|
// いちばん悪い(動いていない席を「動いている」と見せる)。閾値は bridge の心拍30秒の3倍
|
|
360
393
|
const age=m.status_at?Date.now()-Date.parse(m.status_at):Infinity
|
|
361
394
|
const st=(m.status&&age<STATUS_STALE_MS)?m.status:(m.status?'unknown':null)
|
|
362
395
|
if(st)c.classList.add('is-'+st)
|
|
363
|
-
if(st)meta.push('状態 '+({busy:'作業中',idle:'待機',dead:'停止',unknown:'
|
|
396
|
+
if(st)meta.push('状態 '+({busy:'作業中',idle:'待機',dead:'停止',blocked:'承認待ち',unknown:'不明(報告が途絶えている)'}[st]??st))
|
|
364
397
|
const usage=[]
|
|
365
398
|
const busyAge=m.busy_since?Date.now()-Date.parse(m.busy_since):NaN
|
|
366
|
-
if(st==='busy'&&Number.isFinite(busyAge)&&busyAge>=0)usage.push('継続 '+elapsed(busyAge))
|
|
399
|
+
if((st==='busy'||st==='blocked')&&Number.isFinite(busyAge)&&busyAge>=0)usage.push('継続 '+elapsed(busyAge))
|
|
367
400
|
if(Number.isSafeInteger(m.pane_token_hint)&&m.pane_token_hint>=0)usage.push(compactCount(m.pane_token_hint)+' tokens')
|
|
368
401
|
if(usage.length)meta.push('消費目安 '+usage.join(' / ')+'(pane観測)')
|
|
369
402
|
c.title=m.name+'(参加 '+new Date(m.joined_at).toLocaleString()+')'+(meta.length?'\\n'+meta.join('\\n'):'')
|
|
@@ -418,14 +451,19 @@ function celebrate(name){
|
|
|
418
451
|
}
|
|
419
452
|
const BEAT=${HEARTBEAT_MS}
|
|
420
453
|
const STATUS_STALE_MS=90000 // 稼働状態の鮮度。これを過ぎた報告は unknown(bridge 心拍30秒の3倍)
|
|
421
|
-
let lastSeq=0,lastBeat=Date.now(),es=null,emptyEl=null,firstLoad=true,catching=false
|
|
454
|
+
let lastSeq=0,lastBeat=Date.now(),es=null,emptyEl=null,firstLoad=true,catching=false,memberDebounce=null
|
|
422
455
|
// seq で二重描画を弾く。張り直し後の追いつきと SSE の新着が重なっても同じ発言は1回しか出ない
|
|
423
456
|
function apply(m,live=false){
|
|
424
457
|
if(m.seq<=lastSeq)return false
|
|
425
458
|
lastSeq=m.seq
|
|
426
459
|
if(emptyEl){emptyEl.remove();emptyEl=null}
|
|
427
460
|
const row=render(m)
|
|
428
|
-
if(live)
|
|
461
|
+
if(live){
|
|
462
|
+
row.classList.add('flow')
|
|
463
|
+
// ライブ新着だけ、バブル内の各ブロックへ順に出現アニメを当てる。実際の生成とは同期しない単なる演出
|
|
464
|
+
const bub=row.querySelector('.bubble')
|
|
465
|
+
if(bub){bub.classList.add('reveal');[...bub.children].forEach((c,i)=>{c.style.animationDelay=(Math.min(i,8)*90)+'ms'})}
|
|
466
|
+
}
|
|
429
467
|
if(m.from!=='system')recent=m.from
|
|
430
468
|
return true
|
|
431
469
|
}
|
|
@@ -451,6 +489,8 @@ function connect(){
|
|
|
451
489
|
// 心拍は room の最新 seq を積んでくる。繋がったまま取りこぼした(=途絶しないので watchdog が気づけない)
|
|
452
490
|
// 場合は、この差分だけが手掛かりになる
|
|
453
491
|
es.addEventListener('ping',e=>{lastBeat=Date.now();if(Number(e.data)>lastSeq)catchUp()})
|
|
492
|
+
// 稼働状態・素性の変化。既存の refreshMembers() を150msデバウンスで呼ぶ(部分更新は実装しない)
|
|
493
|
+
es.addEventListener('member',()=>{clearTimeout(memberDebounce);memberDebounce=setTimeout(refreshMembers,150)})
|
|
454
494
|
es.onmessage=e=>{
|
|
455
495
|
lastBeat=Date.now()
|
|
456
496
|
const m=JSON.parse(e.data),stick=nearBottom()
|
package/skill/SKILL.md
CHANGED
|
@@ -29,7 +29,7 @@ description: 任意プロジェクトに Peertable チーム(対等メンバ
|
|
|
29
29
|
|
|
30
30
|
手順は **聞き取り → script → 着任指示** の3段である。scripts が機械部分を全部持つので、AI が手で tmux を組み立てることはしない。
|
|
31
31
|
|
|
32
|
-
1. **聞き取り**: 対象プロジェクトのパス / **工程正本(`Lattice 併用`=既定 / `単独`)** / メンバー数とモデル・effort(モデル既定: Sonnet
|
|
32
|
+
1. **聞き取り**: 対象プロジェクトのパス / **工程正本(`Lattice 併用`=既定 / `単独`)** / メンバー数とモデル・effort(モデル既定: Sonnet。**effort は既定値を置かない——席ごとに必ず決める**。`launch-seat.sh` の effort は必須引数で、未指定は着席前に非ゼロ終了する。モデル選定は作業の性質——設計か確定実装か——を軸にする。決定49)/ 初期タスク群(何を作るか)/ room 名(既定: プロジェクトのディレクトリ名)/ **公開URL基底**(Lattice 併用のみ。外部ペインに書く URL。クオ環境は `https://peertable.kitepon.dev`。未指定なら room サーバーの URL がそのまま入る=LAN URL は Lattice を外から見た時に開けない)
|
|
33
33
|
- **メンバー数の既定**: Lattice 併用なら plan compile 結果の幅(`max_frontier_width`)に合わせる(実測: 幅3→3人、第2 campaign で幅4→4人目追加)。frontier より多い席は最初から遊ぶ。単独モードには frontier が無いので既定の根拠も無く、聞き取りで決める
|
|
34
34
|
- **運用中の席数の標準は「ready+activeな実装ToDo数」(決定68)**: 起動時の幅は計画値、この式は生きた実測値で、campaignが進めば自然に縮む。標準を上回った席は親が縮退する。監査・受入は実装席が兼ねる(決定67)ので監査要員を席数に数えない。claimできるToDoが無い席は仕事を発明せず(依頼されていない監査・照会・自主レビュー禁止)、待機宣言して黙るか親に畳まれる
|
|
35
35
|
- **モードの選び分け**: タスク間に依存があり並列境界の機械保証が要るなら Lattice 併用。依存の無い小規模作業で、対象プロジェクトに Lattice を持ち込みたくないなら単独。単独で失うのは task 間スケジューリングの機械保証だけで、円卓の核(room・憲章・宣言による協力)は変わらない(決定47)
|
|
@@ -41,7 +41,7 @@ description: 任意プロジェクトに Peertable チーム(対等メンバ
|
|
|
41
41
|
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 正典)に従う。設計メモは各タスクに必ず書く
|
|
42
42
|
- `make-plan-input.mjs` が digest 計算と `hard_dependencies` の `(from,to)` 昇順ソートを持つ(**手書きで2回踏んだ罠**。順序が崩れると `INPUT_INVALID / pointer:"/"` としか言われない)。`project_id` の既定は project ディレクトリ名で、`external-pane.mjs` が書く `project.json` の既定と一致させてある——**両者がずれると Lattice が identity 検証で落ちる**
|
|
43
43
|
- 単独モードのタスク正本は手順3で生成した `.team/tasks.md` だけである。状態(誰が持っているか・何が終わったか)は持たせない——claim と完了は room の宣言だけが正(決定48 の延長)。ミニタスクトラッカーを別途作らない(決定36)
|
|
44
|
-
5. **メンバー起動**: メンバーごとに `scripts/launch-seat.sh <project> <name> <model>
|
|
44
|
+
5. **メンバー起動**: メンバーごとに `scripts/launch-seat.sh <project> <name> <model> <claude|codex> <effort> [着任指示]` を実行する。**vendor・effort とも必須引数**(未指定は usage を出して非ゼロ終了。既定値をコードへ埋めない——席を立てる時に決める)。tmux 作成(aiterm と同じソケットなので、立った席はそのまま `pty_read`/`pty_send` で読める)→ env 注入 → 起動 → 既知ダイアログ通過 → 着席確認まで1回で行き、着席しなければ最後の画面を出して非ゼロで落ちる(黙って進まない)
|
|
45
45
|
- 起動前に `pty_list` で既存の `peer-*` 席を確認する(前の卓の残骸を99席実測したことがある)。同名の席は launch-seat.sh が落としてから立て直す
|
|
46
46
|
- 着任指示を第6引数に渡すと着席後に送る。文面: 「あなたは「<日本語名>」。.team/roles/member.md を読んで着任し、作業ループを開始せよ。全タスク完了の宣言まで自律的に続けること。」
|
|
47
47
|
- 席が読む 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 で復元する
|
|
@@ -50,13 +50,16 @@ description: 任意プロジェクトに Peertable チーム(対等メンバ
|
|
|
50
50
|
- **黙って止まらないための三段**(決定58 の受信側の作法): ①75秒なにも届かなければ自分から切って繋ぎ直す ②繋ぎ直したら `?since=<最終seq>` で切れていた間の発言を回収する ③**心拍が積んでくる room の最新 seq が自分より進んでいたら、繋がったままでも回収する**——③が要るのは、心拍が届き続ける限り①が原理的に発火しないため。**server 側の心拍(`event: ping`・25秒周期)が前提**なので、古い room サーバーへ繋ぐと①だけが効く形になる
|
|
51
51
|
- ログは `.team/wakeup-bridge.log`。**0件でも0件と出す**ので、起こせているか・取りこぼしていないかはログを見れば分かる。再現ハーネスは `experiments/bridge-catchup-repro.mjs`
|
|
52
52
|
|
|
53
|
-
6.5
|
|
54
|
-
-
|
|
53
|
+
6.5 **席の稼働状態ブリッジ(setup.sh が自動で起こす)**: 手順3の scaffold が `nohup node scripts/seat-status-bridge.mjs <project> > <project>/.team/seat-status-bridge.log 2>&1 &` を起こすので、**AI が手で叩く段は無い**(手で起こし直す時だけ同じ command を使う)。**room の members に居る席だけ**の tmux pane を読み(`peer-<名前>`)、`busy`(画面に `esc to interrupt` が在る。**Claude 席・Codex 席の共通マーカーで、スピナーの語は毎回変わるので使わない**)/`blocked`(既知の承認ダイアログ文言が在る。判定順は busy → blocked → idle——承認プロンプト表示中は `esc to interrupt` が消えるため。動けない席を「待機」と誤表示しない)/`idle`/`dead`(`pane_dead`・セッション消失)を判定して room サーバーへ送る。**AI は使わず、席へは1バイトも送らない**。参加者一覧に点が出て、**報告が途絶えたら `unknown`(中空の輪)へ落ちる**——古い状態を出し続けない。**server が稼働状態を保持しない版なら1件も送らない**(送ると保存されないうえに system 発言を撒く)。停止は `node scripts/seat-status-bridge.mjs <project> --stop`(**teardown.sh が自動で行う**)
|
|
54
|
+
- **setup が起こし、teardown が止める(対称)。** 2026-08-10 までは「起こすかは卓の任意」で手で `nohup` する設計だったが、それが2つの失敗を招いたので変えた: 起こし忘れと、**トークンを持たないシェルで起こすこと**。後者は実害が出た——`~/.config/peertable.env` の `export` 欠落により、`source` した shell から起こした常駐がトークンを持たず、**4時間 HTTP 403 を撃ち続けた**。参加者一覧には点が1つも出ず、**「起こしていない」と見分けがつかなかった**
|
|
55
|
+
- **書込トークンは起こす側の env に依存しない。** ブリッジ自身が `launch-seat.sh:25-27` と同じ規則で解決する(env が先・無ければ `~/.config/peertable.env` を読む。`export` の有無を問わない)。同じ規則を `run-bridge.mjs` も使う
|
|
56
|
+
- **書けない常駐は生まれない。** 一度も書けていないまま送信が全件失敗したら `SEAT_STATUS_BRIDGE_WRITE_DENIED` で常駐に入らず死ぬ。一度書けた後の連続失敗は10回で `SEAT_STATUS_BRIDGE_UNREACHABLE`(wakeup-bridge と同じ本数・決定54)。**席がまだ立っていない tick は失敗に数えない**ので、setup 直後に起こしても死なない
|
|
57
|
+
- 止め忘れると `.team/` と一緒に pid 記録が消えて **`--stop` でも止められない常駐**が残る(teardown が自動で止めるので通常経路では起きない)
|
|
55
58
|
|
|
56
59
|
6.7 **effort変更(本人要請→親実行)**: 本人は親だけへ`[effort変更依頼] <level>`を明示DMする。親は席がidleなのを確認して`skill/scripts/change-effort.sh <project> <member> <level> [parent_name]`を実行する。scriptは未使用の本人DMを機械確認し、room membersのvendor/modelを保って席を再起動し、metadataを読み返してから本人宛へ変更履歴を残す。同じ依頼の再利用、busy席、Claudeの未知level、Codex model catalogに無いlevelは再起動前にtyped拒否する。新設定での起動失敗時は旧effortで1回だけ明示rollbackし、両方失敗なら手動復旧が必要だと非0で出す。
|
|
57
60
|
- **会話contextは引き継がない**。本人は作業を安全に中断できる時だけ依頼し、再起動後はrole・工程正本・roomログから再着任する。変更できたのに履歴記録だけ失敗した場合も成功へ丸めず、`EFFORT_CHANGE_CHANGED_BUT_HISTORY_FAILED`で現在状態を明示する
|
|
58
61
|
|
|
59
|
-
7. **親の着卓**(このセッション): `scripts/parent-join.sh <project> [name] [model] [effort]` で member
|
|
62
|
+
7. **親の着卓**(このセッション): `scripts/parent-join.sh <project> [name] [model] [effort]` で member 登録を行う。**`effort` は任意のまま据え置く**——席は `launch-seat.sh` が `--effort` で実際に設定するので「渡した値=実挙動」だが、親は既に走っているセッションで自分の effort を機械的に知る経路が無く、推測して載せると画面が嘘をつく。**bell宛DM番犬**を Monitor で張る(形は下記「親の operating notes」の番犬仕様)。broadcastのkickoffは廃止済み。以後の post も API 直(同 notes)
|
|
60
63
|
8. **起動確認**: room の members に全員いる / 最初の claim が room に流れる(Lattice 併用モードはそれが Lattice へ到達している=`lattice todo status --json` の active に出ることも確認する。単独モードは room の claim 宣言だけが到達の証拠)/ Web UI で観測できる、をチェックして報告する
|
|
61
64
|
|
|
62
65
|
## teardown
|
|
@@ -170,7 +173,8 @@ witness をどう生成するかは**対象 project 側の作法に従う**(La
|
|
|
170
173
|
- **再着卓の契機は Monitor stream の終了通知**(2026-08-08 実測。1日に2回——引き継ぎ時と障害復旧後)。親の側には「途絶した」と教えてくれるものが他に無いので、**Monitor が終わったら再着卓の手順に入る**と決めておく
|
|
171
174
|
- **順序の要点は「room と工程正本を読み終えるまで発言しない」**。読む前に喋ると、自分が行き違いを作る側になる(実例あり)
|
|
172
175
|
- **やらないこと**: 復帰の挨拶で席を起こさない。作業の再確認を席へ聞いて回らない——**現在地は上の1〜2で取れる**ので、聞くのは席の時間を奪うだけである
|
|
173
|
-
- **宛先の規律**: postはメンバー名または必要なメンバー名の配列だけを受理し、broadcast shortcutはtyped拒否する。channels/起床ブリッジが起こすのも明示宛先だけ。roomログは宛先に関係なく全員がpull
|
|
176
|
+
- **宛先の規律**: postはメンバー名または必要なメンバー名の配列だけを受理し、broadcast shortcutはtyped拒否する。channels/起床ブリッジが起こすのも明示宛先だけ。roomログは宛先に関係なく全員がpullで読める(§16)ので、宛先は秘匿ではなく「今起こす必要がある相手」を表す
|
|
177
|
+
- **親(bell)宛DMを受理してよいのは決定71の3種だけ**: ①done報告・監査受理要請、②オーナー承認gateに関わる物件、③親・オーナーにしか解けないblocker・裁定依頼(`[effort変更依頼]`はこれに該当)。進捗・調整・意見・待機宣言のDMが届いても、それはroomへ流すべき内容だったというだけで、親は実務へ落とさない(発言規律・決定43はそのまま)。同じ基準は席同士のDMにも及ぶ——**同報・返答不要の情報共有目的の複数宛先は禁止**で、判断基準は「この宛先は受け取って何をする?」に答えられるかである
|
|
174
178
|
- **発言規律(決定43・正典 §3.4)**: 親の room 発言は ①監査結果の事実(受理/異議。「次はこうせよ」を続けない)②承認 gate の状態 ③オーナー裁定の伝達(必ず「オーナー裁定」と明示)の3種だけ。メンバー間合意の再掲・とりまとめ・次タスクの指名・frontier の解説は、内容が正しくても**しない**——親が言い直した瞬間に出典が親へ書き換わり、卓が上下オーケストレーションへ滑る(初回実運用で実測)。裁定依頼が来たら自分で判断せず、オーナー宛の議題として運ぶ
|
|
175
179
|
- 督促の検出源は room の報告途絶と Lattice 工程表の乖離。**単独円卓モードでは工程表が無いので、検出源は `.team/tasks.md` の議題と room ログの照合だけになる**——完走の判定も同じで、全議題に完了報告が揃ったことを親が room ログで確認し、散会を宣言する(この確認と宣言が単独モードの done gate である)
|
|
176
180
|
- **席の縮退も親の進行権能**(散会と同じ性質。決定51): frontier が細って遊休席が出たら親が畳む。順序を守る——①対象席へ名指しで通告 ②本人に WIP と未報告の作業が無いことを確認する(本人が「まだ持っている」と言えば畳まない。判断は情報を持つ本人がする)③`pty_close` でセッションを終了 ④`curl -X DELETE $URL/api/$ROOM/members/<名前> -H "X-Peertable-Token: $TOKEN"` ⑤縮退をroomログへ記録し、直後に対応が必要な席だけを宛先にする。**先に member を消すと本人が最後の報告を出せない**
|
|
@@ -1,15 +1,24 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
2
|
# 席を1つ立てる(tmux 作成 → env 注入 → エージェント起動 → 既知ダイアログ通過 → 着席確認)。
|
|
3
|
-
# usage: launch-seat.sh <project_dir> <name> <model>
|
|
4
|
-
# vendor: claude
|
|
3
|
+
# usage: launch-seat.sh <project_dir> <name> <model> <vendor> <effort> [brief]
|
|
4
|
+
# vendor: claude / codex
|
|
5
|
+
# effort: 必須。既定値をコードへ埋めない——席を立てる時に決める(オーナー裁定)
|
|
5
6
|
# brief: 着席が成立したら送る着任指示(省略時は送らない)
|
|
6
7
|
#
|
|
7
8
|
# room 名・server URL・モード・plan key は <project>/.team/setup-state.json から読む(setup.sh の後に呼ぶ)。
|
|
8
9
|
# 書込トークンは環境変数 PEERTABLE_POST_TOKEN、無ければ ~/.config/peertable.env から取る。
|
|
9
10
|
# tmux は aiterm-mcp と同じソケットへ作るので、立てた席はそのまま pty_read / pty_send で読める。
|
|
10
11
|
set -e
|
|
11
|
-
proj="$1"; name="$2"; model="$3"; vendor="$
|
|
12
|
-
[ -n "$proj" ] && [ -n "$name" ] && [ -n "$model" ]
|
|
12
|
+
proj="$1"; name="$2"; model="$3"; vendor="$4"; effort="$5"; brief="$6"
|
|
13
|
+
[ -n "$proj" ] && [ -n "$name" ] && [ -n "$model" ] && [ -n "$vendor" ] && [ -n "$effort" ] || {
|
|
14
|
+
echo "usage: launch-seat.sh <project_dir> <name> <model> <vendor> <effort> [brief]" >&2; exit 1;
|
|
15
|
+
}
|
|
16
|
+
if [ "$vendor" = "claude" ]; then
|
|
17
|
+
case "$effort" in
|
|
18
|
+
low|medium|high|xhigh|max) ;;
|
|
19
|
+
*) echo "unknown effort: ${effort}(claude は low|medium|high|xhigh|max)" >&2; exit 1 ;;
|
|
20
|
+
esac
|
|
21
|
+
fi
|
|
13
22
|
|
|
14
23
|
sock="${PEERTABLE_TMUX_SOCKET:-${TMPDIR}claude-tmux-sockets/claude.sock}"
|
|
15
24
|
sess="peer-$name"
|
|
@@ -25,6 +25,8 @@ import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
|
25
25
|
import { join } from 'node:path'
|
|
26
26
|
import { promisify } from 'node:util'
|
|
27
27
|
|
|
28
|
+
import { resolvePostToken } from './seat-usage.mjs'
|
|
29
|
+
|
|
28
30
|
const run = promisify(execFile)
|
|
29
31
|
const [proj, ...rest] = process.argv.slice(2)
|
|
30
32
|
if (!proj) {
|
|
@@ -121,10 +123,12 @@ if (rest[0] === '--stop') process.exit(0)
|
|
|
121
123
|
|
|
122
124
|
const state = JSON.parse(readFileSync(join(proj, '.team', 'setup-state.json'), 'utf8'))
|
|
123
125
|
const { room, server_url: url } = state
|
|
124
|
-
|
|
126
|
+
// launch-seat.sh:25-27 と同じ解決規則(env が先・無ければ `~/.config/peertable.env`)。
|
|
127
|
+
// **`export` の有無に常駐の生死を握らせない**——seat-status-bridge が同じ依存で4時間死んだ(2026-08-10)
|
|
128
|
+
const token = resolvePostToken(process.env)
|
|
125
129
|
if (token.length === 0) {
|
|
126
130
|
// 投稿できないブリッジは何も返せない。起きてから黙って何もしない常駐を作らない
|
|
127
|
-
console.error('RUN_BRIDGE_TOKEN_MISSING: PEERTABLE_POST_TOKEN
|
|
131
|
+
console.error('RUN_BRIDGE_TOKEN_MISSING: 書込トークンが無い(環境変数 PEERTABLE_POST_TOKEN か ~/.config/peertable.env)')
|
|
128
132
|
process.exit(1)
|
|
129
133
|
}
|
|
130
134
|
|
|
@@ -6,12 +6,16 @@
|
|
|
6
6
|
// AI は使わない。読むのは `tmux capture-pane -p` の末尾だけで、席へは1バイトも送らない。
|
|
7
7
|
//
|
|
8
8
|
// 判定(2026-08-08 実測。**推測でパターンを書かない**):
|
|
9
|
-
// busy
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
9
|
+
// busy = pane の末尾に `esc to interrupt` が在る。Claude 席のステータス行にも Codex 席の `Working (…)` にも
|
|
10
|
+
// 同じ文字列が入るので **vendor 分岐が要らない**。実行中の動名詞(Cogitating/Coalescing/Effecting/
|
|
11
|
+
// Gallivanting/Fermenting/Symbioting…)は**毎回変わる**ので判定に使わない——語で照合すると全席を
|
|
12
|
+
// idle と誤判定して、画面が嘘をつく
|
|
13
|
+
// blocked = 既知の承認ダイアログ文言が末尾に在る(`esc to interrupt` は消えている)。2026-08-08 実測:
|
|
14
|
+
// 確認ダイアログで停止した席が busy と `esc to interrupt` 不在から idle 側に誤判定され、
|
|
15
|
+
// 動けない席へ卓が代走を申し出るところまで進んだ。判定順は busy → blocked → idle
|
|
16
|
+
// (承認プロンプト表示中は `esc to interrupt` が消えるので、この順で正しい)
|
|
17
|
+
// dead = tmux セッションが無い/`pane_dead=1`
|
|
18
|
+
// idle = 生きていて busy でも blocked でもない
|
|
15
19
|
//
|
|
16
20
|
// 送信は「変化した時」+「変化が無くても心拍」の2本立て。変化時だけだと、**bridge が死んだのか状態が
|
|
17
21
|
// 変わっていないのかを server が区別できない**(決定58 の liveness と cursor の分離と同じ形)。
|
|
@@ -20,7 +24,7 @@ import { execFileSync } from 'node:child_process'
|
|
|
20
24
|
import { existsSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs'
|
|
21
25
|
import { join } from 'node:path'
|
|
22
26
|
|
|
23
|
-
import { parsePaneTokenHint, supportsMemberObservation } from './seat-usage.mjs'
|
|
27
|
+
import { classifyPaneTail, decideBridgeContinuation, deriveMissingSession, parsePaneTokenHint, resolvePostToken, resolveTmuxSocket, supportsMemberObservation } from './seat-usage.mjs'
|
|
24
28
|
|
|
25
29
|
const args = process.argv.slice(2)
|
|
26
30
|
const proj = args[0]
|
|
@@ -67,10 +71,15 @@ if (existsSync(pidPath)) {
|
|
|
67
71
|
const setup = JSON.parse(readFileSync(setupPath, 'utf8'))
|
|
68
72
|
const url = setup.server_url
|
|
69
73
|
const room = setup.room
|
|
70
|
-
|
|
74
|
+
// launch-seat.sh:25-27 と同じ解決規則(env が先・無ければ `~/.config/peertable.env`)。
|
|
75
|
+
// **起こす側の shell の env に依存しない**——依存していた時、`export` 欠落だけで常駐が丸ごと死んだ
|
|
76
|
+
const token = resolvePostToken(process.env)
|
|
71
77
|
writeFileSync(pidPath, JSON.stringify({ pid: process.pid, started_at: new Date().toISOString() }) + '\n')
|
|
72
78
|
|
|
73
|
-
|
|
79
|
+
// launch-seat.sh:14 が席を作るソケットと同じ解決規則(既定ソケットへ黙って fallback しない——
|
|
80
|
+
// 見えないなら「見えない」と言う。同じルールを二重に書かない)
|
|
81
|
+
const socket = resolveTmuxSocket(process.env)
|
|
82
|
+
const tmux = (...a) => { try { return execFileSync('tmux', ['-S', socket, ...a], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }) } catch { return null } }
|
|
74
83
|
|
|
75
84
|
// 監視するのは room の members に居る席だけ。tmux の `peer-*` を全部拾うと、同じマシンで走る別の卓を晒す
|
|
76
85
|
async function seats() {
|
|
@@ -82,16 +91,18 @@ async function seats() {
|
|
|
82
91
|
function readSeat(name, previous, observedAt) {
|
|
83
92
|
const target = `peer-${name}`
|
|
84
93
|
const dead = tmux('list-panes', '-t', target, '-F', '#{pane_dead}')
|
|
85
|
-
|
|
94
|
+
// セッションが見つからない。tmux 席を持たない member(親など)は一度も観測できないので送らない
|
|
95
|
+
// (`previous` が無い=一度も観測できていない)。過去に観測できていた席が消えたなら実際に落ちた
|
|
96
|
+
if (dead === null) return deriveMissingSession(previous)
|
|
86
97
|
if (dead.trim().split('\n')[0] === '1') {
|
|
87
98
|
return { status: 'dead', busySince: null, paneTokenHint: null }
|
|
88
99
|
}
|
|
89
100
|
const pane = tmux('capture-pane', '-t', target, '-p')
|
|
90
101
|
if (pane === null) return { status: 'dead', busySince: null, paneTokenHint: null }
|
|
91
102
|
const tail = pane.split('\n').slice(-14).join('\n')
|
|
92
|
-
const status = tail
|
|
93
|
-
const busySince = status === 'busy'
|
|
94
|
-
? (previous?.status === 'busy' && previous.busySince ? previous.busySince : observedAt)
|
|
103
|
+
const status = classifyPaneTail(tail)
|
|
104
|
+
const busySince = status === 'busy' || status === 'blocked'
|
|
105
|
+
? ((previous?.status === 'busy' || previous?.status === 'blocked') && previous.busySince ? previous.busySince : observedAt)
|
|
95
106
|
: null
|
|
96
107
|
return { status, busySince, paneTokenHint: parsePaneTokenHint(tail) }
|
|
97
108
|
}
|
|
@@ -123,25 +134,33 @@ const last = new Map() // name -> { status, at }
|
|
|
123
134
|
let supported = null // server が status を保持する版か(未判定は null)
|
|
124
135
|
const tokenBucket = value => value === null ? null : Math.floor(value / 1_000)
|
|
125
136
|
|
|
137
|
+
// 送信の結果を数えて返す。**呼び出し側が「1件も届いていない」を判定できる形にする**——
|
|
138
|
+
// 数えないと、失敗を1行ずつ吐きながら永久に常駐する(2026-08-10 に4時間そうなった)
|
|
139
|
+
const NOTHING_ATTEMPTED = { attempted: 0, failed: 0 }
|
|
140
|
+
|
|
126
141
|
async function tick() {
|
|
127
142
|
let names
|
|
128
|
-
try { names = await seats() } catch (e) { console.error(`seat-status-bridge: members を読めない: ${e.message}`); return }
|
|
143
|
+
try { names = await seats() } catch (e) { console.error(`seat-status-bridge: members を読めない: ${e.message}`); return NOTHING_ATTEMPTED }
|
|
129
144
|
// **送る前に、server が status を持つ版かを確かめる。**
|
|
130
145
|
// 現行の `POST /members` は、既存メンバーでも `<名前> が参加した` を必ず room へ流す(`post()` が
|
|
131
146
|
// `if (!members.has(name))` の外にある)。status を保持しない版へ投げると、**保存されないうえに
|
|
132
147
|
// 席全員を起こす system 発言を撒く**——2026-08-08 に私がこれで6件撒いて全席を1ターン起こした。
|
|
133
148
|
// 保持する版かどうかは GET で分かるので、**分かるまで投げない**。
|
|
134
149
|
if (supported === null) {
|
|
135
|
-
try { supported = await serverKeepsStatus() } catch { return } // 判定できない間は送らない
|
|
150
|
+
try { supported = await serverKeepsStatus() } catch { return NOTHING_ATTEMPTED } // 判定できない間は送らない
|
|
136
151
|
if (!supported) console.error('seat-status-bridge: この room サーバーは稼働状態を保持しない版(GET /members に status が無い)。送信すると保存されないうえに system 発言を撒くので、送信しない')
|
|
137
152
|
}
|
|
138
|
-
if (!supported) { console.error(`seat-status-bridge: ${names.length} 席を見たが、server が未対応なので送っていない`); return }
|
|
153
|
+
if (!supported) { console.error(`seat-status-bridge: ${names.length} 席を見たが、server が未対応なので送っていない`); return NOTHING_ATTEMPTED }
|
|
139
154
|
const now = Date.now()
|
|
140
155
|
const observedAt = new Date(now).toISOString()
|
|
141
156
|
let sent = 0
|
|
157
|
+
let skipped = 0
|
|
158
|
+
let failed = 0
|
|
142
159
|
for (const name of names) {
|
|
143
160
|
const prev = last.get(name)
|
|
144
161
|
const observation = readSeat(name, prev, observedAt)
|
|
162
|
+
// tmux 席を持たない member(親など)は一度も観測できないので送らない(deriveMissingSession が null を返す)
|
|
163
|
+
if (observation === null) { skipped++; continue }
|
|
145
164
|
const changed = !prev || prev.status !== observation.status
|
|
146
165
|
|| prev.busySince !== observation.busySince
|
|
147
166
|
// token表示は実行中に細かく増える。1k未満の差で8秒ごとにPOSTせず、表示精度に合う粒度で送る。
|
|
@@ -154,15 +173,49 @@ async function tick() {
|
|
|
154
173
|
sent++
|
|
155
174
|
if (changed) console.error(`seat-status-bridge: ${name} → ${observation.status}${prev ? `(${prev.status} から)` : ''}`)
|
|
156
175
|
} catch (e) {
|
|
176
|
+
failed++
|
|
157
177
|
console.error(`seat-status-bridge: ${name} の送信に失敗: ${e.message}`)
|
|
158
178
|
}
|
|
159
179
|
}
|
|
160
180
|
// 0件でも0件と言う(条件付きログにしない。沈黙する失敗を作らない・決定58)
|
|
161
|
-
console.error(`seat-status-bridge: ${names.length} 席を見て ${sent}
|
|
181
|
+
console.error(`seat-status-bridge: ${names.length} 席を見て ${sent} 件送った(tmux席を持たず観測対象外: ${skipped})`)
|
|
182
|
+
return { attempted: sent + failed, failed }
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// **書けることを実証してから常駐に入る。** `serverKeepsStatus()` は GET で「保存する版か」を
|
|
186
|
+
// 確かめる先例なのに、書込側は確かめずに常駐していた——だから「起動は成功したのに1件も届かない」
|
|
187
|
+
// という**見分けのつかない状態**が存在できた(2026-08-10 実測: トークン欠落で全件403のまま4時間)。
|
|
188
|
+
// 送るものが1件も無い tick は失敗ではない(席がまだ立っていない卓が正常にありうる)。
|
|
189
|
+
const FAILED_TICK_LIMIT = 10 // wakeup-bridge の連続失敗停止と同じ本数
|
|
190
|
+
|
|
191
|
+
function die(code, message) {
|
|
192
|
+
console.error(`${code}: ${message}`)
|
|
193
|
+
try { unlinkSync(pidPath) } catch { /* 既に消えている=目的は達成されている */ }
|
|
194
|
+
process.exit(1)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
let failedTicks = 0
|
|
198
|
+
let provenWritable = false
|
|
199
|
+
|
|
200
|
+
async function guardedTick() {
|
|
201
|
+
const { attempted, failed } = await tick() ?? NOTHING_ATTEMPTED
|
|
202
|
+
const decided = decideBridgeContinuation({ attempted, failed, provenWritable, failedTicks, limit: FAILED_TICK_LIMIT })
|
|
203
|
+
provenWritable = decided.provenWritable
|
|
204
|
+
failedTicks = decided.failedTicks
|
|
205
|
+
if (decided.verdict === 'write_denied') {
|
|
206
|
+
die('SEAT_STATUS_BRIDGE_WRITE_DENIED',
|
|
207
|
+
`送信 ${attempted} 件がすべて失敗し、一度も書けていない。常駐しない——書けない常駐は、`
|
|
208
|
+
+ '起動していない場合と同じ結果(点が出ない)を、起動しているように見せる。'
|
|
209
|
+
+ '書込トークン(環境変数 PEERTABLE_POST_TOKEN か ~/.config/peertable.env)と room の到達性を確認すること')
|
|
210
|
+
}
|
|
211
|
+
if (decided.verdict === 'unreachable') {
|
|
212
|
+
die('SEAT_STATUS_BRIDGE_UNREACHABLE',
|
|
213
|
+
`${FAILED_TICK_LIMIT} 回連続で全件送信に失敗した。黙って再試行を続けるゾンビを作らない(決定54)`)
|
|
214
|
+
}
|
|
162
215
|
}
|
|
163
216
|
|
|
164
217
|
process.on('SIGTERM', () => { try { unlinkSync(pidPath) } catch {} process.exit(0) })
|
|
165
218
|
process.on('SIGINT', () => { try { unlinkSync(pidPath) } catch {} process.exit(0) })
|
|
166
219
|
|
|
167
|
-
await
|
|
168
|
-
if (!once) setInterval(
|
|
220
|
+
await guardedTick()
|
|
221
|
+
if (!once) setInterval(() => { guardedTick() }, interval * 1000)
|
|
@@ -1,5 +1,11 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { homedir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
|
|
1
5
|
const TOKEN_HINT = /[↓↑]\s*([0-9]+(?:\.[0-9]+)?)\s*([kKmM]?)\s*tokens\b/gu
|
|
2
6
|
|
|
7
|
+
const POST_TOKEN_LINE = /^\s*(?:export\s+)?PEERTABLE_POST_TOKEN\s*=\s*(.*)$/
|
|
8
|
+
|
|
3
9
|
const TOKEN_MULTIPLIER = Object.freeze({
|
|
4
10
|
'': 1,
|
|
5
11
|
k: 1_000,
|
|
@@ -10,6 +16,86 @@ export function supportsMemberObservation(payload) {
|
|
|
10
16
|
return payload?.capabilities?.member_observation_v1 === true
|
|
11
17
|
}
|
|
12
18
|
|
|
19
|
+
/**
|
|
20
|
+
* launch-seat.sh:14 と同じ解決規則で aiterm-mcp の tmux ソケットを決める。
|
|
21
|
+
* 規則を二重に書かない——片方だけ直すと同じ穴がもう一度開く。
|
|
22
|
+
*/
|
|
23
|
+
export function resolveTmuxSocket(env) {
|
|
24
|
+
return env.PEERTABLE_TMUX_SOCKET || `${env.TMPDIR}claude-tmux-sockets/claude.sock`
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* `~/.config/peertable.env` の本文から書込トークンを読む。**`export` の有無に依存しない。**
|
|
29
|
+
* 2026-08-10 実測: `export` を落とした設定ファイルを `source` した shell から `nohup node …` で
|
|
30
|
+
* 起こした bridge は、トークンを持たないまま常駐して **4時間 HTTP 403 を撃ち続けた**。
|
|
31
|
+
* 起こす側の shell の書き方に、常駐の生死を握らせない。
|
|
32
|
+
*/
|
|
33
|
+
export function parsePostTokenEnvFile(text) {
|
|
34
|
+
if (typeof text !== 'string') return null
|
|
35
|
+
for (const line of text.split('\n')) {
|
|
36
|
+
const matched = POST_TOKEN_LINE.exec(line)
|
|
37
|
+
if (!matched) continue
|
|
38
|
+
let value = matched[1].trim()
|
|
39
|
+
const quoted = (value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))
|
|
40
|
+
if (quoted && value.length >= 2) value = value.slice(1, -1)
|
|
41
|
+
if (value) return value
|
|
42
|
+
}
|
|
43
|
+
return null
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* launch-seat.sh:25-27 と同じ解決規則で書込トークンを決める(env が先・無ければ設定ファイル)。
|
|
48
|
+
* 規則を二重に書かない——bash 側だけが自力解決を持ち、.mjs 側が起こす側の env に丸投げしていたのが
|
|
49
|
+
* 上記 403 の構造的な原因だった。readFile は試験のための注入口で、既定は実ファイルを読む。
|
|
50
|
+
*/
|
|
51
|
+
export function resolvePostToken(env, readFile = path => { try { return readFileSync(path, 'utf8') } catch { return null } }) {
|
|
52
|
+
if (env.PEERTABLE_POST_TOKEN) return env.PEERTABLE_POST_TOKEN
|
|
53
|
+
return parsePostTokenEnvFile(readFile(join(env.HOME || homedir(), '.config', 'peertable.env'))) ?? ''
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 送信結果から常駐を続けてよいかを決める。**「一度も書けていない」と「一度は書けたが今落ちている」を
|
|
58
|
+
* 分ける**——前者は起動不良で、常駐しても「点が出ない」という**起動していない場合と同じ結果**を
|
|
59
|
+
* 「起動している」ように見せてしまう。だから常駐に入らせない。後者は途中の障害なので、
|
|
60
|
+
* 連続 limit 回で止める(黙って再試行を続けるゾンビを作らない・決定54)。
|
|
61
|
+
* 送るものが1件も無い tick は失敗ではない(席がまだ立っていない卓が正常にありうる)。
|
|
62
|
+
*/
|
|
63
|
+
export function decideBridgeContinuation({ attempted, failed, provenWritable, failedTicks, limit }) {
|
|
64
|
+
if (attempted === 0) return { verdict: 'idle', provenWritable, failedTicks }
|
|
65
|
+
if (failed < attempted) return { verdict: 'ok', provenWritable: true, failedTicks: 0 }
|
|
66
|
+
const next = failedTicks + 1
|
|
67
|
+
if (!provenWritable) return { verdict: 'write_denied', provenWritable, failedTicks: next }
|
|
68
|
+
return { verdict: next >= limit ? 'unreachable' : 'degraded', provenWritable, failedTicks: next }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* tmux セッションが見つからない席の扱いを決める。**tmux 席を持たない member(親など)は
|
|
73
|
+
* 一度も観測できない**ので `null`(送らない)。**過去に観測できていた席が消えたら実際に落ちた**
|
|
74
|
+
* ので `dead` を返す。previous は直前に送信できた観測(`{status,...}` か undefined)。
|
|
75
|
+
*/
|
|
76
|
+
export function deriveMissingSession(previous) {
|
|
77
|
+
return previous ? { status: 'dead', busySince: null, paneTokenHint: null } : null
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// launch-seat.sh:74-77 と docs/plan.md §11 が実測で記録した既知ダイアログ文言の集合
|
|
81
|
+
export const BLOCKED_MARKERS = [
|
|
82
|
+
'1. Yes, I trust this folder',
|
|
83
|
+
'1. I am using this for local development',
|
|
84
|
+
'1. Yes, continue',
|
|
85
|
+
'Do you want to proceed?',
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* pane 末尾の生文字列から画面状態を判定する。判定順は busy → blocked → idle
|
|
90
|
+
* (承認プロンプト表示中は `esc to interrupt` が消えるので busy を先に見る)。
|
|
91
|
+
*/
|
|
92
|
+
export function classifyPaneTail(tail) {
|
|
93
|
+
if (typeof tail !== 'string') return 'idle'
|
|
94
|
+
if (tail.includes('esc to interrupt')) return 'busy'
|
|
95
|
+
if (BLOCKED_MARKERS.some(marker => tail.includes(marker))) return 'blocked'
|
|
96
|
+
return 'idle'
|
|
97
|
+
}
|
|
98
|
+
|
|
13
99
|
/**
|
|
14
100
|
* paneのstatus行が公開しているtoken値だけを読む。
|
|
15
101
|
* vendor固有のログや課金単価は推測せず、表示が無い席はnullのままにする。
|
package/skill/scripts/setup.sh
CHANGED
|
@@ -162,4 +162,14 @@ fi
|
|
|
162
162
|
# 手順どおり打っても届かない(suzune の監査で実測・room [1037])。
|
|
163
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
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"
|
|
165
|
+
|
|
166
|
+
# 稼働状態ブリッジを起こす。**teardown が止めるのと対称にする**——「起こすかは卓の任意」だった間、
|
|
167
|
+
# 手で `nohup` する経路が2つの失敗を招いた: 起こし忘れと、**トークンを持たないシェルで起こす**
|
|
168
|
+
# (2026-08-10 実測: `export` 欠落の設定ファイルを source した shell から起こした常駐が、
|
|
169
|
+
# 4時間 HTTP 403 を撃ち続け、参加者一覧には点が1つも出なかった=起こしていないのと見分けがつかない)。
|
|
170
|
+
# AI は使わず席へも1バイト送らないので卓の作業を邪魔しない。**書けない時は常駐せずに死ぬ**ので、
|
|
171
|
+
# ここで壊れた常駐が黙って残ることは無い。setup-state.json を読むので、必ずその後で起こす。
|
|
172
|
+
nohup node "$repo/skill/scripts/seat-status-bridge.mjs" "$proj" > "$tdir/seat-status-bridge.log" 2>&1 &
|
|
173
|
+
echo "seat-status-bridge: 起こした(ログ $tdir/seat-status-bridge.log・停止は teardown が行う)"
|
|
174
|
+
|
|
165
175
|
echo "scaffold done: $tdir"
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
1. `.team/tasks.md` で議題を見る。`read_log` で既存の claim と完了報告を照合し、まだ誰も持っていないものを選ぶ
|
|
10
10
|
2. 憲章の手順で`read_log`から先行claimを確認し、roomに`[claim] <タスク>`を記録する。起こす必要のある相手だけを宛先へ入れ、記録だけなら自分宛でよい。**`[claim]` は独立した1発言で出す**——完了報告や他タスクの話と同じ発言に畳まない。宣言としては有効でも、後から機械的に追えなくなり、監査が「宣言が無い」と誤読する(2026-08-08 実測)。単独モードでは room ログが唯一の正本なので、より効く
|
|
11
|
-
3.
|
|
11
|
+
3. 実装する。インターフェースなど他タスクに影響する決定は、影響を受けるメンバーを明示宛先にして一行で共有する。宛先は**その内容で今すぐ動く必要がある相手だけ**——同報・返答不要の情報共有目的で複数人を並べない(決定71)
|
|
12
12
|
4. 完了手順:
|
|
13
13
|
- 何を作り、どう確認したかを自分で確かめる(テスト・実行・実測。「たぶん動く」で閉じない)
|
|
14
14
|
- 変更ファイルを `git add` して commit する(メッセージは日本語一行。対象ファイルを明示して他人の作業中変更を巻き込まない)
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
|
|
26
26
|
## 注意
|
|
27
27
|
|
|
28
|
+
- **DM宛先は最小化する(決定71)**: 親宛DMは①done報告・監査受理要請、②オーナー承認gateに関わる物件、③親・オーナーにしか解けないblocker・裁定依頼(`[effort変更依頼]`はこれに該当)の3種だけ。進捗・調整・意見の共有は親宛DMにしない。席同士のDMも、その内容で**今すぐ動く必要がある相手だけ**へ送る——同報・返答不要の情報共有目的の複数宛先は禁止。宛先を選ぶ基準は「この宛先は受け取って何をする?」に答えられるかで、答えられない宛先は外す。記録目的はroomログ(全員pull可読)で足りる
|
|
28
29
|
- 誰が何を持っているかを機械に問い合わせられない卓である。claim と完了の宣言を落とした瞬間に重複作業になるので、宣言を省かない
|
|
29
30
|
- room の新着通知が来たら read_unread で読む。返事が要るものには post で応える
|
|
30
31
|
- **Codex 席の場合**: 起床は channels ではなく wakeup-bridge が担う。`room に新着あり(<誰> → <宛先>)。read_unread で読むこと。` が端末へ直接届くので、Claude 席と同じく read_unread で読む。**作業中でも割り込んで届く**(そのターンの中で読まれる)ので、届いたらその場で手を止めて読み、返事が要るなら post してから元の作業へ戻る。自分の発言では起きない
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
1. `lattice todo status --json` で ready なタスクを見る。{{CLAIM_SCOPE}}
|
|
10
10
|
2. 憲章の手順で room に claim を宣言する。**`[claim]` は独立した1発言で出す**——完了報告や他タスクの話と同じ発言に畳まない。宣言としては有効でも、後から機械的に追えなくなり、監査が「宣言が無い」と誤読する(2026-08-08 実測)
|
|
11
11
|
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 でよい
|
|
12
|
-
4.
|
|
12
|
+
4. 実装する。インターフェースなど他タスクに影響する決定は、決めた時点で影響を受けるメンバーを明示宛先にして一行で共有する。宛先は**その内容で今すぐ動く必要がある相手だけ**——同報・返答不要の情報共有目的で複数人を並べない(決定71)。broadcastは無く、他のメンバーはroomログをpullで読む。**正しさの確認は実測だけが与える(決定66)**——着手していない工程の設計・契約・手順を思想で検証しない。次工程の起草は「着手できる最小限」で止め、実測で答えられる問いを議論で答えない。監査も対象に触れるもの(diff・実行・突合)だけが監査で、触れない予想は監査ではない
|
|
13
13
|
5. 完了手順:
|
|
14
14
|
- 証跡ファイル `evidence/{{PLAN_KEY}}/<task_id>.md` に「何を作り、どう確認したか」を書く(ディレクトリが無ければ作る。task_id は campaign を跨いで再利用されるので、平置きにすると前の campaign の証跡を上書きで消す)
|
|
15
15
|
- 変更ファイルと証跡を `git add` して commit する(メッセージは日本語一行。対象ファイルを明示して他人の作業中変更を巻き込まない)
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
9. **監査findingに採れるのは欠陥だけ(決定65)。** 受入をblockできるfindingは「実際に再現するエラー・受入条件の不成立・明白な論理破綻・具体的な安全事故経路」だけ。**「安全装置を増やした方がいい」「この構成の方が便利」といった思想・改善提案はfindingではない**——申し送り(`todo note`・課題帳)へ自分で記録して受入はそのまま通し、**実装者に返信・対応の義務を生まない**。思想を1つ1つのToDoの監査結果へ混ぜると開発は永遠に終わらない(オーナー裁定 2026-08-09)。欠陥か思想かの線引きは**あなたの判断**で行う——機械的な形式基準は置かない(基準で縛ると本物の指摘まで形式で弾かれる)。再現手順を添えると監査は速く強くなるが、それは義務ではなく技法である
|
|
29
29
|
10. **監査の重さは2段で使い分け、閉じるのは欠陥の枯渇で決める(決定64)。** 通常のToDoは軽く: 実装者以外の席が対象diff・受入条件・focused testを確認して閉じる。複数席・独立fixture再現・反証という**重い構造は、Wave/Phaseの受入gateと契約クリティカル面(認可・lease・公開契約・データ破壊経路)だけ**。**終了条件は巡数ではない**——欠陥(決定65資格)が出た巡の次は何巡目でも正当で、欠陥の出なかった巡で閉じる。何席・何巡は自分の判断(数の上限で縛らない)。迷ったら軽い方——全ToDoを重くすると卓全体が止まる(2026-08-09実測)
|
|
30
30
|
11. **監査は文脈近接の独立1席(決定67)。** 監査を依頼する時は、**同じ工程・関連ToDoを担当した席**(文脈を既に持つ席)から**1席だけ**選ぶ。無関係の席へ依頼しない(契約・経緯の冷間読み込みでトークンを焼く)。独立の要件は「実装者本人でない」ことだけで、文脈近接と両立する。**自分が依頼されていない監査へ自発参加しない・他席の監査結果へ追認を送らない**。2席目(別レンズ)を付けてよいのは決定64の重い面(契約クリティカル・Wave/Phase gate)だけで、付ける側が理由1行を添える
|
|
31
|
-
12. **claimできるToDoが無いなら仕事を発明しない(決定68)。**
|
|
31
|
+
12. **claimできるToDoが無いなら仕事を発明しない(決定68)。** 依頼されていない監査・他席への状況照会・正典の自主レビューを暇つぶしに始めない(空いた時間は成果ではなくコスト)。**roomへ**「[待機] <名前>」を1行宣言して黙る(親宛DMにしない——決定71。親は決定68の運用側どおりroomログをpullして席数を判断しており、DMで起こす必要はない)。縮退の打診が来たらWIP棚卸し(claim中ToDo・未commit変更・未報告結果・残process/一時fixture)を正直に返す
|
|
32
32
|
13. 1 へ戻る
|
|
33
33
|
|
|
34
34
|
## effortを変更してほしい時
|
|
@@ -116,6 +116,7 @@ lattice run intake --run .lattice/runs/<run-id> --task <id>
|
|
|
116
116
|
|
|
117
117
|
## 注意
|
|
118
118
|
|
|
119
|
+
- **DM宛先は最小化する(決定71)**: 親宛DMは①done報告・監査受理要請、②オーナー承認gateに関わる物件、③親・オーナーにしか解けないblocker・裁定依頼(`[effort変更依頼]`はこれに該当)の3種だけ。進捗・調整・意見の共有や待機宣言は親宛DMにしない。席同士のDMも、その内容で**今すぐ動く必要がある相手だけ**へ送る——同報・返答不要の情報共有目的の複数宛先は禁止。宛先を選ぶ基準は「この宛先は受け取って何をする?」に答えられるかで、答えられない宛先は外す。記録目的はroomログ(全員pull可読)で足りる
|
|
119
120
|
- Lattice の書き込みが `STORE_WRITE_CONFLICT` 等で弾かれたら、1〜2 秒待って同じコマンドを再実行する(同時書込の正常な負け方であり、壊れてはいない)
|
|
120
121
|
- `--parallel-frontier` を付けた start が `parallel_frontier_not_applicable` で弾かれたら、それは**その task がもう `next_ready` に居ない**(他人が着手済み・依存で塞がった)という意味である。フラグの不具合ではないので付け外しで粘らず、`lattice todo status --json` と room ログで claim 状況を確認し直す
|
|
121
122
|
- claim が衝突したら、Lattice の start 記録(誰が in-progress か)を機械の事実として使う。**装置は claim の争いを裁定しない**——装置が見るのは着手済み task 同士の競合だけで、誰が取るかは卓が決める
|