@clubs.place/cli 0.1.17 → 0.1.23

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 (53) hide show
  1. package/PROVENANCE.json +55 -48
  2. package/README.md +11 -0
  3. package/bin/clubs-nippo +0 -5
  4. package/bin/clubs-nippo-web.mjs +0 -5
  5. package/bin/clubs-nippo.mjs +1 -526
  6. package/bin/clubs.mjs +643 -0
  7. package/docs/events-schema.md +1 -1
  8. package/package.json +5 -8
  9. package/src/aggregate.mjs +32 -5
  10. package/src/aggregate.slots.test.mjs +23 -0
  11. package/src/autorun.mjs +0 -5
  12. package/src/autorun.test.mjs +0 -5
  13. package/src/cards.mjs +0 -5
  14. package/src/collect.mjs +0 -5
  15. package/src/config.mjs +10 -6
  16. package/src/generators/claude.mjs +0 -5
  17. package/src/generators/cli.mjs +0 -5
  18. package/src/generators/codex.mjs +0 -5
  19. package/src/generators/copilot.mjs +0 -5
  20. package/src/generators/gemini.mjs +0 -5
  21. package/src/generators/openai-compat.mjs +0 -5
  22. package/src/generators/openrouter.mjs +0 -5
  23. package/src/history.mjs +2 -5
  24. package/src/ingest.mjs +0 -5
  25. package/src/lemma.docHash.test.mjs +0 -5
  26. package/src/lemma.mjs +0 -5
  27. package/src/mask.mjs +0 -5
  28. package/src/mcp.mjs +0 -5
  29. package/src/narrative.blockRepos.test.mjs +48 -0
  30. package/src/narrative.mjs +47 -8
  31. package/src/profiles.mjs +110 -0
  32. package/src/profiles.test.mjs +66 -0
  33. package/src/prompt.mjs +99 -24
  34. package/src/prompt.test.mjs +87 -20
  35. package/src/remote-config.mjs +5 -6
  36. package/src/render.mjs +1 -5
  37. package/src/routines.mjs +61 -0
  38. package/src/routines.test.mjs +29 -0
  39. package/src/send.mjs +0 -5
  40. package/src/settings.mjs +0 -5
  41. package/src/sources/claude-code.mjs +0 -5
  42. package/src/sources/codex-cli.mjs +0 -5
  43. package/src/sources/copilot-cli.mjs +0 -5
  44. package/src/sources/cowork.mjs +0 -5
  45. package/src/sources/events-file.mjs +0 -5
  46. package/src/sources/gemini-cli.mjs +0 -5
  47. package/src/sources/hermes.mjs +0 -5
  48. package/src/store.mjs +0 -5
  49. package/src/tasks.mjs +0 -5
  50. package/src/tool-name.mjs +0 -5
  51. package/web/assets/qr.js +0 -5
  52. package/web/assets/sidebar.js +0 -5
  53. package/web/assets/twemoji.js +0 -5
