@horizon_works/banto 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.
Files changed (36) hide show
  1. package/README.md +109 -0
  2. package/env.js +83 -0
  3. package/gate_core.js +629 -0
  4. package/gtasks.js +233 -0
  5. package/license/client.js +138 -0
  6. package/package.json +45 -0
  7. package/payload/compliance/SKILL.md +113 -0
  8. package/payload/compliance/dict/00_/345/213/225/344/275/234/347/242/272/350/252/215.json +14 -0
  9. package/payload/compliance/dict/06_house_rules.json +16 -0
  10. package/payload/compliance/dict/_sample/04_common_keihyo.json +270 -0
  11. package/payload/compliance/dict/_sample/README.md +72 -0
  12. package/payload/compliance/lint.js +265 -0
  13. package/payload/disciplines//347/225/252/351/240/255/343/201/256/344/275/234/346/263/225.md +64 -0
  14. package/payload/disciplines//350/207/252/345/267/261/345/201/245/350/250/272.md +74 -0
  15. package/payload/disciplines//351/200/261/346/254/241/343/203/241/343/203/263/343/203/206.md +60 -0
  16. package/payload/migration/protocol.md +35 -0
  17. package/payload/skill-maker/SKILL_base_anthropic.md +357 -0
  18. package/payload/skill-maker/skill-maker-v1.0.md +305 -0
  19. package/payload/skills/_/343/201/202/343/201/250/343/201/247/350/266/263/343/201/233/343/202/213/343/202/202/343/201/256.md +47 -0
  20. package/payload/skills/image-gen/SKILL.md +147 -0
  21. package/payload/skills/image-gen/gen.js +225 -0
  22. package/payload/skills/remember/SETUP_Google/351/200/243/346/220/272.md +106 -0
  23. package/payload/skills/remember/SKILL.md +158 -0
  24. package/payload/skills/remember/handoff.js +149 -0
  25. package/payload/skills/remember/tasks.js +320 -0
  26. package/payload/skills/slide-deck/SKILL.md +160 -0
  27. package/payload/skills/transcribe/README.md +182 -0
  28. package/payload/skills/transcribe/dict.json +10 -0
  29. package/payload/skills/transcribe/enroll_speaker.py +76 -0
  30. package/payload/skills/transcribe/identify_speakers.py +153 -0
  31. package/payload/skills/transcribe/transcribe.py +126 -0
  32. package/privacy.js +234 -0
  33. package/server.js +1671 -0
  34. package/shelf.js +172 -0
  35. package/subconscious.js +150 -0
  36. package/supplements.json +192 -0
