peertable 0.8.54 → 0.8.56

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 (74) 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 +12 -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 +156 -0
  26. package/skill/scripts/launch-seat.sh +3 -797
  27. package/skill/scripts/launch-seat.test.mjs +159 -0
  28. package/skill/scripts/leave-seat.mjs +42 -0
  29. package/skill/scripts/leave-seat.sh +3 -92
  30. package/skill/scripts/leave-seat.test.mjs +53 -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-observer.mjs +54 -0
  49. package/skill/scripts/seat-observer.test.mjs +58 -0
  50. package/skill/scripts/seat-session.mjs +17 -0
  51. package/skill/scripts/seat-status-bridge.mjs +33 -206
  52. package/skill/scripts/seat-usage.mjs +3 -334
  53. package/skill/scripts/setup.sh +3 -215
  54. package/skill/scripts/teardown.mjs +113 -0
  55. package/skill/scripts/teardown.sh +3 -410
  56. package/skill/scripts/teardown.test.mjs +62 -0
  57. package/skill/scripts/upgrade-team-assets.mjs +268 -0
  58. package/skill/scripts/upgrade-team-assets.sh +2 -282
  59. package/skill/scripts/wakeup-bridge.mjs +24 -32
  60. package/skill/scripts/wakeup-delivery.mjs +7 -18
  61. package/skill/templates/charter.md +1 -1
  62. package/skill/templates/member-standalone.md +1 -1
  63. package/skill/templates/member.md +3 -3
  64. package/skill/scripts/agent-pane-status.mjs +0 -6
  65. package/skill/scripts/aiterm-configure.mjs +0 -31
  66. package/skill/scripts/aiterm-launch.mjs +0 -64
  67. package/skill/scripts/aiterm-send.mjs +0 -22
  68. package/skill/scripts/claude-dialog.mjs +0 -26
  69. package/skill/scripts/codex-dialog.mjs +0 -67
  70. package/skill/scripts/platform/windows/build-bridge-command.mjs +0 -52
  71. package/skill/scripts/seat-input.mjs +0 -36
  72. package/skill/scripts/tmux-at.bash +0 -22
  73. package/skill/scripts/tmux-socket.mjs +0 -19
  74. package/skill/scripts/vendors/grok/pane-status.mjs +0 -30
