peertable 0.8.55 → 0.8.57

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.
Files changed (75) hide show
  1. package/README.ja.md +32 -33
  2. package/README.md +36 -37
  3. package/package.json +3 -1
  4. package/room/client.mjs +24 -33
  5. package/room/server.mjs +1 -1
  6. package/skill/SKILL.md +69 -94
  7. package/skill/scripts/aiterm-client.mjs +48 -0
  8. package/skill/scripts/aiterm-deliver.mjs +3 -1
  9. package/skill/scripts/change-seat.mjs +86 -0
  10. package/skill/scripts/change-seat.sh +3 -276
  11. package/skill/scripts/change-seat.test.mjs +129 -0
  12. package/skill/scripts/cli.mjs +77 -0
  13. package/skill/scripts/doctor.mjs +53 -0
  14. package/skill/scripts/doctor.sh +2 -213
  15. package/skill/scripts/ensure-all-bridges.mjs +42 -0
  16. package/skill/scripts/ensure-all-bridges.sh +3 -80
  17. package/skill/scripts/ensure-bridge.mjs +11 -0
  18. package/skill/scripts/ensure-bridge.sh +2 -95
  19. package/skill/scripts/ensure-codex-room-mcp.mjs +1 -1
  20. package/skill/scripts/ensure-project-runtime.mjs +85 -0
  21. package/skill/scripts/ensure-project-runtime.sh +2 -11
  22. package/skill/scripts/ensure-project-runtime.test.mjs +40 -0
  23. package/skill/scripts/install-skill.mjs +89 -0
  24. package/skill/scripts/install-skill.test.mjs +84 -0
  25. package/skill/scripts/launch-seat.mjs +163 -0
  26. package/skill/scripts/launch-seat.sh +3 -797
  27. package/skill/scripts/launch-seat.test.mjs +174 -0
  28. package/skill/scripts/leave-seat.mjs +44 -0
  29. package/skill/scripts/leave-seat.sh +3 -92
  30. package/skill/scripts/leave-seat.test.mjs +64 -0
  31. package/skill/scripts/legacy-entry.mjs +33 -0
  32. package/skill/scripts/platform/windows/resolve-lattice-command.mjs +12 -1
  33. package/skill/scripts/project-runtime.mjs +95 -0
  34. package/skill/scripts/project-runtime.test.mjs +29 -0
  35. package/skill/scripts/project-scaffold.mjs +179 -0
  36. package/skill/scripts/project-scaffold.test.mjs +129 -0
  37. package/skill/scripts/refresh-seat-identity.mjs +11 -8
  38. package/skill/scripts/remove-managed-room-mcp.mjs +9 -4
  39. package/skill/scripts/resume.sh +2 -225
  40. package/skill/scripts/room-api.mjs +28 -0
  41. package/skill/scripts/room-public-session.test.mjs +73 -0
  42. package/skill/scripts/runtime-contract.test.mjs +13 -146
  43. package/skill/scripts/runtime-launch-command.mjs +19 -0
  44. package/skill/scripts/runtime-launch-command.test.mjs +32 -0
  45. package/skill/scripts/seat-approval.mjs +24 -0
  46. package/skill/scripts/seat-approval.test.mjs +43 -0
  47. package/skill/scripts/seat-identity.mjs +2 -71
  48. package/skill/scripts/seat-launch-phase.mjs +11 -0
  49. package/skill/scripts/seat-observer.mjs +54 -0
  50. package/skill/scripts/seat-observer.test.mjs +58 -0
  51. package/skill/scripts/seat-session.mjs +17 -0
  52. package/skill/scripts/seat-status-bridge.mjs +33 -206
  53. package/skill/scripts/seat-usage.mjs +3 -334
  54. package/skill/scripts/setup.sh +3 -215
  55. package/skill/scripts/teardown.mjs +113 -0
  56. package/skill/scripts/teardown.sh +3 -410
  57. package/skill/scripts/teardown.test.mjs +62 -0
  58. package/skill/scripts/upgrade-team-assets.mjs +268 -0
  59. package/skill/scripts/upgrade-team-assets.sh +2 -282
  60. package/skill/scripts/wakeup-bridge.mjs +26 -32
  61. package/skill/scripts/wakeup-delivery.mjs +4 -14
  62. package/skill/templates/charter.md +1 -1
  63. package/skill/templates/member-standalone.md +1 -1
  64. package/skill/templates/member.md +3 -3
  65. package/skill/scripts/agent-pane-status.mjs +0 -6
  66. package/skill/scripts/aiterm-configure.mjs +0 -31
  67. package/skill/scripts/aiterm-launch.mjs +0 -64
  68. package/skill/scripts/aiterm-send.mjs +0 -22
  69. package/skill/scripts/claude-dialog.mjs +0 -26
  70. package/skill/scripts/codex-dialog.mjs +0 -67
  71. package/skill/scripts/platform/windows/build-bridge-command.mjs +0 -52
  72. package/skill/scripts/seat-input.mjs +0 -36
  73. package/skill/scripts/tmux-at.bash +0 -22
  74. package/skill/scripts/tmux-socket.mjs +0 -19
  75. package/skill/scripts/vendors/grok/pane-status.mjs +0 -30
