peertable 0.8.39 → 0.8.41

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/.github/og.png ADDED
Binary file
package/README.ja.md CHANGED
@@ -113,9 +113,9 @@ Windows工場hostはPowerShell 7(`pwsh.exe`)を前提とし、5.1しかな
113
113
 
114
114
  動いており、**自分自身の開発に使っている**。2026-08-08 に end-to-end 検証済み——オーケストレーターなしの完全な一周(2 メンバーが相談し、claim し、インターフェースを交渉し、見つけた罠を共有して小さなプロジェクトを出荷)を**外部介入ゼロ**で完走。2026-08-13の実席ライフサイクルでは、作業席が親を通じてsession contextを保ったままmodel / effortを変更し、再起動後はroomと工程正本から再着任した。2026-08-14にはGrok 4.6席の着席、room参加、同一sessionの4.6↔4.5変更、DM起床を実機で確認した。2026-08-17にGrok席はidle待ち、broadcastは本文を残し、tmuxの無い親でbridge cursorが止まらないよう直した。
115
115
 
116
- 現在のnpm releaseは **peertable 0.8.31**。
116
+ 現在のnpm releaseは **peertable 0.8.40**。
117
117
 
118
- 設計文書と決定履歴(**129 決定**)は [docs/plan.md](docs/plan.md)
118
+ 製品の現行契約は [docs/current-design.md](https://github.com/kitepon/peertable/blob/main/docs/current-design.md)。完了計画と累積decision logは`docs/archive/`へ置き、現行文書の地図は [docs/00_overview.md](https://github.com/kitepon/peertable/blob/main/docs/00_overview.md) を正とする。
119
119
 
120
120
  Claude Code channels はリサーチプレビューのため、フラグ・プロトコルは変わりうる。
121
121
 
package/README.md CHANGED
@@ -85,8 +85,8 @@ room/ room server (zero-dependency Node) + per-session MCP channel client
85
85
  skill/ "peertable" skill for Claude Code: setup / disband (teardown) of a full table,
86
86
  plus the seat launcher and the wake-up / seat-state / run bridges
87
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
88
+ docs/ current-design.md — the current product contract (Japanese),
89
+ plus one plan_*.md per active campaign and archived history
90
90
  evidence/ per-task completion evidence referenced by the Lattice plan store
91
91
  experiments/ verification harnesses — one per pitfall we actually hit, each pinning the
92
92
  behaviour so it cannot silently regress (channels, Lattice concurrency, the full
@@ -129,7 +129,7 @@ export PEERTABLE_URL=http://localhost:8790 PEERTABLE_ROOM=myproject PEERTABLE_ME
129
129
  claude --dangerously-load-development-channels server:room
130
130
  ```
131
131
 
132
- **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.
132
+ **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; [archived decision 44](https://github.com/kitepon/peertable/blob/main/docs/archive/plan.md)). The skill handles this for you and reverts the file on teardown.
133
133
 
134
134
  The member gets five tools — `post`, `read_unread`, `read_log`, `members`, `delivery_status` — and a channel that wakes it whenever teammates address it. `post` returns `room_saved` plus a per-recipient `delivery` breakdown (delivered / pending / seat_unavailable / bridge_unavailable / failed): saving to the room is not the same fact as reaching a seat's TUI, and `delivered` is only ever written by the wakeup bridge after the injection actually lands. `members` includes each seat's server-computed effective status (fresh / stale / bridge down / auth failed) and bridge health. (`--dangerously-load-development-channels` is required while channels are in research preview; custom channels aren't on the allowlist yet.)
135
135
 
@@ -149,9 +149,9 @@ It interviews you, names the members, scaffolds `.team/` (charter + roles, isola
149
149
 
150
150
  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, and shipped a small project with **zero external intervention**. A 2026-08-13 real-seat lifecycle verified in-place model/effort changes and restart recovery. On 2026-08-14, a Grok 4.6 seat joined the room, changed 4.6↔4.5 in the same session, and woke on a direct message in a live acceptance run. On 2026-08-17 the wake-up path was corrected so Grok seats wait for idle, broadcasts keep their body, and a parent without a tmux seat cannot stall the bridge cursor.
151
151
 
152
- The current npm release is **peertable 0.7.1**.
152
+ The current npm release is **peertable 0.8.40**.
153
153
 
154
- The design document and decision log (**106 decisions**, in Japanese) live in [docs/plan.md](docs/plan.md).
154
+ The current product contract is [docs/current-design.md](https://github.com/kitepon/peertable/blob/main/docs/current-design.md). Completed plans and the cumulative decision log are kept under `docs/archive/`; the current document map is [docs/00_overview.md](https://github.com/kitepon/peertable/blob/main/docs/00_overview.md).
155
155
 
156
156
  Depends on Claude Code **channels**, currently a research preview — flags and protocol may change.
157
157
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "peertable",
3
- "version": "0.8.39",
3
+ "version": "0.8.41",
4
4
  "description": "A round table of peer agents. No orchestrator at the head. Turn Claude Code, Codex, and Grok sessions into a team of equal, long-lived peers.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -26,6 +26,7 @@
26
26
  ],
27
27
  "scripts": {
28
28
  "verify:release-commit": "node scripts/verify-release-commit.mjs",
29
+ "test:docs": "node --test scripts/docs-contract.test.mjs",
29
30
  "prepublishOnly": "node scripts/prepublish.mjs"
30
31
  },
31
32
  "bin": {
@@ -33,6 +34,7 @@
33
34
  "peertable-client": "room/client.mjs"
34
35
  },
35
36
  "files": [
37
+ ".github/og.png",
36
38
  "room/server.mjs",
37
39
  "room/client.mjs",
38
40
  "room/message-bounds.mjs",
@@ -46,5 +48,12 @@
46
48
  },
47
49
  "dependencies": {
48
50
  "@modelcontextprotocol/sdk": "^1.0.0"
51
+ },
52
+ "devDependencies": {
53
+ "parse-srcset": "1.0.2",
54
+ "parse5": "^8.0.1",
55
+ "remark-gfm": "^4.0.1",
56
+ "remark-parse": "^11.0.0",
57
+ "unified": "^11.0.5"
49
58
  }
50
59
  }
package/room/client.mjs CHANGED
@@ -14,7 +14,7 @@ import { boundedRecent, boundedUnread } from './message-bounds.mjs'
14
14
 
15
15
  // client.mjs 側のハードコード版数。package.json の version と一致していることを
16
16
  // diagnostics の version_consistency が見る(2 つの版数源の drift 検出。決定45)
17
- const MCP_VERSION = '0.8.39'
17
+ const MCP_VERSION = '0.8.41'
18
18
  const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
19
19
 
20
20
  const USAGE = `usage:
package/room/server.mjs CHANGED
@@ -2,7 +2,7 @@
2
2
  // Peertable room サーバー。チャットルームの正本を所有し、Web UI を内蔵する。
3
3
  // 起動: node server.mjs(PEERTABLE_PORT / PEERTABLE_DATA / PEERTABLE_POST_TOKEN で設定)
4
4
  import http from 'node:http'
5
- import { mkdirSync, readFileSync, appendFileSync, existsSync, rmSync, readdirSync, renameSync } from 'node:fs'
5
+ import { mkdirSync, readFileSync, appendFileSync, existsSync, rmSync, readdirSync, renameSync, writeFileSync } from 'node:fs'
6
6
  import { dirname, join } from 'node:path'
7
7
  import { fileURLToPath } from 'node:url'
8
8
  import { DatabaseSync } from 'node:sqlite'
@@ -129,6 +129,12 @@ function normalizeMemberMeta(meta) {
129
129
  // room 状態はプロセスが所有する(member 正本は上の SQLite、ログは append-only file)
130
130
  const rooms = new Map() // name -> { seq, streams: Set<res> }
131
131
 
132
+ // archive 状態の正本は room dir 内の marker file。卓の teardown が付け、次の setup の
133
+ // member 登録が自動で外す。archived room は公開一覧(/api/rooms・トップページの主一覧)から
134
+ // 外れるだけで、ログ・member 台帳・個別ページはそのまま読める——過去卓の記録は消さない。
135
+ const archiveMarker = name => join(DATA, name, 'archived.json')
136
+ const isArchived = name => existsSync(archiveMarker(name))
137
+
132
138
  // create=true は書込系だけが渡す。読み取りは room を作らない
133
139
  function loadRoom(name, create = false) {
134
140
  const cached = rooms.get(name)
@@ -369,11 +375,17 @@ http.createServer(async (req, res) => {
369
375
 
370
376
  // API: /api/rooms — 公開されている全room名。状態は既存のroom別summaryから読む。
371
377
  if (req.method === 'GET' && seg.length === 2 && seg[0] === 'api' && seg[1] === 'rooms') {
372
- const roomNames = readdirSync(DATA, { withFileTypes: true })
378
+ const allRooms = readdirSync(DATA, { withFileTypes: true })
373
379
  .filter(entry => entry.isDirectory())
374
380
  .map(entry => entry.name)
375
381
  .sort((a, b) => a.localeCompare(b))
376
- return json(res, 200, { schema: 'peertable.rooms.v1', rooms: roomNames }, CORS)
382
+ // rooms は現役卓だけ。archived を混ぜると、公開トップの live 窓が「最後に配達失敗が
383
+ // 流れた死卓」を最新活動として選び続ける(2026-08-30 実測)。archived は別欄で返す
384
+ return json(res, 200, {
385
+ schema: 'peertable.rooms.v1',
386
+ rooms: allRooms.filter(name => !isArchived(name)),
387
+ archived: allRooms.filter(isArchived),
388
+ }, CORS)
377
389
  }
378
390
 
379
391
  // API: /api/<room>/...
@@ -468,6 +480,15 @@ http.createServer(async (req, res) => {
468
480
  // delivered は wakeup-bridge の投入成立 receipt だけが作る。sent の一語で両者を混ぜない
469
481
  return json(res, 200, { ...saved, room_saved: true, delivery: deliveryPlanFor(room.name, saved, bridgeHealth(room.name)) })
470
482
  }
483
+ // 卓の解散(teardown)が room を公開一覧から外す。復活は下の members 登録が自動で行う
484
+ if (req.method === 'POST' && rest === 'archive') {
485
+ writeFileSync(archiveMarker(room.name), JSON.stringify({ archived_at: new Date().toISOString() }) + '\n')
486
+ return json(res, 200, { ok: true })
487
+ }
488
+ if (req.method === 'DELETE' && rest === 'archive') {
489
+ rmSync(archiveMarker(room.name), { force: true })
490
+ return json(res, 200, { ok: true })
491
+ }
471
492
  // bridge 心拍(決定103)。seat-status / wakeup が 30 秒ごとに送る。正常心拍は 403 観測を消す
472
493
  if (req.method === 'POST' && rest === 'bridges') {
473
494
  const { kind, pid, state, detail } = JSON.parse(body)
@@ -508,6 +529,9 @@ http.createServer(async (req, res) => {
508
529
  if (normalized.observe == null && known?.observe) delete normalized.observe
509
530
  const merged = { name, joined_at: known?.joined_at ?? new Date().toISOString(), ...known, ...normalized }
510
531
  putMember(room.name, merged)
532
+ // 同じ room 名で次の卓が立ったら archive を自動解除する。setup も席の client も
533
+ // member 登録から始まるので、ここが「卓が現役へ戻った」の実測点になる
534
+ rmSync(archiveMarker(room.name), { force: true })
511
535
  // **system 発言は本当に新規の時だけ**。欄の更新で「参加した」を流すと、状態を数秒ごとに
512
536
  // 送る消費者が卓の全席を起こし続ける(既存メンバーへの再 POST で実測・room [285])
513
537
  if (!known) post(room, 'system', name, `${name} が参加した`)
@@ -536,7 +560,7 @@ http.createServer(async (req, res) => {
536
560
  if (req.method === 'GET' && seg.length === 0) {
537
561
  const list = readdirSync(DATA, { withFileTypes: true }).filter(d => d.isDirectory()).map(d => d.name)
538
562
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
539
- return res.end(INDEX(list))
563
+ return res.end(INDEX(list.filter(name => !isArchived(name)), list.filter(isArchived)))
540
564
  }
541
565
  if (req.method === 'GET' && seg.length === 1) {
542
566
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
@@ -926,15 +950,20 @@ setInterval(()=>{if(Date.now()-lastBeat>BEAT*2.5)connect()},BEAT/2)
926
950
  setInterval(refreshMembers,30000) // 退席(member DELETE)は発言を出さないので定期に取り直す
927
951
  </script></body></html>`
928
952
 
929
- const INDEX = list => `<!doctype html><html lang="ja"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
953
+ const INDEX = (list, archived = []) => `<!doctype html><html lang="ja"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
930
954
  <title>Peertable</title>${FAVICON}<style>${STYLE}
931
955
  .wrap{max-width:560px;margin:0 auto;padding:56px 16px}
932
956
  .tag{color:var(--dim);margin:6px 0 22px}
933
957
  .rooms{list-style:none;padding:0;margin:0;display:flex;flex-direction:column;gap:8px}
934
958
  .rooms a{display:flex;align-items:center;gap:9px;padding:11px 14px;border:1px solid var(--line);border-radius:11px;background:var(--surface);text-decoration:none;color:var(--fg);font-weight:650}
935
959
  .rooms a::before{content:"";width:7px;height:7px;border-radius:50%;background:var(--accent)}
960
+ .archived{margin-top:26px}
961
+ .archived summary{color:var(--dim);cursor:pointer;margin-bottom:10px}
962
+ .archived .rooms a{font-weight:450;color:var(--dim)}
963
+ .archived .rooms a::before{background:var(--dim)}
936
964
  </style></head><body><div class="wrap">
937
965
  <div class="brand">${MARK}Peertable</div>
938
966
  <p class="tag">A round table of peer agents. No orchestrator at the head.</p>
939
- <ul class="rooms">${list.map(r => `<li><a href="/${encodeURIComponent(r)}">${esc(r)}</a></li>`).join('') || '<li class="empty">(まだ卓が無い)</li>'}</ul>
967
+ <ul class="rooms">${list.map(r => `<li><a href="/${encodeURIComponent(r)}">${esc(r)}</a></li>`).join('') || '<li class="empty">(現役の卓は無い)</li>'}</ul>
968
+ ${archived.length ? `<details class="archived"><summary>解散した卓(${archived.length})</summary><ul class="rooms">${archived.map(r => `<li><a href="/${encodeURIComponent(r)}">${esc(r)}</a></li>`).join('')}</ul></details>` : ''}
940
969
  </div></body></html>`
@@ -1,103 +1,38 @@
1
- # 02_models 役割→モデル×エフォート順位表(唯一の参照点)
1
+ # Peertable seat placement snapshot
2
2
 
3
- <!-- 前提: 2026-08-19 更新(Claude 5 / GPT-5.6 / Grok 4.6 世代)。バージョン固定禁止(PLAN 原則9)。モデル名をこの表以外+公認例外(codex/agents/*.toml・.codex-sidecar.yml)に書き散らさない -->
3
+ ```yaml
4
+ schema: peertable.models-placement.v1
5
+ source_repository: https://github.com/kitepon/dotagents
6
+ source_path: docs/02_models.md
7
+ source_commit: dcf4d9064b5c4f21a7643cbd169b7047cf3607bf
8
+ captured_at: 2026-08-30
9
+ ```
4
10
 
5
- 方針: skill・agents・委譲契約・スクリプトは**役割名**でモデルを指し、具体名への解決はこの表だけが担う。本書は役割を先に定義し、役割ごとに適したモデル×effortを1位〜3位で与える。ベンダー別レーンの表構造は撤廃した(オーナー裁定 2026-08-19)。世代交代時は**この1枚+公認例外2種を更新して push すれば全端末が追従**する。更新トリガーはオーナーの宣言(PLAN 原則6)。
6
-
7
- 背骨: **役割が要求する能力で選び、順位は実測で昇降格する。ただし、現状維持に候補側だけの立証責任を負わせない。** 新しい有力候補は代表実務へ期限・範囲を切って投入し、成功率・手戻り・監査工数・総token・所要時間・quotaで現役と比較する。未検証の現役を「安全」、未検証の新顔を「危険」とは扱わない。
8
-
9
- 本書の根拠は次の4種を混ぜない。
10
-
11
- 1. **一次事実**: model提供元公式の価格、context、対応effort、live catalog。
12
- 2. **外部観測**: benchmark、Xの利用報告、独立評価。harness・標本数・再現条件を併記する。
13
- 3. **dotagents実測**: 当工場のtask、監査通過率、配線実測。一般性能へ拡張しない。
14
- 4. **運用判断**: 上記を踏まえた配置。客観的に確定した事実を「オーナー裁定」と呼ばない。
15
-
16
- **親のモデル×effortはオーナーの領分**であり、規範・AIはピンを打ち替えない。親候補と根拠は提示し、子の配置はこの表で解決する。
17
-
18
- ## 役割の定義(9つ)
19
-
20
- 1. **統括** — campaign/会話の主体。裁定・契約クリティカルな受入・commit
21
- 2. **反証** — 実ファイルを読んで主張を殺しにかかる
22
- 3. **監査・発見** — 自成果物(repo・設計・文書)の欠陥出し。誤検知は後段が裁く(第三者reviewを含む)
23
- 4. **設計** — 実装前の構造・境界・停止判断の案出し
24
- 5. **相談** — 実読不要の純推論second opinion
25
- 6. **実装** — 仕様固定済みのまとまった実装・テスト・移設
26
- 7. **局所コーディング** — focused test付きの狭い修正・実装
27
- 8. **軽作業** — 分類・抽出・字面回収
28
- 9. **調査** — 外部事実の回収〜統合(浅↔深はeffortで刻む)
11
+ これはPeertableがrelease単位で所有する着席配置の既定値である。役割、順位、model slug、effortだけを同梱し、dotagentsの説明文や内部リンクは複製しない。外部表を使う場合は`PEERTABLE_MODELS_DOC`または`DOTAGENTS_ROOT`を明示する。
29
12
 
30
13
  ## 順位表(役割→1位〜3位)
31
14
 
32
- 「実測」= dotagents 2026-08-19 役割配置実験([実験記録](../rag/models/role-placement-experiment-20260819.md)、各セルn=1)。
33
-
34
15
  | 役割 | 1位 | 2位 | 3位 |
35
16
  |---|---|---|---|
36
- | 統括 | **オーナー指定**(参考候補: Opus 5×high、Grok 4.6×high、Fable 5×highスポット。順位は参考情報でpinはオーナー) | — | — |
37
- | 反証 | Grok 4.6×high(実測14/14) | Sol×high(実測14/14。同着はGrok優先=オーナー裁定 2026-08-19) | Opus 5×high(実測13/14。唯一の誤りは真だが無害な指摘を殺した方向) |
38
- | 監査・発見 | Grok 4.6×medium(実測recall 7/10・FP 0) | Sonnet 5×medium(実測6/10+partial 1・FP 0) | Terra×medium(実測6/10・FP 0) |
39
- | 設計 | Opus 5×high(GDPval-AA・APEX首位 2026-08-19時点) | Grok 4.6×high(独立視点) | Sol×medium(過剰設計報告に注意) |
40
- | 相談 | ChatGPT(gpt-connector・別quota。[06_gpt-connector.md](06_gpt-connector.md)) | Grok 4.6×medium | Fable 5×highスポット(契約critical) |
41
- | 実装 | Terra×high(Terminal-Bench 2.1 78.4%) | Sonnet 5×medium〜high | Grok 4.6×medium(repo横断・長時間はhigh) |
42
- | 局所コーディング | Luna×medium(実測: maxと品質差なし・14/14) | Sonnet 5×medium | Grok 4.6×medium |
43
- | 軽作業 | Luna×low〜medium | Haiku(Claude枠で閉じる時。effortなし) | Grok 4.6×low |
44
- | 調査 | Grok 4.6×low(字面)〜medium(統合)。X直結は唯一 | Sonnet 5×medium+Web | Sol/Terraで独立確認 |
45
-
46
- ### 構造規則(3つ)
47
-
48
- 1. **反証の1位は、成果を作ったモデルと別ベンダーから選ぶ。**
49
- 2. **順位は既定であって拘束ではない。** quota逼迫・入口障害・catalog不在時は次順位へ落とし、落とした事実を報告する。存在しないmodel/effortへはfallbackせず明示エラーにする。
50
- 3. **見逃し対策は2段構造で行う。** finderの増席でなく「疑いを列挙→反証役に裁かせる」工程を挟む(実測: finder 3席が全員見逃した欠陥3件を反証工程が回収した)。
51
-
52
- ## モデル台帳(slug・価格の解決はここだけ)
53
-
54
- | モデル | slug | API定価(入力/出力 per Mtok) | context | effort段階 |
55
- |---|---|---|---|---|
56
- | Claude Fable 5 | alias `fable` | $10/$50 | 1M | low〜xhigh/max |
57
- | Claude Opus 5 | alias `opus` | $5/$25 | 1M | low〜xhigh/max |
58
- | Claude Sonnet 5 | alias `sonnet` | **$2/$10(恒久価格)** | 1M | low〜xhigh/max |
59
- | Claude Haiku 4.5 | alias `haiku` | $1/$5 | 200K | **effortなし** |
60
- | GPT-5.6 Sol | `gpt-5.6-sol` | $5/$30(長contextは$10/$45) | 1.05M | none〜max(Codex CLIはultraあり) |
61
- | GPT-5.6 Terra | `gpt-5.6-terra` | $2/$12(長contextは$4/$18) | 1.05M | 同上 |
62
- | GPT-5.6 Luna | `gpt-5.6-luna` | $0.20/$1.20(長contextは$0.40/$1.80) | 1.05M | none〜max(ultraなし) |
63
- | Grok 4.6 | `grok-4.6` | $2/$6(200K超は$4/$12) | 500K | low/medium/high(既定)/xhigh |
64
-
65
- 価格は標準API定価であり、Claude Code・Codex・Grok Buildのsubscription quotaとは別物。OpenAIの長context課金は階層制(境界と現行値は公式pricingを実行時に確認)。Grok Buildのcatalogに`grok-build-0.1`(Composer 2.5系)の記載が公式docsにあるが、端末live catalogでの実在は未検証——Composer入口はcatalog確認まで`unsupported`のまま、Grokへfallbackしない。根拠は [GPT-5.6](../rag/models/gpt-5.6-family.md)、[Claude 5](../rag/models/claude-5-family.md)、[Grok 4.6](../rag/models/xai-grok46.md)。
66
-
67
- ### 消費枠(quotaの独立勘定。規則2のfallback判断に使う)
68
-
69
- Anthropic(Claude Code本体・Agent/Workflow)/OpenAI Codex(Codex CLI・codex-sidecar・aiterm codex_agent)/OpenAI ChatGPT(gpt-connector・API fallback禁止)/xAI(Grok Build・aiterm grok_agent)の4枠は別勘定。同役割の次順位が別枠なら、quota逼迫時のfallbackはコスト増でなく枠の移動になる。
70
-
71
- ## 入口と使い分け
72
-
73
- - **Codex親の三入口を分ける**: ① native subagent=repo密結合、② external execution=codex-sidecar/aiterm、③ consultation=gpt-connector。Grok/ComposerはAitermの別harness入口であり、Codex→Codexの入口判断とは別契約。
74
- - Aitermの`codex_agent`/`grok_agent`/`claude_agent`はmodelとeffortを毎回明示する。live catalog不在・effort非対応は明示エラーにし、別modelへfallbackしない。
75
- - **委譲の安全・回収・受入契約は[委譲契約](../shared/orchestrate/delegation-contract.md)が正本**。Aitermの運用型は[aiterm-dispatch](../shared/orchestrate/aiterm-dispatch.md)を正とする。external writerはinstalled→registered→verified→execution-verifiedの最終段だけに置く。
76
- - codex-sidecarはmodel/effortを毎回明示するか`.codex-sidecar.yml` defaultsへ置く。現行schemaはlow〜xhighでmaxを渡せない。
77
- - **役割と配置関係の機械可読な対応**は`lib/orchestrate/placement-policy.mjs` v1が固定する。自動ConsultationはAnthropic/OpenAIだけだが、これは現行配線のclosed enumであり、xAIの能力評価ではない。
78
-
79
- ## effortの規範
80
-
81
- effortは単調に品質を上げない。まず役割に合うpresetへ置き、同じ代表taskで隣接levelを比べる。失敗後に変えるのはmodel tierかeffortの片方だけにする。
82
-
83
- | model | 出発点 | 上げ下げの規律 |
84
- |---|---|---|
85
- | Opus 5 | **medium**(工程限定の実装・review)、**high**(長期agent・複雑設計) | xhigh/maxは長時間の知識仕事で測定差が出た時だけ。権限境界が緩い工程へ高effortで置かない |
86
- | Fable 5 | high | 契約criticalのスポット。常用親と同義になる使い方はしない |
87
- | Sonnet 5 | medium | finderの字面回収だけlow。高effortは代表taskで差が出た時 |
88
- | Haiku 4.5 | effortなし | 存在しない`haiku×low`を指定しない |
89
- | Sol/Terra | medium | high/xhighは測定可能な品質差がある時。maxは最難関quality-first。ultraはCodex harnessのmax+自動fan-outであり明示要求時だけ |
90
- | Luna | **分解済み・仕様固定・focused test付きはmedium**(2026-08-19実測: medium=max同格・14/14) | 分解が甘い仕事はLunaのeffortを上げて救わず別モデルを選ぶ(旧「maxのみ」実測の教訓を適用範囲限定で継承) |
91
- | Grok 4.6 | low=X/字面回収、medium=統合調査・監査finder、high=統括・反証・長時間agent | xhighは思考loopと遅延の外部報告がある。代表taskでhighを上回った時だけ使う |
92
-
93
- ## Grok 4.6をどう読むか
94
-
95
- xAI公式値ではoffice/agentic系で最前線級、DeepSWE・TerminalBenchでは比較対象を下回る。Xには統括・review・長時間実装の成功例と、security誤判定・部分読み完了誤認・思考loopの失敗例が併存する。dotagents実測(2026-08-19)では監査finderのrecall首位(7/10・FP 0)と反証満点(14/14)で、監査・反証・調査の第一候補として実戦配置する。
96
-
97
- ## 指定と世代交代時の更新手順
98
-
99
- - Claude Code内はfloating alias(`fable`/`opus`/`sonnet`/`haiku`)だけを使う。Agent/Workflowのmodelとeffortは対応する場合に毎回明示し、Haikuへeffortを付けない。
100
- - Codexにfloating aliasがないため、`codex/agents/*.toml`と`.codex-sidecar.yml`は具体slugを持つ公認例外。世代交代時は本書と同一commitで更新する。
101
- - 外部CLIはpinせず`agents-update`でlatest追従する。model live catalogは実行直前に見る。
102
- - オーナーの世代交代宣言後、`grep -rn "前提:"`で影響面を列挙し、本書、公認例外、focused fixture、RAGを同時更新する。新規候補は小さな実戦比較を行い、失敗も次の配置判断へ残す。
103
- - 順位の変更は実測(役割配置実験の再走または実戦の成功率・手戻り)を根拠にし、実験記録をRAGへ残してから表を書き換える。
17
+ | 統括 | オーナー指定 | — | — |
18
+ | 反証 | Grok 4.6×high | Sol×high | Opus 5×high |
19
+ | 監査・発見 | Grok 4.6×medium | Sonnet 5×medium | Terra×medium |
20
+ | 設計 | Opus 5×high | Grok 4.6×high | Sol×medium |
21
+ | 相談 | ChatGPT(gpt-connector・別quota) | Grok 4.6×medium | Fable 5×high |
22
+ | 実装 | Terra×high | Sonnet 5×medium | Grok 4.6×medium |
23
+ | 局所コーディング | Luna×medium | Sonnet 5×medium | Grok 4.6×medium |
24
+ | 軽作業 | Luna×low | Haiku | Grok 4.6×low |
25
+ | 調査 | Grok 4.6×low | Sonnet 5×medium | Sol×medium |
26
+
27
+ ## モデル台帳
28
+
29
+ | モデル | slug |
30
+ |---|---|
31
+ | Claude Fable 5 | alias `fable` |
32
+ | Claude Opus 5 | alias `opus` |
33
+ | Claude Sonnet 5 | alias `sonnet` |
34
+ | Claude Haiku 4.5 | alias `haiku` |
35
+ | GPT-5.6 Sol | `gpt-5.6-sol` |
36
+ | GPT-5.6 Terra | `gpt-5.6-terra` |
37
+ | GPT-5.6 Luna | `gpt-5.6-luna` |
38
+ | Grok 4.6 | `grok-4.6` |
package/skill/SKILL.md CHANGED
@@ -5,7 +5,7 @@ description: 任意プロジェクトに Peertable チーム(対等メンバ
5
5
 
6
6
  # Peertable — setup / teardown
7
7
 
8
- 正典は peertable リポジトリの docs/plan.md(設計・決定履歴)。本スキルは手順書である。
8
+ 製品境界と設計の正典は peertable リポジトリの docs/current-design.md。本スキルはsetup / resume / teardownの手順正本である。
9
9
 
10
10
  ## 前提
11
11
 
@@ -50,7 +50,7 @@ script は内部で Aiterm の公開 `claude_agent` / `codex_agent` / `grok_agen
50
50
 
51
51
  手順は **聞き取り → script → 着任指示** の3段である。scripts が機械部分を全部持つので、AI が手で tmux を組み立てることはしない。
52
52
 
53
- 1. **聞き取り**: 対象プロジェクトのパス / **工程正本(`Lattice 併用`=既定 / `単独`)** / メンバー数と**役割**(dotagents `docs/02_models.md` の役割名そのもの。未指定・未知は着席しない。model×effort は `launch-seat.sh` が順位表1位から機械解決する——呼び出し側が model / harness / effort を渡して正本を迂回しない。決定49改・決定91)/ 初期タスク群(何を作るか)/ room 名(既定: プロジェクトのディレクトリ名)/ **公開URL基底**(Lattice 併用のみ。外部ペインに書く URL。クオ環境は `https://peertable.kitepon.dev`。未指定なら room サーバーの URL がそのまま入る=LAN URL は Lattice を外から見た時に開けない)
53
+ 1. **聞き取り**: 対象プロジェクトのパス / **工程正本(`Lattice 併用`=既定 / `単独`)** / メンバー数と**役割**(同梱 `02_models.snapshot.md` の役割名そのもの。未指定・未知は着席しない。model×effort は `launch-seat.sh` が同梱順位表1位から機械解決する。隣接dotagentsは暗黙に読まず、外部表は明示opt-inだけ。呼び出し側が model / harness / effort を渡して正本を迂回しない。決定49改・決定91)/ 初期タスク群(何を作るか)/ room 名(既定: プロジェクトのディレクトリ名)/ **公開URL基底**(Lattice 併用のみ。外部ペインに書く URL。クオ環境は `https://peertable.kitepon.dev`。未指定なら room サーバーの URL がそのまま入る=LAN URL は Lattice を外から見た時に開けない)
54
54
  - **メンバー数の既定**: Lattice 併用なら plan compile 結果の幅(`max_frontier_width`)に合わせる(実測: 幅3→3人、第2 campaign で幅4→4人目追加)。frontier より多い席は最初から遊ぶ。単独モードには frontier が無いので既定の根拠も無く、聞き取りで決める
55
55
  - **運用中のworker席数の標準は「ready+activeな実装ToDo数」(決定68)**: 監査専任席はworker数、reclaim、scale-down候補へ含めず、最終試験結果を待って監査を担う。claimできるToDoが無いworker席は仕事を発明せず、最終手段として親だけへ待機DMする
56
56
  - **モードの選び分け**: タスク間に依存があり並列境界の機械保証が要るなら Lattice 併用。依存の無い小規模作業で、対象プロジェクトに Lattice を持ち込みたくないなら単独。単独で失うのは task 間スケジューリングの機械保証だけで、円卓の核(room・憲章・宣言による協力)は変わらない(決定47)
@@ -124,7 +124,7 @@ script は内部で Aiterm の公開 `claude_agent` / `codex_agent` / `grok_agen
124
124
  - **席と spool は接触しない。** 席が触るのは room と worktree だけで、`.lattice/` の直読み・直書き禁止の契約はそのまま
125
125
  - **席の作法の正本は `templates/member.md` の「Lattice の実行層へ自分の着手を載せる」節**(intake→intervention 判定→attach→作業→監査担当が `done.sh`→intake 席が accept の順・1席1 intake・禁止操作・検証の回し方・成果の正本)。ここに二重化しない
126
126
  - **席は自分の pid を装置へ渡す(attach)。** その pid は room 台帳の member 行(`launch-seat.sh` が着席直後に登録する本人性欄)が持ち、席は `pull-attach-input.mjs` で読むだけで attach input になる(席file は 2026-08-22 廃止・member に帰属する情報の正本は台帳だけ)。**raw argv を保存しない**——token値をargvから除いた後も、将来の引数を無条件に複製しない。持つのはdigestだけ。Lattice の再観測は `/bin/ps -o command=`。pid/lstart が一致して digest だけ違うときは親が `skill/scripts/refresh-seat-identity.mjs <project> <name>` で揃える。席は台帳の本人性欄を書き換えない
127
- - **run-bridge は退役した(2026-08-22・オーナー裁定)。** 介入(hold)は席が自分の Lattice コマンド応答(intake / attach / accept / `run intake intervention`)で受け取る——これが唯一の経路である。装置の介入を DM で先回り通知する中継は、凍った席には届かず、届いた席を退席させ、死んだ席の shell へ打鍵する事故だけを生んだので廃止した。ブリッジは wakeupseat-status の2本だけ
127
+ - **run-bridge は退役した(2026-08-22・オーナー裁定)。** 介入(hold)は席が自分の Lattice コマンド応答(intake / attach / accept / `run intake intervention`)で受け取る——これが唯一の経路である。装置の介入を DM で先回り通知する中継は、凍った席には届かず、届いた席を退席させ、死んだ席の shell へ打鍵する事故だけを生んだので廃止した。現行の常駐bridgeは wakeupseat-status、alarm3本であり、Lattice介入の中継は持たない
128
128
 
129
129
  運用側が踏みやすい所(実測で確認した挙動):
130
130
 
@@ -53,8 +53,19 @@ fi
53
53
  # **手渡された env を常駐へ渡す。** 新しい session が継ぐのは tmux *server* の環境であって
54
54
  # 呼び出し元 client の環境ではないので、素で起こすと `PEERTABLE_TMUX_SOCKET` の手渡しが黙って消え、
55
55
  # 常駐が別の socket(本番の既定)を観測しにいく(2026-08-11 実測)。決定73 と同じ形の裏返しである。
56
+ # **秘密値はコマンドラインへ載せない。** `env PEERTABLE_POST_TOKEN=… node …` の形は
57
+ # tmux のsession起動コマンドと ps の両方に平文で残る(2026-08-30 実測)。トークンは
58
+ # 0600 の credential file へ落とし、常駐へはそのパスだけを渡す(bridge 側の
59
+ # resolvePostToken が PEERTABLE_CREDENTIAL_FILE → ~/.config/peertable.env の順で読む)
60
+ if [ -n "${PEERTABLE_POST_TOKEN:-}" ] && [ -z "${PEERTABLE_CREDENTIAL_FILE:-}" ]; then
61
+ cred_dir="$proj/.team/credentials"
62
+ mkdir -p "$cred_dir" && chmod 700 "$cred_dir"
63
+ ( umask 177; printf '%s\n' "$PEERTABLE_POST_TOKEN" > "$cred_dir/bridge.token" )
64
+ PEERTABLE_CREDENTIAL_FILE="$cred_dir/bridge.token"
65
+ export PEERTABLE_CREDENTIAL_FILE
66
+ fi
56
67
  env_prefix=""
57
- for v in PEERTABLE_TMUX_SOCKET PEERTABLE_POST_TOKEN PEERTABLE_CREDENTIAL_FILE PEERTABLE_URL PEERTABLE_PARENT_NAME; do
68
+ for v in PEERTABLE_TMUX_SOCKET PEERTABLE_CREDENTIAL_FILE PEERTABLE_URL PEERTABLE_PARENT_NAME; do
58
69
  eval "val=\${$v:-}"
59
70
  [ -n "$val" ] && env_prefix="$env_prefix $v=$(printf '%q' "$val")"
60
71
  done
@@ -2,9 +2,10 @@
2
2
  import { resolve } from 'node:path'
3
3
  import { fileURLToPath } from 'node:url'
4
4
 
5
+ // PEERTABLE_POST_TOKEN は渡さない——EncodedCommand は base64 なだけで process 一覧から
6
+ // 復元できる。秘密値は ensure-bridge.sh が credential file へ落とし、ここへはパスだけが来る
5
7
  const FORWARDED_ENV = [
6
8
  'PEERTABLE_TMUX_SOCKET',
7
- 'PEERTABLE_POST_TOKEN',
8
9
  'PEERTABLE_CREDENTIAL_FILE',
9
10
  'PEERTABLE_URL',
10
11
  'PEERTABLE_PARENT_NAME',
@@ -18,7 +18,9 @@ export function parseWindowsCreationDate(value) {
18
18
  export function observeWindowsPidCommand(pid, hashArgv) {
19
19
  const filter = `ProcessId=${pid}`
20
20
  const script = `Get-CimInstance Win32_Process -Filter ${JSON.stringify(filter)} | Select-Object ProcessId,CreationDate,CommandLine | ConvertTo-Json -Compress`
21
- const output = execFileSync('powershell.exe', ['-NoProfile', '-Command', script], { encoding: 'utf8' }).trim()
21
+ const output = execFileSync('pwsh.exe', ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', script], {
22
+ encoding: 'utf8',
23
+ }).trim()
22
24
  if (!output) throw new Error('pid のWin32_Processを観測できない')
23
25
  const row = JSON.parse(output)
24
26
  const started = parseWindowsCreationDate(row.CreationDate)
@@ -1,19 +1,32 @@
1
1
  export function resolveWindowsCommand(cli, argv, {
2
2
  platform = process.platform,
3
3
  exists,
4
- comspec = process.env.ComSpec || 'cmd.exe',
4
+ pwsh = 'pwsh.exe',
5
5
  } = {}) {
6
6
  if (platform !== 'win32') return { command: cli, argv }
7
7
  const lower = cli.toLowerCase()
8
- const shim = lower.endsWith('.cmd') || lower.endsWith('.bat') || lower.endsWith('.exe')
8
+ if (lower.endsWith('.exe')) return { command: cli, argv }
9
+
10
+ const ps1 = lower.endsWith('.ps1')
9
11
  ? cli
10
- : exists?.(`${cli}.cmd`)
11
- ? `${cli}.cmd`
12
- : cli
13
- if (/\.exe$/i.test(shim)) return { command: shim, argv }
14
- return { command: comspec, argv: ['/d', '/c', shim, ...argv] }
12
+ : /\.(?:cmd|bat)$/iu.test(cli)
13
+ ? `${cli.slice(0, -4)}.ps1`
14
+ : `${cli}.ps1`
15
+ if (!exists?.(ps1)) {
16
+ const error = new Error(`PowerShell 7 shimが見つかりません: ${ps1}`)
17
+ error.code = 'PEERTABLE_WINDOWS_PWSH_SHIM_REQUIRED'
18
+ throw error
19
+ }
20
+ return {
21
+ command: pwsh,
22
+ argv: ['-NoLogo', '-NoProfile', '-NonInteractive', '-File', ps1, ...argv],
23
+ }
15
24
  }
16
25
 
17
- export function resolveWindowsLatticeCommand(cli, exists) {
18
- return resolveWindowsCommand(cli, ['todo', 'status', '--json'], { exists })
26
+ export function resolveWindowsLatticeCommand(cli, exists, { pwsh = 'pwsh.exe' } = {}) {
27
+ return resolveWindowsCommand(cli, ['todo', 'status', '--json'], {
28
+ platform: 'win32',
29
+ exists,
30
+ pwsh,
31
+ })
19
32
  }
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
- // 02_models.md の役割→1位〜3位から、着席可能な harness / model / effort を解決する。
3
- // 具体モデル名はここに持たない。台帳と順位表をその場で読む。
2
+ // 同梱snapshotの役割→1位〜3位から、着席可能な harness / model / effort を解決する。
3
+ // 外部文書を読むのは呼出側が明示した時だけ。隣接repoの有無で製品挙動を変えない。
4
4
  import { existsSync, readFileSync, realpathSync } from 'node:fs'
5
5
  import { dirname, join, resolve } from 'node:path'
6
6
  import { fileURLToPath } from 'node:url'
@@ -23,8 +23,6 @@ const fail = (code, message) => {
23
23
  export function findModelsDoc({ env = process.env, exists = existsSync, scriptDir = here } = {}) {
24
24
  if (env.PEERTABLE_MODELS_DOC) return resolve(env.PEERTABLE_MODELS_DOC)
25
25
  if (env.DOTAGENTS_ROOT) return join(resolve(env.DOTAGENTS_ROOT), 'docs/02_models.md')
26
- const sibling = resolve(scriptDir, '../../../dotagents/docs/02_models.md')
27
- if (exists(sibling)) return sibling
28
26
  const bundled = join(scriptDir, '../02_models.snapshot.md')
29
27
  if (exists(bundled)) return bundled
30
28
  return null
@@ -270,7 +268,7 @@ if (isMain) {
270
268
  if (!roles) roles = positional[0] ?? ''
271
269
  const doc = findModelsDoc()
272
270
  if (!doc) {
273
- fail('SEAT_MODELS_DOC_MISSING', '02_models.md が見つからない(PEERTABLE_MODELS_DOC または DOTAGENTS_ROOT を渡す)')
271
+ fail('SEAT_MODELS_DOC_MISSING', '同梱の02_models.snapshot.mdが見つからない')
274
272
  }
275
273
  if (!existsSync(doc)) {
276
274
  fail('SEAT_MODELS_DOC_MISSING', `02_models.md が無い: ${doc}`)
@@ -94,6 +94,21 @@ test('bridge更新は版数だけでなくruntime source digestへ束縛する',
94
94
  assert.ok(source.includes('peertable_runtime_digest'))
95
95
  })
96
96
 
97
+ test('bridge起動は書込トークンをコマンドラインへ載せない', () => {
98
+ const ensure = readFileSync(new URL('./ensure-bridge.sh', import.meta.url), 'utf8')
99
+ // env_prefix の転送対象に生トークンを含めない(tmux session コマンドと ps に平文で残る・2026-08-30 実測)
100
+ assert.ok(!ensure.includes('PEERTABLE_TMUX_SOCKET PEERTABLE_POST_TOKEN'))
101
+ assert.ok(ensure.includes('bridge.token'))
102
+ const launch = buildWindowsBridgeLaunch({
103
+ script: 'bridge.mjs', project: 'C:\\work', log: 'C:\\work\\bridge.log',
104
+ env: { PEERTABLE_POST_TOKEN: 'secret-raw-token', PEERTABLE_CREDENTIAL_FILE: 'C:\\work\\.team\\credentials\\bridge.token' },
105
+ })
106
+ const encodedAt = launch.argv.indexOf('-EncodedCommand')
107
+ const decoded = Buffer.from(launch.argv[encodedAt + 1], 'base64').toString('utf16le')
108
+ assert.ok(!decoded.includes('secret-raw-token'))
109
+ assert.ok(decoded.includes('bridge.token'))
110
+ })
111
+
97
112
  test('Windows bridgeはUTF-8を明示してログへ書く', () => {
98
113
  const launch = buildWindowsBridgeLaunch({ script: 'bridge.mjs', project: 'C:\\work', log: 'C:\\work\\bridge.log' })
99
114
  const encodedAt = launch.argv.indexOf('-EncodedCommand')
@@ -59,7 +59,9 @@ function posixIdentity(pid) {
59
59
 
60
60
  function winQuery(filter) {
61
61
  const script = `Get-CimInstance Win32_Process -Filter ${JSON.stringify(filter)} | Select-Object ProcessId,ParentProcessId,CreationDate,CommandLine | ConvertTo-Json -Compress`
62
- const out = execFileSync('powershell.exe', ['-NoProfile', '-Command', script], { encoding: 'utf8' }).trim()
62
+ const out = execFileSync('pwsh.exe', ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', script], {
63
+ encoding: 'utf8',
64
+ }).trim()
63
65
  if (!out) return []
64
66
  const parsed = JSON.parse(out)
65
67
  return Array.isArray(parsed) ? parsed : [parsed]
@@ -102,7 +102,7 @@ export function tmuxArgv(extraArgs = [], { socket, env = process.env, platform =
102
102
 
103
103
  /**
104
104
  * npm の extensionless shim は Windows の execFile で ENOENT。
105
- * 隣の .cmd を cmd.exe /c で起動する(shell:true は DEP0190 かつ引数連結)。
105
+ * 同じnpm binにある .ps1 shimPowerShell 7で起動し、cmd.exeへfallbackしない。
106
106
  */
107
107
  export function resolveLatticeExecutable(cli, { platform = process.platform, exists = existsSync } = {}) {
108
108
  if (typeof cli !== 'string' || !cli) return { command: cli, argv: ['todo', 'status', '--json'] }
@@ -113,9 +113,9 @@ export function resolveLatticeExecutable(cli, { platform = process.platform, exi
113
113
  export function resolveLatticeInvocation(cli, argv, {
114
114
  platform = process.platform,
115
115
  exists = existsSync,
116
- comspec = process.env.ComSpec || 'cmd.exe',
116
+ pwsh = 'pwsh.exe',
117
117
  } = {}) {
118
- return resolveWindowsCommand(cli, argv, { platform, exists, comspec })
118
+ return resolveWindowsCommand(cli, argv, { platform, exists, pwsh })
119
119
  }
120
120
 
121
121
  /** member の自己申告を優先し、無い既存 member だけ旧 session 名へ互換フォールバックする。 */
@@ -311,6 +311,17 @@ urllib.request.urlopen(req, timeout=10).read()
311
311
  else
312
312
  miss "停止済みbridge台帳の解除に失敗(HTTP ${bridge_code:-000})"
313
313
  fi
314
+ # 解散した room を公開一覧(/api/rooms・トップページ主一覧)から外す。外さないと公開面が
315
+ # 過去卓で埋まり、公開トップの live 窓が死卓を選び続ける(2026-08-30 実測)。
316
+ # 次の setup の member 登録が自動で解除するので、復活に手作業は要らない
317
+ arch_code=$(curl -s -o /dev/null -w '%{http_code}' -X POST "$url/api/$room/archive" -H "X-Peertable-Token: $PEERTABLE_POST_TOKEN" || true)
318
+ if [ "$arch_code" = 200 ]; then
319
+ did "room を公開一覧から archive(個別ページとログは残る)"
320
+ elif [ "$arch_code" = 404 ]; then
321
+ skip "room の archive(server が archive を持たない版・公開一覧に残る)"
322
+ else
323
+ miss "room の archive に失敗(HTTP ${arch_code:-000})"
324
+ fi
314
325
  fi
315
326
  # room 削除は --purge だけ。トークンを要する唯一の段で、ここだけが外部サービスへの依存境界
316
327
  elif [ "$log_saved" = no ]; then
@@ -3,7 +3,7 @@
3
3
  //
4
4
  // 背景: main plan の migrate 時に narrative_ref へ #L 行番号を持たせなかったため、
5
5
  // `lattice todo split` が `predecessor_source_inventory_unavailable` で機構的に失敗した
6
- // (実測 2026-08-11。docs/plan_peertable-autonomy-runtime-fx-20260811.md f4)。`todo revise` は
6
+ // (実測 2026-08-11。docs/archive/plan_peertable-autonomy-runtime-fx-20260811.md f4)。`todo revise` は
7
7
  // 使えるが desired_plan 全体・source_cutover_batch を要求する重量級 API で、発見者が親裁定なしに
8
8
  // 選べる手段ではない。このツールは「計画 Markdown に `### <task_id> <title>` 見出しで task を書く
9
9
  // →本ツールで extraction.json を自動生成→`lattice todo migrate` で新規 companion plan として起票」
@@ -179,7 +179,7 @@ async function notifyParentOfFailure(seq, recipient, result, reason) {
179
179
  if (!parentName) return
180
180
  const key = `${seq}:${recipient}`
181
181
  if (result === 'delivered') {
182
- notifiedFailures.delete(key)
182
+ if (notifiedFailures.delete(key)) saveDeliveryState()
183
183
  failureStreaks.delete(key)
184
184
  return
185
185
  }
@@ -202,6 +202,7 @@ async function notifyParentOfFailure(seq, recipient, result, reason) {
202
202
  })
203
203
  if (!res.ok) throw new Error(`HTTP ${res.status}`)
204
204
  notifiedFailures.add(key)
205
+ saveDeliveryState()
205
206
  } catch (error) {
206
207
  log(`DELIVERY_FAILURE_NOTIFY_FAILED ${JSON.stringify({ seq, recipient, detail: error.message.split('\n')[0] })}`)
207
208
  }
@@ -234,7 +235,10 @@ setInterval(beatBridge, 5_000) // 送信自体は BEAT_MS で間引く。失敗
234
235
  // 席ごとに未配達を溜めて、2秒ごとにまとめて1回起こす(連投で席を何度も起こさない)。
235
236
  // pending は room message の宛先名から作る。起動引数や harness は候補集合を決めない。
236
237
  const pending = new Map() // name -> Map<seq, message>
237
- const stuckStreaks = new Map() // name -> { key: 'seq,seq', count }(DELIVERY_STUCK連続数。5回で打ち切り)
238
+ const stuckStreaks = new Map() // name -> { key: 'seq,seq', code, count }(連続失敗数。STUCK=5・席不在=150周期で打ち切り)
239
+ // 席不在系の打ち切り周期(150周期≈5分)。上書きは experiments の再現 harness が待ち時間短縮に使うだけ
240
+ const goneAbandonCycles = Number(process.env.PEERTABLE_GONE_ABANDON_CYCLES) > 0
241
+ ? Number(process.env.PEERTABLE_GONE_ABANDON_CYCLES) : 150
238
242
  const deliveryStates = new Map() // seq -> { message, targets, delivered }
239
243
  let seats = []
240
244
  let members = new Map()
@@ -300,14 +304,15 @@ function markReady() {
300
304
  function loadDeliveryState() {
301
305
  try {
302
306
  const saved = JSON.parse(readFileSync(deliveryStatePath, 'utf8'))
303
- if (saved.room !== room || saved.server_url !== url) return { primed: false, lastSeq: 0, delivered: new Set() }
307
+ if (saved.room !== room || saved.server_url !== url) return { primed: false, lastSeq: 0, delivered: new Set(), notified: new Set() }
304
308
  return {
305
309
  primed: saved.primed === true,
306
310
  lastSeq: Number.isSafeInteger(saved.last_seq) && saved.last_seq >= 0 ? saved.last_seq : 0,
307
311
  delivered: new Set(Array.isArray(saved.delivered) ? saved.delivered.filter(key => typeof key === 'string') : []),
312
+ notified: new Set(Array.isArray(saved.notified) ? saved.notified.filter(key => typeof key === 'string') : []),
308
313
  }
309
314
  } catch {
310
- return { primed: false, lastSeq: 0, delivered: new Set() }
315
+ return { primed: false, lastSeq: 0, delivered: new Set(), notified: new Set() }
311
316
  }
312
317
  }
313
318
 
@@ -315,6 +320,10 @@ const deliveryState = loadDeliveryState()
315
320
  let lastSeq = deliveryState.lastSeq
316
321
  const delivered = deliveryState.delivered
317
322
  let primed = deliveryState.primed
323
+ // 通知済み集合を耐再起動にする。メモリだけだと bridge が再起動するたび同じ [配達失敗] DM を
324
+ // room へ再投稿する(2026-08-30 実測: 13回の再起動で同一通知が13重複し、公開feedの末尾78件が
325
+ // 全部配達失敗になった)
326
+ for (const key of deliveryState.notified) notifiedFailures.add(key)
318
327
  function saveDeliveryState() {
319
328
  const temp = `${deliveryStatePath}.${process.pid}.tmp`
320
329
  writeFileSync(temp, JSON.stringify({
@@ -323,6 +332,7 @@ function saveDeliveryState() {
323
332
  primed,
324
333
  last_seq: lastSeq,
325
334
  delivered: [...delivered].slice(-10_000),
335
+ notified: [...notifiedFailures].slice(-10_000),
326
336
  }) + '\n')
327
337
  renameSync(temp, deliveryStatePath)
328
338
  }
@@ -723,18 +733,31 @@ async function flushSeat(seat) {
723
733
  // 再試行が成立すれば同じ (seq, recipient) を delivered で上書きする
724
734
  const result = ['SEAT_TUI_GONE', 'MEMBER_MISSING', 'DESCRIPTOR_MISSING'].includes(code) ? 'seat_unavailable' : 'failed'
725
735
  for (const msg of msgs) await postReceipt(msg.seq, seat, result, code)
726
- // 同一集合のSTUCKが5周期続いたら再試行を打ち切る。failed receiptと親宛[配達失敗]DM
727
- // 既に出ており、無限の打鍵再試行は席のcomposerを汚し続けるだけで誰も救わない。
728
- if (code === 'DELIVERY_STUCK') {
736
+ // 同一集合の失敗が続いたら再試行を打ち切る。STUCK は5周期(composer汚染を止める)、
737
+ // 席不在系は150周期≈5分(席の再起動・立て直しは待ち、卓ごと死んだ席だけを見切る)。
738
+ // 打ち切りは delivered 台帳へ耐再起動で記録する——記録しないと lastSeq が前進せず、
739
+ // bridge の再起動ごとに同じ seq の再試行が蘇る(2026-08-30 実測: 席全滅の卓で
740
+ // seq 2件が18時間・3.7万行の SEAT_TUI_GONE を刻み続けた)。failed receipt と
741
+ // 親宛[配達失敗]DMは既に出ており、無限再試行は誰も救わない。
742
+ const abandonAfter = code === 'DELIVERY_STUCK' ? 5
743
+ : ['SEAT_TUI_GONE', 'MEMBER_MISSING', 'DESCRIPTOR_MISSING'].includes(code) ? goneAbandonCycles
744
+ : null
745
+ if (abandonAfter !== null) {
729
746
  const key = msgs.map((msg) => msg.seq).join(',')
730
747
  const prev = stuckStreaks.get(seat)
731
- const count = prev?.key === key ? prev.count + 1 : 1
732
- stuckStreaks.set(seat, { key, count })
733
- if (count >= 5) {
748
+ const count = prev?.key === key && prev.code === code ? prev.count + 1 : 1
749
+ stuckStreaks.set(seat, { key, code, count })
750
+ if (count >= abandonAfter) {
734
751
  const queue = pending.get(seat)
735
- if (queue) for (const msg of msgs) queue.delete(msg.seq)
752
+ for (const msg of msgs) {
753
+ if (queue) queue.delete(msg.seq)
754
+ deliveryStates.get(msg.seq)?.delivered.add(seat)
755
+ delivered.add(deliveryKey(msg.seq, seat))
756
+ }
736
757
  stuckStreaks.delete(seat)
737
- log(`DELIVERY_ABANDONED: ${seat} への seq ${key} は5周期連続STUCKのため再試行を打ち切る(receipt=failed・親へ通知済み)`)
758
+ saveDeliveryState()
759
+ advanceLastSeq()
760
+ log(`DELIVERY_ABANDONED: ${seat} への seq ${key} は${count}周期連続${code}のため再試行を打ち切る(receipt=${result}・親へ通知済み・再起動後も再試行しない)`)
738
761
  }
739
762
  } else {
740
763
  stuckStreaks.delete(seat)