package/bin/clubs.mjs ADDED
@@ -0,0 +1,643 @@
1
+ #!/usr/bin/env node
2
+ import { writeFileSync, mkdirSync, readFileSync, existsSync } from "node:fs";
3
+ import { join, dirname } from "node:path";
4
+ import { platform, homedir } from "node:os";
5
+ import { execSync } from "node:child_process";
6
+ import { createInterface } from "node:readline";
7
+ import { loadConfig, APP_HOME, LOADED_ENV_KEYS } from "../src/config.mjs";
8
+ import {
9
+ CHILD_FLAG,
10
+ chooseInitHome,
11
+ listProfiles,
12
+ readEnvFile,
13
+ runProfiles,
14
+ } from "../src/profiles.mjs";
15
+ import { fileURLToPath } from "node:url";
16
+ import { collect } from "../src/collect.mjs";
17
+ import { generateNarrative } from "../src/narrative.mjs";
18
+ import { registerProof, documentHash } from "../src/lemma.mjs";
19
+ import { pushToClubs } from "../src/ingest.mjs";
20
+ import { renderHtml, buildSubject } from "../src/render.mjs";
21
+ import { sendMail } from "../src/send.mjs";
22
+ import { saveDraft } from "../src/store.mjs";
23
+ import { appendHistory, readHistory } from "../src/history.mjs";
24
+ import { evaluateCards } from "../src/cards.mjs";
25
+ import { fetchCollectorConfig } from "../src/remote-config.mjs";
26
+ import { tickCommand } from "../src/autorun.mjs";
27
+ import { localRoutines } from "../src/routines.mjs";
28
+
29
+ /** [Y/n] を聞く。非対話(パイプ)では既定 Yes(自動化を止めない)。 */
30
+ const promptYesNo = (question) =>
31
+ new Promise((resolve) => {
32
+ if (!process.stdin.isTTY) return resolve(true);
33
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
34
+ rl.question(`${question} `, (a) => {
35
+ rl.close();
36
+ resolve(!/^n/i.test(a.trim()));
37
+ });
38
+ });
39
+
40
+ /**
41
+ * 毎朝の自動実行を登録する。macOS=launchd / Windows=schtasks / それ以外=crontab。
42
+ * cron は PATH が細いので npx はフルパスで渡す。既存の clubs-nippo 登録は入れ替える(冪等)。
43
+ */
44
+ const registerAutorun = (hourlyLine, npx) => {
45
+ if (platform() === "darwin") {
46
+ const plistPath = join(homedir(), "Library", "LaunchAgents", "com.frame00.clubs-nippo.plist");
47
+ const path = `${dirname(process.execPath)}:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:${join(homedir(), ".local", "bin")}`;
48
+ const plist = `<?xml version="1.0" encoding="UTF-8"?>
49
+ <plist version="1.0"><dict>
50
+ <key>Label</key><string>com.frame00.clubs-nippo</string>
51
+ <key>ProgramArguments</key><array>${tickCommand(npx).map((a) => `<string>${a}</string>`).join("")}</array>
52
+ <key>EnvironmentVariables</key><dict><key>PATH</key><string>${path}</string></dict>
53
+ <key>StartCalendarInterval</key><dict><key>Minute</key><integer>0</integer></dict>
54
+ <key>StandardOutPath</key><string>${join(APP_HOME, "out", "cron.log")}</string>
55
+ <key>StandardErrorPath</key><string>${join(APP_HOME, "out", "cron.err.log")}</string>
56
+ </dict></plist>
57
+ `;
58
+ mkdirSync(dirname(plistPath), { recursive: true });
59
+ writeFileSync(plistPath, plist, "utf8");
60
+ try {
61
+ execSync(`launchctl unload ${JSON.stringify(plistPath)} 2>/dev/null`);
62
+ } catch {
63
+ /* 初回は未ロードで失敗する=無視 */
64
+ }
65
+ execSync(`launchctl load ${JSON.stringify(plistPath)}`);
66
+ return `launchd に登録しました(${plistPath})`;
67
+ }
68
+ if (platform() === "win32") {
69
+ return "Windows では自動実行を登録できません。WSL(Windows Subsystem for Linux)をお使いください。\n" +
70
+ " WSL 内で `clubs-nippo init` を実行すると crontab に登録されます。";
71
+ }
72
+ let current = "";
73
+ try {
74
+ current = execSync("crontab -l 2>/dev/null").toString();
75
+ } catch {
76
+ /* crontab 未設定 */
77
+ }
78
+ const kept = current.split("\n").filter((l) => l && !l.includes("clubs-nippo"));
79
+ execSync("crontab -", { input: [...kept, hourlyLine].join("\n") + "\n" });
80
+ return "crontab に登録しました";
81
+ };
82
+
83
+ const parseArgs = (argv) => {
84
+ const out = { _: [] };
85
+ for (const arg of argv) {
86
+ if (arg === "--dry-run") out.dryRun = true;
87
+ else if (arg === "--draft") out.draft = true;
88
+ else if (arg === "--no-llm") out.noLlm = true;
89
+ else if (arg.startsWith("--date=")) out.date = arg.slice(7);
90
+ else if (arg.startsWith("--out=")) out.out = arg.slice(6);
91
+ else if (arg.startsWith("--source=")) out.source = arg.slice(9);
92
+ else if (arg.startsWith("--events=")) out.eventsFile = arg.slice(9);
93
+ else if (arg.startsWith("--url=")) out.url = arg.slice(6);
94
+ else if (arg.startsWith("--token=")) out.token = arg.slice(8);
95
+ else if (arg.startsWith("--code=")) out.code = arg.slice(7);
96
+ else if (arg.startsWith("--agent-name=")) out.agentName = arg.slice(13);
97
+ else if (arg.startsWith("--owner-name=")) out.ownerName = arg.slice(13);
98
+ else if (arg.startsWith("--only=")) out.only = arg.slice(7);
99
+ else if (arg === "--help" || arg === "-h") out.help = true;
100
+ else out._.push(arg);
101
+ }
102
+ return out;
103
+ };
104
+
105
+ const HELP = `clubs-nippo — Claude Code の実行ログから前日ぶんの AI業務日報メールを作って送る
106
+
107
+ 使い方:
108
+ clubs-nippo init --url=… --code=…
109
+ 接続設定を書く(CLUBS の接続画面が発行する
110
+ 接続コードをトークンに引き換える・10分/1回限り)。
111
+ 設定と履歴は ~/.clubs-nippo に置かれる。
112
+ --token=(再発行モーダルの直接指定)
113
+ --source= --agent-name= --owner-name= --events= も受ける
114
+ clubs-nippo 前日ぶんを生成して EMAIL_TO に送信
115
+ clubs-nippo --dry-run 送信せず HTML をローカルに書き出すだけ
116
+ clubs-nippo --draft 生成して下書き保存だけ(登録も送信もしない)
117
+ 承認フローを使う場合はこれ。Web画面から承認すると
118
+ そこで初めて Lemma に登録される
119
+ clubs-nippo --date=2026-07-13 対象日を指定(既定=前日・JST)
120
+ clubs-nippo --date=… --only=claude-code
121
+ その収集元の設定だけ動かす(あとから作るとき)
122
+ ※ 別の AI をつなぐと ~/.clubs-nippo/agents/<ID>/ に並べて置き、毎時の
123
+ 自動実行と手動の実行が全部の AI の分を動かします
124
+ clubs-nippo --no-llm LLM を使わず集計だけで本文を組む
125
+ clubs-nippo --out=path.html HTML の出力先
126
+ clubs-nippo tick cron の毎時起動用。CLUBS の設定(配信時刻・
127
+ 収集元・生成器)を読み、時刻到達&本日未配信の
128
+ ときだけ配信する。設定はアプリ側が正——
129
+ crontab 行は init が表示する(npx のフルパス入り)
130
+ clubs-nippo collect 集計結果を JSON で表示(送信しない)
131
+ clubs-nippo backfill [日数] 実ログから過去ぶんの履歴を作る(既定14日)
132
+ clubs-nippo cards カードを評価して発行(送信はしない)
133
+ clubs-nippo cards --dry-run 評価するが発行しない。しきい値を試すときは必ずこちら
134
+ (達成カードは剥奪できないので、誤発行は取り消せない)
135
+ clubs-nippo mcp MCP サーバー(stdio)として起動。AIクライアント
136
+ (Claude Code / Cursor 等)に登録すると、対話の中で
137
+ CLUBS への登録(clubs_register)と疎通確認
138
+ (clubs_link_status)ができる。例:
139
+ claude mcp add clubs -- npx -y clubs-nippo@latest mcp
140
+
141
+ 収集元:
142
+ --source=claude-code この端末の Claude Code セッション記録(既定)
143
+ --source=cowork Cowork(Claude のもう1つの面。claude-code と合算可)
144
+ --source=codex-cli Codex CLI(~/.codex/sessions)
145
+ --source=gemini-cli Gemini CLI(指示は logs.json、ツール実行は telemetry
146
+ 有効時のみ=GEMINI_TELEMETRY_FILE)
147
+ --source=copilot-cli GitHub Copilot CLI(~/.copilot・実験的対応)
148
+ --source=events-file --events=path.jsonl
149
+ エージェントが書き出した実行記録(Hermes など)
150
+ 形式は docs/events-schema.md を参照
151
+
152
+ 環境変数は .env か通常の環境変数で渡す。README.md を参照。`;
153
+
154
+ /** 収集器が読める集める元(collector-config の sources のうち、そのまま渡せるもの)。
155
+ * hermes は state.db を直読みする(events-file と違い EVENTS_FILE の外部設定が
156
+ * 要らない)ので、そのまま渡してよい。 */
157
+ const SOURCES = new Set([
158
+ "claude-code",
159
+ "cowork",
160
+ "events-file",
161
+ "codex-cli",
162
+ "gemini-cli",
163
+ "copilot-cli",
164
+ "hermes",
165
+ ]);
166
+
167
+ const main = async () => {
168
+ const argv = parseArgs(process.argv.slice(2));
169
+ if (argv.help) {
170
+ console.log(HELP);
171
+ return;
172
+ }
173
+
174
+ // ── mcp: MCP サーバー(stdio)。stdout はプロトコル専用になる ──────
175
+ if (argv._[0] === "mcp") {
176
+ const { runMcpServer } = await import("../src/mcp.mjs");
177
+ await runMcpServer();
178
+ return;
179
+ }
180
+
181
+ // ── init: 接続画面が発行するコマンド1行で .env を書く(npx 導入の入口)──
182
+ // 対話にしない。値は CLUBS の接続画面が持っているので、コピペ1発で終わるべき。
183
+ if (argv._[0] === "init") {
184
+ // 接続コード方式(v1.4 §3.2): 画面はトークンを見せず、10分・1回限りの
185
+ // コードだけを出す。ここで redeem してトークンに引き換える。
186
+ // --token 直接指定(従来・再発行モーダル)とは排他で、コードを優先する。
187
+ if (argv.code) {
188
+ if (!argv.url) {
189
+ console.log("使い方: clubs-nippo init --url=<取込URL> --code=<接続コード>");
190
+ process.exit(1);
191
+ }
192
+ const redeemUrl = new URL("/api/nippo/agents/redeem", argv.url).toString();
193
+ let res;
194
+ try {
195
+ res = await fetch(redeemUrl, {
196
+ method: "POST",
197
+ headers: { "content-type": "application/json" },
198
+ body: JSON.stringify({ code: argv.code }),
199
+ });
200
+ } catch (e) {
201
+ console.log(`接続先に届きませんでした(${e?.message ?? e})。URL を確認してください: ${redeemUrl}`);
202
+ process.exit(1);
203
+ }
204
+ const body = await res.json().catch(() => ({}));
205
+ if (!res.ok || !body.token) {
206
+ // コードは 1 回限り・TTL 10 分。失効はエラーではなく「やり直す」導線。
207
+ console.log(
208
+ body.error ??
209
+ "接続コードが無効か期限切れです。CLUBS の接続画面(体の詳細)で再発行してください。",
210
+ );
211
+ process.exit(1);
212
+ }
213
+ argv.token = body.token;
214
+ argv.agentId = body.agentId;
215
+ console.log("✓ 接続コードをトークンに引き換えました。");
216
+ }
217
+ const updates = Object.fromEntries(
218
+ Object.entries({
219
+ NIPPO_INGEST_URL: argv.url,
220
+ NIPPO_INGEST_TOKEN: argv.token,
221
+ SOURCE: argv.source,
222
+ EVENTS_FILE: argv.eventsFile,
223
+ AGENT_NAME: argv.agentName,
224
+ OWNER_NAME: argv.ownerName,
225
+ }).filter(([, v]) => v),
226
+ );
227
+ if (Object.keys(updates).length === 0) {
228
+ console.log(
229
+ "使い方: clubs-nippo init --url=<取込URL> --code=<接続コード> [--source=…]\n" +
230
+ "コマンドは CLUBS の接続画面からコピーできます。",
231
+ );
232
+ process.exit(1);
233
+ }
234
+ /* 1台で複数の AI(profiles.mjs)。今の設定の鍵が別の AI のものなら、上書きせず
235
+ agents/<ID>/ に並べる(10/8 に Codex をつないだら Claude Code が上書きされた)。
236
+ 今の鍵が同じ AI か・もう使えないなら、今までどおり上書き。 */
237
+ const rootEnv = readEnvFile(join(APP_HOME, ".env"));
238
+ /* 接続ページのコマンドは --source を付けない。新しい鍵で CLUBS に聞いて埋める
239
+ (並べた設定に集める元が無いと、手動の実行が既定の claude-code で動く)。 */
240
+ if (argv.code && !argv.source) {
241
+ const n = await fetchCollectorConfig({ ingestUrl: argv.url, ingestToken: argv.token });
242
+ const known = (n.ok ? (n.config.sources ?? []) : []).filter((x) => SOURCES.has(x));
243
+ if (known.length) argv.source = updates.SOURCE = [...new Set(known)].join(",");
244
+ }
245
+ let rootIs = { status: "unknown" };
246
+ if (rootEnv.NIPPO_INGEST_TOKEN && argv.agentId && !rootEnv.AGENT_ID) {
247
+ const r = await fetchCollectorConfig({
248
+ ingestUrl: rootEnv.NIPPO_INGEST_URL ?? argv.url,
249
+ ingestToken: rootEnv.NIPPO_INGEST_TOKEN,
250
+ });
251
+ if (r.ok && r.config.agentId) rootIs = { status: "ok", agentId: r.config.agentId };
252
+ else if (r.status === 401 || r.status === 403) rootIs = { status: "dead" };
253
+ }
254
+ const home = argv.code
255
+ ? chooseInitHome({
256
+ home: APP_HOME,
257
+ root: rootEnv,
258
+ rootIs,
259
+ newAgent: argv.agentId,
260
+ newSource: argv.source,
261
+ profiles: listProfiles(APP_HOME),
262
+ })
263
+ : APP_HOME;
264
+ if (argv.agentId) updates.AGENT_ID = argv.agentId;
265
+ if (home !== APP_HOME) {
266
+ // 並べた側は取込先も持つ(子プロセスは APP_HOME の .env を読まない)。
267
+ updates.NIPPO_INGEST_URL ??= rootEnv.NIPPO_INGEST_URL;
268
+ console.log(
269
+ existsSync(join(home, ".env"))
270
+ ? `並べてあるこの AI の設定を更新します: ${home}`
271
+ : `別の AI として並べて置きます(今の設定はそのまま): ${home}`,
272
+ );
273
+ }
274
+ mkdirSync(join(home, "out"), { recursive: true });
275
+ const envPath = join(home, ".env");
276
+ // 既存の .env は生かし、渡されたキーだけ書き換える。
277
+ // トークン再発行や収集元変更で再実行しても、メール設定などが消えない。
278
+ const lines = existsSync(envPath)
279
+ ? readFileSync(envPath, "utf8").replace(/\n$/, "").split("\n")
280
+ : ["# clubs-nippo の設定。キーの一覧は https://www.npmjs.com/package/clubs-nippo を参照。"];
281
+ for (const [key, value] of Object.entries(updates)) {
282
+ const row = `${key}=${value}`;
283
+ const idx = lines.findIndex((l) => l.match(new RegExp(`^\\s*${key}\\s*=`)));
284
+ if (idx >= 0) lines[idx] = row;
285
+ else lines.push(row);
286
+ }
287
+ writeFileSync(envPath, lines.join("\n") + "\n", "utf8");
288
+ console.log(`設定を書きました: ${envPath}`);
289
+ console.log(` ${Object.keys(updates).join(", ")}`);
290
+ // cron は PATH が細く npx が見つからないことが多い。実際に動いている node の
291
+ // 隣の npx をフルパスで使う(この行をそのまま登録すれば動く)。
292
+ const npx = join(dirname(process.execPath), "npx");
293
+ const hourlyLine = `0 * * * * ${tickCommand(npx).join(" ")} >> ${join(APP_HOME, "out", "cron.log")} 2>&1`;
294
+ console.log("");
295
+ if (home !== APP_HOME) {
296
+ // 自動実行は1本のまま。毎時の tick が並べた AI の分も動かす。
297
+ console.log("毎朝の自動実行は今のものがこの AI の分も動かします(登録し直しは不要)。");
298
+ console.log(`今すぐ試す(前日分を発行): CLUBS_NIPPO_HOME=${home} npx clubs-nippo@latest`);
299
+ return;
300
+ }
301
+ const yes = await promptYesNo("毎朝の自動実行を登録しますか? [Y/n]");
302
+ if (yes) {
303
+ try {
304
+ const msg = registerAutorun(hourlyLine, npx);
305
+ console.log(`✓ ${msg}。明日の朝、最初の日報が届きます。`);
306
+ } catch (e) {
307
+ console.log(`自動登録に失敗しました(${e?.message ?? e})。うまくいかない場合は、この行を crontab -e に手で追記してください:`);
308
+ console.log(` ${hourlyLine}`);
309
+ }
310
+ } else {
311
+ console.log("自動登録はスキップしました。あとで登録するには、この行を crontab -e に追記:");
312
+ console.log(` ${hourlyLine}`);
313
+ }
314
+ console.log("");
315
+ console.log("今すぐ試す(前日分を発行): npx clubs-nippo@latest");
316
+ return;
317
+ }
318
+
319
+ /* 申し送り済みの用件(未完了・原文=同じものは書かせない)と、確定した代表表記。
320
+ どちらも CLUBS 側が持っているので collector-config から受け取り、生成
321
+ プロンプトに載せる。取れなくても生成は続ける(言い回しの揺れが残るだけで、
322
+ 日報が出ないよりよい)。 */
323
+ let handedOff = [];
324
+ let terms = [];
325
+ let cases = [];
326
+ /* きのう CLUBS で決まったこと(もちの次の一手の材料・本人 2026-10-04)。 */
327
+ let decided = [];
328
+ /* この4週の CLUBS の MCP のくり返し(日曜ぶん=月曜朝だけ CLUBS が返す)。 */
329
+ let clubRoutines = [];
330
+
331
+ // ── tick: cron の毎時起動から呼ばれる(自動化レイヤ §3.3)─────────
332
+ // 配信ルールの正は CLUBS 設定。毎ティックで collector-config を読み、
333
+ // 「有効 ∧ 時刻到達 ∧ 本日未配信(サーバ側 draft の有無)」のときだけ発火する。
334
+ // 8時→9時に変えたら翌8時ティックはスキップ・9時ティックで発火。
335
+ // 取得できないティックは何もしない(次のティックで取り戻す)。
336
+ if (argv._[0] === "tick") {
337
+ const r = await fetchCollectorConfig();
338
+ if (!r.ok) {
339
+ console.log(`[tick] 設定を取得できませんでした(${r.reason})。次のティックで再試行します。`);
340
+ return;
341
+ }
342
+ const c = r.config;
343
+ if (!c.due) {
344
+ const why = !c.schedule?.enabled
345
+ ? "配信は停止中"
346
+ : c.delivered
347
+ ? `本日分(${c.reportDate})は配信済み`
348
+ : `配信時刻 ${c.schedule.time}(${c.schedule.tz})前`;
349
+ console.log(`[tick] 発火しません — ${why}(現在 ${c.now?.time})`);
350
+ return;
351
+ }
352
+
353
+ // アプリの設定で実行内容を上書きする(アプリ=設定の正・秘密はローカルのまま)。
354
+ argv.date = c.reportDate;
355
+ if (Array.isArray(c.handedOff)) handedOff = c.handedOff;
356
+ if (Array.isArray(c.terms)) terms = c.terms;
357
+ if (Array.isArray(c.cases)) cases = c.cases;
358
+ if (Array.isArray(c.decided)) decided = c.decided;
359
+ if (Array.isArray(c.routines)) clubRoutines = c.routines;
360
+ const ADAPTERS = SOURCES;
361
+ const mapped = [
362
+ ...new Set(
363
+ (c.sources ?? []).map((s) => (ADAPTERS.has(s) ? s : "events-file")),
364
+ ),
365
+ ];
366
+ if (mapped.length > 0) argv.source = mapped.join(",");
367
+ if (
368
+ mapped.includes("events-file") &&
369
+ !process.env.EVENTS_FILE &&
370
+ !argv.eventsFile
371
+ ) {
372
+ // 収集できないまま毎時 exit 1 を繰り返さない(cron ノイズ防止)。
373
+ console.log(
374
+ `[tick] この接続(${c.sources?.join(",") ?? ""})はローカルの実行記録 ` +
375
+ `EVENTS_FILE の設定が必要です。.env に EVENTS_FILE=path.jsonl を設定して ` +
376
+ `ください(docs/events-schema.md)。今回は何もしません。`,
377
+ );
378
+ return;
379
+ }
380
+ if (mapped.includes("events-file") && c.agentName) {
381
+ // events-file 系(Hermes/OpenClaw/その他)は名義を外から与える。
382
+ // claude-code 等のブランド収集元は帰属の規律で名義固定(上書きしない)。
383
+ process.env.AGENT_NAME = c.agentName;
384
+ }
385
+ if (c.generator?.backend === "openrouter") {
386
+ process.env.GENERATOR = "openrouter";
387
+ if (c.generator.model) process.env.OPENROUTER_MODEL = c.generator.model;
388
+ } else if (c.generator?.backend === "openai-compat") {
389
+ // base URL / model は接続設定(control plane)から。API キーはローカル .env のまま。
390
+ process.env.GENERATOR = "openai-compat";
391
+ if (c.generator.baseUrl) process.env.BACKEND_BASE_URL = c.generator.baseUrl;
392
+ if (c.generator.model) process.env.BACKEND_MODEL = c.generator.model;
393
+ } else if (c.generator?.backend === "claude-cli") {
394
+ process.env.GENERATOR = "claude";
395
+ }
396
+ console.log(
397
+ `[tick] 発火: 対象日 ${c.reportDate} / source=${argv.source ?? "(ローカル設定)"} / generator=${process.env.GENERATOR ?? "claude"}`,
398
+ );
399
+ argv.tick = true; // 取込失敗時の早期打ち切り・メール未設定スキップに使う
400
+ argv._ = []; // 以降は通常パイプライン(収集→生成→登録→取込→送信→カード)
401
+ }
402
+
403
+ /* --only=<収集元>(あとから作るとき・例 claude-code):その収集元の設定だけ動かす。
404
+ 並べた AI も同じ引数で動くので、それぞれが自分の SOURCE で判断する。 */
405
+ if (argv.only && !argv._[0]) {
406
+ const mine = String(argv.source ?? process.env.SOURCE ?? "claude-code")
407
+ .split(",")
408
+ .map((x) => x.trim());
409
+ if (!mine.includes(argv.only)) return;
410
+ }
411
+
412
+ const config = loadConfig(argv);
413
+ const activity = await collect(config);
414
+
415
+ // 実ログから過去ぶんの履歴を作り直す。カードの「連続」「累計」条件は系列が要る。
416
+ if (argv._[0] === "backfill") {
417
+ const days = Number(argv._[1] ?? 14);
418
+ const rows = [];
419
+ for (let i = days; i >= 0; i -= 1) {
420
+ const d = new Date(Date.now() + config.tzOffsetMin * 60_000 - i * 86_400_000)
421
+ .toISOString()
422
+ .slice(0, 10);
423
+ const a = await collect({ ...config, date: d });
424
+ if ((a.aggregates?.totalCalls ?? 0) === 0) continue; // 稼働していない日は積まない
425
+ appendHistory(a);
426
+ rows.push(`${d}: 実行 ${a.aggregates?.totalCalls ?? 0} / 失敗 ${a.aggregates?.failedCalls ?? 0} / ${a.aggregates?.activeHours ?? 0}h`);
427
+ }
428
+ console.log(`履歴に ${rows.length} 日ぶんを積みました(out/history.jsonl)`);
429
+ for (const r of rows) console.log(" " + r);
430
+ return;
431
+ }
432
+
433
+ if (argv._[0] === "collect") {
434
+ console.log(JSON.stringify({ date: activity.date, ...activity.aggregates }, null, 2));
435
+ return;
436
+ }
437
+
438
+ if (argv._[0] === "cards") {
439
+ appendHistory(activity);
440
+ // --dry-run なら保存しない。しきい値を変えて試すときは必ずこちら。
441
+ // 発行してしまうと、達成カードは剥奪できないので取り消せない。
442
+ const r = evaluateCards({
443
+ history: readHistory(),
444
+ date: activity.date,
445
+ config,
446
+ dryRun: config.dryRun,
447
+ });
448
+ if (r.dryRun) console.log("(--dry-run: 保存していません)");
449
+ console.log(`発行: ${r.issued.map((c) => `${c.name}(${c.rarity})`).join(", ") || "なし"}`);
450
+ console.log(`進行中: ${r.progress.map((p) => `${p.name} ${p.progress}%`).join(", ") || "なし"}`);
451
+ console.log(`判定不可: ${r.unavailable.map((u) => `${u.id}(${u.reason})`).join(", ") || "なし"}`);
452
+ console.log(`保有: 達成 ${r.data.achievements.length} / 称号 ${r.data.titles.length} / 解放可能な権限 ${r.data.permissionsUnlockable.length}`);
453
+ return;
454
+ }
455
+
456
+ console.log(
457
+ `[1/5] 収集: ${activity.date} / ${activity.agentName}(${activity.source}) — 実行 ${activity.aggregates?.totalCalls ?? 0}回 / 成功率 ${activity.aggregates?.successRate ?? "-"}% / 実働 ${activity.aggregates?.activeHours ?? 0}h`,
458
+ );
459
+
460
+ /* 手動実行(tick 以外)でも申し送り済みの用件・確定した表記・案件名を拾う。tick で既に
461
+ 取れていれば引き直さない。取れなくても生成は続ける。 */
462
+ if (
463
+ handedOff.length === 0 &&
464
+ terms.length === 0 &&
465
+ cases.length === 0 &&
466
+ decided.length === 0 &&
467
+ config.ingestUrl &&
468
+ config.ingestToken
469
+ ) {
470
+ const r = await fetchCollectorConfig();
471
+ if (r.ok && Array.isArray(r.config?.handedOff)) handedOff = r.config.handedOff;
472
+ if (r.ok && Array.isArray(r.config?.terms)) terms = r.config.terms;
473
+ if (r.ok && Array.isArray(r.config?.cases)) cases = r.config.cases;
474
+ if (r.ok && Array.isArray(r.config?.decided)) decided = r.config.decided;
475
+ if (r.ok && Array.isArray(r.config?.routines)) clubRoutines = r.config.routines;
476
+ }
477
+ if (handedOff.length > 0) {
478
+ console.log(`[1/5] 申し送り済みの用件 ${handedOff.length}件 を生成に渡します(くり返さない)`);
479
+ }
480
+ if (terms.length > 0) {
481
+ console.log(`[1/5] 確定した表記 ${terms.length}件 を生成に渡します`);
482
+ }
483
+ /* くり返しからルールの候補を出すのは週に1回(日曜ぶん=月曜朝の日報づくり)。
484
+ 手元の履歴(案件・道具)はここで数える。 */
485
+ const weekly = new Date(`${activity.date}T00:00:00Z`).getUTCDay() === 0;
486
+ const routines = weekly ? { clubs: clubRoutines, local: localRoutines(activity.date) } : null;
487
+ if (routines) {
488
+ console.log(
489
+ `[1/5] この4週のくり返し(CLUBS ${routines.clubs.length}・プロジェクト ${routines.local.projects.length}・道具 ${routines.local.tools.length})を生成に渡します(ルールの候補)`,
490
+ );
491
+ }
492
+ if (decided.length > 0) {
493
+ console.log(`[1/5] きのう決まったこと ${decided.length}件 を生成に渡します(次の一手)`);
494
+ }
495
+
496
+ const narrative = await generateNarrative(activity, {
497
+ ...config,
498
+ handedOff,
499
+ terms,
500
+ cases,
501
+ decided,
502
+ routines,
503
+ });
504
+ console.log(`[2/5] 本文生成: ${narrative.generator} — ${narrative.workBlocks.length} ブロック`);
505
+
506
+ // 承認フロー: 生成した時点でいったん下書きとして保存する。
507
+ // 登録も送信もここではしない。承認して初めて記録され、確定する。
508
+ const draft = saveDraft({ activity, narrative });
509
+ if (argv.draft) {
510
+ console.log(`[3/3] 下書き保存: out/${draft.date}.draft.json`);
511
+ console.log(` 承認は Web 画面から: node bin/clubs-nippo-web.mjs`);
512
+ return;
513
+ }
514
+
515
+ // ingest モード(CLUBS 接続済み)では証明もメールもサーバ側の仕事
516
+ // (接続画面デバッグリスト §-1)。ユーザーがローカルに置く鍵は OpenRouter だけで、
517
+ // LEMMA_API_KEY / EMAIL_API_KEY が無くてもエラーにならず正常終了する。
518
+ const ingestMode = Boolean(config.ingestUrl && config.ingestToken);
519
+
520
+ const proof = ingestMode
521
+ ? {
522
+ status: "pending",
523
+ label: "照合予定",
524
+ docHash: documentHash(activity, narrative),
525
+ reason: "サーバ側で照合(取込時に CLUBS が登録)",
526
+ verifyUrl: null,
527
+ }
528
+ : await registerProof(activity, narrative, config);
529
+ console.log(
530
+ ingestMode
531
+ ? `[3/5] 証明: サーバ側で照合します (${proof.docHash.slice(0, 12)}…)`
532
+ : proof.status === "verified"
533
+ ? `[3/5] 証明: ✓ ログ照合済み (${proof.docHash.slice(0, 12)}…)`
534
+ : `[3/5] 証明: 照合予定 — ${proof.reason}`,
535
+ );
536
+
537
+ // web コンソール(clubsx)へ本文ごと取り込ませる。未設定・失敗でもここで止めない。
538
+ // --dry-run はサーバにも送らない: ingest 受信でサーバ側の Lemma 実登録・
539
+ // 日報メール実送信(当日分の送信権の消費)が走るため、dry のつもりが実行為になる。
540
+ const ingest = config.dryRun
541
+ ? { ok: false, reason: "--dry-run のため取込しません" }
542
+ : await pushToClubs(activity, narrative, proof, config);
543
+ console.log(
544
+ ingest.quiet
545
+ ? `[web] コンソール取込: ✓ 受け取られました(稼働なしの日として畳まれます)`
546
+ : ingest.ok
547
+ ? `[web] コンソール取込: ✓ 送信しました`
548
+ : `[web] コンソール取込: スキップ — ${ingest.reason}`,
549
+ );
550
+
551
+ /* 稼働のなかった日(土日など)はサーバが日報にしない。ここで止める——
552
+ この先は描画・メール・カードで、どれも「日報が在ること」が前提になる。
553
+ 履歴だけは積む(月次のグラフと連続日数の判定は、動かなかった日も系列として
554
+ 要る)。tick の冪等はサーバ側の印が持つので、次のティックでは発火しない。 */
555
+ if (ingest.quiet) {
556
+ console.log(
557
+ `[4/5] 稼働なし: ${activity.date} は日報にしません(メールも送りません)。`,
558
+ );
559
+ appendHistory(activity);
560
+ return;
561
+ }
562
+
563
+ // tick モードの冪等はサーバ側 draft(=取込の成功)が門。取込に失敗したまま
564
+ // 先へ進むと、メールだけ送られて delivered が立たず、次のティックで丸ごと
565
+ // 再実行(二重メール・生成し直しで docHash が変わり Lemma 二重登録)になる。
566
+ // ここで止めて、次のティックに任せる。
567
+ if (argv.tick && !ingest.ok) {
568
+ console.log(`[tick] 取込に失敗したため、ここで止めます(次のティックで再試行)。`);
569
+ return;
570
+ }
571
+
572
+ const html = renderHtml({ activity, narrative, proof, config });
573
+ const subject = buildSubject({ activity, config, proof });
574
+
575
+ const outDir = join(APP_HOME, "out");
576
+ mkdirSync(outDir, { recursive: true });
577
+ const outPath = argv.out ?? join(outDir, `nippo-${activity.date}.html`);
578
+ writeFileSync(outPath, html, "utf8");
579
+ console.log(`[4/5] 描画: ${outPath}`);
580
+
581
+ if (config.dryRun) {
582
+ console.log(`[5/5] --dry-run のため送信しません。件名: ${subject}`);
583
+ return;
584
+ }
585
+
586
+ if (ingestMode) {
587
+ // ローカル送信はしない(EMAIL_API_KEY 不要)。メールは CLUBS がサーバ側で
588
+ // 送る(取込時・本人確認済みアドレス宛)。履歴・シール評価は続ける。
589
+ // 取込に失敗していたら証明もメールもどこにも届いていない——成功に見せない
590
+ //(tick は上で早期 return 済み=ここに来る失敗は手動実行)。
591
+ if (ingest.ok) {
592
+ console.log(`[5/6] 送信: メールは CLUBS からお届けします(ローカル送信なし)`);
593
+ } else {
594
+ console.log(
595
+ `[5/6] 送信: なし — 取込に失敗したため、証明もメールも届いていません。時間をおいて再実行してください。`,
596
+ );
597
+ process.exitCode = 1;
598
+ }
599
+ } else {
600
+ const sent = await sendMail({ subject, html, config });
601
+ console.log(`[5/6] 送信: ${sent.to.join(", ")} (id: ${sent.id})`);
602
+ }
603
+
604
+ // 集計を履歴に積み、カードを評価する。
605
+ // 履歴が要るのは「30日連続」のような条件が系列でしか判定できないため。
606
+ appendHistory(activity);
607
+ const cards = evaluateCards({ history: readHistory(), date: activity.date, config });
608
+ console.log(
609
+ cards.issued.length > 0
610
+ ? `[6/6] カード: ${cards.issued.map((c) => `${c.name}(${c.rarity})`).join(", ")} を発行`
611
+ : `[6/6] カード: 発行なし` +
612
+ (cards.progress.length
613
+ ? `(進行中: ${cards.progress.map((p) => `${p.name} ${p.progress}%`).join(", ")})`
614
+ : ""),
615
+ );
616
+ if (cards.unavailable.length > 0) {
617
+ console.log(
618
+ ` 判定できないカード ${cards.unavailable.length} 枚: ${cards.unavailable.map((u) => u.id).join(", ")}`,
619
+ );
620
+ }
621
+ };
622
+
623
+ /* 並べた AI(profiles.mjs)の分も動かす。毎時の tick と、手動の日報づくり
624
+ (サブコマンドなし・--date=… など)だけ。子プロセスは回さない。APP_HOME の分が
625
+ 失敗しても、並べた分は動かす。 */
626
+ const runAgentsToo = async () => {
627
+ if (process.env[CHILD_FLAG]) return;
628
+ const argv = parseArgs(process.argv.slice(2));
629
+ if (argv.help || !(argv._[0] === undefined || argv._[0] === "tick")) return;
630
+ await runProfiles({
631
+ home: APP_HOME,
632
+ args: process.argv.slice(2),
633
+ loadedKeys: LOADED_ENV_KEYS,
634
+ script: fileURLToPath(import.meta.url),
635
+ });
636
+ };
637
+
638
+ main()
639
+ .catch((e) => {
640
+ console.error(`エラー: ${e.message}`);
641
+ process.exitCode = 1;
642
+ })
643
+ .then(runAgentsToo);
@@ -51,7 +51,7 @@ anthropic/claude-sonnet-4.5 ×3 · deepseek/deepseek-v3 ×3 · openai/gpt-5 ×1
51
51
  ## 使い方
52
52
 
53
53
  ```bash
54
- node bin/clubs-nippo.mjs \
54
+ node bin/clubs.mjs \
55
55
  --source=events-file \
56
56
  --events=/path/to/hermes-events.jsonl \
57
57
  --date=2026-07-13 \