@geekbeer/minion 4.2.0 → 4.3.3

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,94 @@
1
+ /**
2
+ * Bundled skill deployment.
3
+ *
4
+ * Skills shipped inside the `@geekbeer/minion` package (`skills/`) must reach the
5
+ * LLM skill directories (`~/.claude/skills/`, etc.) for the agent to actually use
6
+ * them. `minion-cli configure` copies them at setup time, but a plain package
7
+ * update (`update-agent` = `npm install -g @geekbeer/minion@latest && restart`)
8
+ * refreshes the files under node_modules WITHOUT re-running configure — so without
9
+ * this, bundled skill fixes never reach a running minion.
10
+ *
11
+ * Unlike rules/roles/docs (which the servers re-copy unconditionally on every
12
+ * startup because they are purely package-owned), skills can be customized per
13
+ * workspace via `minion-cli skill push` / `fetch`, which writes into the same
14
+ * skill directories. Overwriting on every restart would clobber those custom
15
+ * versions. So this sync is **version-gated**: bundled skills are (re)deployed
16
+ * only when the package version differs from the last recorded sync. An
17
+ * `update-agent` bumps the version → defaults refresh once; ordinary restarts
18
+ * leave HQ-fetched customizations intact.
19
+ */
20
+
21
+ const fs = require('fs')
22
+ const path = require('path')
23
+
24
+ /**
25
+ * (Re)deploy bundled skills into the given target directories when the package
26
+ * version has changed since the last sync.
27
+ *
28
+ * @param {object} opts
29
+ * @param {string} opts.packageRoot Absolute path to the package root (contains `skills/`)
30
+ * @param {string} opts.version Current package version (used as the gate key)
31
+ * @param {string[]} opts.targetDirs Skill directories to deploy into (e.g. ~/.claude/skills)
32
+ * @param {string} opts.markerPath File path storing the last-synced version
33
+ * @param {Console} [opts.logger] Logger (defaults to console)
34
+ * @returns {{ synced: boolean, skills?: number, dirs?: number, reason?: string }}
35
+ */
36
+ function syncBundledSkills({ packageRoot, version, targetDirs, markerPath, logger = console }) {
37
+ const bundledSkillsDir = path.join(packageRoot, 'skills')
38
+
39
+ try {
40
+ if (!fs.existsSync(bundledSkillsDir)) {
41
+ return { synced: false, reason: 'no_bundled_skills' }
42
+ }
43
+ if (!version) {
44
+ return { synced: false, reason: 'no_version' }
45
+ }
46
+ if (!Array.isArray(targetDirs) || targetDirs.length === 0) {
47
+ return { synced: false, reason: 'no_target_dirs' }
48
+ }
49
+
50
+ // Version gate: skip if we already deployed this exact version.
51
+ let lastSynced = null
52
+ try {
53
+ lastSynced = fs.readFileSync(markerPath, 'utf8').trim()
54
+ } catch {
55
+ /* no marker yet → first deploy */
56
+ }
57
+ if (lastSynced === version) {
58
+ return { synced: false, reason: 'up_to_date' }
59
+ }
60
+
61
+ const skillNames = fs.readdirSync(bundledSkillsDir).filter((name) => {
62
+ try {
63
+ return fs.statSync(path.join(bundledSkillsDir, name)).isDirectory()
64
+ } catch {
65
+ return false
66
+ }
67
+ })
68
+
69
+ for (const targetDir of targetDirs) {
70
+ fs.mkdirSync(targetDir, { recursive: true })
71
+ for (const name of skillNames) {
72
+ const src = path.join(bundledSkillsDir, name)
73
+ const dest = path.join(targetDir, name)
74
+ // Clean replace so files removed from the bundle don't linger.
75
+ fs.rmSync(dest, { recursive: true, force: true })
76
+ fs.cpSync(src, dest, { recursive: true })
77
+ }
78
+ }
79
+
80
+ fs.mkdirSync(path.dirname(markerPath), { recursive: true })
81
+ fs.writeFileSync(markerPath, version)
82
+
83
+ logger.log(
84
+ `[Skills] Re-deployed ${skillNames.length} bundled skill(s) to ${targetDirs.length} dir(s) for v${version}` +
85
+ (lastSynced ? ` (was v${lastSynced})` : ' (first deploy)'),
86
+ )
87
+ return { synced: true, skills: skillNames.length, dirs: targetDirs.length }
88
+ } catch (err) {
89
+ logger.error(`[Skills] Failed to sync bundled skills: ${err.message}`)
90
+ return { synced: false, reason: 'error' }
91
+ }
92
+ }
93
+
94
+ module.exports = { syncBundledSkills }
@@ -133,6 +133,7 @@ function save(session) {
133
133
  * Add a message to a session.
134
134
  * Creates a new session if none exists for the given session_id.
135
135
  * Past sessions are preserved (not deleted).
136
+ * Wrapped in a transaction so partial saves can't happen on failure.
136
137
  * @param {string} sessionId - Claude CLI session ID
137
138
  * @param {{ role: string, content: string }} msg - Message to add
138
139
  * @param {number} [turnCount] - Optional turn count to add
@@ -140,40 +141,71 @@ function save(session) {
140
141
  */
141
142
  function addMessage(sessionId, msg, turnCount, workspaceId) {
142
143
  const db = getDb()
143
- const existing = db.prepare('SELECT * FROM chat_sessions WHERE session_id = ?').get(sessionId)
144
+ const tx = db.transaction(() => {
145
+ const existing = db.prepare('SELECT session_id FROM chat_sessions WHERE session_id = ?').get(sessionId)
146
+
147
+ if (!existing) {
148
+ const now = Date.now()
149
+ db.prepare(
150
+ 'INSERT INTO chat_sessions (session_id, workspace_id, turn_count, created_at, updated_at) VALUES (?, ?, ?, ?, ?)'
151
+ ).run(sessionId, workspaceId || null, 0, now, now)
152
+ }
144
153
 
145
- // If session doesn't exist, create a new one (past sessions remain in DB)
146
- if (!existing) {
147
- const now = Date.now()
148
154
  db.prepare(
149
- 'INSERT INTO chat_sessions (session_id, workspace_id, turn_count, created_at, updated_at) VALUES (?, ?, ?, ?, ?)'
150
- ).run(sessionId, workspaceId || null, 0, now, now)
151
- }
155
+ 'INSERT INTO chat_messages (session_id, role, content, timestamp) VALUES (?, ?, ?, ?)'
156
+ ).run(sessionId, msg.role, msg.content, Date.now())
152
157
 
153
- // Insert message
154
- db.prepare(
155
- 'INSERT INTO chat_messages (session_id, role, content, timestamp) VALUES (?, ?, ?, ?)'
156
- ).run(sessionId, msg.role, msg.content, Date.now())
157
-
158
- // Update session metadata
159
- const newTurnCount = (turnCount && turnCount > 0) ? turnCount : 0
160
- db.prepare(`
161
- UPDATE chat_sessions
162
- SET updated_at = ?,
163
- turn_count = turn_count + ?
164
- WHERE session_id = ?
165
- `).run(Date.now(), newTurnCount, sessionId)
166
-
167
- // Prune old messages for this session (keep only MAX_MESSAGES most recent)
168
- const count = db.prepare('SELECT COUNT(*) as cnt FROM chat_messages WHERE session_id = ?').get(sessionId).cnt
169
- if (count > MAX_MESSAGES) {
158
+ const newTurnCount = (turnCount && turnCount > 0) ? turnCount : 0
170
159
  db.prepare(`
171
- DELETE FROM chat_messages WHERE id IN (
172
- SELECT id FROM chat_messages WHERE session_id = ?
173
- ORDER BY id ASC LIMIT ?
174
- )
175
- `).run(sessionId, count - MAX_MESSAGES)
176
- }
160
+ UPDATE chat_sessions
161
+ SET updated_at = ?,
162
+ turn_count = turn_count + ?
163
+ WHERE session_id = ?
164
+ `).run(Date.now(), newTurnCount, sessionId)
165
+
166
+ const count = db.prepare('SELECT COUNT(*) as cnt FROM chat_messages WHERE session_id = ?').get(sessionId).cnt
167
+ if (count > MAX_MESSAGES) {
168
+ db.prepare(`
169
+ DELETE FROM chat_messages WHERE id IN (
170
+ SELECT id FROM chat_messages WHERE session_id = ?
171
+ ORDER BY id ASC LIMIT ?
172
+ )
173
+ `).run(sessionId, count - MAX_MESSAGES)
174
+ }
175
+ })
176
+ tx()
177
+ }
178
+
179
+ /**
180
+ * Rename a session's ID, carrying over all messages.
181
+ * Used when a pending session ID (generated locally before the LLM call) is
182
+ * replaced by the real session_id returned by Claude CLI.
183
+ * If `newSessionId` already exists, messages from `oldSessionId` are merged
184
+ * into it and the old session row is dropped.
185
+ * @param {string} oldSessionId
186
+ * @param {string} newSessionId
187
+ * @returns {boolean} true if any rows were touched
188
+ */
189
+ function rekeySession(oldSessionId, newSessionId) {
190
+ if (!oldSessionId || !newSessionId || oldSessionId === newSessionId) return false
191
+ const db = getDb()
192
+ const tx = db.transaction(() => {
193
+ const oldSession = db.prepare('SELECT * FROM chat_sessions WHERE session_id = ?').get(oldSessionId)
194
+ if (!oldSession) return false
195
+
196
+ const existingNew = db.prepare('SELECT session_id FROM chat_sessions WHERE session_id = ?').get(newSessionId)
197
+ if (existingNew) {
198
+ db.prepare('UPDATE chat_messages SET session_id = ? WHERE session_id = ?').run(newSessionId, oldSessionId)
199
+ db.prepare('UPDATE chat_sessions SET updated_at = ?, turn_count = turn_count + ? WHERE session_id = ?')
200
+ .run(Date.now(), oldSession.turn_count || 0, newSessionId)
201
+ db.prepare('DELETE FROM chat_sessions WHERE session_id = ?').run(oldSessionId)
202
+ } else {
203
+ db.prepare('UPDATE chat_sessions SET session_id = ? WHERE session_id = ?').run(newSessionId, oldSessionId)
204
+ db.prepare('UPDATE chat_messages SET session_id = ? WHERE session_id = ?').run(newSessionId, oldSessionId)
205
+ }
206
+ return true
207
+ })
208
+ return tx() === true
177
209
  }
178
210
 
179
211
  /**
@@ -208,4 +240,4 @@ function deleteSession(sessionId) {
208
240
  return result.changes > 0
209
241
  }
210
242
 
211
- module.exports = { load, loadById, listSessions, save, addMessage, clear, deleteSession }
243
+ module.exports = { load, loadById, listSessions, save, addMessage, rekeySession, clear, deleteSession }
@@ -2358,22 +2358,132 @@ POST レスポンス例:
2358
2358
  | 409 | `version_conflict` (他者の編集と衝突)、`name_conflict`、`order_key_collision` (同時挿入の競合、再送可) |
2359
2359
  | 410 | `deck_in_trash` (デッキがゴミ箱にある) |
2360
2360
 
2361
- ### Accounting Receipts 🧪 (HQ, experimental)
2361
+ ### Accounting (HQ, experimental, v4.3.0〜)
2362
2362
 
2363
2363
  `workspaces.feature_flags.experimental_accounting = true` のワークスペース向け。
2364
- レシート (画像/PDF) の一覧取得・ダウンロード・取引紐付けをBearer認証で行える。
2365
- **典型用途**: 人間が `/accounting/receipts` から大量アップロードした未仕訳レシートを
2366
- ミニオン側で OCR → 仕訳作成 → 紐付け、という記帳ワークフロー。
2364
+ **全エンドポイントが accountant ロール必須** — 対象 workspace 内のいずれかのプロジェクトで
2365
+ accountant ロールが付与されたミニオンのみアクセスできる (職務分掌の原則)。
2366
+
2367
+ 不正アクセス時のレスポンス:
2368
+ - 403 `{error, code: 'accountant_role_required'}` — ロール無し
2369
+ - 403 `{error, code: 'no_projects'}` — workspace にプロジェクトが存在せず検証不能
2370
+ - 403 `{error, code: 'feature_disabled'}` — accounting フラグ OFF
2371
+ - 401 `{error: 'Missing or invalid Authorization header'}` / `{error: 'Invalid API token'}`
2372
+
2373
+ 書き込み API は `created_by_minion_id` / `updated_by_minion_id` 列に呼び出し元ミニオン ID を記録する (HQ UI で「ミニオンが書き込み」バッジが表示される)。仕訳の `source` は常に `'ai_generated'` で記録される。
2374
+
2375
+ **人間専用操作** (ミニオンから提供しない API):
2376
+
2377
+ 以下は会計データの根幹に影響するため、ミニオンが自律的に実行することを禁じている。
2378
+ ミニオンは [Threads API](#threads-hq) で `@user` メンションして人間に依頼すること。
2379
+
2380
+ - 決算期の `close` / `reopen` / 期首残高設定
2381
+ - 法人税計算の `finalize` / `journalize` / `reopen`
2382
+ - 仕訳・科目・取引先・立替・税計算の `DELETE`
2383
+ - 勘定科目の rename / アーカイブ (`PATCH`) — ミニオンは作成 (POST) のみ。編集・アーカイブは人間専用
2384
+ - 立替経費の `settle` / `bulk-settle`
2385
+ - レシートの `attach` / `detach` (= 仕訳との紐付け)
2386
+
2387
+ 人間依頼の Threads body 例:
2388
+ ```json
2389
+ {
2390
+ "thread_type": "help",
2391
+ "mentions": ["user"],
2392
+ "title": "法人税計算 FY3 の finalize お願いします",
2393
+ "content": "計算ID xxxx の入力が完了しました。\nHQダッシュボード /accounting/tax-calculations/xxxx でレビュー後、Finalize → Journalize ボタンを押してください。",
2394
+ "context": { "accounting_action": "tax_finalize", "calculation_id": "xxxx" }
2395
+ }
2396
+ ```
2397
+
2398
+ #### Books / Periods
2399
+
2400
+ | Method | Endpoint | 説明 |
2401
+ |--------|----------|-------------|
2402
+ | GET | `/api/minion/workspaces/:id/accounting/books` | 帳簿メタ取得 (org_type, currency, accounting_start_date, closed_through 等)。未存在なら自動作成 |
2403
+ | GET | `/api/minion/workspaces/:id/accounting/periods` | 決算期一覧 (FY1, FY2, ... と status: open/closed) |
2404
+
2405
+ #### Accounts
2406
+
2407
+ | Method | Endpoint | 説明 |
2408
+ |--------|----------|-------------|
2409
+ | GET | `/api/minion/workspaces/:id/accounting/accounts` | 勘定科目一覧。Query: `?type=asset\|liability\|equity\|revenue\|expense`, `?include_archived=true\|false` (default false) |
2410
+ | POST | `/api/minion/workspaces/:id/accounting/accounts` | 勘定科目追加。Body: `{ code?, name, type, is_wallet?, sort_order? }` |
2411
+
2412
+ #### Counterparties (取引先マスタ)
2413
+
2414
+ | Method | Endpoint | 説明 |
2415
+ |--------|----------|-------------|
2416
+ | GET | `/api/minion/workspaces/:id/accounting/counterparties` | 取引先一覧。Query: `?kind=vendor\|customer\|director\|employee\|other`, `?include_archived=true\|false` |
2417
+ | GET | `/api/minion/workspaces/:id/accounting/counterparties/:counterpartyId` | 取引先詳細 + 残高 (`{counterparty, balance: {debit_total, credit_total, net}}`) |
2418
+ | POST | `/api/minion/workspaces/:id/accounting/counterparties` | 取引先追加。Body: `{ name, kind, member_id?, default_payable_account_id? }` |
2419
+ | PATCH | `/api/minion/workspaces/:id/accounting/counterparties/:counterpartyId` | 取引先更新。Body: `{ name?, kind?, member_id?, default_payable_account_id?, is_archived? }` |
2420
+
2421
+ #### Journal Entries (仕訳)
2422
+
2423
+ | Method | Endpoint | 説明 |
2424
+ |--------|----------|-------------|
2425
+ | GET | `/api/minion/workspaces/:id/accounting/entries` | 仕訳一覧。Query: `?from=YYYY-MM-DD&to=YYYY-MM-DD&entry_type=income,expense,...&limit=100&offset=0` |
2426
+ | GET | `/api/minion/workspaces/:id/accounting/entries/:entryId` | 仕訳詳細 (lines 込み) |
2427
+ | POST | `/api/minion/workspaces/:id/accounting/entries` | 仕訳作成。`source='ai_generated'` で記録。期締め後の日付は 409 `code: 'period_closed'` |
2428
+ | PATCH | `/api/minion/workspaces/:id/accounting/entries/:entryId` | 仕訳更新。**`source='ai_generated'` の仕訳のみ更新可** (人間作成仕訳は 409 `code: 'human_owned_entry'`) |
2429
+
2430
+ 仕訳 Body の形式は `SimpleEntryInput` (income/expense/transfer) と `ManualEntryInput` (manual) があり、後者は明細を直接指定する。詳細は `src/lib/accounting/entry-builder.ts` を参照。
2431
+
2432
+ #### Receipts (レシート添付)
2367
2433
 
2368
2434
  | Method | Endpoint | 説明 |
2369
2435
  |--------|----------|-------------|
2370
2436
  | GET | `/api/minion/workspaces/:id/accounting/receipts` | レシート一覧。Query: `?attached=true\|false\|all` (default `false`), `?limit=100` (max 500) |
2371
2437
  | GET | `/api/minion/workspaces/:id/accounting/receipts/:receiptId` | メタデータ |
2372
2438
  | GET | `/api/minion/workspaces/:id/accounting/receipts/:receiptId/download` | 60秒有効な signed URL を返す (`{ url, mime_type, file_name, expires_in_seconds }`) |
2373
- | PATCH | `/api/minion/workspaces/:id/accounting/receipts/:receiptId` | 取引と紐付け / 解除。Body: `{ entry_id: string \| null }`。entry_id 指定時は同じ book に属する取引でないと 400 |
2439
+
2440
+ **破壊的変更 (v4.3.0)**: 以前は workspace メンバー全員に開いていたが、accountant ロール必須化。
2441
+ attach/detach (`PATCH`) は人間専用化したので、ミニオンが OCR → 仕訳作成した場合は HQ UI から
2442
+ ユーザーが添付する運用にする。
2374
2443
 
2375
2444
  レシートのMIME種別は `image/png`, `image/jpeg`, `image/webp`, `image/gif`, `image/heic`, `image/heif`, `application/pdf` のいずれか。
2376
2445
 
2377
- **未実装**: ミニオン向け仕訳作成 API (`POST /api/minion/workspaces/:id/accounting/entries`) は現状未実装。
2378
- OCR後の仕訳作成は、当面ユーザー操作で `/accounting/new` から行うか、HQ管理者向けの Supabase session 経由のみ。
2446
+ #### Expense Reimbursements (立替経費)
2447
+
2448
+ | Method | Endpoint | 説明 |
2449
+ |--------|----------|-------------|
2450
+ | GET | `/api/minion/workspaces/:id/accounting/reimbursements` | 立替経費一覧。Query: `?status=settled\|unsettled\|all`, `?counterparty_id=<uuid>`, `?limit=100&offset=0` |
2451
+ | POST | `/api/minion/workspaces/:id/accounting/reimbursements` | 立替経費追加。Body: `{ paid_by_counterparty_id, occurred_on, amount, description?, expense_account_id, payable_account_id? }`。仕訳 (借方=expense, 貸方=payable) を自動生成 |
2452
+
2453
+ #### Reports (試算表 / B/S / P/L)
2454
+
2455
+ | Method | Endpoint | 説明 |
2456
+ |--------|----------|-------------|
2457
+ | GET | `/api/minion/workspaces/:id/accounting/reports/trial-balance` | 試算表 + B/S + P/L 一括。Query: `?from&to&includeZero=true\|false`。`netAssetBalanceSheet` (正味財産区分付き B/S) は 一般社団法人プロファイルのときのみ含まれる |
2458
+ | GET | `/api/minion/workspaces/:id/accounting/reports/income-statement` | P/L のみ (軽量) |
2459
+ | GET | `/api/minion/workspaces/:id/accounting/reports/balance-sheet` | B/S のみ (軽量) |
2460
+
2461
+ #### Tax Calculations (法人税計算ウィザード)
2462
+
2463
+ `org_type=corporation` または `general_incorporated_association` のみ。それ以外は 403 `code: 'org_type_ineligible'`。
2464
+
2465
+ | Method | Endpoint | 説明 |
2466
+ |--------|----------|-------------|
2467
+ | GET | `/api/minion/workspaces/:id/accounting/tax-calculations` | 計算一覧。Query: `?fiscal_period_id=<uuid>&status=draft,finalized,journalized` |
2468
+ | GET | `/api/minion/workspaces/:id/accounting/tax-calculations/:calcId` | 詳細 `{calculation, adjustments, components}` |
2469
+ | POST | `/api/minion/workspaces/:id/accounting/tax-calculations` | 計算新規作成。Body: `{ fiscal_period_id, notes? }`。6 税目を seed する |
2470
+ | PATCH | `/api/minion/workspaces/:id/accounting/tax-calculations/:calcId` | pretax_income / notes 更新 |
2471
+ | GET | `/api/minion/workspaces/:id/accounting/tax-calculations/:calcId/pretax-income` | P/L から税引前当期純利益を再計算 (法人税科目は除外) |
2472
+ | GET | `/api/minion/workspaces/:id/accounting/tax-calculations/:calcId/ensure-accounts` | 必要4科目 (法人税費用 / 未払 / 仮払 / 未収還付) の存在状況確認 |
2473
+ | POST | `/api/minion/workspaces/:id/accounting/tax-calculations/:calcId/ensure-accounts` | 不足4科目をテンプレから自動追加 |
2474
+ | POST | `/api/minion/workspaces/:id/accounting/tax-calculations/:calcId/adjustments` | 別表四相当の加算/減算追加。Body: `{ adjustment_type: addition\|deduction, category, amount, description?, sort_order? }`。category は `entertainment_excess` / `depreciation_excess` / `executive_salary_excess` / `reserve_addition` / `dividend_excluded` / `tax_refund_excluded` / `custom` |
2475
+ | PATCH | `/api/minion/workspaces/:id/accounting/tax-calculations/:calcId/adjustments/:adjId` | 加算/減算更新 |
2476
+ | DELETE | `/api/minion/workspaces/:id/accounting/tax-calculations/:calcId/adjustments/:adjId` | 加算/減算削除 |
2477
+ | PATCH | `/api/minion/workspaces/:id/accounting/tax-calculations/:calcId/components/:compId` | 税目別の税率・中間納付・(均等割のみ) 直接税額を更新。`tax_rate` は % 表記 (例 `23.2`)。`calculated_amount` は `tax_type='inhabitant_per_capita'` のみ受理 |
2478
+
2479
+ **status 遷移**: ミニオンが触れるのは `draft` のみ。`finalized` / `journalized` 状態の calc を更新しようとすると 409 `code: 'invalid_status'`。reopen も人間専用。
2480
+
2481
+ **典型ワークフロー**:
2482
+ 1. `GET /periods` で期一覧を取得し、対象期 (status='open') の id を選ぶ
2483
+ 2. `POST /tax-calculations` で新規作成 (pretax_income は P/L から自動取得)
2484
+ 3. `POST /tax-calculations/:calcId/ensure-accounts` で必要科目を揃える
2485
+ 4. 国税庁・自治体サイトで税率調査 → `PATCH /components/:compId` で各税目の `tax_rate` を入力
2486
+ 5. 各税目の中間納付額がある場合 `PATCH /components/:compId` で `prepaid_amount` を入力
2487
+ 6. 必要なら `POST /adjustments` で別表四相当の加減算を追加 (調査・税理士相談の上)
2488
+ 7. `POST /api/threads` で人間に finalize 依頼
2379
2489
 
@@ -152,6 +152,25 @@ minion-cli skill list
152
152
  minion-cli skill fetch <name>
153
153
  ```
154
154
 
155
+ `skill fetch` は **HQ レジストリ** から取得する操作です。HQ に push されていないスキルは
156
+ `Skill not found` になります。
157
+
158
+ ### 4. バンドルスキルの配布 (パッケージ同梱スキル)
159
+
160
+ `@geekbeer/minion` パッケージには既定スキル (例: `accounting-bookkeeping`) が同梱されています。
161
+ これらは **HQ レジストリとは別系統** で、以下のタイミングで `~/.claude/skills/` (および有効な
162
+ 他 LLM のスキルディレクトリ) へ自動配備されます:
163
+
164
+ - `minion-cli configure` 実行時 (初期セットアップ / HQ接続)
165
+ - **パッケージ更新後のエージェント再起動時** (v4.3.3〜) — `update-agent` で `npm install` した後、
166
+ サーバー起動時に **パッケージのバージョンが変わっていれば** バンドルスキルを再配備する
167
+ (`~/.minion/skills-sync-version` で前回配備バージョンを記録)
168
+
169
+ そのため、バンドルスキルの修正を反映するのに `skill fetch` は不要です。`update-agent` →
170
+ 再起動だけで最新版が `~/.claude/skills/` に反映されます。HQ にカスタム版を登録している場合は、
171
+ 通常の再起動ではそのカスタム版が保持され (バージョン未変更時は再配備しない)、パッケージ更新時
172
+ のみバンドル既定版で上書きされます。ローカルのスキルは `minion-cli skill list --local` で確認できます。
173
+
155
174
  ---
156
175
 
157
176
  ## DAG ワークフロー (ノード/エッジ形式)
@@ -17,6 +17,7 @@
17
17
  */
18
18
 
19
19
  const { spawn } = require('child_process')
20
+ const crypto = require('crypto')
20
21
  const fs = require('fs')
21
22
  const path = require('path')
22
23
  const { verifyToken } = require('../../core/lib/auth')
@@ -57,10 +58,16 @@ async function chatRoutes(fastify) {
57
58
  // so the user's chat log keeps just the [task:UUID] tag, not a noisy dump.
58
59
  const prompt = await buildContextPrefix(message, context, session_id, workspaceId, referenced_tasks)
59
60
 
60
- // Store user message
61
+ // Persist the user message BEFORE invoking the LLM so that crashes,
62
+ // timeouts, or unparseable CLI output can't lose it. For new sessions we
63
+ // mint a local pending ID; it gets rekeyed to the real Claude CLI session
64
+ // ID once that comes back on the SSE stream.
61
65
  const currentSessionId = session_id || null
62
- if (currentSessionId) {
63
- await chatStore.addMessage(currentSessionId, { role: 'user', content: message }, undefined, workspaceId)
66
+ const pendingSessionId = currentSessionId || `pending-${crypto.randomUUID()}`
67
+ try {
68
+ await chatStore.addMessage(pendingSessionId, { role: 'user', content: message }, undefined, workspaceId)
69
+ } catch (err) {
70
+ console.error('[Chat] failed to persist user message:', err.message)
64
71
  }
65
72
 
66
73
  // Take over response handling from Fastify for SSE streaming
@@ -74,7 +81,7 @@ async function chatRoutes(fastify) {
74
81
  reply.raw.flushHeaders()
75
82
 
76
83
  try {
77
- await streamLlmResponse(reply.raw, prompt, currentSessionId, workspaceId, message)
84
+ await streamLlmResponse(reply.raw, prompt, currentSessionId, workspaceId, message, pendingSessionId)
78
85
  } catch (err) {
79
86
  console.error('[Chat] stream error:', err.message)
80
87
  const errorEvent = JSON.stringify({ type: 'error', error: err.message })
@@ -541,16 +548,16 @@ function getLlmBinary() {
541
548
  * Tracks block types to correctly forward tool_use vs text events
542
549
  * and counts turns for session management.
543
550
  */
544
- async function streamLlmResponse(res, prompt, sessionId, workspaceId, originalMessage) {
551
+ async function streamLlmResponse(res, prompt, sessionId, workspaceId, originalMessage, pendingSessionId) {
545
552
  // Plugin system path: Primary is set → delegate to plugin
546
553
  const primary = getActivePrimary()
547
554
  if (primary) {
548
- return streamViaPlugin(primary, res, prompt, sessionId, workspaceId, originalMessage)
555
+ return streamViaPlugin(primary, res, prompt, sessionId, workspaceId, originalMessage, pendingSessionId)
549
556
  }
550
- return streamViaLegacyLlmCommand(res, prompt, sessionId, workspaceId, originalMessage)
557
+ return streamViaLegacyLlmCommand(res, prompt, sessionId, workspaceId, originalMessage, pendingSessionId)
551
558
  }
552
559
 
553
- async function streamViaPlugin(plugin, res, prompt, sessionId, workspaceId, originalMessage) {
560
+ async function streamViaPlugin(plugin, res, prompt, sessionId, workspaceId, originalMessage, pendingSessionId) {
554
561
  const input = { prompt }
555
562
  const activeRef = { current: null }
556
563
  activeChatChild = { kill: () => activeRef.current?.kill?.('SIGTERM') }
@@ -576,6 +583,7 @@ async function streamViaPlugin(plugin, res, prompt, sessionId, workspaceId, orig
576
583
 
577
584
  res.on('close', () => { activeRef.current?.kill?.('SIGTERM') })
578
585
 
586
+ let pluginError = null
579
587
  try {
580
588
  let output
581
589
  if (plugin.capabilities.streaming && typeof plugin.stream === 'function') {
@@ -592,25 +600,42 @@ async function streamViaPlugin(plugin, res, prompt, sessionId, workspaceId, orig
592
600
  }
593
601
  }
594
602
  resolvedSessionId = output?.metadata?.sessionId || resolvedSessionId
603
+ } catch (err) {
604
+ // Swallow here so we can persist any partial response first; rethrow below.
605
+ pluginError = err
595
606
  } finally {
596
607
  activeChatChild = null
597
608
  }
598
609
 
599
- if (resolvedSessionId) {
600
- if (!sessionId) {
601
- await chatStore.addMessage(resolvedSessionId, { role: 'user', content: originalMessage || prompt }, undefined, workspaceId)
610
+ // For new sessions, the user message was persisted under pendingSessionId
611
+ // before the plugin call. Rekey it to the real session ID now that we
612
+ // know it. If the plugin never reported a session ID, leave the message
613
+ // under the pending key so the history isn't lost.
614
+ const persistSessionId = resolvedSessionId || pendingSessionId
615
+ try {
616
+ if (!sessionId && resolvedSessionId && pendingSessionId && pendingSessionId !== resolvedSessionId) {
617
+ chatStore.rekeySession(pendingSessionId, resolvedSessionId)
602
618
  }
603
- if (fullResponse) {
604
- await chatStore.addMessage(resolvedSessionId, { role: 'assistant', content: fullResponse }, turnCount, workspaceId)
619
+ if (fullResponse && persistSessionId) {
620
+ await chatStore.addMessage(persistSessionId, { role: 'assistant', content: fullResponse }, turnCount, workspaceId)
605
621
  }
622
+ } catch (err) {
623
+ console.error('[Chat] failed to persist assistant message:', err.message)
606
624
  }
607
625
 
608
- const session = await chatStore.load(workspaceId)
609
- const totalTurnCount = session?.turn_count || turnCount
626
+ if (pluginError) throw pluginError
627
+
628
+ let totalTurnCount = turnCount
629
+ try {
630
+ const session = await chatStore.load(workspaceId)
631
+ totalTurnCount = session?.turn_count || turnCount
632
+ } catch (err) {
633
+ console.error('[Chat] failed to load session for done event:', err.message)
634
+ }
610
635
  res.write(`data: ${JSON.stringify({ type: 'done', session_id: resolvedSessionId, turn_count: totalTurnCount })}\n\n`)
611
636
  }
612
637
 
613
- function streamViaLegacyLlmCommand(res, prompt, sessionId, workspaceId, originalMessage) {
638
+ function streamViaLegacyLlmCommand(res, prompt, sessionId, workspaceId, originalMessage, pendingSessionId) {
614
639
  return new Promise((resolve, reject) => {
615
640
  const binaryName = getLlmBinary()
616
641
  if (!binaryName) {
@@ -786,16 +811,21 @@ function streamViaLegacyLlmCommand(res, prompt, sessionId, workspaceId, original
786
811
  console.log(`[Chat] final stderr (tail 500): ${stderrBuffer.slice(-500)}`)
787
812
  }
788
813
 
789
- // Store messages in chat-store
790
- if (resolvedSessionId) {
791
- // If this was a new session, also store the user message now
792
- if (!sessionId) {
793
- await chatStore.addMessage(resolvedSessionId, { role: 'user', content: originalMessage || prompt }, undefined, workspaceId)
814
+ // For new sessions, the user message was already persisted under
815
+ // pendingSessionId before spawn. Rekey it to the real session ID when
816
+ // Claude CLI reported one; otherwise leave the message under the
817
+ // pending key so the history is never lost on crash.
818
+ const persistSessionId = resolvedSessionId || pendingSessionId
819
+ try {
820
+ if (!sessionId && resolvedSessionId && pendingSessionId && pendingSessionId !== resolvedSessionId) {
821
+ chatStore.rekeySession(pendingSessionId, resolvedSessionId)
794
822
  }
795
- // Store assistant response with turn count
796
- if (fullResponse) {
797
- await chatStore.addMessage(resolvedSessionId, { role: 'assistant', content: fullResponse }, turnCount, workspaceId)
823
+ // Persist any partial response we managed to collect, even on error
824
+ if (fullResponse && persistSessionId) {
825
+ await chatStore.addMessage(persistSessionId, { role: 'assistant', content: fullResponse }, turnCount, workspaceId)
798
826
  }
827
+ } catch (err) {
828
+ console.error('[Chat] failed to persist assistant message:', err.message)
799
829
  }
800
830
 
801
831
  if (code !== 0) {
@@ -811,8 +841,13 @@ function streamViaLegacyLlmCommand(res, prompt, sessionId, workspaceId, original
811
841
  }
812
842
 
813
843
  // Load current turn count from session for the done event
814
- const session = await chatStore.load(workspaceId)
815
- const totalTurnCount = session?.turn_count || turnCount
844
+ let totalTurnCount = turnCount
845
+ try {
846
+ const session = await chatStore.load(workspaceId)
847
+ totalTurnCount = session?.turn_count || turnCount
848
+ } catch (err) {
849
+ console.error('[Chat] failed to load session for done event:', err.message)
850
+ }
816
851
 
817
852
  const doneEvent = JSON.stringify({
818
853
  type: 'done',
package/linux/server.js CHANGED
@@ -58,6 +58,10 @@ const { startTerminalProxy, stopTerminalProxy } = require('./terminal-proxy')
58
58
  // Config warnings (included in heartbeat)
59
59
  const { getConfigWarnings } = require('../core/lib/config-warnings')
60
60
 
61
+ // Bundled skill deployment (version-gated, see core/lib/bundled-skills.js)
62
+ const { syncBundledSkills } = require('../core/lib/bundled-skills')
63
+ const { getActiveSkillDirs } = require('../core/llm-plugins/lib/skill-dirs')
64
+
61
65
  // Pull-model daemons (from core/)
62
66
  const stepPoller = require('../core/lib/step-poller')
63
67
  const dagStepPoller = require('../core/lib/dag-step-poller')
@@ -280,6 +284,25 @@ function syncBundledDocs() {
280
284
  }
281
285
  }
282
286
 
287
+ /**
288
+ * (Re)deploy bundled skills to the active LLM skill directories when the package
289
+ * version changed. Lets `update-agent` (npm install + restart) propagate skill
290
+ * fixes without re-running configure, while preserving HQ-fetched customizations
291
+ * across ordinary restarts. See core/lib/bundled-skills.js.
292
+ */
293
+ function syncBundledSkillsOnStartup() {
294
+ try {
295
+ syncBundledSkills({
296
+ packageRoot: PACKAGE_ROOT,
297
+ version,
298
+ targetDirs: getActiveSkillDirs(),
299
+ markerPath: path.join(config.HOME_DIR, '.minion', 'skills-sync-version'),
300
+ })
301
+ } catch (err) {
302
+ console.error(`[Skills] Bundled skill sync skipped: ${err.message}`)
303
+ }
304
+ }
305
+
283
306
  /**
284
307
  * Register all routes (shared + Linux-specific)
285
308
  */
@@ -339,6 +362,7 @@ async function start() {
339
362
  syncBundledRules()
340
363
  syncBundledRoles()
341
364
  syncBundledDocs()
365
+ syncBundledSkillsOnStartup()
342
366
  syncPermissions()
343
367
  syncTmuxConfig()
344
368
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geekbeer/minion",
3
- "version": "4.2.0",
3
+ "version": "4.3.3",
4
4
  "description": "AI Agent runtime for Minion - manages status and skill deployment on VPS",
5
5
  "main": "linux/server.js",
6
6
  "bin": {
@@ -5,22 +5,31 @@
5
5
  ## 責務
6
6
 
7
7
  - **記帳**: 取引(仕訳)をHQの会計帳簿に正確に記録する
8
- - **立替経費の処理**: 役員・従業員が立て替えた経費を記帳・精算する
8
+ - **立替経費の処理**: 役員・従業員が立て替えた経費を記帳する(精算の実行は人間に依頼)
9
9
  - **取引先マスタの整備**: 仕入先・顧客・役員・従業員などの取引先を整備する
10
10
  - **補助元帳の照会**: 取引先別の残高・明細を確認し、PMやユーザーへ報告する
11
+ - **勘定科目の追加**: テンプレートに不足している科目を追加する(rename/アーカイブ/削除は人間専用)
12
+ - **財務諸表の照会・報告**: 試算表・B/S・P/L を取得し、PMやユーザーへ報告する
13
+ - **法人税計算の補助**: 税引前利益の確認・税率調査・別表四加減算の入力を行い、finalize/journalize は人間に依頼する
11
14
 
12
15
  ## 利用するスキル
13
16
 
14
17
  会計操作の具体手順とAPI仕様は専用スキルにまとまっています。コアルールにはあえて含めていません(トークン圧迫を避けるため)。
15
18
 
16
- - `/accounting-bookkeeping` — 取引記帳・立替経費・精算・補助元帳の標準フロー
19
+ - `/accounting-bookkeeping` — 取引記帳・立替経費・補助元帳・財務諸表照会・法人税計算の標準フロー
17
20
  - 詳細仕様は `~/.claude/skills/accounting-bookkeeping/references/` 配下を必要時に参照
18
21
 
19
- スキルが未配布の場合は `minion-cli skill fetch accounting-bookkeeping` で取得してください。
22
+ このスキルは `@geekbeer/minion` パッケージに同梱されており、セットアップ時およびパッケージ更新後の
23
+ エージェント再起動時に `~/.claude/skills/` へ自動配備されます。通常 `skill fetch` は不要です。
24
+ 万一ローカルに存在しない場合は `minion-cli skill list --local` で確認し、HQ にカスタム版を登録している
25
+ 場合のみ `minion-cli skill fetch accounting-bookkeeping` で取得してください
26
+ (バンドル版は HQ には登録されていないため、未カスタムだと `Skill not found` になります)。
20
27
 
21
28
  ## 重要な原則
22
29
 
30
+ - **高リスク操作は人間に依頼する。** 決算期の close/reopen、法人税計算の finalize/journalize、各リソースの DELETE、勘定科目の rename/アーカイブ、立替経費の精算(settle)はミニオンに提供されていません。必要な場合は `POST /api/threads` で `@user` メンションして HQ UI 操作を依頼すること。
23
31
  - **仕訳の確定済み(closed)期は修正しない。** 締め後の仕訳訂正はPMの承認が必要です。
32
+ - **編集できる仕訳は自分(AI)が作成したものだけ。** 人間作成仕訳の PATCH は 409 `human_owned_entry` で弾かれます。訂正が必要なら人間に依頼。
24
33
  - **立替経費は取引先(counterparty)を必ず紐付ける。** 「誰の立替か」を後から照会できるようにするため。
25
34
  - **default_payable_account_id 未設定の取引先には立替記帳できない。** API が 422 を返します。先に取引先ページで貸方科目を設定してください。
26
35
  - **判断に迷ったら勝手に進めない。** 不明な取引・推測の余地がある仕訳は、threadで PM にエスカレーションすること。