@@ -0,0 +1,113 @@
1
+ // 解散はroomと履歴を残す。公開APIで席を止めてから、所有する足場を撤去する。
2
+ import { existsSync, mkdirSync, writeFileSync, rmSync, copyFileSync } from 'node:fs'
3
+ import { execFileSync } from 'node:child_process'
4
+ import { join } from 'node:path'
5
+ import { AitermClient } from './aiterm-client.mjs'
6
+ import { RoomApi } from './room-api.mjs'
7
+ import { leaveSeat } from './leave-seat.mjs'
8
+ import { projectPath, readSetup, readJson, runScript, removeExclude, fail } from './project-scaffold.mjs'
9
+ import { packageRoot } from './install-skill.mjs'
10
+ import { resolveLatticeInvocation } from './seat-usage.mjs'
11
+ import { seatSessionId } from './seat-session.mjs'
12
+
13
+ function landingReports(project, state) {
14
+ if (state.mode !== 'lattice') return []
15
+ const run = args => {
16
+ const invocation = resolveLatticeInvocation(process.env.LATTICE_CLI || state.lattice_cli || 'lattice', args)
17
+ return JSON.parse(execFileSync(invocation.command, invocation.argv, { cwd: project, encoding: 'utf8' }))
18
+ }
19
+ const listed = run(['run', 'list', '--json'])
20
+ if (listed.schema !== 'lattice.run_list.v1' || !Array.isArray(listed.active_runs)) fail('PEERTABLE_LANDING_INVALID', 'run listの契約が異なります')
21
+ return listed.active_runs.map(entry => {
22
+ if (!entry?.run_ref) fail('PEERTABLE_LANDING_INVALID', 'run_refがありません')
23
+ const report = run(['run', 'landing', '--run', entry.run_ref])
24
+ if (report.schema !== 'lattice.run_landing_report.v1' || !Array.isArray(report.accepted_receipts)
25
+ || report.accepted_receipts.some(receipt => typeof receipt.landed !== 'boolean')) fail('PEERTABLE_LANDING_INVALID', 'run landingの契約が異なります')
26
+ return report
27
+ })
28
+ }
29
+
30
+ export async function archiveLog(api, project, room) {
31
+ const { messages } = await api.request('messages')
32
+ const dir = join(project, 'docs', 'archive')
33
+ mkdirSync(dir, { recursive: true })
34
+ const file = join(dir, `room-log_${room}_${new Date().toISOString().replace(/[:.]/g, '-')}.md`)
35
+ const lines = [`# 円卓ログ — room ${room}(全${messages.length}発言)`, '', '解散前の控え。roomと過去ログの原本はサーバーに残ります。', '']
36
+ for (const msg of messages) lines.push(`## [${msg.seq}] ${msg.from} → ${msg.to_names?.join(', ') ?? msg.to} ・ ${msg.ts}`, '', msg.body ?? '(本文欠落)', '')
37
+ writeFileSync(file, lines.join('\n'))
38
+ return file
39
+ }
40
+
41
+ export async function teardownProject(options, dependencies = {}) {
42
+ const project = projectPath(options.project)
43
+ const state = readSetup(project)
44
+ const team = join(project, '.team')
45
+ const env = { ...process.env }
46
+ delete env.PEERTABLE_POST_TOKEN
47
+ const script = dependencies.runScript ?? runScript
48
+ const credential = script('seat-credential.mjs', ['prepare', project, state.room, 'teardown'], { env })
49
+ const api = dependencies.api ?? new RoomApi(state, { credential })
50
+ const aiterm = dependencies.aiterm ?? new AitermClient({ env })
51
+ const steps = []
52
+ const errors = []
53
+ const step = async (name, action) => {
54
+ try { const result = await action(); steps.push({ name, status: 'done', result }); return true }
55
+ catch (error) { errors.push(error); steps.push({ name, status: 'failed', message: error.message }); return false }
56
+ }
57
+ try {
58
+ const members = await api.members()
59
+ await aiterm.sessions()
60
+ if (!options.purge) await step('roomログの控え', () => archiveLog(api, project, state.room))
61
+ let stopped = true
62
+ for (const member of members) {
63
+ if (member.delivery?.kind === 'parent_watch' || !seatSessionId(member)) continue
64
+ if (!await step(`席 ${member.name} の退席`, () => leaveSeat(project, member.name, { aiterm }))) stopped = false
65
+ }
66
+ for (const kind of ['wakeup', 'seat-status', 'alarm']) {
67
+ const recordPath = join(team, `${kind}-bridge.json`)
68
+ if (!existsSync(recordPath)) { steps.push({ name: `${kind}停止`, status: 'skipped', reason: '起動記録なし' }); continue }
69
+ const record = readJson(recordPath)
70
+ if (!await step(`${kind}停止`, async () => {
71
+ script(`${kind}-bridge.mjs`, [project, '--stop'], { env })
72
+ const id = record.aiterm_session_id || `peertable-${kind}-${state.room}`
73
+ if ((await aiterm.sessions()).some(session => session.session_id === id)) await aiterm.call('pty_close', { session_id: id })
74
+ if ((await aiterm.sessions()).some(session => session.session_id === id)) fail('PEERTABLE_RUNTIME_STOP_FAILED', `${id} が残っています`)
75
+ })) stopped = false
76
+ }
77
+ await step('Lattice成果の着地確認', () => landingReports(project, state))
78
+ // 停止できなかったruntimeの記録を残し、同じ入口から再実行できる状態を保つ。
79
+ if (!stopped) fail('PEERTABLE_TEARDOWN_STOP_FAILED', '席またはruntimeの停止に失敗しました。.teamを残しています')
80
+ if (errors.length) fail('PEERTABLE_TEARDOWN_INCOMPLETE', 'ログ保存または成果確認に失敗しました。再実行用の.teamを残しています')
81
+ const roomDone = await step(options.purge ? 'room削除' : 'room解散', async () => {
82
+ if (options.purge) {
83
+ await api.request('', { method: 'DELETE' })
84
+ } else {
85
+ await api.request('messages', { method: 'POST', body: { from: 'system', to: 'system', body: `解散。この卓はここまで。参加者: ${members.map(member => member.name).join(', ')}。部屋と過去ログは残ります。` } })
86
+ for (const member of await api.members()) await api.request(`members/${encodeURIComponent(member.name)}`, { method: 'DELETE' })
87
+ await api.request('bridges', { method: 'DELETE' })
88
+ await api.request('archive', { method: 'POST' })
89
+ }
90
+ })
91
+ if (!roomDone) fail('PEERTABLE_TEARDOWN_ROOM_FAILED', 'roomの撤去に失敗しました。再実行用の.teamを残しています')
92
+ if (state.external_pane) {
93
+ if (state.project_json_preexisting) copyFileSync(join(team, 'project.json.bak'), join(project, '.lattice', 'project.json'))
94
+ else rmSync(join(project, '.lattice', 'project.json'), { force: true })
95
+ }
96
+ const managed = state.room_mcp_managed ?? state.added_root_mcp ?? state.root_mcp_json_fallback ?? false
97
+ if (managed) script('remove-managed-room-mcp.mjs', [project], { env })
98
+ script('ensure-codex-room-mcp.mjs', ['remove', project, packageRoot], { env: { ...env, CODEX_HOME: join(project, '.codex') } })
99
+ if (state.work_order_adapter && !state.runtime_preexisting) rmSync(join(project, '.lattice', 'runtime'), { recursive: true, force: true })
100
+ if (options.purge && !state.lattice_preexisting) rmSync(join(project, '.lattice'), { recursive: true, force: true })
101
+ for (const [flag, rule] of [['added_exclude', '.team/'], ['added_mcp_exclude', '/.mcp.json'], ['added_runtime_exclude', '/.lattice/runtime/']]) {
102
+ if (state[flag]) removeExclude(project, rule)
103
+ }
104
+ rmSync(team, { recursive: true })
105
+ steps.push({ name: 'projectの足場撤去', status: 'done' })
106
+ const result = { schema: 'peertable.teardown-result.v1', status: errors.length ? 'incomplete' : 'done', mode: options.purge ? 'purge' : 'archive', project, steps }
107
+ if (errors.length) throw Object.assign(new AggregateError(errors, '一部の工程に失敗しました'), { code: 'PEERTABLE_TEARDOWN_INCOMPLETE', result })
108
+ return result
109
+ } catch (error) {
110
+ error.result ??= { schema: 'peertable.teardown-result.v1', status: 'incomplete', project, steps }
111
+ throw error
112
+ } finally { if (!dependencies.aiterm) await aiterm.close() }
113
+ }
@@ -1,411 +1,4 @@
1
1
  #!/bin/bash