package/server.js ADDED
@@ -0,0 +1,1671 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 番頭ゲート MCP サーバー(最小版 v0.1)
4
+ *
5
+ * 目的: 「AIに中身を見せずに、AIに検査させる」が本当に成立するかを実測するための1本。
6
+ *
7
+ * 設計の要:
8
+ * - check_file は、パスだけを受け取る。ファイルを読むのは**このサーバー**であって、AIではない
9
+ * - 戻り値は「件数」「種別」「判定」だけ。★該当行・該当語・本文は一切返さない
10
+ * - つまり、検査対象の中身は AIプロバイダのクラウドを一度も通らない
11
+ *
12
+ * 依存ゼロ(MCP SDKを使わず、stdio上の改行区切りJSON-RPCを直接扱う)。
13
+ * 利用者環境で npm install を要求しないため。
14
+ */
15
+
16
+ const fs = require('fs');
17
+ const path = require('path');
18
+ const { execFileSync } = require('child_process');
19
+ const readline = require('readline');
20
+
21
+ const REPO = path.resolve(__dirname, '../../..');
22
+
23
+ // ★検査の本体(=フリー版)。2026-08-20、配った形で起動したら**落ちた**。
24
+ // 原因=隣のフォルダを相対で呼んでいた。配布先に隣は無い。
25
+ // → 自分の中(./gate_core.js)を先に見る。開発中は隣を使う。
26
+ const FREE = (() => {
27
+ for (const p of ['./gate_core.js', '../bantou_gate_free/server.js']) {
28
+ try { return require(p); } catch (_) {}
29
+ }
30
+ return null;
31
+ })();
32
+ if (!FREE) {
33
+ process.stderr.write('検査の本体が見つかりません(gate_core.js)\n');
34
+ process.exit(1);
35
+ }
36
+
37
+ // ★うちの辞書つきチェッカー。**配布版には入れない**(顧客名が71語入っている)。
38
+ // 2026-08-20、単体起動が落ちたおかげで気づいた。落ちなければ同梱していた。
39
+ // → 「あれば使う/無ければフリー版の3系統だけで動く」。無いことを異常にしない。
40
+ const CHECKER = (() => {
41
+ const p = path.join(REPO, 'skills', 'utils', 'check_anonymization.js');
42
+ try { return fs.existsSync(p) ? p : null; } catch (_) { return null; }
43
+ })();
44
+
45
+ // ---------------------------------------------------------------- 検査の実体
46
+
47
+ /**
48
+ * 匿名化ゲートを走らせ、★件数と種別だけを取り出す。
49
+ * 出力本文(該当行に固有名が載っている)は、ここで捨てる。呼び出し元へ返さない。
50
+ */
51
+ function inspect(targetPath) {
52
+ // ★辞書つきチェッカーが無い環境(=配った先)では、フリー版の3系統だけで検査する。
53
+ // 「辞書が無い」は異常ではない。利用者が自分の辞書を育てる形が既定。
54
+ if (!CHECKER) return inspectWithoutDictionary(targetPath);
55
+
56
+ let raw;
57
+ try {
58
+ raw = execFileSync(process.execPath, [CHECKER, targetPath], {
59
+ cwd: REPO,
60
+ encoding: 'utf8',
61
+ maxBuffer: 32 * 1024 * 1024,
62
+ stdio: ['ignore', 'pipe', 'pipe'],
63
+ });
64
+ } catch (e) {
65
+ // チェッカーは検出時に非0で終わることがある。stdout は取れているので使う
66
+ raw = (e.stdout || '') + (e.stderr || '');
67
+ if (!raw) return { error: '検査を実行できませんでした' };
68
+ }
69
+
70
+ const files = (raw.match(/走査\s+(\d+)\s*ファイル/) || [])[1];
71
+ const hits = (raw.match(/ヒット\s+(\d+)\s*件/) || [])[1];
72
+
73
+ // ❌行から「種別(辞書の語)」だけを数える。★行の本文は捨てる
74
+ const kinds = {};
75
+ for (const line of raw.split('\n')) {
76
+ const m = line.match(/^\s*❌\s+\S+\s+\[([^\]]+)\]/);
77
+ if (m) kinds[m[1]] = (kinds[m[1]] || 0) + 1;
78
+ }
79
+
80
+ const candidates = (raw.match(/辞書に無い固有名の候補[:: ]?\s*(\d+)/) || [])[1];
81
+ const internal = (raw.match(/内部メモらしき記述\s*(\d+)\s*件/) || [])[1];
82
+ // ★辞書そのものの健康状態。検査対象の中身ではないので返してよい。
83
+ // 生のチェッカーは0件でも毎回出す設計なのに、初版のゲートはこれを落としていた
84
+ // (=「0件のときも出す」と決めた警告が、ゲート経由で消えていた)。
85
+ const oneSided = (raw.match(/辞書に片側だけの語が\s*(\d+)\s*件/) || [])[1];
86
+
87
+ // ★★ ここが「見せない設計」の要。
88
+ // 検出された語そのもの(=顧客名・相手のAI名)は、絶対に戻り値へ入れない。
89
+ // 2026-08-20 の初回実装では kinds に語を並べており、実測で「相手のAI名」「顧客名」が
90
+ // AIの文脈へ流れていた。便利にしようとすると漏れる、の実例。
91
+ // 詳細は人が手元で読む。AIは件数しか受け取らない。
92
+ const detail = raw
93
+ .split('\n')
94
+ .filter((l) => /^\s*(❌|🔎|📝|\s+・|\s+\[)/.test(l))
95
+ .join('\n');
96
+
97
+ let reportPath = null;
98
+ if (detail.trim()) {
99
+ reportPath = path.join(
100
+ require('os').tmpdir(),
101
+ 'bantou_gate_report_' + Date.now() + '.txt'
102
+ );
103
+ fs.writeFileSync(
104
+ reportPath,
105
+ '検査対象: ' + targetPath + '\n' +
106
+ '★このファイルはあなた(人)が手元で開くためのものです。AIはこの中身を読んでいません。\n' +
107
+ '─'.repeat(60) + '\n' + detail + '\n',
108
+ 'utf8'
109
+ );
110
+ }
111
+
112
+ // ★★ verdict は3軸で決める。初版は hit_count しか見ておらず、
113
+ // 「内部メモ3件・固有名0件」のファイルに合格を出していた(=2026-08-03 に実際に起きた
114
+ // 規約ページの内部メモ公開と同じ中身で通る)。判定だけ読む人には、その穴が見えない。
115
+ const nHit = hits ? Number(hits) : 0;
116
+ const nCand = candidates ? Number(candidates) : 0;
117
+ const nInternal = internal ? Number(internal) : 0;
118
+
119
+ // ★★ 検査器を2本走らせて、どちらかが鳴ったら鳴らす(2026-08-20)。
120
+ // 由来=この日の通し検品で、うちの版だけが「田中さんの件は保留中です」を見逃した。
121
+ // check_anonymization.js とフリー版のパターン検査は、拾う層が違う。
122
+ // 片方だけを合格の根拠にしない(→ output-gate.md「1つの✅を全体の合格にしない」)。
123
+ const second = secondOpinion(targetPath);
124
+
125
+ // ★2本目が拾ったものも、同じ「人が読むファイル」へ入れる(読む場所を分けない)
126
+ if (second.detail.trim()) {
127
+ if (!reportPath) {
128
+ reportPath = path.join(require('os').tmpdir(), 'bantou_gate_report_' + Date.now() + '.txt');
129
+ fs.writeFileSync(
130
+ reportPath,
131
+ '検査対象: ' + targetPath + '\n' +
132
+ '★このファイルはあなた(人)が手元で開くためのものです。AIはこの中身を読んでいません。\n',
133
+ 'utf8'
134
+ );
135
+ }
136
+ fs.appendFileSync(
137
+ reportPath,
138
+ '\n' + '─'.repeat(60) + '\n■ パターン検査(辞書を使わない側)\n\n' + second.detail + '\n',
139
+ 'utf8'
140
+ );
141
+ }
142
+
143
+ const nInternalAll = Math.max(nInternal, second.internal);
144
+ const nCandAll = Math.max(nCand, second.candidates);
145
+
146
+ const reasons = [];
147
+ if (nHit > 0) reasons.push('辞書の固有名');
148
+ if (nInternalAll > 0) reasons.push('内部メモらしき記述');
149
+ if (nCandAll > 0) reasons.push('辞書に無い固有名の候補(要目視)');
150
+
151
+ return {
152
+ files_scanned: files ? Number(files) : 0,
153
+ hit_count: nHit,
154
+ kind_count: Object.keys(kinds).length, // ★種類の「数」だけ。語は返さない
155
+ dictionary_free_candidates: nCandAll,
156
+ internal_note_count: nInternalAll,
157
+ dictionary_one_sided: oneSided ? Number(oneSided) : 0,
158
+ // ★2本のうちどちらが何件拾ったか(数だけ。語は含まない)
159
+ detectors: {
160
+ '辞書チェッカー': { 内部メモ: nInternal, 固有名候補: nCand },
161
+ 'パターン検査': { 内部メモ: second.internal, 固有名候補: second.candidates },
162
+ },
163
+ verdict: reasons.length ? '要確認' : 'この2つの検査では検出なし',
164
+ verdict_reasons: reasons, // ★何で引っかかったかの「系統」。語は含まない
165
+ detail_report: reportPath
166
+ ? '詳細は ' + reportPath + ' に書きました。★人が手元で開いてください(AIは読みません)'
167
+ : null,
168
+ note:
169
+ 'この結果は「この2つの検査で引っかかった数」です。辞書に無い綴りは辞書側では永遠に0件になります。' +
170
+ '0件は「検査して問題なし」ではありません。' +
171
+ '★検出された語そのものは、意図的にこの結果へ含めていません(中身をクラウドへ渡さないため)。',
172
+ };
173
+ }
174
+
175
+ /**
176
+ * 辞書つきチェッカーが無いときの検査(=配った先での既定の姿)。
177
+ * ★フリー版と同じ3系統。利用者の辞書は フリー版側の loadDict が拾う。
178
+ */
179
+ function inspectWithoutDictionary(targetPath) {
180
+ const dict = FREE.loadDict();
181
+ const files = FREE.walk(targetPath, [], 0);
182
+ if (!files.length) throw new Error('検査できるファイルがありません: ' + targetPath);
183
+ const merged = { dictHits: [], internal: [], candidates: [] };
184
+ for (const f of files) {
185
+ const r = FREE.inspectText(fs.readFileSync(f, 'utf8'), dict);
186
+ merged.dictHits.push(...r.dictHits);
187
+ merged.internal.push(...r.internal);
188
+ merged.candidates.push(...r.candidates);
189
+ }
190
+ const out = FREE.summarize(merged, dict, targetPath);
191
+ out.files_scanned = files.length;
192
+ return out;
193
+ }
194
+
195
+ /**
196
+ * フリー版(=正本)のパターン検査を、同じ対象に当てる。
197
+ * ★返すのは件数だけ。詳細は人向けのファイルへ追記する。
198
+ */
199
+ function secondOpinion(targetPath) {
200
+ const empty = { path: null, words: [] }; // 辞書は上の本体が見ているので、ここでは使わない
201
+ try {
202
+ const files = FREE.walk(targetPath, [], 0);
203
+ let internal = 0, candidates = 0;
204
+ const detail = [];
205
+ for (const f of files) {
206
+ const r = FREE.inspectText(fs.readFileSync(f, 'utf8'), empty);
207
+ internal += r.internal.length;
208
+ candidates += r.candidates.length;
209
+ if (r.internal.length || r.candidates.length) {
210
+ detail.push(FREE.report(f, r, empty));
211
+ }
212
+ }
213
+ return { internal, candidates, detail: detail.join('\n\n') };
214
+ } catch (_) {
215
+ return { internal: 0, candidates: 0, detail: '' };
216
+ }
217
+ }
218
+
219
+ // ---------------------------------------------------------------- 道具の定義
220
+
221
+ const TOOLS = [
222
+ {
223
+ name: 'check_file',
224
+ description:
225
+ 'ファイル(またはフォルダ)に、外へ出してはいけない固有名・内部メモが含まれていないかを検査します。' +
226
+ '★ファイルを読むのはこのサーバーであって、あなた(AI)ではありません。' +
227
+ '返るのは件数と種別だけで、本文・該当行・該当語の周辺は返りません。' +
228
+ '顧客名の入った下書きを、中身をクラウドへ渡さずに検査できます。',
229
+ inputSchema: {
230
+ type: 'object',
231
+ properties: {
232
+ path: {
233
+ type: 'string',
234
+ description: '検査するファイルまたはフォルダのパス(このサーバーが読みます)',
235
+ },
236
+ },
237
+ required: ['path'],
238
+ },
239
+ },
240
+ {
241
+ name: 'remember',
242
+ description:
243
+ '気づいたこと・失敗・学びを1行だけ記録します。★会話を止めずに使ってください(記録は促しであって関所ではありません)。' +
244
+ '同じ型キーが3件たまったら「ルールか手順に落とす番」だと返します。★溜めるのが目的ではありません。',
245
+ inputSchema: {
246
+ type: 'object',
247
+ properties: {
248
+ text: { type: 'string', description: '何が起きたか・なぜか(1行)' },
249
+ tag: { type: 'string', description: 'failure(失敗)/insight(発見)/value(価値観)/fact(事実)' },
250
+ sig: { type: 'string', description: '型キー。同じ失敗を数えるための短い名前(例: 確かめる前に断定した)' },
251
+ },
252
+ required: ['text'],
253
+ },
254
+ },
255
+ {
256
+ name: 'recall',
257
+ description: '最近の記録を引きます。★一日の終わりに見て、ルールに落とすものを選んでください。番号(id)が返るので settle で片づけられます。',
258
+ inputSchema: {
259
+ type: 'object',
260
+ properties: {
261
+ days: { type: 'number', description: '何日ぶんか(既定7)' },
262
+ only_open: { type: 'boolean', description: '未処理だけに絞る' },
263
+ },
264
+ },
265
+ },
266
+ {
267
+ name: 'review',
268
+ description:
269
+ '溜まったものを型ごとに数えて、★**次に何をするか**を返します。3件たまった型は「ルールにする番」、' +
270
+ 'ルールにした後で再発した型は「ルールが効いていない」と出ます。★一日の終わりに呼んでください。',
271
+ inputSchema: { type: 'object', properties: {} },
272
+ },
273
+ {
274
+ name: 'settle',
275
+ description:
276
+ '片づけた記録に印を付けます。★鳴った音を閉じる口です。ルールに落としたときは sig と ruled_to を渡してください。',
277
+ inputSchema: {
278
+ type: 'object',
279
+ properties: {
280
+ ids: { type: 'array', items: { type: 'number' }, description: '片づけた記録の番号' },
281
+ sig: { type: 'string', description: 'ルールにした型キー' },
282
+ ruled_to: { type: 'string', description: 'どこに書いたか' },
283
+ },
284
+ },
285
+ },
286
+ {
287
+ name: 'checkup',
288
+ description: '番頭がちゃんと入っているかを自己点検します。',
289
+ inputSchema: { type: 'object', properties: {} },
290
+ },
291
+ {
292
+ name: 'install',
293
+ description:
294
+ '★スキルと道具の**実体**を、利用者のフォルダへ置きます。' +
295
+ 'これを一度やれば、スキルを持っていない方でも、そのまま使えるようになります。' +
296
+ '(画像・スライド・文字起こし・自己健診・週次メンテ・スキルの作り方・文章の関所・引っ越し手順)' +
297
+ '★同じ名前のファイルは飛ばします。入れ替えるときも元は .bak へ寄せて、消しません。',
298
+ inputSchema: {
299
+ type: 'object',
300
+ properties: {
301
+ to: { type: 'string', description: '置き場所(仕事のフォルダ)' },
302
+ overwrite: { type: 'boolean', description: '同じ名前があっても入れ替える(元は .bak へ寄せます)' },
303
+ },
304
+ required: ['to'],
305
+ },
306
+ },
307
+ {
308
+ name: 'list_installable',
309
+ description: '★install で置けるものの一覧を見ます。置く前に、何が入るか確かめられます。',
310
+ inputSchema: { type: 'object', properties: {} },
311
+ },
312
+ {
313
+ name: 'policy',
314
+ description:
315
+ '★機密の扱いをどの段にするかを見る/変えます。' +
316
+ '段0=そのまま渡す(絶対層だけ止める) / 段1=伏せて渡す / 段2=判定だけ返す。' +
317
+ '★秘密鍵・パスワード・カード番号などの「絶対層」は、どの段でも必ず止まります(選べません)。',
318
+ inputSchema: {
319
+ type: 'object',
320
+ properties: {
321
+ dir: { type: 'string', description: '仕事のフォルダ(省略すると今の場所)' },
322
+ level: { type: 'number', description: '0 / 1 / 2。省略すると、いまの段を見るだけ' },
323
+ },
324
+ },
325
+ },
326
+ {
327
+ name: 'make_dict',
328
+ description:
329
+ '★伏せたい言葉の辞書を、一緒に作ります。フォルダを検査して**候補を手元のファイルへ書き出し**、' +
330
+ 'AIには件数だけ返します。★様式(ひな形)も一緒に置くので、あとはご自身で選んでください。',
331
+ inputSchema: {
332
+ type: 'object',
333
+ properties: {
334
+ from: { type: 'string', description: '見に行くフォルダ(普段の仕事の場所)' },
335
+ to: { type: 'string', description: '辞書を置く場所(省略すると from と同じ)' },
336
+ },
337
+ required: ['from'],
338
+ },
339
+ },
340
+ {
341
+ name: 'mask_file',
342
+ description:
343
+ '★ファイルを「伏せた版」にします。AIへ返すのは**伏せた版の場所と件数だけ**で、' +
344
+ '対応表も実名も渡りません。★この伏せた版だけを読んで、直してください。' +
345
+ '鍵やパスワードは伏せずに**削除**します(戻しません)。',
346
+ inputSchema: {
347
+ type: 'object',
348
+ properties: { path: { type: 'string', description: '正本のファイル' } },
349
+ required: ['path'],
350
+ },
351
+ },
352
+ {
353
+ name: 'unmask_file',
354
+ description:
355
+ '★直した「伏せた版」を、正本へ戻します。' +
356
+ '戻す前に3つ確かめます(表に無い印/使われなかった印/★AIが新しく書いた固有名)。' +
357
+ 'ひとつでも合わなければ**書きません**。元のファイルは .bak として残します。',
358
+ inputSchema: {
359
+ type: 'object',
360
+ properties: {
361
+ path: { type: 'string', description: '直した「伏せた版」のファイル' },
362
+ to: { type: 'string', description: '書き先(省略すると元の正本)' },
363
+ force: { type: 'boolean', description: '★確かめで止まっても進める(おすすめしません)' },
364
+ },
365
+ required: ['path'],
366
+ },
367
+ },
368
+ {
369
+ name: 'render',
370
+ description:
371
+ '★AIが書いた「伏せたままの文」を、実名に戻して手元のファイルに出します。' +
372
+ 'AIは印しか見ていません。**開くのは人**です。' +
373
+ '(チャット欄そのものは書き換えられないので、すぐ横のファイルに出す形です)',
374
+ inputSchema: {
375
+ type: 'object',
376
+ properties: {
377
+ mask_id: { type: 'string', description: '伏せた版の先頭にある印(bantou-mask:◯◯)' },
378
+ text: { type: 'string', description: '伏せたままの文(★印のまま渡してください)' },
379
+ to: { type: 'string', description: '書き先(省略すると自動)' },
380
+ dir: { type: 'string', description: '対応表のあるフォルダ(省略すると今の場所)' },
381
+ },
382
+ required: ['mask_id', 'text'],
383
+ },
384
+ },
385
+ {
386
+ name: 'list_supplements',
387
+ description:
388
+ '★お使いいただけるサプリの一覧を見ます(給与・規程・助成金・36協定など)。' +
389
+ '名前・版・大きさと、いま入っているかどうかが分かります。★中身はまだ取りません。',
390
+ inputSchema: {
391
+ type: 'object',
392
+ properties: { to: { type: 'string', description: '仕事のフォルダ(入っているかを見るため)' } },
393
+ },
394
+ },
395
+ {
396
+ name: 'about_me',
397
+ description:
398
+ '★主のこと(北極星・呼び方・大事にしていること)を見ます。' +
399
+ '北極星がまだなら、決め方をご案内します。★ここが空のまま使い続けないでください。',
400
+ inputSchema: { type: 'object', properties: {} },
401
+ },
402
+ {
403
+ name: 'set_polaris',
404
+ description:
405
+ '★北極星(何のためにいるか)を決めて、書き留めます。' +
406
+ '「いま、いちばん面倒だと思っていることは何ですか」→「それが無くなったら、その時間で何をしたいですか」' +
407
+ '→「逆に、人の手でやりたいことは何ですか」の順で聞いてから呼んでください。★「効率化」で止めないこと。',
408
+ inputSchema: {
409
+ type: 'object',
410
+ properties: {
411
+ text: { type: 'string', description: '北極星(1〜2行)' },
412
+ why: { type: 'string', description: 'そう決めた理由(本人の言葉で)' },
413
+ },
414
+ required: ['text'],
415
+ },
416
+ },
417
+ {
418
+ name: 'learn',
419
+ description:
420
+ '★主のことを覚えます。★出所を必ず添えてください(本人/資料/実測/推測)。' +
421
+ 'これが無いと、私の推測が、いつのまにかご本人の言葉として残ります。',
422
+ inputSchema: {
423
+ type: 'object',
424
+ properties: {
425
+ kind: { type: 'string', description: '呼び方 / 価値観 / 手を出さない / 事実' },
426
+ text: { type: 'string', description: '覚える中身' },
427
+ source: { type: 'string', description: '出所:本人 / 資料 / 実測 / 推測' },
428
+ },
429
+ required: ['kind', 'text'],
430
+ },
431
+ },
432
+ {
433
+ name: 'check_env',
434
+ description:
435
+ '★下ごしらえ(Node.js / Python / Playwright)が揃っているかを見て、**次の一手だけ**をご案内します。' +
436
+ '★Playwright は「作った画面を目で見て確かめる」ために使います。全部そろっていなくても、他の道具は使えます。',
437
+ inputSchema: {
438
+ type: 'object',
439
+ properties: { want: { type: 'array', items: { type: 'string' }, description: 'node / python / playwright' } },
440
+ },
441
+ },
442
+ {
443
+ name: 'setup_google',
444
+ description:
445
+ '★Googleのやること(Tasks)とつなぐ手順を、**いま止まっているところだけ**ご案内します。' +
446
+ '★あとからで構いません。つながなくても、番頭の他の道具は全部使えます。',
447
+ inputSchema: { type: 'object', properties: {} },
448
+ },
449
+ {
450
+ name: 'connect_google',
451
+ description:
452
+ '★Googleにつなぎます(ブラウザが開いて、ご本人が許可します)。' +
453
+ 'つながると「番頭のやること」というリストを1本だけ作ります。★ふだんのリストは触りません。',
454
+ inputSchema: { type: 'object', properties: {} },
455
+ },
456
+ {
457
+ name: 'list_tasks',
458
+ description: '★やることを見ます。期限の近い順に出ます。終わったものは complete_task で閉じてください。',
459
+ inputSchema: {
460
+ type: 'object',
461
+ properties: { all: { type: 'boolean', description: '終わったものも含める' } },
462
+ },
463
+ },
464
+ {
465
+ name: 'add_task',
466
+ description:
467
+ '★やることを登録します。「あとで」「忘れないで」「来週まで」と言われたら、その場で登録してください。' +
468
+ '★人に覚えておかせない。',
469
+ inputSchema: {
470
+ type: 'object',
471
+ properties: {
472
+ title: { type: 'string', description: 'やることの中身' },
473
+ due: { type: 'string', description: '期限(YYYY-MM-DD)' },
474
+ notes: { type: 'string', description: '覚え書き' },
475
+ },
476
+ required: ['title'],
477
+ },
478
+ },
479
+ {
480
+ name: 'complete_task',
481
+ description: '★やることを終わりにします。題の一部でも探せます。',
482
+ inputSchema: {
483
+ type: 'object',
484
+ properties: {
485
+ title: { type: 'string', description: '題(一部でよい)' },
486
+ id: { type: 'string', description: '分かっていれば' },
487
+ },
488
+ },
489
+ },
490
+ {
491
+ name: 'sync_core',
492
+ description:
493
+ '★番頭の核(安全境界・仕事の型・記憶のしかた)とルールを取ってきて、お手元に控えます。' +
494
+ '鍵が要ります。★一度取れば、切れてもお手元の控えは読めます(新しい版だけが来なくなります)。' +
495
+ '取ったあとは、AIの窓を開き直してください。',
496
+ inputSchema: { type: 'object', properties: {} },
497
+ },
498
+ {
499
+ name: 'open_shelf',
500
+ description:
501
+ '★サプリの棚を、ブラウザで開きます。一覧を見て、入れる/外すを選べます。' +
502
+ 'ボタンを押すと「番頭ゲートで◯◯を入れて」がコピーされるので、そのまま貼ってください。' +
503
+ '★このページは利用者のパソコンの中にできます(外につながりません)。',
504
+ inputSchema: {
505
+ type: 'object',
506
+ properties: {
507
+ to: { type: 'string', description: '仕事のフォルダ' },
508
+ no_open: { type: 'boolean', description: '開かずに、置いた場所だけ返す' },
509
+ },
510
+ },
511
+ },
512
+ {
513
+ name: 'add_supplement',
514
+ description:
515
+ '★サプリを1本、仕事のフォルダへ入れます。' +
516
+ 'お手元で書き換えられているファイルには触りません。★入れたあと SKILL.md をお読みください。',
517
+ inputSchema: {
518
+ type: 'object',
519
+ properties: {
520
+ name: { type: 'string', description: 'サプリの名前(list_supplements で見た名前)' },
521
+ to: { type: 'string', description: '仕事のフォルダ' },
522
+ },
523
+ required: ['name'],
524
+ },
525
+ },
526
+ {
527
+ name: 'remove_supplement',
528
+ description:
529
+ '★サプリを外します。**消しません**。「_外したサプリ」へ移すだけなので、あとで戻せます。',
530
+ inputSchema: {
531
+ type: 'object',
532
+ properties: {
533
+ name: { type: 'string', description: 'サプリの名前' },
534
+ to: { type: 'string', description: '仕事のフォルダ' },
535
+ },
536
+ required: ['name'],
537
+ },
538
+ },
539
+ {
540
+ name: 'tidy',
541
+ description:
542
+ '★フォルダの散らかりを診て、片づけます。**apply を付けないと何も動きません**。' +
543
+ '寄せるのは「同じ中身のファイルの2つ目以降」と「作業の残骸(.bak / .old / ~$)」だけです。' +
544
+ '★「古い」「大きい」「空」は動かしません(使っていないことと、要らないことは別なので)。' +
545
+ '★消しません。「_かたづけ」へ移すだけで、tidy_undo で全部戻せます。',
546
+ inputSchema: {
547
+ type: 'object',
548
+ properties: {
549
+ dir: { type: 'string', description: '診るフォルダ' },
550
+ days: { type: 'number', description: '何日さわっていないものを「古い」とするか(既定180・報告のみ)' },
551
+ apply: { type: 'boolean', description: '実際に寄せる(付けないと下見だけ)' },
552
+ },
553
+ required: ['dir'],
554
+ },
555
+ },
556
+ {
557
+ name: 'tidy_undo',
558
+ description: '★片づけで寄せたものを、記録から全部元に戻します。',
559
+ inputSchema: {
560
+ type: 'object',
561
+ properties: { dir: { type: 'string', description: '片づけたフォルダ' } },
562
+ required: ['dir'],
563
+ },
564
+ },
565
+ {
566
+ name: 'update',
567
+ description:
568
+ '★置いてあるスキルや道具を、新しい版にします。' +
569
+ '**お手元で書き換えられたものには触りません**(新しい版を隣に .new として置くだけ)。' +
570
+ '入れ替えたものも、元を .bak として残します。★まず apply なしで下見してください。',
571
+ inputSchema: {
572
+ type: 'object',
573
+ properties: {
574
+ to: { type: 'string', description: '置いてある場所(install したフォルダ)' },
575
+ apply: { type: 'boolean', description: '実際に入れ替える(付けないと下見だけ)' },
576
+ },
577
+ required: ['to'],
578
+ },
579
+ },
580
+ {
581
+ name: 'check_text',
582
+ description:
583
+ '渡された文字列を検査します。★この道具は文字列を引数に取るので、その文字列はあなたの文脈に入ります。' +
584
+ '機密を含む可能性があるものは check_file を使ってください。',
585
+ inputSchema: {
586
+ type: 'object',
587
+ properties: { text: { type: 'string', description: '検査する文字列' } },
588
+ required: ['text'],
589
+ },
590
+ },
591
+ ];
592
+
593
+ // ★常に効く核。コードに埋めず別ファイルにする(resources でも同じものを出すため)
594
+ // ★核とルールの置き場(2026-08-21 主の裁定「C」)
595
+ // FL=踏んだ穴の記録。**サプリより価値がある**ので、鍵の内側に置く。
596
+ // npm には器だけを入れ、核は鍵で取ってくる。取ったら手元に控える。
597
+ // ★切れ方=**控えは読める/新しい版は来ない**。払い続ける理由が「更新」になる。
598
+ const CORE_HOME = path.join(
599
+ process.env.BANTO_HOME || path.join(require('os').homedir(), '.banto'), 'core');
600
+
601
+ function coreFile(rel) { return path.join(CORE_HOME, rel.split('/').join(path.sep)); }
602
+
603
+ function loadInstructions() {
604
+ let core = null;
605
+ // ① 手元の控え(★鍵が切れても読める)
606
+ try { core = fs.readFileSync(coreFile('instructions.md'), 'utf8'); } catch (_) {}
607
+ // ② 開発中は同じフォルダのものを使う(配布物には入っていない)
608
+ if (!core) { try { core = fs.readFileSync(path.join(__dirname, 'instructions.md'), 'utf8'); } catch (_) {} }
609
+ const 核なし = !core;
610
+ if (核なし) {
611
+ // ★無いなら、無いと言う。番頭のふりをさせない
612
+ // ★ただし「いまの状態」は下で必ず足す(核が無くても、記録と下ごしらえは動くので)
613
+ core = [
614
+ 'このMCPは「番頭ゲート」です。いまは**検査と記録だけ**が使えます。',
615
+ '',
616
+ '★番頭の核(安全境界・仕事の型・記憶のしかた)とルールは、まだ入っていません。',
617
+ 'お手元に鍵がある場合は、次のように頼んでください。',
618
+ '',
619
+ ' 「番頭の核を取ってきて」(sync_core)',
620
+ '',
621
+ '鍵が無い場合、使えるのは次だけです。',
622
+ ' ・check_file … ★中身をAIに見せずにファイルを検査する',
623
+ ' ・remember / recall / review / settle … 気づいたことを1行ずつ記録し、3件たまったら知らせる',
624
+ ' ・tidy … 散らかりを診る(★何も動かしません)',
625
+ '',
626
+ '★これらは鍵が無くてもお使いいただけます。',
627
+ ].join('\n');
628
+ }
629
+
630
+ // ★起動のたびの自己診断を、ここへ足す(2026-08-21)。
631
+ // MCPは常駐しないので定期実行はできないが、**instructions は毎回作られる**。
632
+ // ここに載せれば「番頭が自分から言い出す」形になり、**人が覚えておく必要がなくなる**。
633
+ // (→ tooling-check.md「自走する仕組みを作ったら、人に届く口を必ず作る」)
634
+ const 行 = [];
635
+
636
+ // ① ★北極星(2026-08-21 主「北極星を決めても更新されない」)
637
+ // ★決まるまで毎回出す。空のまま使い続けさせない
638
+ // ★核が無いときは出さない(番頭になっていないので、北極星を聞く場面ではない)
639
+ try {
640
+ const me = ME.quick();
641
+ if (!核なし && (!me.ある || !me.北極星)) {
642
+ 行.push('★**北極星がまだ決まっていません。** 何のためにこの番頭がいるのかを、最初に決めてください。');
643
+ 行.push(' 「いま、いちばん面倒だと思っていることは何ですか」から始めて、');
644
+ 行.push(' 「それが無くなったら、その時間で何をしたいですか」まで聞いてから set_polaris を呼びます。');
645
+ 行.push(' ★「効率化」で止めないこと。その先まで聞けて、初めて判断の基準になります。');
646
+ }
647
+ } catch (_) {}
648
+
649
+ // ② ★やること(2026-08-21 主「タスク連携後は、新規タブでタスクも見るようにする」)
650
+ try {
651
+ const t = GT.quickCount();
652
+ if (t) {
653
+ 行.push('やること ' + t.残り + ' 件' + (t.期限切れ ? '(★期限切れ ' + t.期限切れ + ' 件)' : '') +
654
+ ' →「やること見せて」で開きます');
655
+ } else if (!GT.state().つながっている) {
656
+ 行.push('やること:まだGoogleとつながっていません(「Googleの手順を教えて」で1手ずつご案内します。★あとで構いません)');
657
+ }
658
+ } catch (_) {}
659
+
660
+ // ③ 記録(3件たまったら鳴る)
661
+ try { const d = FREE.selfDiagnosis(); if (d) 行.push(d); } catch (_) {}
662
+
663
+ // ④ 下ごしらえ(★足りないものを1つだけ)
664
+ try {
665
+ const e = ENV.guide();
666
+ if (e.足りないもの) 行.push('下ごしらえ:' + e.次にすること.何が + '(「下ごしらえを見て」で手順が出ます)');
667
+ } catch (_) {}
668
+
669
+ if (行.length) core += '\n\n---\n\n## ★いまの状態(起動のたびに自動で診ています)\n\n' + 行.join('\n') + '\n';
670
+ return core;
671
+ }
672
+
673
+ // ★詳しいルールは resources で出す。instructions は長さに限りがあるので核だけ。
674
+ // ★毎回作り直す(sync_core で増えたぶんを、次の起動から拾うため)
675
+ function resources() {
676
+ const out = [];
677
+ const seen = new Set();
678
+ const add = (rel, abs, name, desc) => {
679
+ if (seen.has(rel)) return;
680
+ seen.add(rel);
681
+ out.push({ uri: 'banto://' + rel, name, description: desc, mimeType: 'text/markdown', _path: abs });
682
+ };
683
+ // ① 手元の控え(★鍵で取ってきたもの。切れても読める)
684
+ // ② 同じフォルダ(開発中だけ)
685
+ for (const base of [CORE_HOME, __dirname]) {
686
+ try {
687
+ if (fs.existsSync(path.join(base, 'instructions.md'))) {
688
+ add('core', path.join(base, 'instructions.md'), '番頭の核(常に効く分)', '安全境界・仕事の型・記憶のしかた');
689
+ }
690
+ const rd = path.join(base, 'rules');
691
+ for (const f of fs.readdirSync(rd)) {
692
+ if (!/\.md$/i.test(f)) continue;
693
+ const n = f.replace(/\.md$/i, '');
694
+ add('rules/' + encodeURIComponent(n), path.join(rd, f), n, '番頭のルール');
695
+ }
696
+ } catch (_) { /* 無くても動く */ }
697
+ }
698
+ return out;
699
+ }
700
+
701
+ // ───────────────────────────────── 実体を利用者の手元へ置く(install)
702
+ //
703
+ // ★コンセプト(2026-08-21 主が確認)=
704
+ // **利用者がスキルを持っていなくても、このMCPがあればスキルを使える。**
705
+ // ルールと核は resources / instructions で配れるが、
706
+ // ★**プログラムの実体(gen.js・Python・lint.js)は、ファイルとして手元に無いと動かない。**
707
+ // その1本だけが足りていなかった。
708
+ //
709
+ // ★正本の置き場を分ける(2か所に同じものを置かない)
710
+ // 核・ルール → MCP(instructions / resources)
711
+ // 実体・作法 → ここ(利用者のフォルダ)
712
+ // 記憶 → MCP(tools)
713
+ const PAYLOAD = path.join(__dirname, 'payload');
714
+ // ★版は package.json だけに書く(2026-08-21)。
715
+ // コードにも書くと、上げ忘れて**別々の版を名乗る**。正本は1つ。
716
+ const VERSION = (() => {
717
+ for (const p of ['./package.json', '../package.json']) {
718
+ try { return require(p).version; } catch (_) {}
719
+ }
720
+ return '0.0.0-dev';
721
+ })();
722
+
723
+ // ★見せ方の段と、伏せる/戻す(2026-08-21)
724
+ const PRIV = require('./privacy.js');
725
+ const GT = require('./gtasks.js'); // やること(Google Tasks)
726
+ const ENV = require('./env.js'); // 下ごしらえ(Node / Python / Playwright)
727
+ const ME = require('./subconscious.js'); // 主のこと(北極星・価値観)
728
+ function stamp() {
729
+ const d = new Date(), p = (n) => String(n).padStart(2, '0');
730
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
731
+ }
732
+
733
+ // ★別に用意が要るもの。**黙って置かない**(動かないものを置いて「入れました」と言わない)
734
+ const NEEDS = {
735
+ 'skills/transcribe': 'Python と Whisper が別に要ります(★入っていません)',
736
+ 'skills/image-gen': '画像生成サービスの鍵が別に要ります(★利用者ご自身のもの)',
737
+ 'skills/remember': 'Googleカレンダー・ToDoと繋ぐ場合だけ、別に設定が要ります',
738
+ };
739
+
740
+ function listPayload() {
741
+ const out = [];
742
+ if (!fs.existsSync(PAYLOAD)) return out;
743
+ (function w(d) {
744
+ for (const e of fs.readdirSync(d)) {
745
+ const p = path.join(d, e);
746
+ fs.statSync(p).isDirectory() ? w(p) : out.push(path.relative(PAYLOAD, p).split('\\').join('/'));
747
+ }
748
+ })(PAYLOAD);
749
+ return out.sort();
750
+ }
751
+
752
+ // ★置いたときの中身を控えておく。**あとで「利用者が書き換えたか」を見分けるため**。
753
+ // これが無いと update は「全部上書き」か「何もしない」の二択になり、
754
+ // 育てたものを壊すか、古いまま置き去るかのどちらかになる。
755
+ const MANIFEST = '.bantou-install.json';
756
+ const digest = (p) => {
757
+ try { return require('crypto').createHash('sha256').update(fs.readFileSync(p)).digest('hex').slice(0, 16); }
758
+ catch (_) { return null; }
759
+ };
760
+ function readManifest(dest) {
761
+ try { return JSON.parse(fs.readFileSync(path.join(dest, MANIFEST), 'utf8')); }
762
+ catch (_) { return { version: null, files: {} }; }
763
+ }
764
+ function writeManifest(dest, m) {
765
+ m.updated_at = stamp();
766
+ fs.writeFileSync(path.join(dest, MANIFEST), JSON.stringify(m, null, 2), 'utf8');
767
+ }
768
+
769
+ function install(to, overwrite) {
770
+ const files = listPayload();
771
+ if (!files.length) throw new Error('配るものが見つかりません(payload が空です)');
772
+ const dest = path.resolve(String(to || '').trim());
773
+ if (!dest || dest === path.parse(dest).root) throw new Error('置き場所を指定してください');
774
+
775
+ const m = readManifest(dest);
776
+ const placed = [], skipped = [], backed = [];
777
+ for (const f of files) {
778
+ const src = path.join(PAYLOAD, f);
779
+ const dst = path.join(dest, f);
780
+ if (fs.existsSync(dst)) {
781
+ if (!overwrite) { skipped.push(f); continue; }
782
+ // ★上書きするときも消さない。控えへ寄せる
783
+ const bak = dst + '.bak-' + stampCompact();
784
+ try { fs.renameSync(dst, bak); backed.push(f); } catch (_) { skipped.push(f); continue; }
785
+ }
786
+ fs.mkdirSync(path.dirname(dst), { recursive: true });
787
+ fs.copyFileSync(src, dst);
788
+ m.files[f] = digest(src); // ★配った時点の中身
789
+ placed.push(f);
790
+ }
791
+ m.version = VERSION;
792
+ try { writeManifest(dest, m); } catch (_) {}
793
+
794
+ // 置いたものに対応する「別に要るもの」だけを出す
795
+ const needs = Object.entries(NEEDS)
796
+ .filter(([k]) => placed.some((f) => f.indexOf(k) === 0))
797
+ .map(([k, v]) => k + ':' + v);
798
+
799
+ return {
800
+ 置き場所: dest,
801
+ 置いた件数: placed.length,
802
+ 飛ばした件数: skipped.length,
803
+ 控えへ寄せた件数: backed.length,
804
+ 置いたもの: placed,
805
+ 飛ばしたもの: skipped.length ? skipped : undefined,
806
+ 別に要るもの: needs.length ? needs : undefined,
807
+ note:
808
+ (skipped.length
809
+ ? '★同じ名前のファイルがあったので飛ばしました。入れ替えるなら overwrite を付けてください(元は .bak へ寄せます)。'
810
+ : '') +
811
+ '★ルールと番頭の核は、このMCPが持っています(ファイルとして置いていません)。' +
812
+ '★置いたものは利用者のものです。書き換えて構いません。',
813
+ };
814
+ }
815
+
816
+ /**
817
+ * 新しい版に入れ替える。
818
+ * ★鉄則=**利用者が書き換えたものには触らない。**
819
+ * 置いたときの中身(マニフェスト)と、いまの中身を比べて判定する。
820
+ * ・同じ → 触っていない → 入れ替えてよい
821
+ * ・違う → ★書き換えている → **触らない**。新しい版を隣に `.new` として置くだけ
822
+ * ・無い → 新しく足す
823
+ * ★消さない・上書きしない・黙って変えない。この3つを守る。
824
+ */
825
+ function update(to, apply) {
826
+ const dest = path.resolve(String(to || '').trim());
827
+ if (!fs.existsSync(dest)) throw new Error('見つかりません: ' + dest);
828
+ const m = readManifest(dest);
829
+ const files = listPayload();
830
+ if (!files.length) throw new Error('配るものが見つかりません(payload が空です)');
831
+
832
+ const 入れ替える = [], 新しく足す = [], 触らない = [], 変わっていない = [];
833
+ for (const f of files) {
834
+ const src = path.join(PAYLOAD, f);
835
+ const dst = path.join(dest, f);
836
+ const now = digest(src);
837
+ if (!fs.existsSync(dst)) { 新しく足す.push(f); continue; }
838
+ const here = digest(dst);
839
+ const when = m.files[f] || null;
840
+ if (here === now) { 変わっていない.push(f); continue; }
841
+ if (when && here === when) 入れ替える.push(f); // 配ったまま=触っていない
842
+ else 触らない.push(f); // ★書き換えている(または記録が無い)
843
+ }
844
+
845
+ const result = {
846
+ 置き場所: dest,
847
+ いまの版: m.version || '(記録なし)',
848
+ 新しい版: VERSION,
849
+ 入れ替える件数: 入れ替える.length,
850
+ 新しく足す件数: 新しく足す.length,
851
+ 触らない件数: 触らない.length,
852
+ 変わっていない件数: 変わっていない.length,
853
+ 触らないもの: 触らない.length ? 触らない : undefined,
854
+ };
855
+
856
+ if (!apply) {
857
+ result.note =
858
+ '★まだ何もしていません(下見です)。' +
859
+ (触らない.length
860
+ ? '★' + 触らない.length + '件は、お手元で書き換えられているので触りません。新しい版を見たい場合は apply を付けると、隣に .new として置きます。'
861
+ : '') +
862
+ ' 実行するときは apply を付けてください。';
863
+ return result;
864
+ }
865
+
866
+ const done = [], added = [], asNew = [];
867
+ for (const f of 入れ替える) {
868
+ const dst = path.join(dest, f);
869
+ try {
870
+ fs.renameSync(dst, dst + '.bak-' + stampCompact()); // ★消さずに控えへ
871
+ fs.copyFileSync(path.join(PAYLOAD, f), dst);
872
+ m.files[f] = digest(path.join(PAYLOAD, f));
873
+ done.push(f);
874
+ } catch (_) {}
875
+ }
876
+ for (const f of 新しく足す) {
877
+ const dst = path.join(dest, f);
878
+ fs.mkdirSync(path.dirname(dst), { recursive: true });
879
+ fs.copyFileSync(path.join(PAYLOAD, f), dst);
880
+ m.files[f] = digest(path.join(PAYLOAD, f));
881
+ added.push(f);
882
+ }
883
+ for (const f of 触らない) {
884
+ // ★元は動かさない。新しい版を隣に置くだけ。見比べて、要るところだけ写してもらう
885
+ try { fs.copyFileSync(path.join(PAYLOAD, f), path.join(dest, f + '.new')); asNew.push(f + '.new'); } catch (_) {}
886
+ }
887
+ m.version = VERSION;
888
+ try { writeManifest(dest, m); } catch (_) {}
889
+
890
+ result.入れ替えた = done.length;
891
+ result.足した = added.length;
892
+ result.隣に置いた新しい版 = asNew.length ? asNew : undefined;
893
+ result.note =
894
+ '★入れ替えたものは、元を .bak として残しています(消していません)。' +
895
+ (asNew.length
896
+ ? '★お手元で書き換えられていた ' + asNew.length + ' 件は、**元のまま**です。新しい版を .new として隣に置いたので、見比べて必要なところだけ写してください。'
897
+ : '');
898
+ return result;
899
+ }
900
+
901
+ /**
902
+ * 辞書づくりの伴走。
903
+ * ★候補は**手元のファイルへ書き出す**。AIには件数だけ返す(候補そのものが顧客名なので)。
904
+ * 様式(ひな形)も一緒に置いて、あとは人が選ぶ。★機械に決めさせない。
905
+ */
906
+ function makeDict(from, to) {
907
+ const src = path.resolve(String(from || '').trim());
908
+ if (!fs.existsSync(src)) throw new Error('見つかりません: ' + src);
909
+ const dst = path.resolve(String(to || src).trim());
910
+ const dict = FREE.loadDict();
911
+
912
+ const files = FREE.walk(src, [], 0);
913
+ const count = new Map();
914
+ for (const f of files) {
915
+ let r;
916
+ try { r = FREE.inspectText(fs.readFileSync(f, 'utf8'), dict); } catch (_) { continue; }
917
+ for (const c of r.candidates) count.set(c.text, (count.get(c.text) || 0) + c.count);
918
+ }
919
+ // ★出現の多い順(よく出る名前ほど、伏せる価値がある)
920
+ const list = [...count.entries()].sort((a, b) => b[1] - a[1]);
921
+
922
+ const draft = path.join(dst, '辞書の候補.txt');
923
+ const lines = [
924
+ '伏せたい言葉の候補',
925
+ '★この一覧は、あなたが読むためのものです。AIには件数しか渡していません。',
926
+ '─'.repeat(50),
927
+ '',
928
+ '使い方:',
929
+ ' 1. 下の一覧から、伏せたい言葉に印(★)を付けてください',
930
+ ' 2. 印を付けたものを bantou-gate-dict.json の "words" に書き写します',
931
+ ' 3. ★人の名前・会社名は、表記ゆれも一緒に入れてください',
932
+ ' (例:「山田」「山田様」「山田さん」「ヤマダ」)',
933
+ '',
934
+ '★機械は「名前らしい形」を拾っただけです。名前でないものも混ざっています。',
935
+ '★逆に、形が違う名前は拾えていません。ご自身で足してください。',
936
+ '',
937
+ '─'.repeat(50),
938
+ '',
939
+ ];
940
+ list.forEach(([w, n]) => lines.push('[ ] ' + w + ' (' + n + '回)'));
941
+ fs.mkdirSync(dst, { recursive: true });
942
+ fs.writeFileSync(draft, lines.join('\n'), 'utf8');
943
+
944
+ // 様式(ひな形)。★既にあれば触らない
945
+ const tpl = path.join(dst, 'bantou-gate-dict.json');
946
+ let tplMade = false;
947
+ if (!fs.existsSync(tpl)) {
948
+ fs.writeFileSync(tpl, JSON.stringify({
949
+ _説明: '伏せたい言葉をここに並べます。表記ゆれも1つずつ入れてください。',
950
+ words: ['(ここに書きます)'],
951
+ }, null, 2), 'utf8');
952
+ tplMade = true;
953
+ }
954
+
955
+ return {
956
+ 見たファイル数: files.length,
957
+ 候補の数: list.length,
958
+ 候補の一覧: draft + ' ★人が開いてください(AIは中身を読んでいません)',
959
+ 辞書の様式: tpl + (tplMade ? '(新しく置きました)' : '(既にあったので触っていません)'),
960
+ いまの辞書: dict.path ? dict.path + '(' + dict.words.length + '語)' : '★まだありません',
961
+ note: '★候補そのものは返していません。一覧を開いて、伏せたいものを選んでください。' +
962
+ '★1回で完成させなくて構いません。使いながら足していくものです。',
963
+ };
964
+ }
965
+
966
+ /**
967
+ * 散らかりを診る。★読むだけ。何も動かさない。
968
+ * (動かす機能は作らない。人のフォルダで消す・移すのは、後戻りできない)
969
+ */
970
+ function tidy(dir, days) {
971
+ const root = path.resolve(String(dir || '').trim());
972
+ if (!fs.existsSync(root)) throw new Error('見つかりません: ' + root);
973
+ const oldDays = Number(days) || 180;
974
+ const now = Date.now();
975
+
976
+ const files = [], emptyDirs = [];
977
+ (function w(d, depth) {
978
+ if (depth > 8) return;
979
+ let es;
980
+ try { es = fs.readdirSync(d); } catch (_) { return; }
981
+ if (!es.length) { emptyDirs.push(path.relative(root, d)); return; }
982
+ for (const e of es) {
983
+ if (e === 'node_modules' || e.startsWith('.git')) continue;
984
+ const p = path.join(d, e);
985
+ let st;
986
+ try { st = fs.statSync(p); } catch (_) { continue; }
987
+ if (st.isDirectory()) w(p, depth + 1);
988
+ else files.push({ p, size: st.size, mtime: st.mtimeMs });
989
+ }
990
+ })(root, 0);
991
+
992
+ // 同じ中身(★大きさが同じものだけハッシュする。全部やると遅い)
993
+ const bySize = {};
994
+ for (const f of files) (bySize[f.size] = bySize[f.size] || []).push(f);
995
+ let dupGroups = 0, dupFiles = 0, dupBytes = 0;
996
+ for (const list of Object.values(bySize)) {
997
+ if (list.length < 2 || list[0].size === 0) continue;
998
+ const h = {};
999
+ for (const f of list) {
1000
+ const d = digest(f.p);
1001
+ if (d) (h[d] = h[d] || []).push(f);
1002
+ }
1003
+ for (const g of Object.values(h)) {
1004
+ if (g.length < 2) continue;
1005
+ dupGroups++; dupFiles += g.length - 1;
1006
+ dupBytes += g[0].size * (g.length - 1);
1007
+ }
1008
+ }
1009
+
1010
+ const old = files.filter((f) => now - f.mtime > oldDays * 86400000);
1011
+ const big = files.filter((f) => f.size > 20 * 1024 * 1024);
1012
+ const mb = (n) => Math.round(n / 1048576 * 10) / 10;
1013
+
1014
+ const 気づき = [];
1015
+ if (dupGroups) 気づき.push('★同じ中身のファイルが ' + dupGroups + ' 組(' + dupFiles + '件・約' + mb(dupBytes) + 'MB)あります。**どれが正本かを決めてください**');
1016
+ if (old.length) 気づき.push(oldDays + '日さわっていないものが ' + old.length + ' 件あります');
1017
+ if (big.length) 気づき.push('20MBを超えるものが ' + big.length + ' 件あります');
1018
+ if (emptyDirs.length) 気づき.push('空のフォルダが ' + emptyDirs.length + ' 件あります');
1019
+ if (!気づき.length) 気づき.push('気になるところはありませんでした');
1020
+
1021
+ return {
1022
+ 見たフォルダ: root,
1023
+ ファイル数: files.length,
1024
+ 合計の大きさ: mb(files.reduce((s, f) => s + f.size, 0)) + 'MB',
1025
+ 同じ中身の組: dupGroups,
1026
+ 余分な件数: dupFiles,
1027
+ 古いもの: old.length,
1028
+ 大きいもの: big.length,
1029
+ 空のフォルダ: emptyDirs.length,
1030
+ 気づき,
1031
+ note:
1032
+ '★診ただけです。**何も動かしていません。**' +
1033
+ '★片づけるときは、消さずに「使わないもの」フォルダへ移すところから始めてください。' +
1034
+ '★同じ中身が複数あるときは、消す前に**どれが正本か**を決めるのが先です。',
1035
+ };
1036
+ }
1037
+
1038
+ // ───────────────────────────────── サプリ(配布口から取る)
1039
+ //
1040
+ // ★正本はうちの薬局(git管理)。ここは**取ってくるだけ**。書き戻さない。
1041
+ // ★通信するのは「一覧」と「取得」のときだけ。**中身は送らない**(鍵のハッシュと名前だけ)。
1042
+ // ★取れなくても番頭は動く(基本セットは同梱してある)。
1043
+
1044
+ function supEndpoint(fn) {
1045
+ const { url } = licenseConfig();
1046
+ if (!url) return null;
1047
+ return url.replace(/\/rpc\/[^/]+$/, '/rpc/' + fn);
1048
+ }
1049
+ function licenseConfig() {
1050
+ const c = { key: process.env.BANTOU_LICENSE_KEY || '', url: process.env.BANTOU_LICENSE_URL || '',
1051
+ anonKey: process.env.BANTOU_LICENSE_ANON || '' };
1052
+ try {
1053
+ const j = JSON.parse(fs.readFileSync(path.join(__dirname, 'license', 'config.json'), 'utf8'));
1054
+ c.key = c.key || j.key || '';
1055
+ c.url = c.url || j.url || '';
1056
+ c.anonKey = c.anonKey || j.anonKey || '';
1057
+ } catch (_) {}
1058
+ return c;
1059
+ }
1060
+
1061
+ function supPost(fn, body) {
1062
+ const { key, anonKey } = licenseConfig();
1063
+ const url = supEndpoint(fn);
1064
+ if (!key || !url || !anonKey) {
1065
+ throw new Error('鍵が設定されていません。サプリは鍵をお預かりしてからお使いいただけます');
1066
+ }
1067
+ const keyHash = require('crypto').createHash('sha256').update(key).digest('hex');
1068
+ const https = require('https');
1069
+ const data = JSON.stringify(Object.assign({ p_key_hash: keyHash }, body));
1070
+ return new Promise((resolve, reject) => {
1071
+ const u = new URL(url);
1072
+ const req = https.request({
1073
+ hostname: u.hostname, path: u.pathname + u.search, method: 'POST',
1074
+ headers: {
1075
+ 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data),
1076
+ apikey: anonKey, Authorization: 'Bearer ' + anonKey,
1077
+ },
1078
+ }, (res) => {
1079
+ let b = '';
1080
+ res.on('data', (d) => (b += d));
1081
+ res.on('end', () => {
1082
+ let j;
1083
+ try { j = JSON.parse(b); } catch (_) { return reject(new Error('応答を読めません')); }
1084
+ if (res.statusCode >= 400) {
1085
+ return reject(new Error((j && (j.message || j.hint)) || ('HTTP ' + res.statusCode)));
1086
+ }
1087
+ resolve(j);
1088
+ });
1089
+ });
1090
+ req.setTimeout(20000, () => req.destroy(new Error('時間切れ(配布口につながりません)')));
1091
+ req.on('error', reject);
1092
+ req.write(data);
1093
+ req.end();
1094
+ });
1095
+ }
1096
+
1097
+ const SUP_STATE = '.bantou-supplements.json';
1098
+ function supState(dir) {
1099
+ try { return JSON.parse(fs.readFileSync(path.join(dir, SUP_STATE), 'utf8')); }
1100
+ catch (_) { return { installed: {} }; }
1101
+ }
1102
+ function saveSupState(dir, s) {
1103
+ s.updated_at = stamp();
1104
+ fs.writeFileSync(path.join(dir, SUP_STATE), JSON.stringify(s, null, 2), 'utf8');
1105
+ }
1106
+
1107
+ /**
1108
+ * 番頭の核とルールを、鍵で取ってきて手元に控える。
1109
+ * ★切れ方(C)=**控えは読める/新しい版は来ない**。
1110
+ * だから取れなかったときも、控えがあれば「使えます」と言う。無いときだけ「入っていません」。
1111
+ */
1112
+ async function syncCore() {
1113
+ const 控え = CORE_HOME;
1114
+ const 前 = (() => { try { return JSON.parse(fs.readFileSync(path.join(控え, '.version.json'), 'utf8')).version; } catch (_) { return null; } })();
1115
+
1116
+ let rows;
1117
+ try {
1118
+ rows = await supPost('get_core', {});
1119
+ } catch (e) {
1120
+ const ある = fs.existsSync(path.join(控え, 'instructions.md'));
1121
+ return {
1122
+ 取れませんでした: String(e.message),
1123
+ 手元の控え: ある ? '✅ あります(版 ' + (前 || '不明') + ')' : '❌ ありません',
1124
+ note: ある
1125
+ ? '★お手元の控えはそのままお使いいただけます。新しい版だけが来ていません。'
1126
+ : '★核がまだ入っていません。鍵をご確認ください(検査と記録は鍵が無くても使えます)。',
1127
+ };
1128
+ }
1129
+ if (!rows.length) throw new Error('配布口に核がありません');
1130
+
1131
+ fs.mkdirSync(控え, { recursive: true });
1132
+ const 置いた = [];
1133
+ for (const r of rows) {
1134
+ const dst = coreFile(r.path);
1135
+ fs.mkdirSync(path.dirname(dst), { recursive: true });
1136
+ fs.writeFileSync(dst, r.content, 'utf8');
1137
+ 置いた.push(r.path);
1138
+ }
1139
+ let 版 = null;
1140
+ try { 版 = await supPost('core_version', {}); } catch (_) {}
1141
+ fs.writeFileSync(path.join(控え, '.version.json'),
1142
+ JSON.stringify({ version: 版, at: stamp(), files: 置いた.length }, null, 2), 'utf8');
1143
+
1144
+ return {
1145
+ 取ってきた件数: 置いた.length,
1146
+ 版: 版 || '不明',
1147
+ 前の版: 前 || '(初めて)',
1148
+ 控えの場所: 控え,
1149
+ note: '★次にこの窓を開き直すと、番頭の核が効きます(いまの窓には反映されません)。' +
1150
+ '★控えはお手元に残ります。鍵が切れても読めます(新しい版だけが来なくなります)。',
1151
+ };
1152
+ }
1153
+
1154
+ async function listSupplements(to) {
1155
+ const rows = await supPost('list_supplements', {});
1156
+ const dir = to ? path.resolve(to) : null;
1157
+ const st = dir ? supState(dir) : { installed: {} };
1158
+ return {
1159
+ 使えるサプリ: rows.map((r) => ({
1160
+ 名前: r.name, 版: r.version,
1161
+ 層: r.tier === 'limited' ? '★限定' : '一般',
1162
+ ファイル数: r.file_count,
1163
+ 大きさ: Math.round(r.bytes / 1024) + 'KB',
1164
+ いまの状態: st.installed[r.name]
1165
+ ? (st.installed[r.name].version === r.version ? '入っています' : '★新しい版があります(' + st.installed[r.name].version + '→' + r.version + ')')
1166
+ : '未導入',
1167
+ 説明: r.description || null,
1168
+ })),
1169
+ 合計: rows.length,
1170
+ note: '★入れるときは add_supplement、外すときは remove_supplement です。' +
1171
+ '★中身はまだ取っていません(名前と大きさだけ見ています)。',
1172
+ };
1173
+ }
1174
+
1175
+ async function addSupplement(name, to) {
1176
+ const n = String(name || '').trim();
1177
+ if (!n) throw new Error('サプリの名前を指定してください');
1178
+ const dir = path.resolve(String(to || process.cwd()).trim());
1179
+ const rows = await supPost('get_supplement', { p_name: n });
1180
+ if (!rows.length) throw new Error('中身がありません: ' + n);
1181
+
1182
+ const base = path.join(dir, 'supplements', n);
1183
+ const st = supState(dir);
1184
+ const placed = [], skipped = [];
1185
+ for (const r of rows) {
1186
+ const dst = path.join(base, r.path);
1187
+ // ★利用者が書き換えたものは触らない(install / update と同じ考え)
1188
+ if (fs.existsSync(dst)) {
1189
+ const prev = st.installed[n] && st.installed[n].files && st.installed[n].files[r.path];
1190
+ const now = require('crypto').createHash('sha256').update(fs.readFileSync(dst)).digest('hex').slice(0, 16);
1191
+ if (prev && prev !== now) { skipped.push(r.path); continue; }
1192
+ }
1193
+ fs.mkdirSync(path.dirname(dst), { recursive: true });
1194
+ fs.writeFileSync(dst, r.content, 'utf8');
1195
+ placed.push(r.path);
1196
+ }
1197
+ const files = {};
1198
+ for (const r of rows) files[r.path] = require('crypto').createHash('sha256').update(Buffer.from(r.content, 'utf8')).digest('hex').slice(0, 16);
1199
+ const meta = (await supPost('list_supplements', {})).find((x) => x.name === n) || {};
1200
+ st.installed[n] = { version: meta.version || '?', at: stamp(), files };
1201
+ saveSupState(dir, st);
1202
+
1203
+ return {
1204
+ 入れたサプリ: n, 版: meta.version || '?',
1205
+ 置き場所: base,
1206
+ 置いた件数: placed.length,
1207
+ 触らなかった件数: skipped.length,
1208
+ 触らなかったもの: skipped.length ? skipped : undefined,
1209
+ note: '★お手元で書き換えられていたものは触っていません。' +
1210
+ '★使い方は置き場所の SKILL.md / README.md をお読みください。',
1211
+ };
1212
+ }
1213
+
1214
+ function removeSupplement(name, to) {
1215
+ const n = String(name || '').trim();
1216
+ const dir = path.resolve(String(to || process.cwd()).trim());
1217
+ const base = path.join(dir, 'supplements', n);
1218
+ if (!fs.existsSync(base)) throw new Error('入っていません: ' + n);
1219
+ // ★消さない。控えへ寄せる(後戻りできる形)
1220
+ const bin = path.join(dir, '_外したサプリ', n + '_' + stampCompact());
1221
+ fs.mkdirSync(path.dirname(bin), { recursive: true });
1222
+ fs.renameSync(base, bin);
1223
+ const st = supState(dir);
1224
+ delete st.installed[n];
1225
+ saveSupState(dir, st);
1226
+ return {
1227
+ 外したサプリ: n,
1228
+ 寄せた先: bin,
1229
+ note: '★消していません。' + bin + ' に移してあります。要らなければ、ご自身で捨ててください。',
1230
+ };
1231
+ }
1232
+
1233
+ // ───────────────────────────────── 片づける(★消さない・戻せる)
1234
+ //
1235
+ // ★2026-08-21 主「ファイルやフォルダがごちゃごちゃになってる人も MCP 使うと整うのが良いよね」
1236
+ // ★動かしてよいのは、**人が見なくても判断できるもの**だけにする。
1237
+ // ・同じ中身が複数ある → 1つ残して、残りを寄せる(★どれを残したかと、その理由を出す)
1238
+ // ・作業の残骸(.bak / .old / ~$ / Thumbs.db) → 寄せる
1239
+ // ★「古い」「大きい」は動かさない。使っていないことと、要らないことは別。
1240
+ // ★消さない。寄せるだけ。**記録を残して、tidy_undo で全部戻せる**。
1241
+
1242
+ const 片づけ先 = '_かたづけ';
1243
+ const 片づけ記録 = '_片づけの記録.json';
1244
+ const 残骸 = [/\.bak(-[\dA-Za-z-]+)?$/i, /\.old$/i, /^~\$/, /\.tmp$/i, /^Thumbs\.db$/i, /^\.DS_Store$/i, /\.orig$/i];
1245
+
1246
+ function tidyPlan(root, oldDays) {
1247
+ const files = [];
1248
+ const emptyDirs = [];
1249
+ (function w(d, depth) {
1250
+ if (depth > 8) return;
1251
+ let es;
1252
+ try { es = fs.readdirSync(d); } catch (_) { return; }
1253
+ if (!es.length) { emptyDirs.push(d); return; }
1254
+ for (const e of es) {
1255
+ if (e === 'node_modules' || e.startsWith('.git') || e === 片づけ先) continue;
1256
+ const p = path.join(d, e);
1257
+ let st;
1258
+ try { st = fs.statSync(p); } catch (_) { continue; }
1259
+ if (st.isDirectory()) w(p, depth + 1);
1260
+ else files.push({ p, name: e, size: st.size, mtime: st.mtimeMs, depth });
1261
+ }
1262
+ })(root, 0);
1263
+
1264
+ // ① 作業の残骸
1265
+ const junk = files.filter((f) => 残骸.some((re) => re.test(f.name)));
1266
+
1267
+ // ② 同じ中身(★大きさが同じものだけハッシュする)
1268
+ const bySize = {};
1269
+ for (const f of files) {
1270
+ if (junk.includes(f) || f.size === 0) continue;
1271
+ (bySize[f.size] = bySize[f.size] || []).push(f);
1272
+ }
1273
+ const dups = [];
1274
+ for (const list of Object.values(bySize)) {
1275
+ if (list.length < 2) continue;
1276
+ const h = {};
1277
+ for (const f of list) {
1278
+ const d = digest(f.p);
1279
+ if (d) (h[d] = h[d] || []).push(f);
1280
+ }
1281
+ for (const g of Object.values(h)) {
1282
+ if (g.length < 2) continue;
1283
+ // ★残すもの=浅い場所/新しい/名前が短い、の順で選ぶ。**理由を出す**
1284
+ const keep = g.slice().sort((a, b) =>
1285
+ a.depth - b.depth || b.mtime - a.mtime || a.p.length - b.p.length)[0];
1286
+ const why = g.every((f) => f.depth === keep.depth)
1287
+ ? (g.every((f) => f.mtime === keep.mtime) ? '名前がいちばん短い' : 'いちばん新しい')
1288
+ : 'いちばん浅い場所にある';
1289
+ dups.push({ keep, move: g.filter((f) => f !== keep), why });
1290
+ }
1291
+ }
1292
+
1293
+ const now = Date.now();
1294
+ const old = files.filter((f) => !junk.includes(f) && now - f.mtime > (Number(oldDays) || 180) * 86400000);
1295
+ const big = files.filter((f) => f.size > 20 * 1024 * 1024);
1296
+ return { files, junk, dups, old, big, emptyDirs };
1297
+ }
1298
+
1299
+ function tidy(dir, days, apply) {
1300
+ const root = path.resolve(String(dir || '').trim());
1301
+ if (!fs.existsSync(root)) throw new Error('見つかりません: ' + root);
1302
+ const pl = tidyPlan(root, days);
1303
+ const mb = (n) => Math.round(n / 1048576 * 10) / 10;
1304
+ const rel = (p) => path.relative(root, p).split('\\').join('/');
1305
+
1306
+ const 動かすもの = [];
1307
+ for (const j of pl.junk) 動かすもの.push({ from: j.p, kind: '残骸' });
1308
+ for (const d of pl.dups) for (const m of d.move) 動かすもの.push({ from: m.p, kind: '重複', keep: d.keep.p, why: d.why });
1309
+
1310
+ const 気づき = [];
1311
+ if (pl.dups.length) 気づき.push('同じ中身のファイルが ' + pl.dups.length + ' 組(余分 ' + pl.dups.reduce((s, d) => s + d.move.length, 0) + '件)');
1312
+ if (pl.junk.length) 気づき.push('作業の残骸が ' + pl.junk.length + ' 件(.bak / .old / ~$ など)');
1313
+ if (pl.old.length) 気づき.push((Number(days) || 180) + '日さわっていないものが ' + pl.old.length + ' 件 ★動かしません');
1314
+ if (pl.big.length) 気づき.push('20MBを超えるものが ' + pl.big.length + ' 件 ★動かしません');
1315
+ if (pl.emptyDirs.length) 気づき.push('空のフォルダが ' + pl.emptyDirs.length + ' 件 ★動かしません');
1316
+ if (!気づき.length) 気づき.push('気になるところはありませんでした');
1317
+
1318
+ const base = {
1319
+ 見たフォルダ: root,
1320
+ ファイル数: pl.files.length,
1321
+ 合計の大きさ: mb(pl.files.reduce((s, f) => s + f.size, 0)) + 'MB',
1322
+ 気づき,
1323
+ };
1324
+
1325
+ if (!apply) {
1326
+ return Object.assign(base, {
1327
+ 寄せる予定: 動かすもの.length,
1328
+ 内訳: 動かすもの.slice(0, 40).map((m) =>
1329
+ m.kind === '重複'
1330
+ ? rel(m.from) + ' → 重複(' + rel(m.keep) + ' を残します:' + m.why + ')'
1331
+ : rel(m.from) + ' → 残骸'),
1332
+ note:
1333
+ '★診ただけです。**何も動かしていません。**' +
1334
+ (動かすもの.length
1335
+ ? ' 寄せるときは apply を付けてください。★消さずに「' + 片づけ先 + '」へ移すだけで、tidy_undo で全部戻せます。'
1336
+ : '') +
1337
+ ' ★「古い」「大きい」「空」は動かしません。使っていないことと、要らないことは別です。',
1338
+ });
1339
+ }
1340
+
1341
+ // ★実行。消さない・寄せるだけ・記録を残す
1342
+ const bin = path.join(root, 片づけ先);
1343
+ const recFile = path.join(bin, 片づけ記録);
1344
+ let rec = { moves: [] };
1345
+ try { rec = JSON.parse(fs.readFileSync(recFile, 'utf8')); } catch (_) {}
1346
+
1347
+ let moved = 0;
1348
+ const failed = [];
1349
+ for (const m of 動かすもの) {
1350
+ const dst = path.join(bin, m.kind === '重複' ? '重複' : '残骸', rel(m.from));
1351
+ try {
1352
+ fs.mkdirSync(path.dirname(dst), { recursive: true });
1353
+ if (fs.existsSync(dst)) { failed.push(rel(m.from) + '(寄せ先に同じ名前)'); continue; }
1354
+ fs.renameSync(m.from, dst);
1355
+ rec.moves.push({ from: m.from, to: dst, kind: m.kind, at: stamp() });
1356
+ moved++;
1357
+ } catch (e) { failed.push(rel(m.from) + '(' + e.message + ')'); }
1358
+ }
1359
+ rec.updated_at = stamp();
1360
+ try { fs.mkdirSync(bin, { recursive: true }); fs.writeFileSync(recFile, JSON.stringify(rec, null, 2), 'utf8'); } catch (_) {}
1361
+
1362
+ return Object.assign(base, {
1363
+ 寄せた件数: moved,
1364
+ 寄せられなかった: failed.length ? failed : undefined,
1365
+ 寄せ先: bin,
1366
+ note: '★消していません。' + bin + ' に移してあります。' +
1367
+ '元に戻すときは tidy_undo を呼んでください(記録から全部戻します)。' +
1368
+ '★中身を見て要らないと決めたら、そのフォルダごと、ご自身で捨ててください。',
1369
+ });
1370
+ }
1371
+
1372
+ function tidyUndo(dir) {
1373
+ const root = path.resolve(String(dir || '').trim());
1374
+ const recFile = path.join(root, 片づけ先, 片づけ記録);
1375
+ if (!fs.existsSync(recFile)) throw new Error('片づけの記録がありません(まだ寄せていないか、記録が消えています)');
1376
+ const rec = JSON.parse(fs.readFileSync(recFile, 'utf8'));
1377
+ let back = 0;
1378
+ const failed = [];
1379
+ // ★新しいものから戻す
1380
+ for (const m of rec.moves.slice().reverse()) {
1381
+ try {
1382
+ if (!fs.existsSync(m.to)) { failed.push(path.basename(m.to) + '(寄せ先に無い)'); continue; }
1383
+ if (fs.existsSync(m.from)) { failed.push(path.basename(m.from) + '(元の場所に同じ名前がある)'); continue; }
1384
+ fs.mkdirSync(path.dirname(m.from), { recursive: true });
1385
+ fs.renameSync(m.to, m.from);
1386
+ back++;
1387
+ } catch (e) { failed.push(path.basename(m.to) + '(' + e.message + ')'); }
1388
+ }
1389
+ const left = rec.moves.length - back;
1390
+ fs.writeFileSync(recFile, JSON.stringify({ moves: [], updated_at: stamp(), 戻せなかった: failed }, null, 2), 'utf8');
1391
+ return {
1392
+ 戻した件数: back,
1393
+ 戻せなかった件数: left,
1394
+ 戻せなかったもの: failed.length ? failed : undefined,
1395
+ note: '★元の場所へ戻しました。' + (left ? '戻せなかったものは「' + 片づけ先 + '」に残っています。' : ''),
1396
+ };
1397
+ }
1398
+
1399
+ function stampCompact() {
1400
+ const d = new Date(), p = (n) => String(n).padStart(2, '0');
1401
+ return '' + d.getFullYear() + p(d.getMonth() + 1) + p(d.getDate()) + '-' + p(d.getHours()) + p(d.getMinutes());
1402
+ }
1403
+
1404
+ // ★鍵が切れたときに、どの道具を止めるか(2026-08-20 決定)
1405
+ // 「溜めるのが止まる/引くのは残る」。recall だけは期限後も通す。
1406
+ // ★片づける口(settle)も残す。閉じられないまま終わらせない。
1407
+ const ALLOWED_WHEN_EXPIRED = new Set(['recall', 'review', 'settle', 'checkup']);
1408
+
1409
+ // ---------------------------------------------------------------- 道具の実行
1410
+
1411
+ function callTool(name, args) {
1412
+ if (name === 'check_file') {
1413
+ const p = String(args && args.path || '');
1414
+ if (!p) throw new Error('path が指定されていません');
1415
+ const abs = path.isAbsolute(p) ? p : path.join(REPO, p);
1416
+ if (!fs.existsSync(abs)) throw new Error('見つかりません: ' + p);
1417
+ return inspect(abs);
1418
+ }
1419
+
1420
+ if (name === 'remember') {
1421
+ return FREE.remember(args && args.text, args && args.tag, args && args.sig);
1422
+ }
1423
+
1424
+ if (name === 'recall') {
1425
+ return FREE.recall(args && args.days, args && args.only_open);
1426
+ }
1427
+
1428
+ if (name === 'review') return FREE.review(true);
1429
+
1430
+ if (name === 'settle') {
1431
+ return FREE.settle(args && args.ids, args && args.sig, args && args.ruled_to);
1432
+ }
1433
+
1434
+ if (name === 'checkup') return FREE.checkup();
1435
+
1436
+ if (name === 'install') {
1437
+ return install(args && args.to, args && args.overwrite);
1438
+ }
1439
+
1440
+ if (name === 'update') {
1441
+ return update(args && args.to, args && args.apply);
1442
+ }
1443
+
1444
+ if (name === 'policy') {
1445
+ const dir = (args && args.dir) || process.cwd();
1446
+ if (args && args.level !== undefined && args.level !== null) {
1447
+ const r = PRIV.setPolicy(dir, args.level);
1448
+ r.note = '★秘密鍵・パスワード・カード番号などは、どの段でも必ず止まります(選べません)。';
1449
+ return r;
1450
+ }
1451
+ const p = PRIV.getPolicy(dir);
1452
+ return {
1453
+ いまの段: p.level, 意味: PRIV.LEVELS[p.level],
1454
+ 選べる段: PRIV.LEVELS,
1455
+ 変えられない層: PRIV.ALWAYS.map(([k]) => k),
1456
+ note: '★段を変えるときは level を渡してください。',
1457
+ };
1458
+ }
1459
+
1460
+ if (name === 'make_dict') return makeDict(args && args.from, args && args.to);
1461
+
1462
+ if (name === 'mask_file') return PRIV.maskFile(FREE, args && args.path);
1463
+
1464
+ if (name === 'unmask_file') {
1465
+ return PRIV.unmaskFile(FREE, args && args.path, args && args.to, args && args.force);
1466
+ }
1467
+
1468
+ if (name === 'render') {
1469
+ return PRIV.render(FREE, args && args.mask_id, args && args.text, args && args.to, args && args.dir);
1470
+ }
1471
+
1472
+ if (name === 'tidy') return tidy(args && args.dir, args && args.days, args && args.apply);
1473
+ if (name === 'tidy_undo') return tidyUndo(args && args.dir);
1474
+
1475
+ // ★通信する道具(Promiseを返す。受け口で待つ)
1476
+ if (name === 'about_me') return ME.aboutMe();
1477
+ if (name === 'set_polaris') return ME.setPolaris(args && args.text, args && args.why);
1478
+ if (name === 'learn') return ME.learn(args && args.kind, args && args.text, args && args.source);
1479
+ if (name === 'check_env') return ENV.guide(args && args.want);
1480
+ if (name === 'setup_google') return GT.setupGuide();
1481
+ if (name === 'connect_google') return GT.connect();
1482
+ if (name === 'list_tasks') {
1483
+ return GT.listTasks(args && args.all).then((r) => {
1484
+ GT.saveCount(r['残っているもの'], r['期限切れ']); // ★次の窓で最初に出すため
1485
+ return r;
1486
+ });
1487
+ }
1488
+ if (name === 'add_task') return GT.addTask(args && args.title, args && args.due, args && args.notes);
1489
+ if (name === 'complete_task') return GT.completeTask(args && args.id, args && args.title);
1490
+ if (name === 'sync_core') return syncCore();
1491
+ if (name === 'list_supplements') return listSupplements(args && args.to);
1492
+
1493
+ if (name === 'open_shelf') {
1494
+ const dir = (args && args.to) || process.cwd();
1495
+ return listSupplements(dir).then((l) => {
1496
+ const r = require('./shelf.js').openShelf(l['使えるサプリ'], dir, args && args.no_open);
1497
+ r.note = '★このページを開いて、要るものを選んでください。' +
1498
+ 'ボタンを押すと頼み方がコピーされるので、そのまま私に貼ってください。' +
1499
+ (r.開きました ? '' : '★自動では開けませんでした。上のパスを開いてください。');
1500
+ return r;
1501
+ });
1502
+ }
1503
+ if (name === 'add_supplement') return addSupplement(args && args.name, args && args.to);
1504
+ if (name === 'remove_supplement') return removeSupplement(args && args.name, args && args.to);
1505
+
1506
+ if (name === 'list_installable') {
1507
+ const files = listPayload();
1508
+ const groups = {};
1509
+ for (const f of files) {
1510
+ const g = f.split('/').slice(0, 2).join('/').replace(/\.[^.]+$/, '');
1511
+ (groups[g] = groups[g] || []).push(f);
1512
+ }
1513
+ return {
1514
+ 置けるもの: Object.entries(groups).map(([g, fs2]) => ({ 名前: g, 件数: fs2.length })),
1515
+ 合計: files.length,
1516
+ 別に要るもの: Object.entries(NEEDS).map(([k, v]) => k + ':' + v),
1517
+ note: '★ルールと番頭の核は、このMCPが持っています。置く必要はありません。',
1518
+ };
1519
+ }
1520
+
1521
+ if (name === 'check_text') {
1522
+ const text = String(args && args.text || '');
1523
+ const tmp = path.join(
1524
+ require('os').tmpdir(),
1525
+ 'bantou_gate_' + process.pid + '_' + Math.random().toString(36).slice(2) + '.md'
1526
+ );
1527
+ fs.writeFileSync(tmp, text, 'utf8');
1528
+ try {
1529
+ return inspect(tmp);
1530
+ } finally {
1531
+ try { fs.unlinkSync(tmp); } catch (_) {}
1532
+ }
1533
+ }
1534
+
1535
+ throw new Error('未知の道具: ' + name);
1536
+ }
1537
+
1538
+ // ---------------------------------------------------------------- JSON-RPC
1539
+
1540
+ function send(msg) {
1541
+ process.stdout.write(JSON.stringify(msg) + '\n');
1542
+ }
1543
+
1544
+ // ★通信する道具(サプリの一覧・取得)があるので、受け口ごと待てる形にする。
1545
+ // 同期のまま Promise を JSON.stringify すると {} が返り、**黙って空の結果**になる。
1546
+ async function handle(req) {
1547
+ const { id, method, params } = req;
1548
+
1549
+ if (method === 'initialize') {
1550
+ return {
1551
+ jsonrpc: '2.0',
1552
+ id,
1553
+ result: {
1554
+ protocolVersion: (params && params.protocolVersion) || '2025-06-18',
1555
+ capabilities: { tools: {}, resources: {} },
1556
+ serverInfo: { name: 'banto', version: VERSION },
1557
+ // ★ここが「貼り付け」の代わり(2026-08-20)。
1558
+ // MCP の initialize は instructions を返せて、クライアントはそれを
1559
+ // システムプロンプトへ入れる。labor-law / tax-law MCP が実際にそうしている。
1560
+ // ★これが無いと、道具は配れてもルールは配れない=zipと貼り付けが要る。
1561
+ instructions: loadInstructions(),
1562
+ },
1563
+ };
1564
+ }
1565
+
1566
+ if (method === 'resources/list') {
1567
+ return { jsonrpc: '2.0', id, result: { resources: resources() } };
1568
+ }
1569
+
1570
+ if (method === 'resources/read') {
1571
+ const uri = params && params.uri;
1572
+ const r = resources().find((x) => x.uri === uri);
1573
+ if (!r) {
1574
+ return { jsonrpc: '2.0', id, error: { code: -32602, message: '見つかりません: ' + uri } };
1575
+ }
1576
+ let text;
1577
+ try { text = fs.readFileSync(r._path, 'utf8'); }
1578
+ catch (e) { text = '(読めませんでした: ' + e.message + ')'; }
1579
+ return {
1580
+ jsonrpc: '2.0', id,
1581
+ result: { contents: [{ uri, mimeType: 'text/markdown', text }] },
1582
+ };
1583
+ }
1584
+
1585
+ if (method === 'tools/list') {
1586
+ return { jsonrpc: '2.0', id, result: { tools: TOOLS } };
1587
+ }
1588
+
1589
+ if (method === 'tools/call') {
1590
+ const name = params && params.name;
1591
+ try {
1592
+ // ★鍵の確認。未設定なら素通り(=自社利用・導入前はいまの動作のまま)
1593
+ // 期限切れでも「手元の記録は読める」=止まるのは検査だけ
1594
+ const lic = LICENSE_STATE;
1595
+ if (lic && lic.allowed === false && !ALLOWED_WHEN_EXPIRED.has(name)) {
1596
+ return {
1597
+ jsonrpc: '2.0', id,
1598
+ result: { content: [{ type: 'text', text: JSON.stringify({
1599
+ verdict: '実行していません', reason: lic.status, message: lic.message,
1600
+ still_available: ['recall'], // ★引くのは残る
1601
+ }, null, 2) }] },
1602
+ };
1603
+ }
1604
+ const out = await callTool(name, params && params.arguments);
1605
+ if (lic && lic.message) out.license_notice = lic.message;
1606
+ return {
1607
+ jsonrpc: '2.0',
1608
+ id,
1609
+ result: { content: [{ type: 'text', text: JSON.stringify(out, null, 2) }] },
1610
+ };
1611
+ } catch (e) {
1612
+ return {
1613
+ jsonrpc: '2.0',
1614
+ id,
1615
+ result: {
1616
+ content: [{ type: 'text', text: 'エラー: ' + e.message }],
1617
+ isError: true,
1618
+ },
1619
+ };
1620
+ }
1621
+ }
1622
+
1623
+ if (method === 'ping') return { jsonrpc: '2.0', id, result: {} };
1624
+
1625
+ // 通知(idなし)は返さない
1626
+ if (id === undefined) return null;
1627
+
1628
+ return {
1629
+ jsonrpc: '2.0',
1630
+ id,
1631
+ error: { code: -32601, message: 'Method not found: ' + method },
1632
+ };
1633
+ }
1634
+
1635
+ // ★鍵の状態。起動時に1回だけ確かめ、以後は使い回す(1日1回の問い合わせは client 側で制御)
1636
+ // 確認に失敗しても止めない(通信不可で仕事ができなくなるのを避ける)
1637
+ let LICENSE_STATE = { allowed: true, status: 'unconfigured', message: '', daysLeft: null };
1638
+ (async () => {
1639
+ try {
1640
+ const { check } = require('./license/client.js');
1641
+ LICENSE_STATE = await check('0.1.0');
1642
+ } catch (_) {
1643
+ // license モジュールが無い構成でも動く
1644
+ }
1645
+ })();
1646
+
1647
+ // ★1つずつ順に処理する(2026-08-21)。
1648
+ // 待てる形(async)にした途端、**要求が同時に走る**ようになり、
1649
+ // 「入れる」の途中で「一覧」が走って『未導入』と返った。
1650
+ // 道具どうしが同じファイルを触るので、順番が崩れると結果が狂う。
1651
+ // ★待てるようにしたら、必ず「順番」も決める。
1652
+ let 順番 = Promise.resolve();
1653
+ readline.createInterface({ input: process.stdin }).on('line', (line) => {
1654
+ const s = line.trim();
1655
+ if (!s) return;
1656
+ let req;
1657
+ try {
1658
+ req = JSON.parse(s);
1659
+ } catch (_) {
1660
+ return;
1661
+ }
1662
+ 順番 = 順番.then(async () => {
1663
+ let res;
1664
+ try { res = await handle(req); }
1665
+ catch (e) {
1666
+ // ★黙って落ちない。待っている相手に必ず返す
1667
+ res = { jsonrpc: '2.0', id: req.id, result: { content: [{ type: 'text', text: 'エラー: ' + e.message }], isError: true } };
1668
+ }
1669
+ if (res) send(res);
1670
+ });
1671
+ });