peertable 0.4.37 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ja.md +3 -1
- package/README.md +3 -1
- package/package.json +2 -2
- package/room/Dockerfile +2 -1
- package/room/client.mjs +5 -11
- package/room/server.mjs +121 -39
- package/skill/SKILL.md +13 -8
- package/skill/scripts/change-seat.sh +2 -1
- package/skill/scripts/codex-dialog.mjs +46 -0
- package/skill/scripts/codex-seat-toml.mjs +6 -0
- package/skill/scripts/doctor.sh +185 -0
- package/skill/scripts/ensure-bridge.sh +1 -1
- package/skill/scripts/ensure-codex-room-mcp.mjs +4 -1
- package/skill/scripts/launch-seat.sh +137 -120
- package/skill/scripts/leave-seat.sh +1 -4
- package/skill/scripts/parent-join.sh +22 -9
- package/skill/scripts/parent-watch-logic.mjs +33 -0
- package/skill/scripts/parent-watch.mjs +34 -1
- package/skill/scripts/pull-attach-input.mjs +53 -0
- package/skill/scripts/refresh-seat-identity.mjs +97 -0
- package/skill/scripts/seat-identity.mjs +37 -6
- package/skill/scripts/seat-usage.mjs +5 -0
- package/skill/scripts/teardown.sh +8 -6
- package/skill/scripts/wakeup-bridge.mjs +52 -2
- package/skill/scripts/wakeup-delivery.mjs +1 -1
- package/skill/templates/member.md +7 -4
- package/skill/scripts/run-bridge.mjs +0 -272
- package/skill/scripts/seat-metadata.mjs +0 -21
|
@@ -34,21 +34,14 @@ fi
|
|
|
34
34
|
member=$(python3 - "$name" "$model" "$effort" "$parent_vendor" "$mission" <<'PY'
|
|
35
35
|
import json, sys
|
|
36
36
|
name, model, effort, vendor, mission = sys.argv[1:6]
|
|
37
|
-
|
|
37
|
+
# 台帳には canonical 欄だけを登録する(settings / role 単数の重複欄は 2026-08-22 廃止)
|
|
38
|
+
body = {'name': name, 'roles': ['統括']}
|
|
38
39
|
if vendor:
|
|
39
40
|
body['vendor'] = vendor
|
|
40
41
|
if model:
|
|
41
42
|
body['model'] = model
|
|
42
|
-
settings = {}
|
|
43
|
-
if vendor:
|
|
44
|
-
settings['vendor'] = vendor
|
|
45
|
-
if model:
|
|
46
|
-
settings['model'] = model
|
|
47
43
|
if effort:
|
|
48
44
|
body['effort'] = effort
|
|
49
|
-
settings['effort'] = effort
|
|
50
|
-
if settings:
|
|
51
|
-
body['settings'] = settings
|
|
52
45
|
if mission:
|
|
53
46
|
body['mission'] = mission
|
|
54
47
|
body['observe'] = None
|
|
@@ -78,6 +71,7 @@ fi
|
|
|
78
71
|
if [ -f "$proj/.team/roles/parent.md" ]; then
|
|
79
72
|
echo "親役割は $proj/.team/roles/parent.md を読むこと"
|
|
80
73
|
fi
|
|
74
|
+
echo "親の権限境界: 各ToDoのクローズは監査担当が行い、親は裁定しない。親の仕事はオーナー窓口・環境修理・終端監査だけ(オーナー裁定 2026-08-22。詳細は skill/SKILL.md「親の operating notes」冒頭)"
|
|
81
75
|
|
|
82
76
|
# parent-watchが初回headを固定する。以後のDMはwatcher不在時間を含め
|
|
83
77
|
# 永続cursorからcatch-upされる。host内のbackground task自体は親セッションだけが所有できる。
|
|
@@ -90,6 +84,25 @@ if PEERTABLE_PARENT_HOST="$parent_vendor" node "$here/parent-watch.mjs" "$proj"
|
|
|
90
84
|
else
|
|
91
85
|
echo "PARENT_WATCH_START_REQUIRED: Claude Monitor(persistent)で ${here}/parent-watch.mjs ${proj} ${name} --follow を1回だけ起動し、通知後も同じMonitorで待機を続けること"
|
|
92
86
|
fi
|
|
87
|
+
# 耳の疎通probe: 番犬が生きていても、その出力を受け取る監視(Monitor等)が現在の
|
|
88
|
+
# 親セッションに繋がっている保証はない(番犬が前セッションの耳へ吠え続け、親宛DMが
|
|
89
|
+
# 全損する実被弾 2026-08-22)。cursor確定後に親宛のprobe DMを1通置き、
|
|
90
|
+
# 「監視イベントとしてこのnonceを受信した」ことを着卓完了の条件にする。
|
|
91
|
+
probe_nonce="$(date +%s)-$$"
|
|
92
|
+
probe_body=$(python3 - "$name" "$probe_nonce" <<'PY'
|
|
93
|
+
import json, sys
|
|
94
|
+
name, nonce = sys.argv[1:3]
|
|
95
|
+
print(json.dumps({'from': 'ear-probe', 'to': name,
|
|
96
|
+
'body': f'[耳疎通probe {nonce}] この通知を監視イベントとして受信できていれば耳は接続済み。受信するまで着卓完了と言わないこと。'}, ensure_ascii=False))
|
|
97
|
+
PY
|
|
98
|
+
)
|
|
99
|
+
if curl -sf -X POST "$url/api/$room/messages" \
|
|
100
|
+
-H "X-Peertable-Token: $PEERTABLE_POST_TOKEN" -H 'content-type: application/json' \
|
|
101
|
+
-d "$probe_body" > /dev/null; then
|
|
102
|
+
echo "EAR_PROBE_SENT: nonce=${probe_nonce}。着卓完了の条件は、上記の監視経由でこのnonceを受信すること。受信できないなら耳は繋がっていない=着卓失敗として扱い、監視を張り直す"
|
|
103
|
+
else
|
|
104
|
+
echo "WARN: EAR_PROBE_POST_FAILED: 耳疎通probeを投稿できない(監視の受信確認ができないまま)" >&2
|
|
105
|
+
fi
|
|
93
106
|
else
|
|
94
107
|
echo "WARN: PARENT_WATCH_PRIME_FAILED: 親(${name})のDM cursorを準備できない" >&2
|
|
95
108
|
fi
|
|
@@ -4,6 +4,39 @@ export function latticeStaffingChanged(previous, next) {
|
|
|
4
4
|
return previous.ready !== next.ready || previous.active !== next.active
|
|
5
5
|
}
|
|
6
6
|
|
|
7
|
+
const STATUS_LABEL = { busy: '作業中', blocked: '承認待ち', idle: '待機', dead: '停止' }
|
|
8
|
+
|
|
9
|
+
// 「工程があるのに作業中の席が1つも無い」が holdMs 続いたら1回だけ警報を出す。
|
|
10
|
+
// observation: { ready, active, workers: [{name, status}] }(membersを読めた時だけ渡す)
|
|
11
|
+
// previous: 保存済み stall 状態({ since, alerted } | null | undefined)
|
|
12
|
+
// 返り値: { stall, event }。event が非null なら親へ届ける。
|
|
13
|
+
export function tableStallUpdate(previous, observation, nowMs, holdMs) {
|
|
14
|
+
if (!observation) return { stall: previous ?? null, event: null }
|
|
15
|
+
const { ready, active, workers } = observation
|
|
16
|
+
const workExists = ready + active > 0
|
|
17
|
+
const anyoneBusy = workers.some(w => w.status === 'busy')
|
|
18
|
+
if (!workExists || anyoneBusy) return { stall: null, event: null }
|
|
19
|
+
const since = previous?.since ?? nowMs
|
|
20
|
+
const alerted = previous?.alerted === true
|
|
21
|
+
if (alerted || nowMs - since < holdMs) {
|
|
22
|
+
return { stall: { since, alerted }, event: null }
|
|
23
|
+
}
|
|
24
|
+
const seatList = workers.length === 0
|
|
25
|
+
? '席が1つもありません'
|
|
26
|
+
: workers.map(w => `${w.name}=${STATUS_LABEL[w.status] ?? w.status ?? '未報告'}`).join('、')
|
|
27
|
+
return {
|
|
28
|
+
stall: { since, alerted: true },
|
|
29
|
+
event: {
|
|
30
|
+
type: 'parent_table_stalled',
|
|
31
|
+
ready,
|
|
32
|
+
active,
|
|
33
|
+
workers,
|
|
34
|
+
body: `警報: 着手可能 ${ready} 件・着手中 ${active} 件の工程があるのに、作業中の席が1つもありません`
|
|
35
|
+
+ `(${Math.round(holdMs / 60000)}分継続)。席: ${seatList}。各席の状態を確認して動かしてください。`,
|
|
36
|
+
},
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
7
40
|
export function addressedToParent(message, parent) {
|
|
8
41
|
return message?.from !== parent
|
|
9
42
|
&& (message?.to === 'all' || message?.to === parent
|
|
@@ -11,7 +11,7 @@ import { execFileSync } from 'node:child_process'
|
|
|
11
11
|
import { existsSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
12
12
|
import { join } from 'node:path'
|
|
13
13
|
import { resolveLatticeExecutable } from './seat-usage.mjs'
|
|
14
|
-
import { addressedToParent as messageAddressedToParent, latticeStaffingChanged } from './parent-watch-logic.mjs'
|
|
14
|
+
import { addressedToParent as messageAddressedToParent, latticeStaffingChanged, tableStallUpdate } from './parent-watch-logic.mjs'
|
|
15
15
|
|
|
16
16
|
const args = process.argv.slice(2)
|
|
17
17
|
const project = args.shift()
|
|
@@ -231,8 +231,40 @@ async function acceptMessage(message) {
|
|
|
231
231
|
return matched
|
|
232
232
|
}
|
|
233
233
|
|
|
234
|
+
// 「工程があるのに作業中の席が1つも無い」を親へ知らせる停滞警報。席の状態は
|
|
235
|
+
// seat-status-bridge が room members へ書く status を使う(親は延べ配信対象外)。
|
|
236
|
+
// members か Lattice を読めない tick は判定を進めず、前回の stall 状態を保つ。
|
|
237
|
+
const stallHoldMs = Number(process.env.PEERTABLE_STALL_ALARM_MS ?? 180_000)
|
|
238
|
+
async function checkStall() {
|
|
239
|
+
const lattice = state.lattice
|
|
240
|
+
if (!lattice || lattice.error !== undefined) return false
|
|
241
|
+
let members
|
|
242
|
+
try { members = (await readJson('/members')).members ?? [] } catch { return false }
|
|
243
|
+
const workers = members
|
|
244
|
+
.filter(m => m.name !== parent && m.delivery?.kind !== 'parent_watch')
|
|
245
|
+
.map(m => ({ name: m.name, status: m.status ?? null }))
|
|
246
|
+
const { stall, event } = tableStallUpdate(
|
|
247
|
+
state.stall ?? null,
|
|
248
|
+
{ ready: lattice.ready, active: lattice.active, workers },
|
|
249
|
+
Date.now(),
|
|
250
|
+
stallHoldMs,
|
|
251
|
+
)
|
|
252
|
+
if (JSON.stringify(stall) !== JSON.stringify(state.stall ?? null)) {
|
|
253
|
+
state = { ...state, stall, last_event_at: event ? now() : state.last_event_at }
|
|
254
|
+
saveState(state)
|
|
255
|
+
}
|
|
256
|
+
if (!event) return false
|
|
257
|
+
await writeEvent({
|
|
258
|
+
schema: 'peertable.parent-watch-event.v1',
|
|
259
|
+
parent,
|
|
260
|
+
...event,
|
|
261
|
+
})
|
|
262
|
+
return true
|
|
263
|
+
}
|
|
264
|
+
|
|
234
265
|
async function catchUp() {
|
|
235
266
|
if (await acceptLatticeState(readLatticeState(state.lattice)) && mode === '--next') return true
|
|
267
|
+
if (await checkStall() && mode === '--next') return true
|
|
236
268
|
const body = await readJson(`/messages?since=${state.last_seq}`)
|
|
237
269
|
for (const message of body.messages ?? []) {
|
|
238
270
|
if (await acceptMessage(message) && mode === '--next') return true
|
|
@@ -295,6 +327,7 @@ for (;;) {
|
|
|
295
327
|
if (eventName === 'ping') {
|
|
296
328
|
const head = Number(data)
|
|
297
329
|
if (await acceptLatticeState(readLatticeState(state.lattice)) && mode === '--next') process.exit(0)
|
|
330
|
+
if (await checkStall() && mode === '--next') process.exit(0)
|
|
298
331
|
if (Number.isSafeInteger(head) && head > state.last_seq && await catchUp()) process.exit(0)
|
|
299
332
|
continue
|
|
300
333
|
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// attach 入力は room 台帳(member 行の本人性欄)だけから作る。呼び出し側の pid 推定を受け付けない。
|
|
3
|
+
// usage: pull-attach-input.mjs <project_dir> <name> [refusedPid]
|
|
4
|
+
import { readFileSync } from 'node:fs'
|
|
5
|
+
import { join, resolve } from 'node:path'
|
|
6
|
+
|
|
7
|
+
const fail = (code, message) => {
|
|
8
|
+
process.stderr.write(`${code}: ${message}\n`)
|
|
9
|
+
process.exit(2)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const [projectDir, name, maybePid] = process.argv.slice(2)
|
|
13
|
+
if (!projectDir || !name) fail('ATTACH_INPUT_ARGS', 'usage: pull-attach-input.mjs <project_dir> <name> [refusedPid]')
|
|
14
|
+
|
|
15
|
+
let setup
|
|
16
|
+
try {
|
|
17
|
+
setup = JSON.parse(readFileSync(join(resolve(projectDir), '.team', 'setup-state.json'), 'utf8'))
|
|
18
|
+
} catch (error) {
|
|
19
|
+
fail('ATTACH_INPUT_STATE_UNREADABLE', error.message)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let member
|
|
23
|
+
try {
|
|
24
|
+
const response = await fetch(`${setup.server_url.replace(/\/$/u, '')}/api/${encodeURIComponent(setup.room)}/members/${encodeURIComponent(name)}`)
|
|
25
|
+
if (!response.ok) fail('ATTACH_INPUT_MEMBER_MISSING', `台帳に ${name} が無い(HTTP ${response.status})`)
|
|
26
|
+
member = (await response.json()).member
|
|
27
|
+
} catch (error) {
|
|
28
|
+
if (error?.code === undefined) fail('ATTACH_INPUT_LEDGER_UNREACHABLE', String(error?.message ?? error))
|
|
29
|
+
throw error
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const pid = member.pid
|
|
33
|
+
if (!Number.isSafeInteger(pid) || pid < 1) fail('ATTACH_INPUT_NO_PID', '台帳の member 行に pid が無い')
|
|
34
|
+
if (maybePid !== undefined && Number(maybePid) !== pid) {
|
|
35
|
+
fail('WORKER_IDENTITY_MISMATCH',
|
|
36
|
+
`invented pid ${maybePid} is not the ledger pid ${pid}(実行層死亡ではない)`)
|
|
37
|
+
}
|
|
38
|
+
if (!member.started_identity || !member.argv_digest) {
|
|
39
|
+
fail('ATTACH_INPUT_IDENTITY_INCOMPLETE', '台帳の member 行に started_identity / argv_digest が無い')
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// `session` は席名。tmux 名(peer-<name>)を入れると Lattice の attach が
|
|
43
|
+
// `WORKER_ACTOR_MISMATCH` で必ず拒否される(actor session は LATTICE_TODO_ACTOR_SESSION=<name>)。
|
|
44
|
+
const input = {
|
|
45
|
+
schema: 'lattice.pull_worker_attach_input.v1',
|
|
46
|
+
name: member.name,
|
|
47
|
+
session: member.name,
|
|
48
|
+
pid,
|
|
49
|
+
started_identity: member.started_identity,
|
|
50
|
+
argv_digest: member.argv_digest,
|
|
51
|
+
recorded_at: member.identity_recorded_at,
|
|
52
|
+
}
|
|
53
|
+
process.stdout.write(`${JSON.stringify(input)}\n`)
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// 台帳(room member 行)の argv_digest を、Lattice attach と同じ /bin/ps -o command= 観測へ揃える。
|
|
3
|
+
// pid / lstart が台帳と違うときは書き換えず終わる(pid 推定も再利用も禁止)。
|
|
4
|
+
import { execFileSync, execFile } from 'node:child_process'
|
|
5
|
+
import { readFileSync } from 'node:fs'
|
|
6
|
+
import { dirname, join, resolve } from 'node:path'
|
|
7
|
+
import { fileURLToPath } from 'node:url'
|
|
8
|
+
import { promisify } from 'node:util'
|
|
9
|
+
|
|
10
|
+
// ps 観測と digest 計算は seat-identity.mjs(OS観測ライブラリ)が唯一の所有者。再実装しない。
|
|
11
|
+
export { hashArgv, observePidCommand } from './seat-identity.mjs'
|
|
12
|
+
import { observePidCommand } from './seat-identity.mjs'
|
|
13
|
+
|
|
14
|
+
export function refreshSeatRecord(raw, observed, recordedAt) {
|
|
15
|
+
if (!Number.isSafeInteger(raw?.pid) || raw.pid < 1) {
|
|
16
|
+
const error = new Error('台帳の member 行に pid が無い')
|
|
17
|
+
error.code = 'SEAT_IDENTITY_NO_PID'
|
|
18
|
+
throw error
|
|
19
|
+
}
|
|
20
|
+
if (observed.pid !== raw.pid) {
|
|
21
|
+
const error = new Error(`live pid ${observed.pid} は台帳の pid ${raw.pid} と違う`)
|
|
22
|
+
error.code = 'SEAT_IDENTITY_PID_CHANGED'
|
|
23
|
+
throw error
|
|
24
|
+
}
|
|
25
|
+
if (observed.started_identity !== raw.started_identity) {
|
|
26
|
+
const error = new Error('pid の lstart が台帳と一致しない')
|
|
27
|
+
error.code = 'SEAT_IDENTITY_LSTART_CHANGED'
|
|
28
|
+
throw error
|
|
29
|
+
}
|
|
30
|
+
return {
|
|
31
|
+
argv_digest: observed.argv_digest,
|
|
32
|
+
name: raw.name,
|
|
33
|
+
pid: raw.pid,
|
|
34
|
+
recorded_at: recordedAt,
|
|
35
|
+
started_identity: raw.started_identity,
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function utcRecordedAt() {
|
|
40
|
+
return execFileSync('date', ['-u', '+%Y-%m-%dT%H:%M:%S.000Z'], { encoding: 'utf8' }).trim()
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const isCli = process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])
|
|
44
|
+
if (isCli) {
|
|
45
|
+
const [project, name] = process.argv.slice(2)
|
|
46
|
+
if (!project || !name) {
|
|
47
|
+
process.stderr.write('usage: refresh-seat-identity.mjs <project> <name>\n')
|
|
48
|
+
process.exit(2)
|
|
49
|
+
}
|
|
50
|
+
let setup
|
|
51
|
+
try {
|
|
52
|
+
setup = JSON.parse(readFileSync(join(resolve(project), '.team', 'setup-state.json'), 'utf8'))
|
|
53
|
+
} catch (error) {
|
|
54
|
+
process.stderr.write(`SEAT_IDENTITY_STATE_UNREADABLE: ${error.message}\n`)
|
|
55
|
+
process.exit(2)
|
|
56
|
+
}
|
|
57
|
+
const api = `${setup.server_url.replace(/\/$/u, '')}/api/${encodeURIComponent(setup.room)}`
|
|
58
|
+
const memberResponse = await fetch(`${api}/members/${encodeURIComponent(name)}`)
|
|
59
|
+
if (!memberResponse.ok) {
|
|
60
|
+
process.stderr.write(`SEAT_IDENTITY_UNREADABLE: 台帳に ${name} が無い(HTTP ${memberResponse.status})\n`)
|
|
61
|
+
process.exit(2)
|
|
62
|
+
}
|
|
63
|
+
const raw = (await memberResponse.json()).member
|
|
64
|
+
let observed
|
|
65
|
+
try {
|
|
66
|
+
observed = observePidCommand(raw.pid)
|
|
67
|
+
} catch (error) {
|
|
68
|
+
process.stderr.write(`${error.code || 'SEAT_IDENTITY_UNOBSERVABLE'}: ${error.message}\n`)
|
|
69
|
+
process.exit(2)
|
|
70
|
+
}
|
|
71
|
+
let next
|
|
72
|
+
try {
|
|
73
|
+
next = refreshSeatRecord(raw, observed, utcRecordedAt())
|
|
74
|
+
} catch (error) {
|
|
75
|
+
process.stderr.write(`${error.code}: ${error.message}\n`)
|
|
76
|
+
process.exit(2)
|
|
77
|
+
}
|
|
78
|
+
if (next.argv_digest === raw.argv_digest) {
|
|
79
|
+
process.stdout.write(`${JSON.stringify({ outcome: 'unchanged', name, pid: raw.pid, argv_digest: raw.argv_digest })}\n`)
|
|
80
|
+
process.exit(0)
|
|
81
|
+
}
|
|
82
|
+
// 書込は席別 credential 経由(秘密値を argv/env に載せない既存境界を使う)
|
|
83
|
+
const credentialHelper = join(dirname(fileURLToPath(import.meta.url)), 'seat-credential.mjs')
|
|
84
|
+
const run = promisify(execFile)
|
|
85
|
+
const credential = (await run('node', [credentialHelper, 'prepare', resolve(project), setup.room, name],
|
|
86
|
+
{ env: { ...process.env, PEERTABLE_POST_TOKEN: undefined } })).stdout.trim()
|
|
87
|
+
const body = JSON.stringify({ name, argv_digest: next.argv_digest, identity_recorded_at: next.recorded_at })
|
|
88
|
+
await run('node', [credentialHelper, 'request', credential, 'POST', `${api}/members`, body],
|
|
89
|
+
{ env: { ...process.env, PEERTABLE_POST_TOKEN: undefined } })
|
|
90
|
+
process.stdout.write(`${JSON.stringify({
|
|
91
|
+
outcome: 'updated',
|
|
92
|
+
name,
|
|
93
|
+
pid: next.pid,
|
|
94
|
+
argv_digest: next.argv_digest,
|
|
95
|
+
previous_argv_digest: raw.argv_digest,
|
|
96
|
+
})}\n`)
|
|
97
|
+
}
|
|
@@ -1,10 +1,37 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
//
|
|
2
|
+
// Peertable スクリプト群の OS プロセス観測ライブラリ(唯一の所有者)。
|
|
3
|
+
// pane_pid から Lattice attach 用の pid / lstart / argv を1件に決める CLI を兼ねる。
|
|
3
4
|
// POSIX は ps。Windows は Win32_Process(ps -Ao は Git Bash で unknown option -- A)。
|
|
5
|
+
// ps の観測規約(LC_ALL=C・/bin/ps 絶対パス・trim)と argv digest の計算はこのファイルだけが持つ。
|
|
6
|
+
// 他スクリプトはここから import し、ps を手書きしない(locale 罠の再発防止。caveat:
|
|
7
|
+
// ps-locale-lstart-lc-time-ascii-argv-lc-ctype-digest)。
|
|
8
|
+
import { createHash } from 'node:crypto'
|
|
4
9
|
import { execFileSync } from 'node:child_process'
|
|
5
10
|
import { resolve } from 'node:path'
|
|
6
11
|
import { fileURLToPath } from 'node:url'
|
|
7
12
|
|
|
13
|
+
export function hashArgv(argv) {
|
|
14
|
+
return createHash('sha256').update(String(argv), 'utf8').digest('hex')
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// 記録済み pid そのものの lstart / argv を観測する(pane からの席特定はしない)。
|
|
18
|
+
// refresh-seat-identity・doctor が使う。
|
|
19
|
+
export function observePidCommand(pid) {
|
|
20
|
+
if (!Number.isSafeInteger(pid) || pid < 1) {
|
|
21
|
+
const error = new Error('pid が正整数でない')
|
|
22
|
+
error.code = 'SEAT_IDENTITY_NO_PID'
|
|
23
|
+
throw error
|
|
24
|
+
}
|
|
25
|
+
const started = execFileSync('/bin/ps', ['-o', 'lstart=', '-p', String(pid)], { encoding: 'utf8', env: psEnv() }).trim()
|
|
26
|
+
const argv = execFileSync('/bin/ps', ['-o', 'command=', '-p', String(pid)], { encoding: 'utf8', env: psEnv() }).trim()
|
|
27
|
+
if (!started || !argv) {
|
|
28
|
+
const error = new Error('pid の lstart/command を観測できない')
|
|
29
|
+
error.code = 'SEAT_IDENTITY_UNOBSERVABLE'
|
|
30
|
+
throw error
|
|
31
|
+
}
|
|
32
|
+
return { pid, started_identity: started, argv, argv_digest: hashArgv(argv) }
|
|
33
|
+
}
|
|
34
|
+
|
|
8
35
|
const AGENT = /(?:claude|codex|grok|composer)(?:\.cmd|\.exe)?(?:\s|$)/iu
|
|
9
36
|
|
|
10
37
|
export function parseWinCreationDate(value) {
|
|
@@ -25,8 +52,12 @@ export function parseWinCreationDate(value) {
|
|
|
25
52
|
return parsed.toISOString()
|
|
26
53
|
}
|
|
27
54
|
|
|
55
|
+
// ps の lstart 書式(LC_TIME)と非ASCII argv のエスケープ(LC_CTYPE)は locale で変わり、
|
|
56
|
+
// 観測者ごとに digest が割れる(2026-08-22 実測)。Lattice 側の観測も LC_ALL=C に固定している。
|
|
57
|
+
const psEnv = () => ({ ...process.env, LC_ALL: 'C' })
|
|
58
|
+
|
|
28
59
|
function posixIdentity(pid) {
|
|
29
|
-
const table = execFileSync('ps', ['-Ao', 'pid=,ppid=,pgid='], { encoding: 'utf8' })
|
|
60
|
+
const table = execFileSync('/bin/ps', ['-Ao', 'pid=,ppid=,pgid='], { encoding: 'utf8', env: psEnv() })
|
|
30
61
|
const leaders = table.split('\n').flatMap((line) => {
|
|
31
62
|
const match = /^\s*(\d+)\s+(\d+)\s+(\d+)\s*$/u.exec(line)
|
|
32
63
|
if (!match) return []
|
|
@@ -34,10 +65,10 @@ function posixIdentity(pid) {
|
|
|
34
65
|
return ppid === String(pid) && child === pgid ? [child] : []
|
|
35
66
|
})
|
|
36
67
|
const chosen = leaders.length === 1 ? leaders[0] : String(pid)
|
|
37
|
-
const started = execFileSync('/bin/ps', ['-o', 'lstart=', '-p', chosen], { encoding: 'utf8' }).trim()
|
|
38
|
-
const argv = execFileSync('/bin/ps', ['-o', '
|
|
68
|
+
const started = execFileSync('/bin/ps', ['-o', 'lstart=', '-p', chosen], { encoding: 'utf8', env: psEnv() }).trim()
|
|
69
|
+
const argv = execFileSync('/bin/ps', ['-o', 'command=', '-p', chosen], { encoding: 'utf8', env: psEnv() }).trim()
|
|
39
70
|
if (!started || !argv) throw new Error('pid の lstart/args を観測できない')
|
|
40
|
-
return { pid: Number(chosen), started_identity: started, argv }
|
|
71
|
+
return { pid: Number(chosen), started_identity: started, argv, argv_digest: hashArgv(argv) }
|
|
41
72
|
}
|
|
42
73
|
|
|
43
74
|
function winQuery(filter) {
|
|
@@ -60,7 +91,7 @@ function winIdentity(pid) {
|
|
|
60
91
|
const started = chosen.CreationDate ? parseWinCreationDate(chosen.CreationDate) : ''
|
|
61
92
|
const argv = String(chosen.CommandLine || '').trim()
|
|
62
93
|
if (!started || !argv) throw new Error('pid の CreationDate/CommandLine を観測できない')
|
|
63
|
-
return { pid: Number(chosen.ProcessId), started_identity: started, argv }
|
|
94
|
+
return { pid: Number(chosen.ProcessId), started_identity: started, argv, argv_digest: hashArgv(argv) }
|
|
64
95
|
}
|
|
65
96
|
|
|
66
97
|
const isCli = process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])
|
|
@@ -189,6 +189,11 @@ export const BLOCKED_MARKERS = [
|
|
|
189
189
|
'1. I am using this for local development',
|
|
190
190
|
'1. Yes, continue',
|
|
191
191
|
'Do you want to proceed?',
|
|
192
|
+
// Codex v0.148 のコマンド承認ダイアログ(2026-08-22 実測: 承認待ちの席が idle と
|
|
193
|
+
// 誤表示され、オーナーが「動いているのに表示が変わらない」と気づいた)
|
|
194
|
+
'Would you like to run the following command?',
|
|
195
|
+
'1. Yes, proceed',
|
|
196
|
+
'Press enter to confirm or esc to cancel',
|
|
192
197
|
]
|
|
193
198
|
|
|
194
199
|
/** Grok TUI の SpaceXAI coding-data バナー(2026-08-21 実測。入力は通るが席が死んだように見える)。 */
|
|
@@ -114,16 +114,18 @@ else
|
|
|
114
114
|
skip "seat-status-bridge(起動記録なし)"
|
|
115
115
|
fi
|
|
116
116
|
|
|
117
|
-
# run
|
|
118
|
-
# 止め残すと、卓が無いのに run の進行と介入を投稿し続ける常駐が残る
|
|
117
|
+
# run-bridge は退役済み(2026-08-22)。旧卓の常駐が残っていれば記録の pid を照合して止める。
|
|
119
118
|
if [ -f "$proj/.team/run-bridge.json" ]; then
|
|
120
|
-
|
|
121
|
-
|
|
119
|
+
rb_pid=$(python3 -c "import json;print(json.load(open('$proj/.team/run-bridge.json')).get('pid',''))" 2>/dev/null || true)
|
|
120
|
+
if [ -n "$rb_pid" ] && LC_ALL=C /bin/ps -o command= -p "$rb_pid" 2>/dev/null | grep -q "run-bridge.mjs"; then
|
|
121
|
+
kill "$rb_pid" 2>/dev/null || true
|
|
122
|
+
did "run-bridge(退役済み・旧常駐 pid ${rb_pid} を停止)"
|
|
122
123
|
else
|
|
123
|
-
|
|
124
|
+
skip "run-bridge(退役済み・生きた常駐なし)"
|
|
124
125
|
fi
|
|
126
|
+
rm -f "$proj/.team/run-bridge.json"
|
|
125
127
|
else
|
|
126
|
-
skip "run-bridge
|
|
128
|
+
skip "run-bridge(退役済み)"
|
|
127
129
|
fi
|
|
128
130
|
|
|
129
131
|
# run の close は成果が既定branchへ着地した証拠ではない。bridgeを止めてから、`run list`が挙げる
|
|
@@ -17,6 +17,7 @@ import { existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from
|
|
|
17
17
|
import { join } from 'node:path'
|
|
18
18
|
import { promisify } from 'node:util'
|
|
19
19
|
import { resolveSeatObservation, tmuxArgv } from './seat-usage.mjs'
|
|
20
|
+
import { keysForCodexPane } from './codex-dialog.mjs'
|
|
20
21
|
import { BROADCAST_RECIPIENT, formatWakeNotice, isIdleSelfWake, isWakeupBridgeTarget, shouldDeferGrokWake } from './wakeup-delivery.mjs'
|
|
21
22
|
import { bridgeRecordLive } from './bridge-record-live.mjs'
|
|
22
23
|
|
|
@@ -271,9 +272,31 @@ async function wake(seat, msgs) {
|
|
|
271
272
|
error.code = code
|
|
272
273
|
throw error
|
|
273
274
|
}
|
|
275
|
+
// **agent CLI が死んで pane が素の shell に戻っていたら、1バイトも打たない。**
|
|
276
|
+
// shell へ room の本文を send-keys すると、本文がそのまま shell コマンドとして実行される
|
|
277
|
+
// (実被弾 2026-08-22: codex 終了後の bash へ配達され `command not found` が走った。
|
|
278
|
+
// 本文次第では席の権限で任意コマンドになる)。配達は止め、毎周期 typed log で叫ぶ。
|
|
279
|
+
const fg = await run('tmux', tmuxArgv(['display-message', '-p', '-t', observation.target, '#{pane_current_command}'], { socket: observation.socket }))
|
|
280
|
+
const fgCommand = String(fg.stdout ?? '').trim()
|
|
281
|
+
if (['bash', 'zsh', 'sh', 'dash', 'fish', 'tcsh', 'csh', 'ksh'].includes(fgCommand)) {
|
|
282
|
+
log(`SEAT_TUI_GONE: ${seat} の pane は shell(${fgCommand})に戻っている=agent CLI が終了済み。`
|
|
283
|
+
+ 'shell へのコマンド実行を防ぐため配達しない。席を立て直すか leave-seat で畳むこと')
|
|
284
|
+
return 'deferred'
|
|
285
|
+
}
|
|
286
|
+
const pane = await run('tmux', tmuxArgv(['capture-pane', '-t', observation.target, '-p'], { socket: observation.socket }))
|
|
287
|
+
const screen = String(pane.stdout ?? '')
|
|
288
|
+
if (member.vendor === 'codex') {
|
|
289
|
+
const dialog = keysForCodexPane(screen)
|
|
290
|
+
if (dialog) {
|
|
291
|
+
for (const key of dialog.keys) {
|
|
292
|
+
await run('tmux', tmuxArgv(['send-keys', '-t', observation.target, key], { socket: observation.socket }))
|
|
293
|
+
}
|
|
294
|
+
log(`Codex ${dialog.kind} を通した: ${seat}`)
|
|
295
|
+
return 'deferred'
|
|
296
|
+
}
|
|
297
|
+
}
|
|
274
298
|
if (member.vendor === 'grok') {
|
|
275
|
-
const
|
|
276
|
-
const tail = String(pane.stdout).split('\n').slice(-14).join('\n')
|
|
299
|
+
const tail = screen.split('\n').slice(-14).join('\n')
|
|
277
300
|
if (shouldDeferGrokWake(member.vendor, tail)) {
|
|
278
301
|
if (!deferredBusy.has(seat)) {
|
|
279
302
|
log(`Grok席が実行中なのでidleまで待つ: ${seat} ← ${msgs.length} 件`)
|
|
@@ -291,6 +314,33 @@ async function wake(seat, msgs) {
|
|
|
291
314
|
await run('tmux', tmuxArgv(['send-keys', '-l', '-t', observation.target, text], { socket: observation.socket }))
|
|
292
315
|
await sleep(750)
|
|
293
316
|
await run('tmux', tmuxArgv(['send-keys', '-t', observation.target, 'Enter'], { socket: observation.socket }))
|
|
317
|
+
// 送信検証。Codex v0.148 は長文入力後に「Create a plan?」popup が出て Enter を呑むことがあり、
|
|
318
|
+
// 本文が入力欄に残ったまま席が沈黙する(2026-08-22 実測: 2席が無音停止し、人が画面を見るまで
|
|
319
|
+
// 誰も気づけなかった)。打鍵成功=配達成功とみなさず、画面で送信成立を確認する。
|
|
320
|
+
// 残っていれば Escape(popup解除)→ Enter で再送信し、それでも残るなら受領を確定せず
|
|
321
|
+
// 次周期に再試行する(毎回ログに残るので沈黙しない)。
|
|
322
|
+
const markerSource = text.replace(/\s+/gu, '')
|
|
323
|
+
const marker = markerSource.slice(-24)
|
|
324
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
325
|
+
await sleep(1200)
|
|
326
|
+
const after = await run('tmux', tmuxArgv(['capture-pane', '-t', observation.target, '-p'], { socket: observation.socket }))
|
|
327
|
+
const tailLines = String(after.stdout ?? '').split('\n').slice(-14)
|
|
328
|
+
const tailJoined = tailLines.join(' ')
|
|
329
|
+
// 折返しはcapture上で行分割されるだけで空白は挟まらないため、空白除去で復元して照合する
|
|
330
|
+
const tailSquashed = tailLines.join('').replace(/\s+/gu, '')
|
|
331
|
+
const queuedOrRunning = tailJoined.includes('esc to interrupt') || tailJoined.includes('to queue message')
|
|
332
|
+
const stuck = tailJoined.includes('esc dismiss')
|
|
333
|
+
|| (marker.length >= 8 && tailSquashed.includes(marker) && !queuedOrRunning)
|
|
334
|
+
if (!stuck) break
|
|
335
|
+
if (attempt === 2) {
|
|
336
|
+
log(`DELIVERY_STUCK: ${seat} の入力欄に本文が残ったまま送信できない(popup/入力詰まり)。受領を確定せず次周期に再試行する`)
|
|
337
|
+
return 'deferred'
|
|
338
|
+
}
|
|
339
|
+
log(`配達が送信されていない(popup/入力詰まり): ${seat}。Escape→Enter で再送信する`)
|
|
340
|
+
await run('tmux', tmuxArgv(['send-keys', '-t', observation.target, 'Escape'], { socket: observation.socket }))
|
|
341
|
+
await sleep(300)
|
|
342
|
+
await run('tmux', tmuxArgv(['send-keys', '-t', observation.target, 'Enter'], { socket: observation.socket }))
|
|
343
|
+
}
|
|
294
344
|
log(`TUIへ入れた: ${seat} ← ${msgs.length} 件(最新 seq ${last.seq})`)
|
|
295
345
|
}
|
|
296
346
|
|
|
@@ -36,7 +36,7 @@ export function isWakeupBridgeTarget(member, options = {}) {
|
|
|
36
36
|
&& observe.tmux_target,
|
|
37
37
|
)
|
|
38
38
|
if (hasPane) return true
|
|
39
|
-
const vendor = member.vendor
|
|
39
|
+
const vendor = member.vendor
|
|
40
40
|
return vendor === 'codex' || vendor === 'grok'
|
|
41
41
|
}
|
|
42
42
|
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
5. 証跡ファイル `evidence/{{PLAN_KEY}}/<task_id>.md` に、最終的な試験内容と試験結果を含めて「何を作り、どう確認したか」を書き、変更ファイルと証跡だけをcommitする
|
|
27
27
|
6. その証跡と同じ最終試験内容・結果を監査担当へ渡す。作業者自身は `.team/scripts/done.sh` や `lattice todo done` を実行しない
|
|
28
28
|
7. 監査担当として結果を受け取った場合は、提出された試験内容と試験結果が元PLAN・工程正本・受入条件に照らして妥当か判断する。試験を再実行せず、個人の思想や計画外の改善を完了条件へ加えない
|
|
29
|
-
8. 妥当なら監査担当が `.team/scripts/done.sh <task_id> --plan <plan_key>`
|
|
29
|
+
8. 妥当なら監査担当が `.team/scripts/done.sh <task_id> --plan <plan_key>` で工程をクローズする。クローズに親・オーナーの裁定は要らず、待たない——親が卓上で別の手順を言っていても、監査担当のクローズ権限が優先する。親は campaign 終端の最終監査で全量を見る(オーナー裁定 2026-08-22)。未着地の feat は `done.sh` が canonical main へ merge して push する。親へ着地を依頼しない。`done.sh` は証跡と同じ本文を Lattice の `test_result` へ記録し、remaining の並列記録も更新してから戻る。done を読返してから `post(to: "all", message: "次の工程に着手してください")` とだけ指示し、具体的な次工程は指示しない
|
|
30
30
|
9. 不合格なら、現在モデルでの修正機会は1回だけとする。再び不合格になったら親へmodel変更を依頼し、`Luna → Terra → Sol`の順で一段昇格する。自分で席設定を変えない
|
|
31
31
|
10. 作業者は監査担当によるクローズを確認し、工程正本から次のreadyを選ぶ
|
|
32
32
|
11. **claimできるToDoが無いなら仕事を発明しない(決定68)。** 依頼されていない監査・他席への状況照会・正典の自主レビューを暇つぶしに始めない。縮退の打診が来たらWIP棚卸しを正直に返す
|
|
@@ -85,17 +85,20 @@ lattice run intake --run .lattice/runs/<run-id> --task <id>
|
|
|
85
85
|
空のまま**である。作った席が他の席の仕事を決めたことにはならない。
|
|
86
86
|
|
|
87
87
|
1. **`todo start` を先に済ませてから intake する。** 装置は Todo 正本の start event へ束縛するので、start していない task は intake できない。**逆順にしない。**
|
|
88
|
-
2. **`intervention.state` を読む。** `none` なら worktree を使ってそのまま進める。`hold` の理由が `record_stale` または `artifact_binding_mismatch`(並列記録の失効・再 compile
|
|
88
|
+
2. **`intervention.state` を読む。** `none` なら worktree を使ってそのまま進める。`hold` の理由が `record_stale` または `artifact_binding_mismatch`(並列記録の失効・再 compile との交差)なら、未受理 intake を解放し、canonical で `.team/scripts/independence-refresh.sh {{PLAN_KEY}}` を通してから intake を打ち直す。他の着手済み task と同じ書き込み資源でぶつかった hold は、**担当 ToDo を手放す理由にならず、退席の理由にもならない**(旧規約の「hold=退席」は 2026-08-22 に廃止。卓を空にしただけだった)。未受理 intake を解放し、競合相手の task と理由を room へ一度記録して、その task のクローズを待つ。待つ間に自分の別の active WIP があればそれを進める。解消は `lattice run intake intervention --run <ref> --task <id>` の読み直しで確認してから intake を打ち直す。**hold を無視して進めない。**
|
|
89
89
|
3. **worktree を受け取ったら、自分の pid を装置へ渡す(attach)。** これをしないと、装置は競合時に「留まれ」と言うことはできても、実際に止めることができない(協調 hold のまま)。
|
|
90
90
|
```
|
|
91
91
|
lattice run intake attach --run <ref> --task <id> --input <file>
|
|
92
92
|
```
|
|
93
|
-
input は **`.team/
|
|
93
|
+
input は **`.team/scripts/pull-attach-input.mjs <project> <あなたの名前>` が room 台帳(member 行の本人性欄)から作る**(席file は 2026-08-22 廃止・変換も再計算も要らない)。
|
|
94
94
|
```json
|
|
95
95
|
{"schema":"lattice.pull_worker_attach_input.v1","name":…,"session":…,"pid":…,
|
|
96
96
|
"started_identity":…,"argv_digest":…,"recorded_at":…}
|
|
97
97
|
```
|
|
98
|
-
**pid を自分で推定しない。**
|
|
98
|
+
**pid を自分で推定しない。** 台帳の member 行だけが正で、無ければ room で言う(黙って別の値を渡さない)。**他の席の本人性欄を使わない。**
|
|
99
|
+
**argv_digest も席が計算し直さない。** pid と lstart が台帳と一致するのに digest だけ live と違うときは実行層死亡ではない。room で親へ「digest 更新が要る」とだけ言い、親が `refresh-seat-identity.mjs` で Lattice と同じ `/bin/ps -o command=` 観測へ揃える。
|
|
100
|
+
`lattice run intake` 自体は pid を取らない。attach の前に CLI の `process.pid` や死んだ子 pid を渡すと `ADAPTER_CONTROLLER_UNAVAILABLE: process start identity観測失敗` になる。それは実行層の死亡ではない。台帳の pid で打ち直す。
|
|
101
|
+
pull run の `driver_state=stopped` かつ `intakes=[]` は、まだ intake していない平常である。親へ「可視性を復旧して」と求めない。
|
|
99
102
|
4. **intake は1本ずつ。** 前の intake が accepted / closed / released になるまで次を取らない。**席は1つの process なので、2本 intake すると片方の hold がもう片方を巻き込む**——制御の粒度が壊れる。
|
|
100
103
|
5. **worktree の中だけを、絶対パスで触る。** `cd` しない・env を書き換えない・別 project へ移らない。席の room 接続と MCP 解決は cwd と env に乗っているので、動かすと卓から落ちる。git は `git -C <worktree> …`、編集は絶対パスで開く
|
|
101
104
|
6. **commit してよい。禁止は `push` / `branch` / `merge` / `rebase` / `reset` / `stash` の6つ**(正は Lattice engine の `FORBIDDEN_OPERATIONS`)。worktree は `base_sha` の detached HEAD なので、そこへ積む commit は base の子孫のままで canonical branch を動かさない。逆に6つは HEAD を base の子孫から外すか、外部へ効果を出す操作なので、観測の前提か公開契約のどちらかを壊す
|