2
- # Peertable teardown の機械部分。席(tmux)も本 script が畳む。
3
- # usage: teardown.sh <project_dir> [--purge]
4
- # 既定(archive): room のログを `docs/archive/` へ書き出し、`.lattice/` は残して畳む
5
- # --purge : 何も残さず全部消す(従来の痕跡ゼロ・ゲスト project 向け)
6
- # 書込トークンは環境変数 PEERTABLE_POST_TOKEN から取る(`~/.config/peertable.env` は export 付きで定義すること)。
7
- #
8
- # 撤去は「何が実施され、何が実施されなかったか」を1行ずつ出す。room 削除だけがトークンを要するので、
9
- # そこが失敗しても残りの撤去は続行し、未実施を明示して非ゼロで終わる(黙って中断しない・決定58)。
10
- #
11
- # **既定が archive なのはオーナー裁定(2026-08-09・決定61)**。痕跡ゼロを既定にしていた時代は、
12
- # 畳んだ瞬間に room の会話ログ(server 側の正本)と Lattice store が消えていた——**卓の議論と
13
- # 工程の記録は、卓そのものより寿命が長い**。ゲスト project を汚さない不可侵原則は `--purge` が持つ。
14
- set -e
15
- proj="$1"
16
- script_dir=$(cd "$(dirname "$0")" && pwd -P)
17
- # shellcheck disable=SC1091
18
- . "$script_dir/tmux-at.bash"
19
- peertable_repo=$(cd "$script_dir/../.." && pwd -P)
20
- mode=archive
21
- for arg in "${@:2}"; do
22
- case "$arg" in
23
- --purge) mode=purge ;;
24
- --archive) mode=archive ;;
25
- *) echo "ERROR: 未知の引数: ${arg}(受けるのは --purge / --archive だけ)" >&2; exit 1 ;;
26
- esac
27
- done
28
- state="$proj/.team/setup-state.json"
29
- room=$(python3 -c "import json;print(json.load(open('$state'))['room'])")
30
- url=$(python3 -c "import json;print(json.load(open('$state'))['server_url'])")
31
- added=$(python3 -c "import json;print(json.load(open('$state'))['added_exclude'])")
32
- lat_pre=$(python3 -c "import json;print(json.load(open('$state'))['lattice_preexisting'])")
33
- runtime_pre=$(python3 -c "import json;d=json.load(open('$state'));print(d.get('runtime_preexisting', True))")
34
- added_runtime_ex=$(python3 -c "import json;d=json.load(open('$state'));print(d.get('added_runtime_exclude', False))")
35
- work_order_adapter=$(python3 -c "import json;d=json.load(open('$state'));print(d.get('work_order_adapter', False))")
36
- work_order_spool_ref=$(python3 -c "import json;d=json.load(open('$state'));print(d.get('work_order_spool_ref', ''))")
37
- # 旧 state(added_root_mcp 不在・手動フォールバック時代の root_mcp_json_fallback)も読む
38
- added_mcp=$(python3 -c "import json;d=json.load(open('$state'));print(d.get('added_root_mcp', d.get('root_mcp_json_fallback', False)))")
39
- added_mcp_ex=$(python3 -c "import json;d=json.load(open('$state'));print(d.get('added_mcp_exclude', d.get('root_mcp_json_fallback', False)))")
40
-
41
- fail=0
42
- did() { echo "teardown: [実施] $*"; }
43
- skip() { echo "teardown: [スキップ] $*"; }
44
- miss() { echo "teardown: [未実施] $*" >&2; fail=1; }
45
- yes_() { [ "$1" = "True" ] || [ "$1" = "true" ]; }
46
- platform=$(node -p 'process.platform')
47
- normalize_read_line() {
48
- if [ "$platform" = win32 ]; then
49
- printf '%s' "$1" | node "$script_dir/platform/windows/normalize-read-line.mjs"
50
- else
51
- printf '%s' "$1"
52
- fi
53
- }
54
-
55
- mcp_remove_action=not-managed
56
- if yes_ "$added_mcp"; then
57
- mcp_remove_result=$(node "$script_dir/remove-managed-room-mcp.mjs" "$proj") || exit "$?"
58
- mcp_remove_action=$(python3 -c 'import json,sys; print(json.loads(sys.argv[1])["action"])' "$mcp_remove_result")
59
- fi
60
-
61
- echo "teardown: mode=${mode}(archive=ログとstoreを残す/purge=痕跡ゼロ)"
62
-
63
- # ---- ログの控え(archive だけ)。room 自体は残るので、これは repo 側の写し ----
64
- # 失敗しても撤去は続ける(room に原本があるため)。--purge の時だけ「消す前の最後の機会」になる
65
- log_saved=skip
66
- if [ "$mode" = archive ]; then
67
- arc="$proj/docs/archive"
68
- out="$arc/room-log_${room}_$(date +%Y%m%d-%H%M%S).md"
69
- mkdir -p "$arc"
70
- if python3 "$(dirname "$0")/archive-room-log.py" "$url" "$room" "$out"; then
71
- did "room ログの写しを ${out#"$proj/"} へ(原本は room に残る)"
72
- log_saved=yes
73
- else
74
- miss "room ログの写しに失敗($url/api/$room が読めない)— **原本は room に残っている**ので撤去は続行する"
75
- log_saved=no
76
- fi
77
- fi
78
-
79
- # ---- 席(tmux)の終了。**`.team/` を消す前**に、この room の member だけを畳む ----
80
- # `peer-*` を全部畳むと、同じマシンの別の卓を巻き込む(bridge が members 起点にしているのと同じ理由)
81
- sock=$(node "$(dirname "$0")/tmux-socket.mjs")
82
- members_json=$(python3 "$(dirname "$0")/archive-room-log.py" --members --json "$url" "$room" 2>/dev/null || true)
83
- seat_names=$(python3 "$(dirname "$0")/archive-room-log.py" --members "$url" "$room" 2>/dev/null || true)
84
- member_lines=$(python3 -c 'import json,sys; [print(json.dumps(x,separators=(",",":"))) for x in json.loads(sys.stdin.read())]' <<<"$members_json" 2>/dev/null || true)
85
- if [ -z "$members_json" ]; then
86
- miss "席の終了 — member 一覧が取れず、畳む相手を特定できない。手で確認: tmux -S \"$sock\" list-sessions | grep peer-"
87
- else
88
- closed=0
89
- while IFS= read -r member; do
90
- [ -n "$member" ] || continue
91
- name=$(python3 -c 'import json,sys; print(json.loads(sys.argv[1])["name"])' "$member")
92
- target=$(python3 -c 'import json,sys; x=json.loads(sys.argv[1]); print((x.get("observe") or {}).get("tmux_target") or "peer-" + x["name"])' "$member")
93
- member_sock=$(python3 -c 'import json,sys; x=json.loads(sys.argv[1]); print((x.get("observe") or {}).get("tmux_socket") or sys.argv[2])' "$member" "$sock")
94
- if tmux_at has-session -t "$target" 2>/dev/null; then
95
- tmux_at kill-session -t "$target" && closed=$((closed + 1))
96
- fi
97
- done <<<"$member_lines"
98
- if [ "$closed" -gt 0 ]; then did "席の終了(${closed}席)"; else skip "席の終了(生きている席なし)"; fi
99
- left=$(tmux_at list-sessions 2>/dev/null | grep -c '^peer-' || true)
100
- [ "${left:-0}" -eq 0 ] || echo "teardown: [注記] 他の卓の peer-* が ${left}件 残っている(この卓のものではないので畳まない)"
101
- fi
102
-
103
- # Codex / Grok 席の TUI 配達(決定54)。常駐 process なので、`.team/` を消す前に確実に止める
104
- if [ -f "$proj/.team/wakeup-bridge.json" ]; then
105
- # 停止に失敗しても **ここで止まらない**。`set -e` で落ちると、t6 の契約(各段の実施・未実施を
106
- # 1行ずつ出す/黙って中断しない)が丸ごと破れる——[未実施] も [手当] も要約も出ずに撤去が全部残る
107
- if node "$(dirname "$0")/wakeup-bridge.mjs" "$proj" --stop; then
108
- did "wakeup-bridge 停止"
109
- else
110
- miss "wakeup-bridge 停止に失敗(常駐が残る)— 上の _STOP_FAILED を見て手で止める"
111
- fi
112
- else
113
- skip "wakeup-bridge(起動記録なし)"
114
- fi
115
-
116
- # 席の稼働状態ブリッジ。同じく常駐 process なので `.team/` を消す前に止める。
117
- # ここで止めないと、pid 記録が `.team/` ごと消えて **`--stop` でも止められなくなる**——しかも
118
- # 「起動記録が無い(既に停止)」と rc=0 で報告する=**止めたと嘘をつく残骸**になる(実測)
119
- if [ -f "$proj/.team/seat-status-bridge.json" ]; then
120
- # 停止に失敗しても **ここで止まらない**。`set -e` で落ちると、t6 の契約(各段の実施・未実施を
121
- # 1行ずつ出す/黙って中断しない)が丸ごと破れる——[未実施] も [手当] も要約も出ずに撤去が全部残る
122
- if node "$(dirname "$0")/seat-status-bridge.mjs" "$proj" --stop; then
123
- did "seat-status-bridge 停止"
124
- else
125
- miss "seat-status-bridge 停止に失敗(常駐が残る)— 上の _STOP_FAILED を見て手で止める"
126
- fi
127
- else
128
- skip "seat-status-bridge(起動記録なし)"
129
- fi
130
-
131
- # 工程変化の目覚ましbridgeも同じWindows processとしてlogを保持する。記録を残したまま
132
- # `.team/`を消すとalarm-bridge.logがEBUSYになり、MCP/exclude復元の途中でteardownが止まる。
133
- if [ -f "$proj/.team/alarm-bridge.json" ]; then
134
- if node "$(dirname "$0")/alarm-bridge.mjs" "$proj" --stop; then
135
- did "alarm-bridge 停止"
136
- else
137
- miss "alarm-bridge 停止に失敗(常駐が残る)— 上の ALARM_BRIDGE_STOP_FAILED を見て手で止める"
138
- fi
139
- else
140
- skip "alarm-bridge(起動記録なし)"
141
- fi
142
-
143
- # run-bridge は退役済み(2026-08-22)。旧卓の常駐が残っていれば記録の pid を照合して止める。
144
- if [ -f "$proj/.team/run-bridge.json" ]; then
145
- rb_pid=$(python3 -c "import json;print(json.load(open('$proj/.team/run-bridge.json')).get('pid',''))" 2>/dev/null || true)
146
- if [ -n "$rb_pid" ] && LC_ALL=C /bin/ps -o command= -p "$rb_pid" 2>/dev/null | grep -q "run-bridge.mjs"; then
147
- kill "$rb_pid" 2>/dev/null || true
148
- did "run-bridge(退役済み・旧常駐 pid ${rb_pid} を停止)"
149
- else
150
- skip "run-bridge(退役済み・生きた常駐なし)"
151
- fi
152
- rm -f "$proj/.team/run-bridge.json"
153
- else
154
- skip "run-bridge(退役済み)"
155
- fi
156
-
157
- # run の close は成果が既定branchへ着地した証拠ではない。bridgeを止めてから、`run list`が挙げる
158
- # **active run** の landing report を読む。**旧版は spool の work order から run を逆算していた**が、
159
- # 配車が無くなって order が出ないので、装置に直接聞く形へ変えた(改・裁定1)。
160
- # **`run list` は closed run を返さない**(実装で除外される)ので、ここで見えるのは未 close の分だけ
161
- # である。closed 済み run の着地は `run close` の返値が landing report を含むので**その時点で読む**。
162
- # `.lattice/runs` を直に走査して補わない——consumer contract 違反で、旧 orders の保持は配車の復活になる。
163
- # 全 closed の再列挙が要るなら Lattice 側の公開面を足す別課題であって、ここで迂回実装しない。
164
- # 未着地・未pushは判断結果なので `lattice run landing` 自体はexit 0を返し、teardownも止めない。
165
- # **CLI の出力と exit code を先に単独で取る。** `cmd | python3` にすると pipeline の rc は
166
- # python のものになり、**CLI が rc≠0 で typed error JSON を返しても parser が `active_runs` 欠落を
167
- # 空配列として飲んで「active run なし」に化ける**(suzune の監査で実測・room [948])。
168
- # 沈黙する偽 green は、着地の見落としをそのまま「確認済み」に見せる——いちばん避けたい壊れ方である。
169
- lattice_cli="${LATTICE_CLI:-$(command -v lattice 2>/dev/null || true)}"
170
- run_list_json=""
171
- run_list_rc=0
172
- if [ -n "$lattice_cli" ] && [ -x "$lattice_cli" ]; then
173
- run_list_json=$(cd "$proj" && "$lattice_cli" run list --json 2>&1) || run_list_rc=$?
174
- fi
175
- if [ -z "$lattice_cli" ] || [ ! -x "$lattice_cli" ]; then
176
- miss "run landing — LATTICE_CLIが実行可能fileを指さず、着地状態を読めない: ${lattice_cli:-未設定}"
177
- elif [ "$run_list_rc" != "0" ]; then
178
- miss "run landing — run list が rc=${run_list_rc} で失敗: $(printf '%s' "$run_list_json" | head -c 200)"
179
- elif ! run_refs=$(printf '%s' "$run_list_json" | python3 -c '
180
- import json, sys
181
-
182
- # schema と active_runs を要求する。**typed error JSON も別 schema も黙って空扱いにしない。**
183
- # f-string の中でバックスラッシュ付きの引用符を使わない——shell の single-quoted `python3 -c`
184
- # へ `\"` がそのまま届き、**Python が SyntaxError で落ちる**(kanade の監査で実測・room [957])。
185
- # 値は先に変数へ取り出して、f-string には名前だけを置く。
186
- raw = sys.stdin.read()
187
- try:
188
- listed = json.loads(raw)
189
- except json.JSONDecodeError as error:
190
- sys.exit(f"run list がJSONでない: {error}")
191
- if not isinstance(listed, dict):
192
- sys.exit(f"run list がobjectでない: {type(listed).__name__}")
193
- actual_schema = listed.get("schema")
194
- if actual_schema != "lattice.run_list.v1":
195
- sys.exit(f"run list の schema が違う: {actual_schema}")
196
- active = listed.get("active_runs")
197
- if not isinstance(active, list):
198
- sys.exit(f"run list に active_runs 配列が無い: {type(active).__name__}")
199
- # **entry を filter で捨てない。** 1件でも読めない entry があれば、active な run を落として
200
- # 「なし」に見せることになる(suzune の監査で実測・room [956])。全件を要求して、
201
- # 満たさなければ非ゼロで落ちる。外部 versioned JSON の境界で fallback しない。
202
- refs = []
203
- for index, entry in enumerate(active):
204
- if not isinstance(entry, dict):
205
- sys.exit(f"active_runs[{index}] がobjectでない: {type(entry).__name__}")
206
- ref = entry.get("run_ref")
207
- if not isinstance(ref, str) or not ref:
208
- sys.exit(f"active_runs[{index}] に run_ref 文字列が無い")
209
- refs.append(ref)
210
- print("\n".join(sorted(refs)))
211
- ' 2>&1); then
212
- miss "run landing — run listを解釈できない: $(printf '%s' "$run_refs" | head -c 200)"
213
- elif [ -z "$run_refs" ]; then
214
- skip "run landing(active runなし)"
215
- else
216
- while IFS= read -r run_ref; do
217
- run_ref=$(normalize_read_line "$run_ref")
218
- [ -n "$run_ref" ] || continue
219
- if landing_report=$(cd "$proj" && "$lattice_cli" run landing --run "$run_ref" 2>&1); then
220
- unlanded_count=""
221
- if unlanded_count=$(printf '%s' "$landing_report" | python3 -c '
222
- import json, sys
223
-
224
- raw = sys.stdin.read()
225
- try:
226
- report = json.loads(raw)
227
- except json.JSONDecodeError as error:
228
- sys.exit(f"run landing がJSONでない: {error}")
229
- if not isinstance(report, dict):
230
- sys.exit(f"run landing がobjectでない: {type(report).__name__}")
231
- actual_schema = report.get("schema")
232
- if actual_schema != "lattice.run_landing_report.v1":
233
- sys.exit(f"run landing の schema が違う: {actual_schema}")
234
- receipts = report.get("accepted_receipts")
235
- if not isinstance(receipts, list):
236
- sys.exit("run landing に accepted_receipts 配列が無い")
237
- for index, receipt in enumerate(receipts):
238
- if not isinstance(receipt, dict) or not isinstance(receipt.get("landed"), bool):
239
- sys.exit(f"accepted_receipts[{index}] の landed が真偽値でない")
240
- print(sum(1 for receipt in receipts if not receipt["landed"]))
241
- ' 2>&1); then
242
- did "run landing ${landing_report}"
243
- if [ "$unlanded_count" != 0 ]; then
244
- echo "未着地 ${unlanded_count}本: run ${run_ref} の受理済み成果が canonical default branch へ着地していない" >&2
245
- fi
246
- else
247
- miss "run landing $run_ref — reportを解釈できない: ${unlanded_count}"
248
- fi
249
- else
250
- miss "run landing $run_ref — ${landing_report}"
251
- fi
252
- done <<EOF
253
- $run_refs
254
- EOF
255
- fi
256
-
257
- # setupが新しく作ったhost固有runtimeだけを撤去する。既存runtimeは他adapterや進行中runの
258
- # 所有物を含み得るので触らない。runtimeを先に消してからexcludeを戻し、teardown後に
259
- # untracked stateが露出する順序逆転を防ぐ。
260
- # **配車を撤去した後の setup は `.lattice/runtime/` を作らない**ので、新しい卓ではここは常に
261
- # skip になる。判定を残してあるのは、**配車時代に立てた卓を畳む時**にだけ効くからである
262
- # (その卓の setup-state は `work_order_adapter: true` を持つ)。
263
- if yes_ "$work_order_adapter" && ! yes_ "$runtime_pre"; then
264
- rm -rf "$proj/.lattice/runtime"
265
- did ".lattice/runtime/ 撤去(配車時代の setup が新規作成したhost固有state)"
266
- elif yes_ "$work_order_adapter"; then
267
- skip ".lattice/runtime/(setup 以前から存在)"
268
- else
269
- skip ".lattice/runtime/(setup は runtime state を作っていない)"
270
- fi
271
-
272
- # 外部ペイン(決定53)。`.team/` を消す前に戻す——退避先が `.team/` の中にある
273
- ext=$(python3 -c "import json;print(json.load(open('$state')).get('external_pane', False))")
274
- pj_pre=$(python3 -c "import json;print(json.load(open('$state')).get('project_json_preexisting', False))")
275
-
276
- # ---- 解散(archive): **部屋は残し、メンバー登録だけ外す** ----
277
- # 円卓の解散は「部屋を畳む」ではなく「集まりが散る」。部屋は場所であって、次の campaign も
278
- # 同じ部屋で続く——**過去ログが同じ部屋の履歴として繋がり、部屋は常に一つに見える**
279
- # (オーナー裁定 2026-08-09・決定61)。参加者一覧だけ空にして、席が戻れば再登録される
280
- if [ "$mode" = archive ]; then
281
- if [ -z "${PEERTABLE_POST_TOKEN:-}" ]; then
282
- miss "メンバー登録の解除 — TOKEN_MISSING: \`~/.config/peertable.env\` の定義が \`export\` 付きでないと子 process へ渡らない"
283
- echo "teardown: [手当] 参加者は次で外せる: curl -X DELETE \"$url/api/$room/members/<名前>\" -H \"X-Peertable-Token: \$PEERTABLE_POST_TOKEN\"" >&2
284
- elif [ -z "$seat_names" ]; then
285
- skip "メンバー登録の解除(一覧が取れていない)"
286
- else
287
- # 履歴に解散の区切りを残す。**部屋が続く以上、どこで卓が変わったかが読めないと
288
- # 過去ログが一続きの会話に見えてしまう**(次の campaign の発言と地続きになる)
289
- body="解散。この卓はここまで。参加者: ${seat_names}。部屋と過去ログはこのまま残り、次の卓も同じ部屋で続く。"
290
- python3 -c "
291
- import json,sys,urllib.parse,urllib.request
292
- room_path=urllib.parse.quote('$room', safe='')
293
- req=urllib.request.Request('$url/api/' + room_path + '/messages', method='POST',
294
- data=json.dumps({'from':'system','to':'system','body':'''$body'''}).encode(),
295
- headers={'Content-Type':'application/json','X-Peertable-Token':'$PEERTABLE_POST_TOKEN'})
296
- urllib.request.urlopen(req, timeout=10).read()
297
- " 2>/dev/null && did "解散の区切りを履歴へ" || skip "解散の区切り(投稿できず・撤去は続行)"
298
- n=0
299
- while IFS= read -r member; do
300
- [ -n "$member" ] || continue
301
- name=$(python3 -c 'import json,sys,urllib.parse; print(urllib.parse.quote(json.loads(sys.argv[1])["name"], safe=""))' "$member")
302
- c=$(curl -s -o /dev/null -w '%{http_code}' -X DELETE "$url/api/$room/members/$name" -H "X-Peertable-Token: $PEERTABLE_POST_TOKEN" || true)
303
- [ "$c" = 200 ] && n=$((n + 1))
304
- done <<<"$member_lines"
305
- did "メンバー登録の解除(${n}名)— **部屋と過去ログは残す**(${url}/${room})"
306
- fi
307
- if [ -n "${PEERTABLE_POST_TOKEN:-}" ]; then
308
- bridge_code=$(curl -s -o /dev/null -w '%{http_code}' -X DELETE "$url/api/$room/bridges" -H "X-Peertable-Token: $PEERTABLE_POST_TOKEN" || true)
309
- if [ "$bridge_code" = 200 ]; then
310
- did "停止済みbridge台帳の解除(archive roomを未登録表示へ戻す)"
311
- else
312
- miss "停止済みbridge台帳の解除に失敗(HTTP ${bridge_code:-000})"
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
325
- fi
326
- # room 削除は --purge だけ。トークンを要する唯一の段で、ここだけが外部サービスへの依存境界
327
- elif [ "$log_saved" = no ]; then
328
- miss "room 削除 $room — ログを保全できていないので消さない(保全より先に消すと会話は二度と戻らない)"
329
- elif [ -z "${PEERTABLE_POST_TOKEN:-}" ]; then
330
- miss "room 削除 $room — TOKEN_MISSING: PEERTABLE_POST_TOKEN が空。\`~/.config/peertable.env\` の定義が \`export\` 付きでないと子 process へ渡らない"
331
- else
332
- code=$(curl -s -o /dev/null -w '%{http_code}' -X DELETE "$url/api/$room" -H "X-Peertable-Token: $PEERTABLE_POST_TOKEN" || true)
333
- [ -n "$code" ] || code=000
334
- if [ "$code" = 200 ]; then
335
- did "room 削除 $room (HTTP 200)"
336
- else
337
- miss "room 削除 $room (HTTP $code) — 403/401 はトークン不一致、000 は server 不達"
338
- fi
339
- fi
340
- # 未実施なら `.team/` と一緒に room 名も消えるので、後から手で消せる形を先に出す
341
- [ "$fail" -eq 0 ] || echo "teardown: [手当] room は次で消せる: curl -X DELETE \"$url/api/$room\" -H \"X-Peertable-Token: \$PEERTABLE_POST_TOKEN\"" >&2
342
-
343
- if yes_ "$ext"; then
344
- if yes_ "$pj_pre"; then
345
- cp "$proj/.team/project.json.bak" "$proj/.lattice/project.json"
346
- did "外部ペイン復元(既存 project.json を書き戻し)"
347
- else
348
- rm -f "$proj/.lattice/project.json"
349
- did "外部ペイン撤去(project.json 削除)"
350
- fi
351
- else
352
- skip "外部ペイン(登録なし)"
353
- fi
354
-
355
- if node "$script_dir/ensure-codex-room-mcp.mjs" remove "$proj" "$peertable_repo"; then
356
- did "Codex project設定からPeertable room MCPを撤去"
357
- else
358
- miss "Codex project設定のPeertable room MCPを撤去できない"
359
- fi
360
-
361
- rm -rf "$proj/.team"
362
- did ".team/ 削除"
363
-
364
- if yes_ "$added_mcp"; then
365
- did ".mcp.json のPeertable room MCP撤去(${mcp_remove_action})"
366
- else
367
- skip ".mcp.json(setup が作っていない)"
368
- fi
369
-
370
- if yes_ "$added_mcp_ex"; then
371
- grep -vx '/\.mcp\.json' "$proj/.git/info/exclude" > "$proj/.git/info/exclude.tmp" || true
372
- mv "$proj/.git/info/exclude.tmp" "$proj/.git/info/exclude"
373
- did "exclude から /.mcp.json を撤去"
374
- else
375
- skip "exclude の /.mcp.json(setup が足していない)"
376
- fi
377
-
378
- if yes_ "$added_runtime_ex"; then
379
- grep -vx '/\.lattice/runtime/' "$proj/.git/info/exclude" > "$proj/.git/info/exclude.tmp" || true
380
- mv "$proj/.git/info/exclude.tmp" "$proj/.git/info/exclude"
381
- did "exclude から /.lattice/runtime/ を撤去"
382
- else
383
- skip "exclude の /.lattice/runtime/(setup が足していない)"
384
- fi
385
-
386
- if yes_ "$added"; then
387
- grep -vx '\.team/' "$proj/.git/info/exclude" > "$proj/.git/info/exclude.tmp" || true
388
- mv "$proj/.git/info/exclude.tmp" "$proj/.git/info/exclude"
389
- did "exclude から .team/ を撤去"
390
- else
391
- skip "exclude の .team/(setup が足していない)"
392
- fi
393
-
394
- if [ "$lat_pre" = "True" ] || [ "$lat_pre" = "true" ]; then
395
- skip ".lattice/(setup 以前から存在)"
396
- elif [ "$mode" = archive ]; then
397
- # 工程正本を残すのが archive の本体。**残すだけでは git が知らない**ので、追跡へ入れるのは人の判断
398
- did ".lattice/ を残す(工程正本・\`lattice todo status\` と \`gantt serve\` が読む)"
399
- echo "teardown: [注記] .lattice/ は git 追跡外のままなら次の clone に残らない。残すなら commit すること"
400
- else
401
- rm -rf "$proj/.lattice"
402
- did ".lattice/ 削除(setup が作ったもの・--purge)"
403
- fi
404
-
405
- if [ "$fail" -ne 0 ]; then
406
- # 「再実行すればいい」と書かないこと。`.team/` は上で消えているので、2回目は setup-state.json が
407
- # 読めずに落ちる=**再実行の経路は存在しない**。残っている道は上の [手当] の curl だけ(2026-08-08 実測)
408
- echo "teardown: 未完了 — 撤去は上のとおり済んでいる。残りは上の [手当] を手で叩くこと(.team/ は削除済みなので teardown.sh の再実行はできない)" >&2
409
- exit 1
410
- fi
411
- echo "teardown done: $proj"
2
+ # 既存の引数を共通の製品入口へ渡す。
3
+ set -euo pipefail
4
+ exec node "$(dirname "$0")/legacy-entry.mjs" teardown "$@"
@@ -0,0 +1,62 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { mkdtempSync, mkdirSync, writeFileSync, existsSync, rmSync, readFileSync } from 'node:fs'
4
+ import { tmpdir } from 'node:os'
5
+ import { join } from 'node:path'
6
+ import { teardownProject, archiveLog } from './teardown.mjs'
7
+
8
+ function fixture(t, { stopFails = false, roomFails = false } = {}) {
9
+ const project = mkdtempSync(join(tmpdir(), 'peertable-teardown-'))
10
+ t.after(() => rmSync(project, { recursive: true, force: true }))
11
+ mkdirSync(join(project, '.team'))
12
+ writeFileSync(join(project, '.team', 'setup-state.json'), JSON.stringify({ room: 'r', server_url: 'http://example', mode: 'standalone', lattice_preexisting: true }))
13
+ writeFileSync(join(project, '.team', 'alarm-bridge.json'), JSON.stringify({ pid: 99, aiterm_session_id: 'alarm' }))
14
+ writeFileSync(join(project, '.mcp.json'), '{"mcpServers":{"other":{"command":"other"}}}')
15
+ const events = []
16
+ let sessions = [{ session_id: 'alarm' }, { session_id: 'other-project' }]
17
+ return { project, events, dependencies: {
18
+ api: { members: async () => [], request: async (path, options = {}) => {
19
+ events.push(`${options.method || 'GET'} ${path}`)
20
+ if (roomFails && path === 'archive') throw new Error('room通信失敗')
21
+ return { messages: [] }
22
+ } },
23
+ aiterm: { sessions: async () => sessions, call: async (_name, args) => {
24
+ events.push(`close ${args.session_id}`)
25
+ sessions = sessions.filter(item => item.session_id !== args.session_id)
26
+ } },
27
+ runScript: (name, args) => {
28
+ events.push(`${name} ${args.at(-1)}`)
29
+ if (name === 'alarm-bridge.mjs' && stopFails) throw new Error('bridge停止失敗')
30
+ return ''
31
+ },
32
+ }, sessions: () => sessions }
33
+ }
34
+
35
+ test('alarm停止と公開APIの閉鎖を終えてから足場を消し、他projectと設定を保つ', async t => {
36
+ const f = fixture(t)
37
+ const result = await teardownProject({ project: f.project }, f.dependencies)
38
+ assert.equal(result.status, 'done')
39
+ assert.ok(f.events.indexOf('alarm-bridge.mjs --stop') < f.events.indexOf('POST archive'))
40
+ assert.equal(existsSync(join(f.project, '.team')), false)
41
+ assert.equal(readFileSync(join(f.project, '.mcp.json'), 'utf8'), '{"mcpServers":{"other":{"command":"other"}}}')
42
+ assert.deepEqual(f.sessions(), [{ session_id: 'other-project' }])
43
+ })
44
+ for (const failure of ['stopFails', 'roomFails']) test(`${failure}では再実行用の記録を保持する`, async t => {
45
+ const f = fixture(t, { [failure]: true })
46
+ await assert.rejects(teardownProject({ project: f.project }, f.dependencies))
47
+ assert.equal(existsSync(join(f.project, '.team', 'setup-state.json')), true)
48
+ })
49
+ test('ログの控えは原本がroomに残ると明示し、日本語本文を保持する', async t => {
50
+ const f = fixture(t)
51
+ const file = await archiveLog({ request: async () => ({ messages: [{ seq: 1, from: 'alice', to: 'all', ts: 'now', body: '議論を保存' }] }) }, f.project, 'r')
52
+ const log = readFileSync(file, 'utf8')
53
+ assert.match(log, /原本はサーバーに残ります/)
54
+ assert.match(log, /議論を保存/)
55
+ })
56
+ test('ログ保存先のI/O失敗でも再実行でき、roomを先に畳まない', async t => {
57
+ const f = fixture(t)
58
+ writeFileSync(join(f.project, 'docs'), '既存file')
59
+ await assert.rejects(teardownProject({ project: f.project }, f.dependencies), { code: 'PEERTABLE_TEARDOWN_INCOMPLETE' })
60
+ assert.equal(existsSync(join(f.project, '.team', 'setup-state.json')), true)
61
+ assert.equal(f.events.includes('POST archive'), false)
62
+ })