@geekbeer/minion 3.43.0 → 3.49.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.
@@ -0,0 +1,402 @@
1
+ /**
2
+ * VNC Auth Proxy (macOS)
3
+ *
4
+ * Replaces websockify for the macOS Screen Sharing backend.
5
+ *
6
+ * Why this exists:
7
+ * macOS `screensharingd` (port 5900) requires authentication; there is no
8
+ * no-auth mode for the system display. websockify is a dumb WS<->TCP relay
9
+ * and cannot perform RFB authentication, so noVNC on the HQ side hangs at
10
+ * the security handshake unless it is given a password — which would force
11
+ * storing VNC credentials in the HQ database.
12
+ *
13
+ * This proxy keeps the password local to the minion (read from
14
+ * `~/.minion/.env` as VNC_PASSWORD). It speaks RFB on both sides:
15
+ * - acts as a no-auth VNC SERVER toward noVNC (on the WS side)
16
+ * - acts as a VNC CLIENT toward screensharingd (on the TCP side),
17
+ * performing VNC Auth (RFB security type 2) with the local password
18
+ * After both handshakes complete it bridges the streams transparently.
19
+ *
20
+ * Flow:
21
+ * browser (noVNC) <--WS--> HQ proxy <--WS--> THIS (port 6080)
22
+ * |
23
+ * +--TCP--> localhost:5900 (screensharingd)
24
+ *
25
+ * RFB protocol references: RFC 6143
26
+ */
27
+
28
+ const http = require('http')
29
+ const net = require('net')
30
+ const crypto = require('crypto')
31
+ const fs = require('fs')
32
+ const path = require('path')
33
+ const { WebSocketServer } = require('ws')
34
+
35
+ const PROXY_PORT = 6080
36
+ const VNC_BACKEND_HOST = '127.0.0.1'
37
+ const VNC_BACKEND_PORT = 5900
38
+
39
+ const RFB_VERSION = Buffer.from('RFB 003.008\n', 'ascii')
40
+ const SEC_TYPE_NONE = 1
41
+ const SEC_TYPE_VNC_AUTH = 2
42
+ const SECURITY_RESULT_OK = Buffer.from([0, 0, 0, 0])
43
+
44
+ /**
45
+ * Mangle a VNC password into an 8-byte DES key.
46
+ * VNC Auth historic quirk: password is NUL-padded / truncated to 8 bytes,
47
+ * then the bit order of each byte is REVERSED (LSB<->MSB).
48
+ */
49
+ function vncPasswordToKey(password) {
50
+ const key = Buffer.alloc(8, 0)
51
+ Buffer.from(password, 'utf-8').copy(key, 0, 0, Math.min(8, password.length))
52
+ for (let i = 0; i < 8; i++) {
53
+ let b = key[i]
54
+ b = ((b & 0x01) << 7) | ((b & 0x02) << 5) | ((b & 0x04) << 3) | ((b & 0x08) << 1)
55
+ | ((b & 0x10) >> 1) | ((b & 0x20) >> 3) | ((b & 0x40) >> 5) | ((b & 0x80) >> 7)
56
+ key[i] = b
57
+ }
58
+ return key
59
+ }
60
+
61
+ /**
62
+ * Encrypt the 16-byte server challenge with DES-ECB using the mangled password key.
63
+ * Two 8-byte blocks; no padding (block-aligned by definition).
64
+ */
65
+ function vncEncryptChallenge(challenge, password) {
66
+ const key = vncPasswordToKey(password)
67
+ const cipher = crypto.createCipheriv('des-ecb', key, null)
68
+ cipher.setAutoPadding(false)
69
+ return Buffer.concat([cipher.update(challenge), cipher.final()])
70
+ }
71
+
72
+ /**
73
+ * Build a sequential "read N bytes" function over a socket.
74
+ *
75
+ * Why not just attach/detach 'data' listeners per call: in flowing mode,
76
+ * any over-read bytes that we'd `socket.unshift()` are lost — they were
77
+ * already drained out of the internal buffer before the next listener
78
+ * attaches. Maintaining one persistent listener + a single accumulating
79
+ * buffer avoids that race entirely.
80
+ *
81
+ * Concurrent reads are not allowed. The handshake is sequential by spec.
82
+ *
83
+ * Also exposes `release()` which detaches our listeners and returns the
84
+ * residual buffered bytes — used to hand the socket off to the bridge
85
+ * after the handshake completes.
86
+ */
87
+ function makeSocketReader(socket) {
88
+ let buf = Buffer.alloc(0)
89
+ let pending = null
90
+ let closed = false
91
+ let lastError = null
92
+
93
+ const tryResolve = () => {
94
+ if (pending && buf.length >= pending.n) {
95
+ const { n, resolve } = pending
96
+ const out = buf.subarray(0, n)
97
+ buf = buf.subarray(n)
98
+ pending = null
99
+ resolve(out)
100
+ } else if (pending && (closed || lastError)) {
101
+ const { reject } = pending
102
+ pending = null
103
+ reject(lastError || new Error(`Socket closed before reading ${pending?.n} bytes`))
104
+ }
105
+ }
106
+
107
+ const onData = (chunk) => {
108
+ buf = Buffer.concat([buf, Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)])
109
+ tryResolve()
110
+ }
111
+ const onError = (err) => { lastError = err; tryResolve() }
112
+ const onClose = () => { closed = true; tryResolve() }
113
+
114
+ socket.on('data', onData)
115
+ socket.once('error', onError)
116
+ socket.once('close', onClose)
117
+
118
+ return {
119
+ readExactly(n) {
120
+ return new Promise((resolve, reject) => {
121
+ if (pending) return reject(new Error('Concurrent reads not supported'))
122
+ pending = { n, resolve, reject }
123
+ tryResolve()
124
+ })
125
+ },
126
+ /** Detach handlers and return whatever bytes are still buffered (for bridging). */
127
+ release() {
128
+ socket.removeListener('data', onData)
129
+ socket.removeListener('error', onError)
130
+ socket.removeListener('close', onClose)
131
+ const residual = buf
132
+ buf = Buffer.alloc(0)
133
+ return residual
134
+ },
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Drive the upstream RFB handshake with screensharingd.
140
+ * Returns when the upstream connection is fully authenticated and ready
141
+ * to receive ClientInit.
142
+ */
143
+ async function authenticateUpstream(reader, write, password) {
144
+ // 1. ProtocolVersion: server -> client
145
+ const serverVersion = await reader.readExactly(12)
146
+ if (!serverVersion.toString('ascii').startsWith('RFB ')) {
147
+ throw new Error(`Unexpected ProtocolVersion from upstream: ${serverVersion.toString('ascii')}`)
148
+ }
149
+ // 2. ProtocolVersion: client -> server (we always speak 3.8)
150
+ write(RFB_VERSION)
151
+
152
+ // 3. Security types: server -> client
153
+ const numTypes = (await reader.readExactly(1))[0]
154
+ if (numTypes === 0) {
155
+ // Failure: server sends 4-byte reason length + reason string
156
+ const reasonLen = (await reader.readExactly(4)).readUInt32BE(0)
157
+ const reason = (await reader.readExactly(reasonLen)).toString('utf-8')
158
+ throw new Error(`Upstream rejected handshake: ${reason}`)
159
+ }
160
+ const types = await reader.readExactly(numTypes)
161
+
162
+ // Pick VNC Auth (type 2) — that's what macOS Screen Sharing uses with -setvncpw
163
+ if (!types.includes(SEC_TYPE_VNC_AUTH)) {
164
+ throw new Error(`Upstream does not offer VNC Auth (type 2). Offered: [${[...types].join(',')}]. Did you enable "VNC viewers may control screen with password"?`)
165
+ }
166
+ write(Buffer.from([SEC_TYPE_VNC_AUTH]))
167
+
168
+ // 4. VNC Auth challenge: 16 bytes
169
+ const challenge = await reader.readExactly(16)
170
+ const response = vncEncryptChallenge(challenge, password)
171
+ write(response)
172
+
173
+ // 5. SecurityResult: 4 bytes (0=OK, 1=failed)
174
+ const result = (await reader.readExactly(4)).readUInt32BE(0)
175
+ if (result !== 0) {
176
+ // RFB 3.8 includes a failure reason after a non-zero result
177
+ try {
178
+ const reasonLen = (await reader.readExactly(4)).readUInt32BE(0)
179
+ const reason = (await reader.readExactly(reasonLen)).toString('utf-8')
180
+ throw new Error(`Upstream VNC Auth failed: ${reason}`)
181
+ } catch {
182
+ throw new Error(`Upstream VNC Auth failed (no reason provided)`)
183
+ }
184
+ }
185
+ }
186
+
187
+ /**
188
+ * Drive the downstream RFB handshake with noVNC, presenting "no auth required".
189
+ * After this returns, noVNC is in the post-security state and will send
190
+ * ClientInit next, which we just forward to upstream.
191
+ */
192
+ async function presentNoAuthDownstream(reader, write) {
193
+ // 1. ProtocolVersion: server (us) -> client (noVNC)
194
+ write(RFB_VERSION)
195
+ // 2. ProtocolVersion: client (noVNC) -> server (us)
196
+ const clientVersion = await reader.readExactly(12)
197
+ if (!clientVersion.toString('ascii').startsWith('RFB ')) {
198
+ throw new Error(`noVNC sent unexpected ProtocolVersion: ${clientVersion.toString('ascii')}`)
199
+ }
200
+ // 3. Security types: us -> noVNC. Offer only "None" (type 1).
201
+ write(Buffer.from([1, SEC_TYPE_NONE]))
202
+ // 4. Chosen type: noVNC -> us. Must be 1.
203
+ const chosen = (await reader.readExactly(1))[0]
204
+ if (chosen !== SEC_TYPE_NONE) {
205
+ throw new Error(`noVNC chose unexpected security type: ${chosen} (expected ${SEC_TYPE_NONE})`)
206
+ }
207
+ // 5. SecurityResult: us -> noVNC. Always OK.
208
+ write(SECURITY_RESULT_OK)
209
+ }
210
+
211
+ /**
212
+ * Build a reader+writer for a WebSocket that has the same shape as
213
+ * `makeSocketReader(tcpSocket)`. Lets the handshake code be agnostic to
214
+ * the underlying transport.
215
+ */
216
+ function makeWsReader(ws) {
217
+ // Re-emit incoming WS binary frames through an EventEmitter that quacks
218
+ // enough like a Socket for makeSocketReader (only on('data'|'error'|'close')).
219
+ const { EventEmitter } = require('events')
220
+ const fakeSock = new EventEmitter()
221
+ ws.on('message', (data) => fakeSock.emit('data', Buffer.isBuffer(data) ? data : Buffer.from(data)))
222
+ ws.on('error', (err) => fakeSock.emit('error', err))
223
+ ws.on('close', () => fakeSock.emit('close'))
224
+ // EventEmitter doesn't have removeListener-by-removeListener method that
225
+ // matches Socket's API exactly, but it does — same `removeListener` exists.
226
+ return makeSocketReader(fakeSock)
227
+ }
228
+
229
+ /**
230
+ * Load VNC password from ~/.minion/.env.
231
+ * Returns null if not configured (server should refuse connections in that case).
232
+ */
233
+ function loadVncPassword() {
234
+ const envPath = path.join(process.env.HOME || '', '.minion', '.env')
235
+ try {
236
+ const content = fs.readFileSync(envPath, 'utf-8')
237
+ for (const line of content.split('\n')) {
238
+ const trimmed = line.trim()
239
+ if (!trimmed || trimmed.startsWith('#')) continue
240
+ const eq = trimmed.indexOf('=')
241
+ if (eq === -1) continue
242
+ const key = trimmed.slice(0, eq).trim()
243
+ if (key === 'VNC_PASSWORD') {
244
+ let value = trimmed.slice(eq + 1).trim()
245
+ // Strip surrounding quotes if any
246
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
247
+ value = value.slice(1, -1)
248
+ }
249
+ return value
250
+ }
251
+ }
252
+ } catch (err) {
253
+ if (err.code !== 'ENOENT') {
254
+ console.error(`[VNC Proxy] Failed to read ${envPath}: ${err.message}`)
255
+ }
256
+ }
257
+ return null
258
+ }
259
+
260
+ /**
261
+ * Handle a single WS upgrade: drive both handshakes, then bridge.
262
+ */
263
+ async function handleConnection(ws, password) {
264
+ const tcp = net.connect(VNC_BACKEND_PORT, VNC_BACKEND_HOST)
265
+
266
+ let authDone = false
267
+ tcp.once('error', (err) => {
268
+ console.error(`[VNC Proxy] Backend connection error: ${err.message}`)
269
+ if (!authDone) {
270
+ try { ws.close(1011, 'Backend unavailable') } catch { /* noop */ }
271
+ }
272
+ })
273
+
274
+ try {
275
+ await new Promise((resolve, reject) => {
276
+ tcp.once('connect', resolve)
277
+ tcp.once('error', reject)
278
+ })
279
+
280
+ const tcpReader = makeSocketReader(tcp)
281
+ const wsReader = makeWsReader(ws)
282
+ const tcpWrite = (buf) => tcp.write(buf)
283
+ const wsWrite = (buf) => ws.send(buf, { binary: true })
284
+
285
+ // Drive both handshakes in parallel — they don't depend on each other
286
+ // until after both complete.
287
+ await Promise.all([
288
+ authenticateUpstream(tcpReader, tcpWrite, password),
289
+ presentNoAuthDownstream(wsReader, wsWrite),
290
+ ])
291
+
292
+ authDone = true
293
+
294
+ // Detach handshake readers; reclaim any over-buffered residual bytes.
295
+ const wsResidual = wsReader.release()
296
+ const tcpResidual = tcpReader.release()
297
+ console.log(`[VNC Proxy] Handshake complete, bridging (residual: ws=${wsResidual.length}b tcp=${tcpResidual.length}b)`)
298
+
299
+ // Forward residual bytes that arrived during handshake but past the
300
+ // handshake's read window (e.g. ClientInit piggybacked with the
301
+ // chosen security type).
302
+ if (wsResidual.length > 0 && !tcp.destroyed) tcp.write(wsResidual)
303
+ if (tcpResidual.length > 0 && ws.readyState === ws.OPEN) ws.send(tcpResidual, { binary: true })
304
+
305
+ // Transparent bridge: WS frames <-> TCP bytes
306
+ ws.on('message', (data) => {
307
+ const buf = Buffer.isBuffer(data) ? data : Buffer.from(data)
308
+ if (!tcp.destroyed) tcp.write(buf)
309
+ })
310
+ tcp.on('data', (chunk) => {
311
+ if (ws.readyState === ws.OPEN) ws.send(chunk, { binary: true })
312
+ })
313
+
314
+ const cleanup = () => {
315
+ try { tcp.destroy() } catch { /* noop */ }
316
+ try { ws.close() } catch { /* noop */ }
317
+ }
318
+ tcp.on('close', cleanup)
319
+ tcp.on('error', cleanup)
320
+ ws.on('close', cleanup)
321
+ ws.on('error', cleanup)
322
+ } catch (err) {
323
+ console.error(`[VNC Proxy] Handshake failed: ${err.message}`)
324
+ try { tcp.destroy() } catch { /* noop */ }
325
+ try { ws.close(1011, `Handshake failed: ${err.message}`) } catch { /* noop */ }
326
+ }
327
+ }
328
+
329
+ /**
330
+ * Start the VNC auth proxy server on PROXY_PORT (default 6080).
331
+ * Returns a Promise that resolves once listening, or rejects on bind failure.
332
+ */
333
+ function startVncAuthProxy() {
334
+ return new Promise((resolve, reject) => {
335
+ const httpServer = http.createServer((req, res) => {
336
+ // Non-WebSocket requests get a friendly error (websockify does the same)
337
+ res.writeHead(426, { 'Content-Type': 'text/plain', 'Upgrade': 'websocket' })
338
+ res.end('WebSocket connections only')
339
+ })
340
+
341
+ const wss = new WebSocketServer({ server: httpServer, handleProtocols: (protocols) => {
342
+ // noVNC asks for 'binary' subprotocol; honor it if offered
343
+ if (protocols.has('binary')) return 'binary'
344
+ return false
345
+ } })
346
+
347
+ wss.on('connection', (ws, req) => {
348
+ console.log(`[VNC Proxy] Connection from ${req.socket.remoteAddress}`)
349
+ const password = loadVncPassword()
350
+ if (!password) {
351
+ console.error('[VNC Proxy] VNC_PASSWORD not configured in ~/.minion/.env; refusing')
352
+ ws.close(1011, 'VNC password not configured on minion')
353
+ return
354
+ }
355
+ handleConnection(ws, password).catch((err) => {
356
+ console.error(`[VNC Proxy] Unhandled error: ${err.message}`)
357
+ })
358
+ })
359
+
360
+ httpServer.listen(PROXY_PORT, '0.0.0.0', () => {
361
+ console.log(`[VNC Proxy] Listening on port ${PROXY_PORT}, backend ${VNC_BACKEND_HOST}:${VNC_BACKEND_PORT}`)
362
+ resolve(httpServer)
363
+ })
364
+ httpServer.once('error', (err) => {
365
+ console.error(`[VNC Proxy] Failed to bind ${PROXY_PORT}: ${err.message}`)
366
+ reject(err)
367
+ })
368
+ })
369
+ }
370
+
371
+ /**
372
+ * Smoke-test DES availability at startup. Node 17+ (OpenSSL 3) requires
373
+ * --openssl-legacy-provider to enable DES. Failing fast here is much clearer
374
+ * than failing per-connection at challenge encryption time.
375
+ */
376
+ function assertDesAvailable() {
377
+ try {
378
+ const c = crypto.createCipheriv('des-ecb', Buffer.alloc(8, 0), null)
379
+ c.setAutoPadding(false)
380
+ c.update(Buffer.alloc(8, 0))
381
+ c.final()
382
+ } catch (err) {
383
+ if (err.code === 'ERR_OSSL_EVP_UNSUPPORTED') {
384
+ console.error('[VNC Proxy] FATAL: DES is unavailable in this Node build.')
385
+ console.error('[VNC Proxy] Re-launch node with --openssl-legacy-provider')
386
+ console.error('[VNC Proxy] (the bundled LaunchAgent plist sets this automatically; fix the plist if you are seeing this).')
387
+ process.exit(2)
388
+ }
389
+ throw err
390
+ }
391
+ }
392
+
393
+ if (require.main === module) {
394
+ // Standalone entrypoint for running under launchd
395
+ assertDesAvailable()
396
+ startVncAuthProxy().catch((err) => {
397
+ console.error(`[VNC Proxy] Fatal: ${err.message}`)
398
+ process.exit(1)
399
+ })
400
+ }
401
+
402
+ module.exports = { startVncAuthProxy, vncPasswordToKey, vncEncryptChallenge }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * macOS workflow runner — re-exports the Linux implementation.
3
+ *
4
+ * Uses tmux + Claude CLI for cron-scheduled workflow execution. Both are
5
+ * available on macOS via Homebrew, no platform branching needed.
6
+ */
7
+ module.exports = require('../linux/workflow-runner')
package/package.json CHANGED
@@ -1,19 +1,22 @@
1
1
  {
2
2
  "name": "@geekbeer/minion",
3
- "version": "3.43.0",
3
+ "version": "3.49.0",
4
4
  "description": "AI Agent runtime for Minion - manages status and skill deployment on VPS",
5
5
  "main": "linux/server.js",
6
6
  "bin": {
7
7
  "minion-cli": "./linux/minion-cli.sh",
8
8
  "hq": "./linux/bin/hq",
9
9
  "minion-cli-win": "./win/bin/minion-cli-win.js",
10
- "hq-win": "./win/bin/hq-win.js"
10
+ "hq-win": "./win/bin/hq-win.js",
11
+ "minion-cli-mac": "./mac/minion-cli.sh",
12
+ "hq-mac": "./mac/bin/hq"
11
13
  },
12
14
  "files": [
13
15
  "postinstall.js",
14
16
  "core/",
15
17
  "linux/",
16
18
  "win/",
19
+ "mac/",
17
20
  "skills/",
18
21
  "rules/",
19
22
  "roles/",
@@ -25,6 +28,7 @@
25
28
  "scripts": {
26
29
  "start": "node linux/server.js",
27
30
  "start:win": "node win/server.js",
31
+ "start:mac": "node mac/server.js",
28
32
  "postinstall": "node postinstall.js",
29
33
  "db:migration:new": "node scripts/new-migration.js"
30
34
  },
package/postinstall.js CHANGED
@@ -1,18 +1,39 @@
1
1
  #!/usr/bin/env node
2
- // postinstall.js — Re-apply file ACLs on Windows after npm install -g.
3
- // On Linux this is a no-op (permissions are not affected by reinstall).
4
-
5
- if (process.platform !== 'win32') process.exit(0);
2
+ // postinstall.js — Platform-specific post-install hooks.
3
+ // - Windows: re-apply file ACLs via win/postinstall.ps1.
4
+ // - macOS: ensure mac/minion-cli.sh and mac/bin/hq are executable
5
+ // (npm preserves modes from the tarball, but defensive chmod matches the win pattern).
6
+ // - Linux: no-op.
6
7
 
7
8
  const { spawnSync } = require('child_process');
8
9
  const path = require('path');
10
+ const fs = require('fs');
11
+
12
+ if (process.platform === 'win32') {
13
+ const ps1 = path.join(__dirname, 'win', 'postinstall.ps1');
14
+ const result = spawnSync('powershell.exe', [
15
+ '-ExecutionPolicy', 'Bypass',
16
+ '-NoProfile',
17
+ '-NoLogo',
18
+ '-File', ps1,
19
+ ], { stdio: 'inherit', timeout: 30_000 });
20
+ process.exit(result.status || 0);
21
+ }
9
22
 
10
- const ps1 = path.join(__dirname, 'win', 'postinstall.ps1');
11
- const result = spawnSync('powershell.exe', [
12
- '-ExecutionPolicy', 'Bypass',
13
- '-NoProfile',
14
- '-NoLogo',
15
- '-File', ps1,
16
- ], { stdio: 'inherit', timeout: 30_000 });
23
+ if (process.platform === 'darwin') {
24
+ const macFiles = [
25
+ path.join(__dirname, 'mac', 'minion-cli.sh'),
26
+ path.join(__dirname, 'mac', 'bin', 'hq'),
27
+ ];
28
+ for (const file of macFiles) {
29
+ try {
30
+ if (fs.existsSync(file)) fs.chmodSync(file, 0o755);
31
+ } catch (err) {
32
+ console.error(`[postinstall] Failed to chmod ${file}: ${err.message}`);
33
+ }
34
+ }
35
+ process.exit(0);
36
+ }
17
37
 
18
- process.exit(result.status || 0);
38
+ // Linux: nothing to do
39
+ process.exit(0);
package/rules/core.md CHANGED
@@ -43,6 +43,7 @@ Minion
43
43
  - **priority**: `low` / `normal` / `high` / `urgent` (default `normal`)。可視化とフィルタ用途(並び順は `sort_order` が優先)
44
44
  - **`milestone_id` は親 EPIC に付ければ配下の子タスクも自動的にその進捗に含まれる**(effective milestone)。子を個別にひもづけ直す必要はない。`/health` は leaf タスク基準で `progress_pct` を算出する
45
45
  - **タスク検索**: `?q=<keyword>` でタイトル+説明の部分一致検索(pg_trgm、日本語OK)、`?priority=high,urgent` でカンマ区切り複数指定
46
+ - **`[task:UUID]` チケットタグ**: HQチャットでユーザーがチケットを指す際に使う形式。受信メッセージに含まれる場合、HQが解決した詳細がプロンプト先頭の「参照チケット」ブロックに同梱される。応答内でチケットへ言及する際にも同タグを使ってよい(HQ側でチップに描画される)
46
47
  - 詳細は `~/.minion/docs/api-reference.md` の「Project Tasks」「Project Milestones」「Project Health」と `~/.minion/docs/task-guides.md` の「プロジェクトタスク管理」を参照
47
48
  - **Workspace**: ミニオンは複数のワークスペースに所属でき、スキルやプロジェクトはワークスペース単位でスコープされる。チャットセッションもワークスペース別に分離される。所属ワークスペースはハートビートで自動同期され、`hq list workspaces` で確認できる。
48
49
  - ミニオンは複数プロジェクトに `pm`、`engineer`、`accountant` として参加できる。
@@ -219,6 +220,35 @@ Routine 実行中は以下もtmuxセッション環境で利用可能:
219
220
  - `pm.md` — PM (Project Manager) のガイドライン
220
221
  - `engineer.md` — Engineer のガイドライン
221
222
 
223
+ ## Board Task Auto-Pickup (ボードタスク自動着手)
224
+
225
+ アクティブスプリント (`sprint.status = 'active'`) に所属し、自分宛 (`assignee_minion_id == 自分`) かつ `status IN ('todo', 'doing')` のボードタスクは、`board-task-poller` が30秒間隔で自動検知し、claim→`doing`遷移→**専用 tmux セッションでの自走**を起動する (v3.46.0〜)。
226
+
227
+ **重要 (v3.46.0):** 以前はチャットセッションを代用していたが、ユーザーの会話履歴を破壊するため廃止。**今は `bt-{taskId8}` 名の独立したtmuxセッション**で `claude -p` を直接実行する。Linuxはホスト上のtmux、Windowsは WSL 内の tmux で動作。
228
+
229
+ セッションには以下が自動でプロンプトに注入される:
230
+
231
+ - タスクのタイトル / 説明 / 優先度 / 期限
232
+ - 受け入れ要件 (acceptance_criteria) のチェックリスト
233
+ - プロジェクトコンテキスト (`project_contexts.content`)
234
+ - プロジェクトメンバー一覧
235
+
236
+ **自動着手時の動作ルール:**
237
+ 1. **受け入れ要件をすべて満たしてから `review` へ遷移する。** `done` への直接遷移は禁止 (人間レビュー後)。
238
+ 2. **acceptance_criteria のチェックを更新する場合は既存IDを保持する。** PATCH の body で `acceptance_criteria: [...]` を渡すと全置換になるため、未編集の項目もIDごと送ること。
239
+ 3. **判断に迷ったら勝手に進めない。** 受け入れ要件が曖昧/不足している場合は、状況を Notes (`hq note create`) や Memory (`POST /api/memory`) に記録し、`status` を `doing` のまま threads (`POST /api/threads`) で PM にエスカレーションする。
240
+ 4. **タスクIDは `[task:UUID]` 形式で参照する。** ノートやスレッドで言及するときも同じタグを使うこと (HQ側でチップ描画される)。
241
+
242
+ **ステータス遷移と持ち越し:**
243
+ - 実行ミニオンが行うのは `todo→doing` (claim時) と `doing→review` (受け入れ要件達成後) のみ。`done` 遷移はレビュアーの責務。
244
+ - 前スプリントから `doing` 状態で持ち越されたタスクもポーラーが拾う。同名 tmux セッションが既に生きていれば再起動せずスキップ (二重実行防止)。
245
+
246
+ **セッション可視性:**
247
+ - Linuxミニオン: `tmux ls` で `bt-*` セッションが見える。WSターミナルからアタッチ可能。
248
+ - Windowsミニオン: WSL内の tmux で動作。HQ ダッシュボードのターミナル一覧 (`/api/terminal/sessions`) でも `wsl-tmux` タイプとして表示される。
249
+
250
+ 詳細な API 仕様は `~/.minion/docs/api-reference.md` の「Project Sprints」「Project Tasks」セクションを参照。
251
+
222
252
  ## Todo運用ルール
223
253
 
224
254
  チャット中のタスクは `/api/todos` に登録して進捗管理すること。圧縮(context compaction)を跨いでも作業を完遂するための仕組みがある。