peertable 0.6.1 → 0.7.1
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/LICENSE +21 -21
- package/README.ja.md +126 -122
- package/README.md +162 -158
- package/package.json +49 -49
- package/room/Dockerfile +7 -7
- package/room/client.mjs +53 -8
- package/room/server.mjs +188 -12
- package/skill/02_models.snapshot.md +103 -103
- package/skill/SKILL.md +264 -259
- package/skill/scripts/alarm-set.sh +0 -0
- package/skill/scripts/archive-room-log.py +0 -0
- package/skill/scripts/bridge-record-live.mjs +0 -0
- package/skill/scripts/change-effort.sh +0 -0
- package/skill/scripts/change-seat.sh +0 -0
- package/skill/scripts/codex-parent-watch.sh +0 -0
- package/skill/scripts/doctor.sh +0 -0
- package/skill/scripts/ensure-bridge.sh +0 -0
- package/skill/scripts/ensure-codex-room-mcp.mjs +0 -0
- package/skill/scripts/ensure-room-mcp.mjs +3 -7
- package/skill/scripts/external-pane.mjs +0 -0
- package/skill/scripts/kickoff-gate.mjs +82 -0
- package/skill/scripts/launch-seat.sh +0 -0
- package/skill/scripts/leave-seat.sh +0 -0
- package/skill/scripts/make-plan-input.mjs +0 -0
- package/skill/scripts/parent-join.sh +0 -0
- package/skill/scripts/remove-managed-room-mcp.mjs +70 -0
- package/skill/scripts/resolve-seat-placement.mjs +0 -0
- package/skill/scripts/resume.sh +234 -0
- package/skill/scripts/room-mcp-config.mjs +24 -0
- package/skill/scripts/seat-status-bridge.mjs +27 -0
- package/skill/scripts/set-mission.sh +0 -0
- package/skill/scripts/setup.sh +0 -0
- package/skill/scripts/teardown.sh +7 -2
- package/skill/scripts/tmux-at.bash +10 -10
- package/skill/scripts/upgrade-team-assets.sh +59 -7
- package/skill/scripts/wakeup-bridge.mjs +71 -4
- package/skill/templates/charter.md +20 -20
- package/skill/templates/mcp.json +5 -5
- package/skill/templates/member-standalone.md +60 -58
- package/skill/templates/member.md +149 -147
- package/skill/templates/parent.md +143 -142
- package/skill/templates/tasks.md +8 -8
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
package/skill/scripts/doctor.sh
CHANGED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
closeSync, fsyncSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync,
|
|
5
5
|
} from 'node:fs'
|
|
6
6
|
import { join, resolve } from 'node:path'
|
|
7
|
+
import { expectedRoomMcp, isExpectedRoomMcp } from './room-mcp-config.mjs'
|
|
7
8
|
|
|
8
9
|
const fail = (code, message) => {
|
|
9
10
|
process.stderr.write(`${code}: ${message}\n`)
|
|
@@ -21,14 +22,9 @@ try { config = JSON.parse(readFileSync(file, 'utf8')) } catch {
|
|
|
21
22
|
}
|
|
22
23
|
if (!config || typeof config !== 'object' || Array.isArray(config))
|
|
23
24
|
fail('SEAT_ROOM_MCP_INVALID', `${file} のrootがobjectでない`)
|
|
24
|
-
const expected =
|
|
25
|
+
const expected = expectedRoomMcp(peertableRepo)
|
|
25
26
|
const current = config?.mcpServers?.room
|
|
26
|
-
if (current
|
|
27
|
-
&& Object.keys(current).sort().join(',') === 'args,command'
|
|
28
|
-
&& current.command === expected.command
|
|
29
|
-
&& Array.isArray(current.args)
|
|
30
|
-
&& current.args.length === 1
|
|
31
|
-
&& current.args[0] === expected.args[0]) process.exit(0)
|
|
27
|
+
if (isExpectedRoomMcp(current, expected)) process.exit(0)
|
|
32
28
|
|
|
33
29
|
if (ownership !== 'managed')
|
|
34
30
|
fail('SEAT_ROOM_MCP_STALE', '既存.mcp.jsonのroom serverをcurrent-tree clientへmergeする必要がある')
|
|
File without changes
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// 円卓開始ゲート(決定104)。kickoff を「依頼済み」と扱ってよいかを機械判定する。
|
|
3
|
+
// 3条件(①対象席の実効稼働状態が fresh ②kickoff message の delivered receipt ③対象席の引受発言)が
|
|
4
|
+
// 全席で成立するまで pending。親の推測・経過時間・room 保存成功による稼働判定を置き換える。
|
|
5
|
+
//
|
|
6
|
+
// usage: kickoff-gate.mjs <project_dir> --seq <kickoff_seq> --seats <a,b,c> [--ack-regex <re>] [--json]
|
|
7
|
+
// 終了コード: 0 = active / 3 = pending / 1 = 判定不能(room へ到達できない等)
|
|
8
|
+
import { readFileSync } from 'node:fs'
|
|
9
|
+
import { join } from 'node:path'
|
|
10
|
+
|
|
11
|
+
const args = process.argv.slice(2)
|
|
12
|
+
const proj = args[0]
|
|
13
|
+
const opt = (name) => {
|
|
14
|
+
const i = args.indexOf(name)
|
|
15
|
+
return i >= 0 ? args[i + 1] : undefined
|
|
16
|
+
}
|
|
17
|
+
const seq = Number(opt('--seq'))
|
|
18
|
+
const seats = (opt('--seats') ?? '').split(',').map(s => s.trim()).filter(Boolean)
|
|
19
|
+
const ackRe = new RegExp(opt('--ack-regex') ?? '引受|着手|claim', 'iu')
|
|
20
|
+
const asJson = args.includes('--json')
|
|
21
|
+
if (!proj || !Number.isSafeInteger(seq) || seq <= 0 || seats.length === 0) {
|
|
22
|
+
console.error('usage: kickoff-gate.mjs <project_dir> --seq <kickoff_seq> --seats <a,b,c> [--ack-regex <re>] [--json]')
|
|
23
|
+
process.exit(1)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const { room, server_url: url } = JSON.parse(readFileSync(join(proj, '.team', 'setup-state.json'), 'utf8'))
|
|
27
|
+
const api = p => `${url}/api/${encodeURIComponent(room)}/${p}`
|
|
28
|
+
const get = async (p) => {
|
|
29
|
+
const res = await fetch(api(p))
|
|
30
|
+
if (!res.ok) throw new Error(`GET ${p} -> HTTP ${res.status}`)
|
|
31
|
+
return res.json()
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
let membersBody, deliveryBody, messagesBody
|
|
35
|
+
try {
|
|
36
|
+
;[membersBody, deliveryBody, messagesBody] = await Promise.all([
|
|
37
|
+
get('members'), get(`deliveries?seq=${seq}`), get(`messages?since=${seq}`),
|
|
38
|
+
])
|
|
39
|
+
} catch (error) {
|
|
40
|
+
console.error(`KICKOFF_GATE_UNREACHABLE: ${error.message}`)
|
|
41
|
+
process.exit(1)
|
|
42
|
+
}
|
|
43
|
+
const members = new Map(membersBody.members.map(m => [m.name, m]))
|
|
44
|
+
if (membersBody.members.length && membersBody.members[0].status_effective === undefined) {
|
|
45
|
+
console.error('KICKOFF_GATE_SERVER_TOO_OLD: server が実効稼働状態を返さない版。判定できない')
|
|
46
|
+
process.exit(1)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const report = {}
|
|
50
|
+
let allPass = true
|
|
51
|
+
for (const seat of seats) {
|
|
52
|
+
const member = members.get(seat)
|
|
53
|
+
const fresh = member?.status_reason === 'fresh' && member.status_effective !== 'dead'
|
|
54
|
+
const delivery = deliveryBody.delivery?.[seat]
|
|
55
|
+
const delivered = delivery?.state === 'delivered'
|
|
56
|
+
const ack = messagesBody.messages.find(m => m.from === seat && m.seq > seq && ackRe.test(m.body ?? ''))
|
|
57
|
+
const pass = fresh && delivered && Boolean(ack)
|
|
58
|
+
allPass = allPass && pass
|
|
59
|
+
report[seat] = {
|
|
60
|
+
pass,
|
|
61
|
+
status: member ? { effective: member.status_effective, reason: member.status_reason } : { effective: null, reason: 'member_not_found' },
|
|
62
|
+
delivery: delivery ?? { state: 'unknown' },
|
|
63
|
+
ack: ack ? { seq: ack.seq, body: String(ack.body).slice(0, 80) } : null,
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const state = allPass ? 'active' : 'pending'
|
|
68
|
+
if (asJson) {
|
|
69
|
+
console.log(JSON.stringify({ schema: 'peertable.kickoff_gate.v1', room, kickoff_seq: seq, state, seats: report }))
|
|
70
|
+
} else {
|
|
71
|
+
console.log(`kickoff-gate: ${state}(room=${room} kickoff seq=${seq})`)
|
|
72
|
+
for (const [seat, r] of Object.entries(report)) {
|
|
73
|
+
const parts = [
|
|
74
|
+
`状態=${r.status.effective ?? '不在'}(${r.status.reason})`,
|
|
75
|
+
`配達=${r.delivery.state}${r.delivery.reason ? `(${r.delivery.reason})` : ''}`,
|
|
76
|
+
`引受=${r.ack ? `seq ${r.ack.seq}` : 'なし'}`,
|
|
77
|
+
]
|
|
78
|
+
console.log(` ${r.pass ? 'OK' : 'NG'} ${seat}: ${parts.join(' / ')}`)
|
|
79
|
+
}
|
|
80
|
+
if (state === 'pending') console.log(' 3条件が揃うまで円卓taskは依頼済みと扱わないこと(決定104)')
|
|
81
|
+
}
|
|
82
|
+
process.exit(state === 'active' ? 0 : 3)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
closeSync, fsyncSync, lstatSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync,
|
|
4
|
+
} from 'node:fs'
|
|
5
|
+
import { basename, dirname, join, resolve } from 'node:path'
|
|
6
|
+
import { randomBytes } from 'node:crypto'
|
|
7
|
+
import { isPeertableRoomMcp } from './room-mcp-config.mjs'
|
|
8
|
+
|
|
9
|
+
const fail = (code, detail) => {
|
|
10
|
+
process.stderr.write(`${JSON.stringify({ schema: 'peertable.managed_room_mcp_remove_result.v1',
|
|
11
|
+
result: 'rejected', code, detail })}\n`)
|
|
12
|
+
process.exit(1)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const [project] = process.argv.slice(2)
|
|
16
|
+
if (!project) fail('PEERTABLE_MANAGED_MCP_REMOVE_ARGS_INVALID', '<project>')
|
|
17
|
+
const file = resolve(project, '.mcp.json')
|
|
18
|
+
let stat
|
|
19
|
+
try { stat = lstatSync(file) } catch (error) {
|
|
20
|
+
if (error?.code === 'ENOENT') {
|
|
21
|
+
process.stdout.write(`${JSON.stringify({ schema: 'peertable.managed_room_mcp_remove_result.v1',
|
|
22
|
+
result: 'ok', action: 'already-absent' })}\n`)
|
|
23
|
+
process.exit(0)
|
|
24
|
+
}
|
|
25
|
+
fail('PEERTABLE_MANAGED_MCP_REMOVE_UNREADABLE', error.message)
|
|
26
|
+
}
|
|
27
|
+
if (stat.isSymbolicLink() || !stat.isFile())
|
|
28
|
+
fail('PEERTABLE_MANAGED_MCP_REMOVE_UNSAFE', `${file} がsymlinkまたはregular fileでない`)
|
|
29
|
+
|
|
30
|
+
let config
|
|
31
|
+
try { config = JSON.parse(readFileSync(file, 'utf8')) } catch (error) {
|
|
32
|
+
fail('PEERTABLE_MANAGED_MCP_REMOVE_INVALID', `${file}: JSONを読めない: ${error.message}`)
|
|
33
|
+
}
|
|
34
|
+
if (!config || typeof config !== 'object' || Array.isArray(config)
|
|
35
|
+
|| !config.mcpServers || typeof config.mcpServers !== 'object' || Array.isArray(config.mcpServers)) {
|
|
36
|
+
fail('PEERTABLE_MANAGED_MCP_REMOVE_INVALID', `${file}: root/mcpServersがobjectでない`)
|
|
37
|
+
}
|
|
38
|
+
if (config.mcpServers.room === undefined) {
|
|
39
|
+
process.stdout.write(`${JSON.stringify({ schema: 'peertable.managed_room_mcp_remove_result.v1',
|
|
40
|
+
result: 'ok', action: 'room-already-absent' })}\n`)
|
|
41
|
+
process.exit(0)
|
|
42
|
+
}
|
|
43
|
+
if (!isPeertableRoomMcp(config.mcpServers.room))
|
|
44
|
+
fail('PEERTABLE_MANAGED_MCP_REMOVE_CONFLICT', `${file}: room blockがPeertable所有形でない`)
|
|
45
|
+
|
|
46
|
+
delete config.mcpServers.room
|
|
47
|
+
if (Object.keys(config.mcpServers).length === 0) delete config.mcpServers
|
|
48
|
+
if (Object.keys(config).length === 0) {
|
|
49
|
+
unlinkSync(file)
|
|
50
|
+
process.stdout.write(`${JSON.stringify({ schema: 'peertable.managed_room_mcp_remove_result.v1',
|
|
51
|
+
result: 'ok', action: 'file-deleted' })}\n`)
|
|
52
|
+
process.exit(0)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const temporary = join(dirname(file), `.${basename(file)}.teardown-${process.pid}-${randomBytes(6).toString('hex')}.tmp`)
|
|
56
|
+
let fd
|
|
57
|
+
try {
|
|
58
|
+
fd = openSync(temporary, 'wx', stat.mode & 0o777)
|
|
59
|
+
writeFileSync(fd, `${JSON.stringify(config, null, 2)}\n`, 'utf8')
|
|
60
|
+
fsyncSync(fd)
|
|
61
|
+
closeSync(fd)
|
|
62
|
+
fd = undefined
|
|
63
|
+
renameSync(temporary, file)
|
|
64
|
+
} catch (error) {
|
|
65
|
+
if (fd !== undefined) closeSync(fd)
|
|
66
|
+
try { unlinkSync(temporary) } catch {}
|
|
67
|
+
fail('PEERTABLE_MANAGED_MCP_REMOVE_FAILED', error.message)
|
|
68
|
+
}
|
|
69
|
+
process.stdout.write(`${JSON.stringify({ schema: 'peertable.managed_room_mcp_remove_result.v1',
|
|
70
|
+
result: 'ok', action: 'room-removed-file-preserved' })}\n`)
|
|
File without changes
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# 既存 room の正規 resume(決定105)。過去ログを残した同じ room を、現行工程へ接続し直して再稼働させる。
|
|
3
|
+
# usage: resume.sh <project_dir> [--plan <plan_key>] [--phase <id>]... [--no-probe]
|
|
4
|
+
#
|
|
5
|
+
# 一回で行うこと:
|
|
6
|
+
# 1. 既存 .team/ と room の確認(無ければ setup.sh を案内して止まる)
|
|
7
|
+
# 2. --plan 指定時、setup-state.json / roles/member.md / 外部ペインを現行 plan へ再束縛
|
|
8
|
+
# 3. 死んだ bridge 記録(pid 不一致・停止)の除去
|
|
9
|
+
# 4. 台帳(room member 行)から現行メンバー構成を読み、席が死んでいるものだけ launch-seat.sh で再起動
|
|
10
|
+
# (credential の再生成・本人性の再記録は launch-seat.sh が持つ)
|
|
11
|
+
# 5. alarm / seat-status / wakeup bridge の再起動(ensure-bridge.sh)
|
|
12
|
+
# 6. fresh heartbeat の読み戻し(server 実効状態が fresh になるまで待つ)
|
|
13
|
+
# 7. テスト DM の配送 receipt 確認(delivered になるまで待つ。--no-probe で省略)
|
|
14
|
+
#
|
|
15
|
+
# 手書きのメンバー一覧・個別再起動 script には依存しない。親の再着卓(parent-join / 番犬)は別手順
|
|
16
|
+
#(SKILL.md「親の再着卓」)——resume は席と bridge の面だけを持つ。
|
|
17
|
+
set -euo pipefail
|
|
18
|
+
|
|
19
|
+
proj="${1:-}"
|
|
20
|
+
[ -n "$proj" ] || { echo "usage: resume.sh <project_dir> [--plan <plan_key>] [--phase <id>]... [--no-probe]" >&2; exit 1; }
|
|
21
|
+
shift
|
|
22
|
+
plan=""
|
|
23
|
+
phases=()
|
|
24
|
+
probe=true
|
|
25
|
+
while [ $# -gt 0 ]; do
|
|
26
|
+
case "$1" in
|
|
27
|
+
--plan) plan="${2:-}"; shift 2 ;;
|
|
28
|
+
--phase) [ -n "${2:-}" ] || { echo "ERROR: --phase には phase id が要る" >&2; exit 1; }; phases+=("$2"); shift 2 ;;
|
|
29
|
+
--no-probe) probe=false; shift ;;
|
|
30
|
+
*) echo "ERROR: 未知の引数: $1" >&2; exit 1 ;;
|
|
31
|
+
esac
|
|
32
|
+
done
|
|
33
|
+
proj=$(cd "$proj" && pwd)
|
|
34
|
+
script_dir=$(cd "$(dirname "$0")" && pwd -P)
|
|
35
|
+
repo="${PEERTABLE_REPO:-$(cd "$script_dir/../.." && pwd -P)}"
|
|
36
|
+
[ -f "$repo/room/client.mjs" ] || { echo "RESUME_PEERTABLE_TREE_UNRESOLVED: $repo が peertable tree でない" >&2; exit 1; }
|
|
37
|
+
setup="$proj/.team/setup-state.json"
|
|
38
|
+
if [ ! -f "$setup" ]; then
|
|
39
|
+
echo "RESUME_NOT_SET_UP: $setup が無い。新規の卓は setup.sh で立てること" >&2
|
|
40
|
+
exit 1
|
|
41
|
+
fi
|
|
42
|
+
|
|
43
|
+
# resumeは同じroomを現行Peertable treeへ再接続する入口である。席が全て生存していて再起動0件でも、
|
|
44
|
+
# Peertable所有のgenerated assetとroot room MCPを先に現行版へ更新する。
|
|
45
|
+
"$script_dir/upgrade-team-assets.sh" "$proj"
|
|
46
|
+
|
|
47
|
+
seats_file="$proj/.team/resume-seats.json"
|
|
48
|
+
|
|
49
|
+
# Phase A: 到達確認・plan 再束縛・死記録掃除・再起動対象の抽出(node が JSON を書き出す)
|
|
50
|
+
RESUME_PROJ="$proj" RESUME_REPO="$repo" RESUME_PLAN="$plan" RESUME_PHASES="${phases[*]:-}" \
|
|
51
|
+
RESUME_SEATS_FILE="$seats_file" node --input-type=module <<'NODE'
|
|
52
|
+
import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
53
|
+
import { execFileSync } from 'node:child_process'
|
|
54
|
+
import { join } from 'node:path'
|
|
55
|
+
import { pathToFileURL } from 'node:url'
|
|
56
|
+
|
|
57
|
+
const proj = process.env.RESUME_PROJ
|
|
58
|
+
const repo = process.env.RESUME_REPO
|
|
59
|
+
const team = join(proj, '.team')
|
|
60
|
+
const scriptDir = join(repo, 'skill', 'scripts')
|
|
61
|
+
const { bridgeRecordLive } = await import(pathToFileURL(join(scriptDir, 'bridge-record-live.mjs')))
|
|
62
|
+
const { resolveSeatObservation, tmuxArgv } = await import(pathToFileURL(join(scriptDir, 'seat-usage.mjs')))
|
|
63
|
+
|
|
64
|
+
const setupPath = join(team, 'setup-state.json')
|
|
65
|
+
const state = JSON.parse(readFileSync(setupPath, 'utf8'))
|
|
66
|
+
const { room, server_url: url } = state
|
|
67
|
+
|
|
68
|
+
// 1. room の存在確認(読み取りは room を作らないので、summary が member 0・seq 0 でも部屋の有無は API 一覧で見る)
|
|
69
|
+
const roomsRes = await fetch(`${url}/api/rooms`).catch((e) => { throw new Error(`RESUME_ROOM_UNREACHABLE: ${url} へ到達できない: ${e.message}`) })
|
|
70
|
+
if (!roomsRes.ok) throw new Error(`RESUME_ROOM_UNREACHABLE: GET /api/rooms -> HTTP ${roomsRes.status}`)
|
|
71
|
+
const { rooms } = await roomsRes.json()
|
|
72
|
+
if (!rooms.includes(room)) throw new Error(`RESUME_ROOM_MISSING: room「${room}」が server に無い。新規は setup.sh で立てること`)
|
|
73
|
+
console.log(`[実施] room 確認: ${room} @ ${url}`)
|
|
74
|
+
|
|
75
|
+
// 2. plan 再束縛(--plan 指定時だけ)
|
|
76
|
+
const plan = process.env.RESUME_PLAN
|
|
77
|
+
const phases = (process.env.RESUME_PHASES ?? '').split(' ').filter(Boolean)
|
|
78
|
+
if (plan) {
|
|
79
|
+
state.mode = 'lattice'
|
|
80
|
+
state.plan_key = plan
|
|
81
|
+
state.phases = phases
|
|
82
|
+
writeFileSync(setupPath, JSON.stringify(state) + '\n')
|
|
83
|
+
const scope = phases.length === 0
|
|
84
|
+
? 'この卓の claim 範囲は plan 全体(phase 指定なしで立っている)。'
|
|
85
|
+
: `**この卓の claim 範囲は phase ${phases.join(' ')} の task だけ**。範囲外の phase の task は、ready に見えていても取らない。範囲外に手を入れる必要が出たら room へ出して裁定を仰ぐ。`
|
|
86
|
+
const template = readFileSync(join(repo, 'skill', 'templates', 'member.md'), 'utf8')
|
|
87
|
+
writeFileSync(join(team, 'roles', 'member.md'), template.replaceAll('{{PLAN_KEY}}', plan).replaceAll('{{CLAIM_SCOPE}}', scope))
|
|
88
|
+
const publicUrl = process.env.PEERTABLE_PUBLIC_URL ?? state.public_url ?? url
|
|
89
|
+
execFileSync('node', [join(scriptDir, 'external-pane.mjs'), proj, room, publicUrl], { stdio: ['ignore', 'ignore', 'inherit'] })
|
|
90
|
+
console.log(`[実施] plan 再束縛: ${plan}${phases.length ? `(phase ${phases.join(',')})` : ''}`)
|
|
91
|
+
} else {
|
|
92
|
+
console.log(`[スキップ] plan 再束縛(--plan 未指定。現行 ${state.plan_key || state.mode} のまま)`)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// 3. 死んだ bridge 記録の除去(生きている記録は触らない。parent-watch.json は cursor 正本なので消さない)
|
|
96
|
+
for (const name of ['wakeup-bridge.json', 'seat-status-bridge.json', 'alarm-bridge.json']) {
|
|
97
|
+
const path = join(team, name)
|
|
98
|
+
if (!existsSync(path)) continue
|
|
99
|
+
let record = null
|
|
100
|
+
try { record = JSON.parse(readFileSync(path, 'utf8')) } catch {}
|
|
101
|
+
if (bridgeRecordLive(record)) { console.log(`[スキップ] ${name}: 生存中(pid ${record.pid})`); continue }
|
|
102
|
+
unlinkSync(path)
|
|
103
|
+
console.log(`[実施] ${name}: 死んだ記録を除去(pid ${record?.pid ?? '不明'})`)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// 4. 台帳から再起動対象を抽出。席の生死は tmux session の実在で判定する
|
|
107
|
+
const { members } = await (await fetch(`${url}/api/${encodeURIComponent(room)}/members`)).json()
|
|
108
|
+
const relaunch = []
|
|
109
|
+
const missingRoles = []
|
|
110
|
+
for (const member of members) {
|
|
111
|
+
if (member.delivery?.kind === 'parent_watch') continue
|
|
112
|
+
const harness = member.harness ?? member.vendor
|
|
113
|
+
if (!harness) continue // 素性の無い行(親の手動登録等)は席ではない
|
|
114
|
+
const observation = resolveSeatObservation(member, null)
|
|
115
|
+
let aliveSeat = false
|
|
116
|
+
if (observation) {
|
|
117
|
+
try {
|
|
118
|
+
execFileSync('tmux', tmuxArgv(['has-session', '-t', observation.target], { socket: observation.socket }), { stdio: 'ignore' })
|
|
119
|
+
aliveSeat = true
|
|
120
|
+
} catch {}
|
|
121
|
+
}
|
|
122
|
+
if (aliveSeat) { console.log(`[スキップ] 席 ${member.name}: tmux session 生存中`); continue }
|
|
123
|
+
if (!Array.isArray(member.roles) || member.roles.length === 0) { missingRoles.push(member.name); continue }
|
|
124
|
+
relaunch.push({
|
|
125
|
+
name: member.name, roles: member.roles.join(','), harness,
|
|
126
|
+
model: member.model ?? null, effort: member.effort ?? null, mission: member.mission ?? null,
|
|
127
|
+
})
|
|
128
|
+
}
|
|
129
|
+
if (missingRoles.length) {
|
|
130
|
+
throw new Error(`RESUME_MEMBER_ROLES_MISSING: 役割の無い member は再起動できない: ${missingRoles.join(', ')}。`
|
|
131
|
+
+ 'launch-seat.sh --roles で個別に立て直すか、member 行を整備すること')
|
|
132
|
+
}
|
|
133
|
+
writeFileSync(process.env.RESUME_SEATS_FILE, JSON.stringify(relaunch) + '\n')
|
|
134
|
+
console.log(`[実施] 再起動対象の抽出: ${relaunch.length} 席(${relaunch.map(s => s.name).join(', ') || 'なし'})`)
|
|
135
|
+
NODE
|
|
136
|
+
|
|
137
|
+
# Phase B: 席の再起動(credential 再生成・本人性再記録は launch-seat.sh が持つ)。
|
|
138
|
+
# 受け渡しは TSV(mission に tab を含む member は台帳整備の対象であり、ここでは想定しない)
|
|
139
|
+
while IFS=$'\t' read -r name roles harness model effort mission; do
|
|
140
|
+
[ -n "$name" ] || continue
|
|
141
|
+
argv=("$proj" "$name" --roles "$roles" --harness "$harness")
|
|
142
|
+
[ -n "$model" ] && argv+=(--model "$model")
|
|
143
|
+
[ -n "$effort" ] && argv+=(--effort "$effort")
|
|
144
|
+
[ -n "$mission" ] && argv+=(--mission "$mission")
|
|
145
|
+
echo "[実施] 席の再起動: $name(roles=$roles harness=$harness${model:+ model=$model}${effort:+ effort=$effort})"
|
|
146
|
+
env -u PEERTABLE_POST_TOKEN "$script_dir/launch-seat.sh" "${argv[@]}"
|
|
147
|
+
done < <(node -e '
|
|
148
|
+
const seats = JSON.parse(require("node:fs").readFileSync(process.argv[1], "utf8"))
|
|
149
|
+
for (const s of seats) console.log([s.name, s.roles, s.harness, s.model ?? "", s.effort ?? "", s.mission ?? ""].join("\t"))
|
|
150
|
+
' "$seats_file")
|
|
151
|
+
rm -f "$seats_file"
|
|
152
|
+
|
|
153
|
+
# Phase C: bridge の再起動
|
|
154
|
+
for kind in alarm seat-status wakeup; do
|
|
155
|
+
if "$script_dir/ensure-bridge.sh" "$proj" "$kind"; then
|
|
156
|
+
echo "[実施] ${kind}-bridge: ready"
|
|
157
|
+
else
|
|
158
|
+
echo "[未実施] ${kind}-bridge: 起動を確認できなかった(ログ $proj/.team/${kind}-bridge.log)" >&2
|
|
159
|
+
exit 1
|
|
160
|
+
fi
|
|
161
|
+
done
|
|
162
|
+
|
|
163
|
+
# Phase D: fresh heartbeat の読み戻しと probe DM の配送 receipt 確認
|
|
164
|
+
RESUME_PROJ="$proj" RESUME_PROBE="$probe" RESUME_SCRIPT_DIR="$script_dir" node --input-type=module <<'NODE'
|
|
165
|
+
import { readFileSync } from 'node:fs'
|
|
166
|
+
import { join } from 'node:path'
|
|
167
|
+
import { pathToFileURL } from 'node:url'
|
|
168
|
+
|
|
169
|
+
const proj = process.env.RESUME_PROJ
|
|
170
|
+
const { room, server_url: url } = JSON.parse(readFileSync(join(proj, '.team', 'setup-state.json'), 'utf8'))
|
|
171
|
+
const api = p => `${url}/api/${encodeURIComponent(room)}/${p}`
|
|
172
|
+
const sleep = ms => new Promise(r => setTimeout(r, ms))
|
|
173
|
+
|
|
174
|
+
// launch-seat.sh 直後の席(tmux session が居る非親 member)が対象
|
|
175
|
+
const { resolvePostToken } = await import(pathToFileURL(join(process.env.RESUME_SCRIPT_DIR, 'seat-usage.mjs')))
|
|
176
|
+
|
|
177
|
+
// 6. fresh heartbeat の読み戻し(seat-status bridge 心拍 30 秒 + 判定余裕)
|
|
178
|
+
const deadline = Date.now() + 90_000
|
|
179
|
+
let pendingSeats = []
|
|
180
|
+
for (;;) {
|
|
181
|
+
const { members, bridges } = await (await fetch(api('members'))).json()
|
|
182
|
+
const seats = members.filter(m => m.delivery?.kind !== 'parent_watch' && (m.harness ?? m.vendor) && m.observe?.tmux_target)
|
|
183
|
+
if (seats.length === 0) {
|
|
184
|
+
console.log('[スキップ] fresh heartbeat 読み戻し(観測対象の席なし)')
|
|
185
|
+
break
|
|
186
|
+
}
|
|
187
|
+
pendingSeats = seats.filter(m => m.status_reason !== 'fresh')
|
|
188
|
+
if (pendingSeats.length === 0) {
|
|
189
|
+
console.log(`[実施] fresh heartbeat 読み戻し: ${seats.length} 席すべて fresh`)
|
|
190
|
+
break
|
|
191
|
+
}
|
|
192
|
+
if (Date.now() > deadline) {
|
|
193
|
+
console.error(`RESUME_HEARTBEAT_STALE: fresh にならない席: ${pendingSeats.map(m => `${m.name}(${m.status_reason})`).join(', ') || '(席なし)'}`
|
|
194
|
+
+ `(bridges: seat_status=${bridges?.seat_status?.state} wakeup=${bridges?.wakeup?.state})`)
|
|
195
|
+
process.exit(1)
|
|
196
|
+
}
|
|
197
|
+
await sleep(3000)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// 7. probe DM の配送 receipt 確認
|
|
201
|
+
if (process.env.RESUME_PROBE !== 'true') {
|
|
202
|
+
console.log('[スキップ] probe DM(--no-probe 指定)')
|
|
203
|
+
process.exit(0)
|
|
204
|
+
}
|
|
205
|
+
const token = resolvePostToken(process.env)
|
|
206
|
+
const headers = { 'Content-Type': 'application/json', ...(token ? { 'X-Peertable-Token': token } : {}) }
|
|
207
|
+
const { members } = await (await fetch(api('members'))).json()
|
|
208
|
+
const targets = members.filter(m => m.delivery?.kind !== 'parent_watch' && (m.harness ?? m.vendor) && m.observe?.tmux_target).map(m => m.name)
|
|
209
|
+
if (!targets.length) { console.log('[スキップ] probe DM(配達対象の席なし)'); process.exit(0) }
|
|
210
|
+
const res = await fetch(api('messages'), {
|
|
211
|
+
method: 'POST', headers,
|
|
212
|
+
body: JSON.stringify({ from: 'resume', to: targets.length === 1 ? targets[0] : targets,
|
|
213
|
+
body: '[resume-probe] 配達経路の確認。応答不要・読み流してよい。' }),
|
|
214
|
+
})
|
|
215
|
+
const saved = await res.json()
|
|
216
|
+
if (!res.ok) { console.error(`RESUME_PROBE_POST_FAILED: ${JSON.stringify(saved)}`); process.exit(1) }
|
|
217
|
+
console.log(`[実施] probe DM 投稿: seq ${saved.seq} → ${targets.join(', ')}`)
|
|
218
|
+
const probeDeadline = Date.now() + 120_000
|
|
219
|
+
for (;;) {
|
|
220
|
+
const { delivery } = await (await fetch(api(`deliveries?seq=${saved.seq}`))).json()
|
|
221
|
+
const undelivered = targets.filter(t => delivery?.[t]?.state !== 'delivered')
|
|
222
|
+
if (undelivered.length === 0) {
|
|
223
|
+
console.log('[実施] probe DM の配送 receipt: 全席 delivered')
|
|
224
|
+
break
|
|
225
|
+
}
|
|
226
|
+
if (Date.now() > probeDeadline) {
|
|
227
|
+
console.error(`RESUME_PROBE_UNDELIVERED: delivered にならない席: ${undelivered.map(t => `${t}=${delivery?.[t]?.state ?? 'unknown'}${delivery?.[t]?.reason ? `(${delivery[t].reason})` : ''}`).join(', ')}`
|
|
228
|
+
+ '(Grok 席は idle 待ちで遅れることがある。.team/wakeup-bridge.log を確認すること)')
|
|
229
|
+
process.exit(1)
|
|
230
|
+
}
|
|
231
|
+
await sleep(3000)
|
|
232
|
+
}
|
|
233
|
+
console.log('resume 完了。親の再着卓(parent-join / 番犬)は SKILL.md「親の再着卓」の手順で別途行うこと')
|
|
234
|
+
NODE
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { resolve } from 'node:path'
|
|
2
|
+
|
|
3
|
+
export function expectedRoomMcp(peertableRepo) {
|
|
4
|
+
return { command: 'node', args: [resolve(peertableRepo, 'room', 'client.mjs')] }
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function isExpectedRoomMcp(current, expected) {
|
|
8
|
+
return Boolean(current && typeof current === 'object' && !Array.isArray(current)
|
|
9
|
+
&& Object.keys(current).sort().join(',') === 'args,command'
|
|
10
|
+
&& current.command === expected.command
|
|
11
|
+
&& Array.isArray(current.args)
|
|
12
|
+
&& current.args.length === 1
|
|
13
|
+
&& current.args[0] === expected.args[0])
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function isPeertableRoomMcp(current) {
|
|
17
|
+
return Boolean(current && typeof current === 'object' && !Array.isArray(current)
|
|
18
|
+
&& Object.keys(current).sort().join(',') === 'args,command'
|
|
19
|
+
&& current.command === 'node'
|
|
20
|
+
&& Array.isArray(current.args)
|
|
21
|
+
&& current.args.length === 1
|
|
22
|
+
&& typeof current.args[0] === 'string'
|
|
23
|
+
&& /(?:^|[\\/])room[\\/]client\.mjs$/u.test(current.args[0]))
|
|
24
|
+
}
|
|
@@ -179,6 +179,32 @@ async function nudgeIfDropped(name, busySince) {
|
|
|
179
179
|
}
|
|
180
180
|
}
|
|
181
181
|
|
|
182
|
+
// bridge 心拍(決定103)。server の bridge 台帳へ 30 秒ごとに送る。途絶=status_bridge_down、
|
|
183
|
+
// 403=server 側が bridge_auth_failed として観測する。旧 server(404)へは送り続けない
|
|
184
|
+
let lastBridgeBeatAt = 0
|
|
185
|
+
let bridgeBeatSupported = null
|
|
186
|
+
async function beatBridge() {
|
|
187
|
+
const now = Date.now()
|
|
188
|
+
if (bridgeBeatSupported === false || now - lastBridgeBeatAt < HEARTBEAT_MS) return
|
|
189
|
+
try {
|
|
190
|
+
const res = await fetch(`${url}/api/${encodeURIComponent(room)}/bridges`, {
|
|
191
|
+
method: 'POST',
|
|
192
|
+
headers: { 'Content-Type': 'application/json', ...(token ? { 'X-Peertable-Token': token } : {}) },
|
|
193
|
+
body: JSON.stringify({ kind: 'seat_status', pid: process.pid, state: 'running' }),
|
|
194
|
+
})
|
|
195
|
+
if (res.status === 404) {
|
|
196
|
+
bridgeBeatSupported = false
|
|
197
|
+
console.error('seat-status-bridge: server が bridge 台帳を持たない版(404)。心拍送信を止める')
|
|
198
|
+
return
|
|
199
|
+
}
|
|
200
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
201
|
+
bridgeBeatSupported = true
|
|
202
|
+
lastBridgeBeatAt = now
|
|
203
|
+
} catch (e) {
|
|
204
|
+
console.error(`seat-status-bridge: bridge心拍の送信に失敗: ${e.message}`)
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
182
208
|
const last = new Map() // name -> { status, at }
|
|
183
209
|
let supported = null // server が status を保持する版か(未判定は null)
|
|
184
210
|
const tokenBucket = value => value === null ? null : Math.floor(value / 1_000)
|
|
@@ -282,6 +308,7 @@ let failedTicks = 0
|
|
|
282
308
|
let provenWritable = false
|
|
283
309
|
|
|
284
310
|
async function guardedTick() {
|
|
311
|
+
await beatBridge()
|
|
285
312
|
const { attempted, failed } = await tick() ?? NOTHING_ATTEMPTED
|
|
286
313
|
const decided = decideBridgeContinuation({ attempted, failed, provenWritable, failedTicks, limit: FAILED_TICK_LIMIT })
|
|
287
314
|
provenWritable = decided.provenWritable
|
|
File without changes
|
package/skill/scripts/setup.sh
CHANGED
|
File without changes
|
|
@@ -44,6 +44,12 @@ skip() { echo "teardown: [スキップ] $*"; }
|
|
|
44
44
|
miss() { echo "teardown: [未実施] $*" >&2; fail=1; }
|
|
45
45
|
yes_() { [ "$1" = "True" ] || [ "$1" = "true" ]; }
|
|
46
46
|
|
|
47
|
+
mcp_remove_action=not-managed
|
|
48
|
+
if yes_ "$added_mcp"; then
|
|
49
|
+
mcp_remove_result=$(node "$script_dir/remove-managed-room-mcp.mjs" "$proj") || exit "$?"
|
|
50
|
+
mcp_remove_action=$(python3 -c 'import json,sys; print(json.loads(sys.argv[1])["action"])' "$mcp_remove_result")
|
|
51
|
+
fi
|
|
52
|
+
|
|
47
53
|
echo "teardown: mode=${mode}(archive=ログとstoreを残す/purge=痕跡ゼロ)"
|
|
48
54
|
|
|
49
55
|
# ---- ログの控え(archive だけ)。room 自体は残るので、これは repo 側の写し ----
|
|
@@ -316,8 +322,7 @@ rm -rf "$proj/.team"
|
|
|
316
322
|
did ".team/ 削除"
|
|
317
323
|
|
|
318
324
|
if yes_ "$added_mcp"; then
|
|
319
|
-
|
|
320
|
-
did ".mcp.json 削除"
|
|
325
|
+
did ".mcp.json のPeertable room MCP撤去(${mcp_remove_action})"
|
|
321
326
|
else
|
|
322
327
|
skip ".mcp.json(setup が作っていない)"
|
|
323
328
|
fi
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
# sourced by launch-seat / leave-seat / teardown / ensure-bridge / change-seat
|
|
2
|
-
# POSIX は tmux -S <sock>。Windows psmux は -L <aiterm-ns>(-S は既定 namespace へ落ちる)。
|
|
3
|
-
_peertable_tmux_scripts=$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
|
|
4
|
-
tmux_at() {
|
|
5
|
-
local prefix
|
|
6
|
-
prefix=$(node "$_peertable_tmux_scripts/tmux-socket.mjs" --prefix) || return 1
|
|
7
|
-
# shellcheck disable=SC2206
|
|
8
|
-
local -a conn=($prefix)
|
|
9
|
-
command tmux "${conn[@]}" "$@"
|
|
10
|
-
}
|
|
1
|
+
# sourced by launch-seat / leave-seat / teardown / ensure-bridge / change-seat
|
|
2
|
+
# POSIX は tmux -S <sock>。Windows psmux は -L <aiterm-ns>(-S は既定 namespace へ落ちる)。
|
|
3
|
+
_peertable_tmux_scripts=$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
|
|
4
|
+
tmux_at() {
|
|
5
|
+
local prefix
|
|
6
|
+
prefix=$(node "$_peertable_tmux_scripts/tmux-socket.mjs" --prefix) || return 1
|
|
7
|
+
# shellcheck disable=SC2206
|
|
8
|
+
local -a conn=($prefix)
|
|
9
|
+
command tmux "${conn[@]}" "$@"
|
|
10
|
+
}
|