@@ -0,0 +1,179 @@
1
+ // 明示されたprojectだけに、Peertableの生成物と所有記録を置く。
2
+ import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs'
3
+ import { execFileSync } from 'node:child_process'
4
+ import { basename, dirname, join, resolve } from 'node:path'
5
+ import { packageRoot } from './install-skill.mjs'
6
+ import { expectedRoomMcp, isExpectedRoomMcp } from './room-mcp-config.mjs'
7
+ import { resolveLatticeInvocation } from './seat-usage.mjs'
8
+
9
+ export const fail = (code, message) => { throw Object.assign(new Error(`${code}: ${message}`), { code }) }
10
+ export const readJson = path => JSON.parse(readFileSync(path, 'utf8'))
11
+ export const writeJson = (path, value) => writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`)
12
+
13
+ export function projectPath(input) {
14
+ if (!input) fail('PEERTABLE_PROJECT_REQUIRED', '対象projectのパスを指定してください')
15
+ const path = realpathSync(input)
16
+ if (!lstatSync(path).isDirectory()) fail('PEERTABLE_PROJECT_INVALID', path)
17
+ return path
18
+ }
19
+
20
+ export function readSetup(project) {
21
+ const file = join(project, '.team', 'setup-state.json')
22
+ if (!existsSync(file)) fail('RESUME_NOT_SET_UP', `${project}は未setupです`)
23
+ const state = readJson(file)
24
+ if (!state || !['standalone', 'lattice'].includes(state.mode) || !state.room || !state.server_url)
25
+ fail('PEERTABLE_SETUP_STATE_INVALID', file)
26
+ return state
27
+ }
28
+
29
+ export function runScript(script, args = [], { root = packageRoot, env = process.env, cwd } = {}) {
30
+ return execFileSync(process.execPath, [join(root, 'skill', 'scripts', script), ...args], {
31
+ encoding: 'utf8', cwd, env, stdio: ['ignore', 'pipe', 'pipe'],
32
+ }).trim()
33
+ }
34
+
35
+ function excludePath(project) {
36
+ try {
37
+ return execFileSync('git', ['-C', project, 'rev-parse', '--path-format=absolute', '--git-path', 'info/exclude'], {
38
+ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
39
+ }).trim()
40
+ } catch (error) {
41
+ if (error.code === 'ENOENT') throw error
42
+ return null // git管理外のprojectも単独モードで使える。
43
+ }
44
+ }
45
+
46
+ export function addExclude(project, rule) {
47
+ const path = excludePath(project)
48
+ if (!path) return false
49
+ const body = existsSync(path) ? readFileSync(path, 'utf8') : ''
50
+ if (body.split(/\r?\n/u).includes(rule)) return false
51
+ mkdirSync(dirname(path), { recursive: true })
52
+ writeFileSync(path, `${body}${body && !body.endsWith('\n') ? '\n' : ''}${rule}\n`)
53
+ return true
54
+ }
55
+
56
+ export function removeExclude(project, rule) {
57
+ const path = excludePath(project)
58
+ if (!path || !existsSync(path)) return
59
+ const body = readFileSync(path, 'utf8')
60
+ writeFileSync(path, body.split('\n').filter(line => line.replace(/\r$/u, '') !== rule).join('\n'))
61
+ }
62
+
63
+ export function ensureProjectRoomMcp(project, state, { root = packageRoot } = {}) {
64
+ const file = join(project, '.mcp.json')
65
+ if (existsSync(file) && lstatSync(file).isSymbolicLink()) fail('PEERTABLE_MCP_CONFLICT', '.mcp.jsonはsymlinkです')
66
+ const mcp = existsSync(file) ? readJson(file) : {}
67
+ if (!mcp || typeof mcp !== 'object' || Array.isArray(mcp)
68
+ || (mcp.mcpServers !== undefined && (!mcp.mcpServers || typeof mcp.mcpServers !== 'object' || Array.isArray(mcp.mcpServers))))
69
+ fail('PEERTABLE_MCP_CONFLICT', '.mcp.jsonの形式が不正です')
70
+ const expected = expectedRoomMcp(root)
71
+ if (mcp.mcpServers?.room !== undefined) {
72
+ if (!isExpectedRoomMcp(mcp.mcpServers.room, expected)) fail('PEERTABLE_MCP_CONFLICT', '既存のroom MCP定義が異なります')
73
+ return
74
+ }
75
+ if (!(state.room_mcp_managed ?? state.added_root_mcp)) fail('PEERTABLE_MCP_CONFLICT', '利用者が管理するroom MCP定義がありません')
76
+ mcp.mcpServers ??= {}
77
+ mcp.mcpServers.room = expected
78
+ // 所有記録の保存後にだけ置換する。書込み失敗で利用者の設定を切り詰めない。
79
+ const temporary = `${file}.peertable-${process.pid}.tmp`
80
+ try {
81
+ writeFileSync(temporary, `${JSON.stringify(mcp, null, 2)}\n`, { mode: existsSync(file) ? lstatSync(file).mode & 0o777 : 0o600 })
82
+ renameSync(temporary, file)
83
+ } finally { rmSync(temporary, { force: true }) }
84
+ }
85
+
86
+ export function scaffoldProject(options, { root = packageRoot } = {}) {
87
+ const project = projectPath(options.project)
88
+ const team = join(project, '.team')
89
+ try {
90
+ const info = lstatSync(team)
91
+ if (info.isSymbolicLink() || !info.isDirectory()) fail('PEERTABLE_SETUP_TEAM_CONFLICT', '.teamは通常ディレクトリではありません')
92
+ } catch (error) { if (error.code !== 'ENOENT') throw error }
93
+ if (existsSync(join(team, 'setup-state.json'))) {
94
+ const state = readSetup(project)
95
+ if ((options.room && options.room !== state.room)
96
+ || (options.url && options.url.replace(/\/+$/u, '') !== state.server_url.replace(/\/+$/u, '')))
97
+ fail('PEERTABLE_SETUP_TARGET_CONFLICT', '既存projectのroomまたはserverと指定が違います')
98
+ ensureProjectRoomMcp(project, state, { root })
99
+ return { project, state, action: 'resume' }
100
+ }
101
+ if (existsSync(team) && (!lstatSync(team).isDirectory() || lstatSync(team).isSymbolicLink() || readdirSync(team).length))
102
+ fail('PEERTABLE_SETUP_TEAM_CONFLICT', '.teamに既存資産があります')
103
+ if (excludePath(project)) {
104
+ const tracked = execFileSync('git', ['-C', project, 'ls-files', '--cached', '--', '.team'], { encoding: 'utf8' })
105
+ if (tracked.trim()) fail('PEERTABLE_SETUP_TEAM_CONFLICT', '.teamにgit追跡済みの資産があります')
106
+ }
107
+ const room = options.room || basename(project)
108
+ if (!/^[A-Za-z0-9._-]+$/u.test(room)) fail('PEERTABLE_ROOM_INVALID', room)
109
+ const url = (options.url || '').replace(/\/+$/u, '')
110
+ let parsedUrl
111
+ try { parsedUrl = new URL(url) } catch { fail('PEERTABLE_SERVER_REQUIRED', '--urlにroom server URLを指定してください') }
112
+ if (!['http:', 'https:'].includes(parsedUrl.protocol) || parsedUrl.username || parsedUrl.password)
113
+ fail('PEERTABLE_SERVER_INVALID', 'HTTP/HTTPSのroom serverを指定してください')
114
+ const mode = options.plan && options.plan !== '-' ? 'lattice' : 'standalone'
115
+ const phases = options.phases ?? []
116
+ if (phases.some(phase => !/^[A-Za-z0-9._-]+$/u.test(phase)) || (mode === 'standalone' && phases.length))
117
+ fail('PEERTABLE_PHASE_INVALID', 'phaseはLatticeモードの有効なIDだけを指定できます')
118
+ let tasks
119
+ if (mode === 'standalone') {
120
+ if (!options.tasks) fail('PEERTABLE_TASKS_REQUIRED', '単独モードには--tasksで議題ファイルを指定してください')
121
+ tasks = readFileSync(resolve(options.tasks), 'utf8')
122
+ if (!tasks.trim()) fail('PEERTABLE_TASKS_REQUIRED', '議題ファイルが空です')
123
+ }
124
+ if (mode === 'lattice') {
125
+ const gitRoot = execFileSync('git', ['-C', project, 'rev-parse', '--show-toplevel'], { encoding: 'utf8' }).trim()
126
+ if (realpathSync(gitRoot) !== project) fail('PEERTABLE_PROJECT_INVALID', 'Latticeモードはgit rootを指定してください')
127
+ const invocation = resolveLatticeInvocation(options.latticeCli || process.env.LATTICE_CLI || 'lattice', ['--version'])
128
+ execFileSync(invocation.command, invocation.argv, { cwd: project, encoding: 'utf8', timeout: 15_000 })
129
+ }
130
+ const mcpPath = join(project, '.mcp.json')
131
+ try { if (lstatSync(mcpPath).isSymbolicLink()) fail('PEERTABLE_MCP_CONFLICT', '.mcp.jsonはsymlinkです') }
132
+ catch (error) { if (error.code !== 'ENOENT') throw error }
133
+ const preexisting = existsSync(mcpPath)
134
+ if (preexisting && (!lstatSync(mcpPath).isFile() || lstatSync(mcpPath).isSymbolicLink()))
135
+ fail('PEERTABLE_MCP_CONFLICT', '.mcp.jsonは通常ファイルである必要があります')
136
+ const mcp = preexisting ? readJson(mcpPath) : {}
137
+ if (!mcp || typeof mcp !== 'object' || Array.isArray(mcp)
138
+ || (mcp.mcpServers !== undefined && (!mcp.mcpServers || typeof mcp.mcpServers !== 'object' || Array.isArray(mcp.mcpServers))))
139
+ fail('PEERTABLE_MCP_CONFLICT', '.mcp.jsonの形式が不正です')
140
+ const roomPreexisting = mcp.mcpServers?.room !== undefined
141
+ if (roomPreexisting && !isExpectedRoomMcp(mcp.mcpServers.room, expectedRoomMcp(root)))
142
+ fail('PEERTABLE_MCP_CONFLICT', '既存のroom MCP定義が異なります')
143
+
144
+ const state = {
145
+ room, server_url: url, public_url: options.publicUrl || url, mode,
146
+ plan_key: mode === 'lattice' ? options.plan : '', phases,
147
+ lattice_cli: options.latticeCli || process.env.LATTICE_CLI || '',
148
+ lattice_preexisting: existsSync(join(project, '.lattice')),
149
+ runtime_preexisting: existsSync(join(project, '.lattice', 'runtime')),
150
+ added_exclude: false, added_mcp_exclude: false, added_runtime_exclude: false,
151
+ added_root_mcp: !preexisting, room_mcp_managed: !roomPreexisting,
152
+ external_pane: false, project_json_preexisting: false,
153
+ work_order_adapter: false, work_order_spool_ref: '',
154
+ }
155
+ try {
156
+ mkdirSync(join(team, 'roles'), { recursive: true })
157
+ mkdirSync(join(team, 'scripts'), { recursive: true })
158
+ if (!roomPreexisting && preexisting) writeFileSync(join(team, 'root-mcp.original.json'), readFileSync(mcpPath))
159
+ if (mode === 'standalone') writeFileSync(join(team, 'tasks.md'), readFileSync(join(root, 'skill', 'templates', 'tasks.md'), 'utf8') + tasks)
160
+ writeJson(join(team, 'setup-state.json'), state)
161
+ } catch (error) {
162
+ // この時点では既存設定へ未着手。今回作った足場だけを戻して再実行可能にする。
163
+ try { rmSync(team, { recursive: true, force: true }) }
164
+ catch (cleanup) { throw new AggregateError([error, cleanup], 'setup記録の保存と足場の撤去が失敗しました') }
165
+ throw error
166
+ }
167
+ ensureProjectRoomMcp(project, state, { root })
168
+ state.added_exclude = addExclude(project, '.team/')
169
+ if (!preexisting) state.added_mcp_exclude = addExclude(project, '/.mcp.json')
170
+ if (mode === 'lattice') state.added_runtime_exclude = addExclude(project, '/.lattice/runtime/')
171
+ writeJson(join(team, 'setup-state.json'), state)
172
+ runScript('upgrade-team-assets.mjs', [project], { root })
173
+ if (mode === 'lattice') {
174
+ state.project_json_preexisting = runScript('external-pane.mjs', [project, room, state.public_url], { root }) === 'true'
175
+ state.external_pane = true
176
+ writeJson(join(team, 'setup-state.json'), state)
177
+ }
178
+ return { project, state, action: 'created' }
179
+ }
@@ -0,0 +1,129 @@
1
+ import assert from 'node:assert/strict'
2
+ import { execFileSync } from 'node:child_process'
3
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
4
+ import fs from 'node:fs'
5
+ import { syncBuiltinESMExports } from 'node:module'
6
+ import { tmpdir } from 'node:os'
7
+ import { join } from 'node:path'
8
+ import test from 'node:test'
9
+ import { scaffoldProject, runScript } from './project-scaffold.mjs'
10
+
11
+ function fixture(t) {
12
+ const dir = mkdtempSync(join(tmpdir(), 'peertable-project-'))
13
+ t.after(() => rmSync(dir, { recursive: true, force: true }))
14
+ const project = join(dir, 'project with spaces')
15
+ mkdirSync(project)
16
+ execFileSync('git', ['init', '--quiet', project])
17
+ const tasks = join(dir, 'tasks.md')
18
+ writeFileSync(tasks, '- smoke: 導入の確認\n')
19
+ return { dir, project, tasks, room: 'install-smoke', url: 'http://127.0.0.1:18860' }
20
+ }
21
+
22
+ test('scaffoldは対象projectに生成し既存MCP設定と他projectを保持する', t => {
23
+ const options = fixture(t)
24
+ const mcp = { custom: true, mcpServers: { existing: { command: 'keep', args: ['a'] } } }
25
+ writeFileSync(join(options.project, '.mcp.json'), JSON.stringify(mcp))
26
+ mkdirSync(join(options.dir, 'other'))
27
+ writeFileSync(join(options.dir, 'other', 'setting'), '保持')
28
+ const result = scaffoldProject(options)
29
+ assert.equal(result.action, 'created')
30
+ assert.equal(result.state.mode, 'standalone')
31
+ assert.equal(result.state.added_root_mcp, false)
32
+ assert.equal(result.state.room_mcp_managed, true)
33
+ const after = JSON.parse(readFileSync(join(options.project, '.mcp.json'), 'utf8'))
34
+ assert.deepEqual(after.mcpServers.existing, mcp.mcpServers.existing)
35
+ assert.equal(after.custom, true)
36
+ assert.equal(after.mcpServers.room.command, 'node')
37
+ assert.equal(readFileSync(join(options.dir, 'other', 'setting'), 'utf8'), '保持')
38
+ assert.ok(existsSync(join(options.project, '.team', 'roles', 'member.md')))
39
+ })
40
+
41
+ test('setup再実行は既存roomと議題を保持してresumeを選ぶ', t => {
42
+ const options = fixture(t)
43
+ scaffoldProject(options)
44
+ const path = join(options.project, '.team', 'tasks.md')
45
+ const tasksBefore = readFileSync(path, 'utf8')
46
+ writeFileSync(options.tasks, '新しい議題')
47
+ const result = scaffoldProject({ project: options.project, tasks: options.tasks })
48
+ assert.equal(result.action, 'resume')
49
+ assert.equal(result.state.room, options.room)
50
+ assert.equal(readFileSync(path, 'utf8'), tasksBefore)
51
+ assert.throws(() => scaffoldProject({ project: options.project, room: 'different' }), { code: 'PEERTABLE_SETUP_TARGET_CONFLICT' })
52
+ assert.throws(() => scaffoldProject({ project: options.project, url: 'http://elsewhere' }), { code: 'PEERTABLE_SETUP_TARGET_CONFLICT' })
53
+ })
54
+
55
+ test('既存team・room設定の衝突は書込み前に拒否する', t => {
56
+ const options = fixture(t)
57
+ writeFileSync(join(options.project, '.mcp.json'), '{"mcpServers":{"room":{"command":"user-owned"}}}')
58
+ assert.throws(() => scaffoldProject(options), { code: 'PEERTABLE_MCP_CONFLICT' })
59
+ assert.equal(existsSync(join(options.project, '.team')), false)
60
+ rmSync(join(options.project, '.mcp.json'))
61
+ mkdirSync(join(options.project, '.team'))
62
+ writeFileSync(join(options.project, '.team', 'owned'), '独自資産')
63
+ assert.throws(() => scaffoldProject(options), { code: 'PEERTABLE_SETUP_TEAM_CONFLICT' })
64
+ assert.equal(readFileSync(join(options.project, '.team', 'owned'), 'utf8'), '独自資産')
65
+ })
66
+
67
+ test('team symlinkへ既存状態をたどらず、別projectへ書かない', t => {
68
+ const options = fixture(t)
69
+ const target = join(options.dir, 'outside')
70
+ mkdirSync(target)
71
+ writeFileSync(join(target, 'setup-state.json'), '{"mode":"standalone","room":"outside","server_url":"http://localhost"}')
72
+ symlinkSync(target, join(options.project, '.team'), process.platform === 'win32' ? 'junction' : 'dir')
73
+ assert.throws(() => scaffoldProject(options), { code: 'PEERTABLE_SETUP_TEAM_CONFLICT' })
74
+ })
75
+
76
+ test('単独modeの議題と明示projectを要求する', t => {
77
+ const options = fixture(t)
78
+ assert.throws(() => scaffoldProject({}), { code: 'PEERTABLE_PROJECT_REQUIRED' })
79
+ assert.throws(() => scaffoldProject({ ...options, tasks: undefined }), { code: 'PEERTABLE_TASKS_REQUIRED' })
80
+ assert.equal(existsSync(join(options.project, '.team')), false)
81
+ })
82
+ test('room block撤去は元の書式を戻し、運用中の他設定変更は保持する', t => {
83
+ for (const original of ['{ "mcpServers": {} }', '{"custom":true,"mcpServers":{"existing":{"command":"keep"}}}']) {
84
+ for (const changed of [false, true]) {
85
+ const options = fixture(t)
86
+ const file = join(options.project, '.mcp.json')
87
+ writeFileSync(file, original)
88
+ scaffoldProject(options)
89
+ if (changed) {
90
+ const current = JSON.parse(readFileSync(file, 'utf8'))
91
+ current.added_by_user = '運用中の変更'
92
+ writeFileSync(file, JSON.stringify(current))
93
+ }
94
+ runScript('remove-managed-room-mcp.mjs', [options.project])
95
+ const actual = readFileSync(file, 'utf8')
96
+ if (!changed) assert.equal(actual, original)
97
+ else {
98
+ assert.equal(JSON.parse(actual).added_by_user, '運用中の変更')
99
+ assert.equal(JSON.parse(actual).mcpServers?.room, undefined)
100
+ }
101
+ }
102
+ }
103
+ })
104
+ test('所有記録とMCP設定の書込み失敗から既存設定を保って再開する', t => {
105
+ for (const point of ['setup-state.json', '.mcp.json.peertable-']) {
106
+ const options = fixture(t)
107
+ const file = join(options.project, '.mcp.json')
108
+ const original = '{ "mcpServers": {} }\n'
109
+ writeFileSync(file, original)
110
+ const write = fs.writeFileSync
111
+ try {
112
+ fs.writeFileSync = (path, ...args) => {
113
+ if (String(path).includes(point)) throw Object.assign(new Error('注入した書込み失敗'), { code: 'ENOSPC' })
114
+ return write(path, ...args)
115
+ }
116
+ syncBuiltinESMExports()
117
+ assert.throws(() => scaffoldProject(options), { code: 'ENOSPC' })
118
+ } finally {
119
+ fs.writeFileSync = write
120
+ syncBuiltinESMExports()
121
+ }
122
+ assert.equal(readFileSync(file, 'utf8'), original)
123
+ const resumed = scaffoldProject(options)
124
+ assert.equal(resumed.action, point === 'setup-state.json' ? 'created' : 'resume')
125
+ assert.ok(JSON.parse(readFileSync(file, 'utf8')).mcpServers.room)
126
+ runScript('remove-managed-room-mcp.mjs', [options.project])
127
+ assert.equal(readFileSync(file, 'utf8'), original)
128
+ }
129
+ })
@@ -1,15 +1,14 @@
1
1
  #!/usr/bin/env node
2
- // 台帳(room member 行)の argv_digest を、Lattice attach と同じ /bin/ps -o command= 観測へ揃える。
2
+ // 台帳のargv_digestを、Aitermの公開プロセス観測へ揃える。
3
3
  // pid / lstart が台帳と違うときは書き換えず終わる(pid 推定も再利用も禁止)。
4
- import { execFileSync, execFile } from 'node:child_process'
4
+ import { execFile } from 'node:child_process'
5
5
  import { readFileSync } from 'node:fs'
6
6
  import { dirname, join, resolve } from 'node:path'
7
7
  import { fileURLToPath } from 'node:url'
8
8
  import { promisify } from 'node:util'
9
9
 
10
- // ps 観測と digest 計算は seat-identity.mjs(OS観測ライブラリ)が唯一の所有者。再実装しない。
11
- export { hashArgv, observePidCommand } from './seat-identity.mjs'
12
- import { observePidCommand } from './seat-identity.mjs'
10
+ import { AitermClient } from './aiterm-client.mjs'
11
+ import { seatSessionId } from './seat-session.mjs'
13
12
 
14
13
  export function refreshSeatRecord(raw, observed, recordedAt) {
15
14
  if (!Number.isSafeInteger(raw?.pid) || raw.pid < 1) {
@@ -37,7 +36,7 @@ export function refreshSeatRecord(raw, observed, recordedAt) {
37
36
  }
38
37
 
39
38
  function utcRecordedAt() {
40
- return execFileSync('date', ['-u', '+%Y-%m-%dT%H:%M:%S.000Z'], { encoding: 'utf8' }).trim()
39
+ return new Date().toISOString()
41
40
  }
42
41
 
43
42
  const isCli = process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])
@@ -62,12 +61,16 @@ if (isCli) {
62
61
  }
63
62
  const raw = (await memberResponse.json()).member
64
63
  let observed
64
+ const aiterm = new AitermClient()
65
65
  try {
66
- observed = observePidCommand(raw.pid)
66
+ const id = seatSessionId(raw)
67
+ if (!id) throw new Error('Aitermのsession_idが台帳にありません')
68
+ observed = (await aiterm.observe(id)).process_identity
69
+ if (!observed) throw new Error('Aitermでプロセス本人性を確認できません')
67
70
  } catch (error) {
68
71
  process.stderr.write(`${error.code || 'SEAT_IDENTITY_UNOBSERVABLE'}: ${error.message}\n`)
69
72
  process.exit(2)
70
- }
73
+ } finally { await aiterm.close() }
71
74
  let next
72
75
  try {
73
76
  next = refreshSeatRecord(raw, observed, utcRecordedAt())
@@ -1,9 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
- closeSync, fsyncSync, lstatSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync,
3
+ closeSync, existsSync, fsyncSync, lstatSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync,
4
4
  } from 'node:fs'
5
5
  import { basename, dirname, join, resolve } from 'node:path'
6
6
  import { randomBytes } from 'node:crypto'
7
+ import { isDeepStrictEqual } from 'node:util'
7
8
  import { isPeertableRoomMcp } from './room-mcp-config.mjs'
8
9
 
9
10
  const fail = (code, detail) => {
@@ -43,9 +44,13 @@ if (config.mcpServers.room === undefined) {
43
44
  if (!isPeertableRoomMcp(config.mcpServers.room))
44
45
  fail('PEERTABLE_MANAGED_MCP_REMOVE_CONFLICT', `${file}: room blockがPeertable所有形でない`)
45
46
 
47
+ const backup = join(resolve(project), '.team', 'root-mcp.original.json')
48
+ const originalText = existsSync(backup) ? readFileSync(backup, 'utf8') : null
49
+ const original = originalText === null ? null : JSON.parse(originalText)
46
50
  delete config.mcpServers.room
47
- if (Object.keys(config.mcpServers).length === 0) delete config.mcpServers
48
- if (Object.keys(config).length === 0) {
51
+ if (Object.keys(config.mcpServers).length === 0 && !Object.hasOwn(original ?? {}, 'mcpServers')) delete config.mcpServers
52
+ const restored = originalText !== null && isDeepStrictEqual(config, original)
53
+ if (!restored && Object.keys(config).length === 0) {
49
54
  unlinkSync(file)
50
55
  process.stdout.write(`${JSON.stringify({ schema: 'peertable.managed_room_mcp_remove_result.v1',
51
56
  result: 'ok', action: 'file-deleted' })}\n`)
@@ -56,7 +61,7 @@ const temporary = join(dirname(file), `.${basename(file)}.teardown-${process.pid
56
61
  let fd
57
62
  try {
58
63
  fd = openSync(temporary, 'wx', stat.mode & 0o777)
59
- writeFileSync(fd, `${JSON.stringify(config, null, 2)}\n`, 'utf8')
64
+ writeFileSync(fd, restored ? originalText : `${JSON.stringify(config, null, 2)}\n`, 'utf8')
60
65
  fsyncSync(fd)
61
66
  closeSync(fd)
62
67
  fd = undefined
@@ -1,227 +1,4 @@
1
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 の面だけを持つ。
2
+ # 既存の引数を共通の製品入口へ渡す。
17
3
  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: runtime を一回の入口で現行版へ収束
154
- "$script_dir/ensure-project-runtime.sh" "$proj"
155
-
156
- # Phase D: fresh heartbeat の読み戻しと probe DM の配送 receipt 確認
157
- RESUME_PROJ="$proj" RESUME_PROBE="$probe" RESUME_SCRIPT_DIR="$script_dir" node --input-type=module <<'NODE'
158
- import { readFileSync } from 'node:fs'
159
- import { join } from 'node:path'
160
- import { pathToFileURL } from 'node:url'
161
-
162
- const proj = process.env.RESUME_PROJ
163
- const { room, server_url: url } = JSON.parse(readFileSync(join(proj, '.team', 'setup-state.json'), 'utf8'))
164
- const api = p => `${url}/api/${encodeURIComponent(room)}/${p}`
165
- const sleep = ms => new Promise(r => setTimeout(r, ms))
166
-
167
- // launch-seat.sh 直後の席(tmux session が居る非親 member)が対象
168
- const { resolvePostToken } = await import(pathToFileURL(join(process.env.RESUME_SCRIPT_DIR, 'seat-usage.mjs')))
169
-
170
- // 6. fresh heartbeat の読み戻し(seat-status bridge 心拍 30 秒 + 判定余裕)
171
- const deadline = Date.now() + 90_000
172
- let pendingSeats = []
173
- for (;;) {
174
- const { members, bridges } = await (await fetch(api('members'))).json()
175
- const seats = members.filter(m => m.delivery?.kind !== 'parent_watch' && (m.harness ?? m.vendor) && m.observe?.tmux_target)
176
- if (seats.length === 0) {
177
- console.log('[スキップ] fresh heartbeat 読み戻し(観測対象の席なし)')
178
- break
179
- }
180
- pendingSeats = seats.filter(m => m.status_reason !== 'fresh')
181
- if (pendingSeats.length === 0) {
182
- console.log(`[実施] fresh heartbeat 読み戻し: ${seats.length} 席すべて fresh`)
183
- break
184
- }
185
- if (Date.now() > deadline) {
186
- console.error(`RESUME_HEARTBEAT_STALE: fresh にならない席: ${pendingSeats.map(m => `${m.name}(${m.status_reason})`).join(', ') || '(席なし)'}`
187
- + `(bridges: seat_status=${bridges?.seat_status?.state} wakeup=${bridges?.wakeup?.state})`)
188
- process.exit(1)
189
- }
190
- await sleep(3000)
191
- }
192
-
193
- // 7. probe DM の配送 receipt 確認
194
- if (process.env.RESUME_PROBE !== 'true') {
195
- console.log('[スキップ] probe DM(--no-probe 指定)')
196
- process.exit(0)
197
- }
198
- const token = resolvePostToken(process.env)
199
- const headers = { 'Content-Type': 'application/json', ...(token ? { 'X-Peertable-Token': token } : {}) }
200
- const { members } = await (await fetch(api('members'))).json()
201
- const targets = members.filter(m => m.delivery?.kind !== 'parent_watch' && (m.harness ?? m.vendor) && m.observe?.tmux_target).map(m => m.name)
202
- if (!targets.length) { console.log('[スキップ] probe DM(配達対象の席なし)'); process.exit(0) }
203
- const res = await fetch(api('messages'), {
204
- method: 'POST', headers,
205
- body: JSON.stringify({ from: 'resume', to: targets.length === 1 ? targets[0] : targets,
206
- body: '[resume-probe] 配達経路の確認。応答不要・読み流してよい。' }),
207
- })
208
- const saved = await res.json()
209
- if (!res.ok) { console.error(`RESUME_PROBE_POST_FAILED: ${JSON.stringify(saved)}`); process.exit(1) }
210
- console.log(`[実施] probe DM 投稿: seq ${saved.seq} → ${targets.join(', ')}`)
211
- const probeDeadline = Date.now() + 120_000
212
- for (;;) {
213
- const { delivery } = await (await fetch(api(`deliveries?seq=${saved.seq}`))).json()
214
- const undelivered = targets.filter(t => delivery?.[t]?.state !== 'delivered')
215
- if (undelivered.length === 0) {
216
- console.log('[実施] probe DM の配送 receipt: 全席 delivered')
217
- break
218
- }
219
- if (Date.now() > probeDeadline) {
220
- console.error(`RESUME_PROBE_UNDELIVERED: delivered にならない席: ${undelivered.map(t => `${t}=${delivery?.[t]?.state ?? 'unknown'}${delivery?.[t]?.reason ? `(${delivery[t].reason})` : ''}`).join(', ')}`
221
- + '(Grok 席は idle 待ちで遅れることがある。.team/wakeup-bridge.log を確認すること)')
222
- process.exit(1)
223
- }
224
- await sleep(3000)
225
- }
226
- console.log('resume 完了。親の再着卓(parent-join / 番犬)は SKILL.md「親の再着卓」の手順で別途行うこと')
227
- NODE
4
+ exec node "$(dirname "$0")/legacy-entry.mjs" resume "$@"
@@ -0,0 +1,28 @@
1
+ // roomのHTTP境界。失敗応答を空の台帳として扱わない。
2
+ import { readFileSync } from 'node:fs'
3
+
4
+ export class RoomApi {
5
+ constructor(state, { credential, fetchImpl = fetch } = {}) {
6
+ this.base = `${state.server_url.replace(/\/$/, '')}/api/${encodeURIComponent(state.room)}`
7
+ this.credential = credential
8
+ this.fetch = fetchImpl
9
+ }
10
+ async request(path, { method = 'GET', body } = {}) {
11
+ const headers = { 'Content-Type': 'application/json' }
12
+ if (method !== 'GET' && this.credential) {
13
+ headers['X-Peertable-Token'] = readFileSync(this.credential, 'utf8').trim()
14
+ }
15
+ let response
16
+ try {
17
+ response = await this.fetch(path ? `${this.base}/${path}` : this.base, {
18
+ method, headers, ...(body === undefined ? {} : { body: JSON.stringify(body) }),
19
+ signal: AbortSignal.timeout(15_000),
20
+ })
21
+ } catch (error) {
22
+ throw Object.assign(new Error(`roomへ到達できません: ${error.message}`), { code: 'PEERTABLE_ROOM_UNREACHABLE' })
23
+ }
24
+ if (!response.ok) throw Object.assign(new Error(`${method} ${path}: HTTP ${response.status}`), { code: 'PEERTABLE_ROOM_REQUEST_FAILED' })
25
+ return response.json()
26
+ }
27
+ async members() { return (await this.request('members')).members }
28
+ }