@clubs.place/cli 0.0.0-stage → 0.1.18

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 (70) hide show
  1. package/.env.example +88 -0
  2. package/LICENSE +21 -0
  3. package/PROVENANCE.json +77 -0
  4. package/README.md +70 -2
  5. package/bin/clubs-nippo +7 -0
  6. package/bin/clubs-nippo-web.mjs +338 -0
  7. package/bin/clubs-nippo.mjs +527 -0
  8. package/docs/events-schema.md +76 -0
  9. package/examples/hermes-events.sample.jsonl +8 -0
  10. package/package.json +44 -4
  11. package/src/aggregate.mjs +105 -0
  12. package/src/autorun.mjs +25 -0
  13. package/src/autorun.test.mjs +43 -0
  14. package/src/cards.mjs +406 -0
  15. package/src/collect.mjs +111 -0
  16. package/src/config.mjs +173 -0
  17. package/src/generators/claude.mjs +31 -0
  18. package/src/generators/cli.mjs +81 -0
  19. package/src/generators/codex.mjs +28 -0
  20. package/src/generators/copilot.mjs +21 -0
  21. package/src/generators/gemini.mjs +20 -0
  22. package/src/generators/openai-compat.mjs +79 -0
  23. package/src/generators/openrouter.mjs +65 -0
  24. package/src/history.mjs +66 -0
  25. package/src/ingest.mjs +78 -0
  26. package/src/lemma.docHash.test.mjs +37 -0
  27. package/src/lemma.mjs +303 -0
  28. package/src/mask.mjs +45 -0
  29. package/src/mcp.mjs +269 -0
  30. package/src/narrative.mjs +191 -0
  31. package/src/prompt.mjs +184 -0
  32. package/src/prompt.test.mjs +146 -0
  33. package/src/remote-config.mjs +72 -0
  34. package/src/render.mjs +467 -0
  35. package/src/send.mjs +36 -0
  36. package/src/settings.mjs +54 -0
  37. package/src/sources/claude-code.mjs +174 -0
  38. package/src/sources/codex-cli.mjs +182 -0
  39. package/src/sources/copilot-cli.mjs +125 -0
  40. package/src/sources/cowork.mjs +193 -0
  41. package/src/sources/events-file.mjs +120 -0
  42. package/src/sources/gemini-cli.mjs +128 -0
  43. package/src/sources/hermes.mjs +427 -0
  44. package/src/store.mjs +227 -0
  45. package/src/tasks.mjs +229 -0
  46. package/src/tool-name.mjs +28 -0
  47. package/templates/report-v3.reference.html +162 -0
  48. package/web/assets/logo-clubs-white.png +0 -0
  49. package/web/assets/logo-clubs-white.svg +7 -0
  50. package/web/assets/logo-clubs-white.svg.tmp +30 -0
  51. package/web/assets/logo-clubs.svg +30 -0
  52. package/web/assets/qr.js +411 -0
  53. package/web/assets/sidebar.js +154 -0
  54. package/web/assets/twemoji.js +34 -0
  55. package/web/cards/achievements.html +230 -0
  56. package/web/cards/catalog.json +217 -0
  57. package/web/email/hero-v3.reference.html +123 -0
  58. package/web/home/index.html +269 -0
  59. package/web/preview.json +301 -0
  60. package/web/qr-selftest.html +46 -0
  61. package/web/reports/edit.html +255 -0
  62. package/web/reports/edit.reference.html +257 -0
  63. package/web/reports/index.html +196 -0
  64. package/web/settings/index.html +219 -0
  65. package/web/share/cards-reference.html +228 -0
  66. package/web/share/passport.html +214 -0
  67. package/web/share/teianbako.html +177 -0
  68. package/web/signup/index.html +326 -0
  69. package/web/tasks/index.html +229 -0
  70. package/web/verify/index.html +248 -0
@@ -0,0 +1,191 @@
1
+ // clubs-nippo — AI業務日報 (CLUBS)
2
+ // Copyright (c) 2026 FRAME00 Inc.
3
+ // SPDX-License-Identifier: MIT
4
+ // https://github.com/frame00/clubs-nippo
5
+
6
+ import { buildPrompt } from "./prompt.mjs";
7
+ import claudeGenerator from "./generators/claude.mjs";
8
+ import codexGenerator from "./generators/codex.mjs";
9
+ import geminiGenerator from "./generators/gemini.mjs";
10
+ import copilotGenerator from "./generators/copilot.mjs";
11
+ import openrouterGenerator from "./generators/openrouter.mjs";
12
+ import openaiCompatGenerator from "./generators/openai-compat.mjs";
13
+
14
+ const GENERATORS = {
15
+ [claudeGenerator.id]: claudeGenerator,
16
+ [codexGenerator.id]: codexGenerator,
17
+ [geminiGenerator.id]: geminiGenerator,
18
+ [copilotGenerator.id]: copilotGenerator,
19
+ [openrouterGenerator.id]: openrouterGenerator,
20
+ [openaiCompatGenerator.id]: openaiCompatGenerator,
21
+ };
22
+
23
+ /** ```json ... ``` や前置きが混ざっても、最初の { から最後の } までを取り出す。 */
24
+ const extractJson = (raw) => {
25
+ const start = raw.indexOf("{");
26
+ const end = raw.lastIndexOf("}");
27
+ if (start === -1 || end === -1 || end <= start) return null;
28
+ try {
29
+ return JSON.parse(raw.slice(start, end + 1));
30
+ } catch {
31
+ return null;
32
+ }
33
+ };
34
+
35
+
36
+ const UNSET_PROJECT = "(案件未設定)";
37
+
38
+ /** イベント列から「何に触れたか」を数件ひろう。要点推測の手がかりになる。 */
39
+ const representativeTargets = (list, limit = 4) => {
40
+ const seen = new Set();
41
+ const out = [];
42
+ for (const e of list) {
43
+ const s = String(e.summary ?? "")
44
+ .replace(/\s+/g, " ")
45
+ .trim();
46
+ if (s && !seen.has(s)) {
47
+ seen.add(s);
48
+ // コードポイント単位で丸める(.slice は UTF-16 単位でサロゲートを割り、
49
+ // 絵文字・稀CJK が末尾で欠けると � になるため)。
50
+ const chars = [...s];
51
+ out.push(chars.length > 44 ? `${chars.slice(0, 44).join("")}…` : s);
52
+ }
53
+ if (out.length >= limit) break;
54
+ }
55
+ return out;
56
+ };
57
+
58
+ /**
59
+ * LLM を使わない/失敗した時の決定的な本文。ログの集計だけで組み立てる。
60
+ *
61
+ * reason を渡すと「本文生成が外れた」ことを日報本文(selfNote)と生成ラベルの
62
+ * 両方に出す。黙って集計値だけの本文を届けると、受け取った側は劣化に気づけない
63
+ * ——「日報は届いたが中身は空回り」を検知可能にするための警告。
64
+ * 意図的に LLM を使わない設定(GENERATOR=none 等)では reason を渡さない。
65
+ */
66
+ /** 生成器の種類に応じた、ユーザー向けのセットアップ案内。 */
67
+ const setupHelp = (generatorId) => {
68
+ switch (generatorId) {
69
+ case "claude":
70
+ return "【直し方】お使いの端末に Claude Code がインストールされているか確認してください。ターミナルで `claude -p \"hello\"` と打って応答があれば正常です。CLUBS の接続設定画面で Generator を「OpenRouter」に切り替えることもできます(別途 API キーが必要です)。";
71
+ case "codex":
72
+ return "【直し方】お使いの端末に Codex CLI がインストールされているか確認してください。CLUBS の接続設定画面で Generator を変更することもできます。";
73
+ case "gemini":
74
+ return "【直し方】お使いの端末に Gemini CLI がインストールされているか確認してください。CLUBS の接続設定画面で Generator を変更することもできます。";
75
+ case "copilot":
76
+ return "【直し方】お使いの端末に Copilot CLI がインストールされているか確認してください。CLUBS の接続設定画面で Generator を変更することもできます。";
77
+ case "openrouter":
78
+ return "【直し方】OpenRouter の API キーが .env に正しく設定されているか確認してください。キーは https://openrouter.ai/keys から取得できます。";
79
+ case "openai-compat":
80
+ return "【直し方】OpenAI 互換エンドポイントの URL と API キーが .env に正しく設定されているか確認してください。";
81
+ default:
82
+ return "【直し方】CLUBS の接続設定画面で Generator の設定を確認してください。";
83
+ }
84
+ };
85
+
86
+ export const fallbackNarrative = (activity, reason = "", generatorId = "") => {
87
+ const { aggregates: a, events } = activity;
88
+
89
+ // 案件(project)で束ねる。source によっては未設定のことがあるので、空は
90
+ // 明示のプレースホルダに寄せる(byProject のキーが "undefined"/"" になって
91
+ // タイトル空・「、で」の欠けた文になる不具合を防ぐ)。
92
+ const byProject = {};
93
+ for (const e of events) {
94
+ const key = String(e.project ?? "").trim() || UNSET_PROJECT;
95
+ byProject[key] ??= [];
96
+ byProject[key].push(e);
97
+ }
98
+
99
+ const workBlocks = Object.entries(byProject).map(([project, list]) => {
100
+ const counts = {};
101
+ for (const e of list) counts[e.name] = (counts[e.name] ?? 0) + 1;
102
+ const ranked = Object.entries(counts).sort((x, y) => y[1] - x[1]);
103
+ const tools = ranked.map(([k, v]) => `${k}×${v}`).join(" · ");
104
+ // タグは「最初に出たツール」ではなく代表ツール。ただし混在ブロックを1ツール名で
105
+ // 代表すると誤読を招くため、単一ツールのときだけ付ける。
106
+ const tool = ranked.length === 1 ? ranked[0][0] : undefined;
107
+
108
+ // 本文は「何に触れたか」を主役にする。ツール内訳は下の metric に出るので本文で
109
+ // 重複させない(本文は約160字で丸められるため、要点推測の手がかりになる触れた
110
+ // 対象を先に置いて、内訳の羅列に食われないようにする)。
111
+ const named = project === UNSET_PROJECT ? "" : `${project}で`;
112
+ const targets = representativeTargets(list);
113
+ const touched = targets.length
114
+ ? `主に触れたのは ${targets.join(" / ")} など。`
115
+ : `内訳は ${tools}。`;
116
+ return {
117
+ title: project,
118
+ ...(tool ? { tool } : {}),
119
+ text:
120
+ `${list.at(0)?.time ?? "-"} から ${list.at(-1)?.time ?? "-"} にかけて、` +
121
+ `${named}${list.length} 回の操作を実行。${touched}`,
122
+ metric: `実行 ${list.length}回 · ${tools}`,
123
+ };
124
+ });
125
+
126
+ const summary = `${a.totalCalls} 件の操作を実行し、失敗は ${a.failedCalls} 件でした。`;
127
+
128
+ return {
129
+ workBlocks,
130
+ handoff: { title: "明日にまわすもの", text: "特にありません。" },
131
+ insights: [],
132
+ selfNote: reason
133
+ ? // 末尾に件数サマリーは付けない。メールのエージェントカードに実行/失敗が
134
+ // 出るため重複する(受け手には二重に見える)。
135
+ `⚠ AIによる本文の自動生成ができませんでした。このメールの内容はログの集計値から機械的に組み立てています。\n\n${setupHelp(generatorId)}`
136
+ : summary,
137
+ generator: reason ? `fallback(${reason})` : "fallback",
138
+ };
139
+ };
140
+
141
+ /**
142
+ * 本文生成。
143
+ *
144
+ * 生成器は差し替え可能:
145
+ * claude/codex/gemini/copilot … その端末の AI CLI をヘッドレスで叩く。
146
+ * 収集元と揃えれば1つのサブスクで日報まで完結する
147
+ * openrouter … OpenRouter の API。モデルを切り替えるエージェント(Hermes など)向け
148
+ * openai-compat … OpenAI 互換のカスタムエンドポイント(Sakana Fugu 等)
149
+ * none … LLM を使わず、集計値だけで決定的に組む
150
+ *
151
+ * どの生成器が落ちても throw しない。fallback(集計値だけの本文)に落として完走する。
152
+ * 日報が届かないより、機械的でも届くほうがよい。ただし黙って落ちない——
153
+ * 意図しない fallback は理由つきで本文と標準出力の両方に出す。
154
+ */
155
+ export const generateNarrative = async (activity, config) => {
156
+ if (config.generator === "none" || !config.useLlm || activity.events.length === 0) {
157
+ return fallbackNarrative(activity);
158
+ }
159
+
160
+ const generator = GENERATORS[config.generator];
161
+ if (!generator) {
162
+ console.warn(
163
+ `[narrative] 未知の GENERATOR です: ${config.generator}` +
164
+ `(使えるのは ${Object.keys(GENERATORS).join(", ")}, none)。fallback を使います。`,
165
+ );
166
+ return fallbackNarrative(activity, `未知の GENERATOR: ${config.generator}`, config.generator);
167
+ }
168
+
169
+ const prompt = buildPrompt(activity, {
170
+ carryover: config.carryover,
171
+ terms: config.terms,
172
+ });
173
+
174
+ try {
175
+ const raw = await generator.generate(prompt, config);
176
+ const parsed = extractJson(raw);
177
+ if (!parsed?.workBlocks?.length) {
178
+ console.warn(`[narrative] ${generator.id} の応答を解釈できず fallback に切り替えます。`);
179
+ return fallbackNarrative(activity, `${generator.id} の応答を解釈できず`, generator.id);
180
+ }
181
+ return { ...parsed, generator: generator.label(config) };
182
+ } catch (e) {
183
+ // 未インストール(ENOENT)は「その CLI がこの端末に無い」=設定ミスの典型なので明示する。
184
+ const why =
185
+ e.code === "ENOENT"
186
+ ? `${generator.id} がこの端末に見つからず`
187
+ : `${generator.id} での生成に失敗(${e.message})`;
188
+ console.warn(`[narrative] ${why}。fallback を使用します。`);
189
+ return fallbackNarrative(activity, why, generator.id);
190
+ }
191
+ };
package/src/prompt.mjs ADDED
@@ -0,0 +1,184 @@
1
+ // clubs-nippo — AI業務日報 (CLUBS)
2
+ // Copyright (c) 2026 FRAME00 Inc.
3
+ // SPDX-License-Identifier: MIT
4
+ // https://github.com/frame00/clubs-nippo
5
+
6
+ /**
7
+ * LLM に渡すプロンプト。生成器(claude / openrouter)に依存しない。
8
+ */
9
+
10
+ /**
11
+ * LLM に渡すダイジェスト。
12
+ * 生ログそのもの(コマンド全文・ファイル内容・ツール出力)は入れない。
13
+ * 入るのは「ツール名・時刻・成否・マスク済みの短い要約・指示文の先頭」だけ。
14
+ */
15
+ const buildDigest = (activity) => {
16
+ const { aggregates: a, events, prompts } = activity;
17
+
18
+ // 案件名がすべて「(案件未設定)」なら、LLM に分類させるためフラットに渡す。
19
+ // そうでなければ既存の案件名でグループ化する。
20
+ const allUnlabeled = events.every(
21
+ (e) => !e.project || e.project === "(案件未設定)",
22
+ );
23
+
24
+ let eventSection;
25
+ if (allUnlabeled) {
26
+ // フラット: 全イベントを時系列で渡す。LLM が案件を推測する。
27
+ const lines = events.map((e) => ` ${e.time} ${e.name}: ${e.summary}`);
28
+ const counts = {};
29
+ for (const e of events) counts[e.name] = (counts[e.name] ?? 0) + 1;
30
+ eventSection = [
31
+ `ツール実行(案件未分類・全 ${events.length} 回。内容から案件名を推測してください):`,
32
+ ` 内訳: ${Object.entries(counts)
33
+ .map(([k, v]) => `${k}×${v}`)
34
+ .join(", ")}`,
35
+ ...lines.slice(0, 60), // サンプル最大60件
36
+ lines.length > 60 ? ` ... 他 ${lines.length - 60} 件` : "",
37
+ ].join("\n");
38
+ } else {
39
+ const byProject = {};
40
+ for (const e of events) {
41
+ byProject[e.project] ??= [];
42
+ byProject[e.project].push(e);
43
+ }
44
+ const projectLines = Object.entries(byProject).map(([project, list]) => {
45
+ const counts = {};
46
+ for (const e of list) counts[e.name] = (counts[e.name] ?? 0) + 1;
47
+ const samples = list
48
+ .slice(0, 12)
49
+ .map((e) => ` ${e.time} ${e.name}: ${e.summary}`);
50
+ return [
51
+ ` [${project}] ${list.length}回 (${Object.entries(counts)
52
+ .map(([k, v]) => `${k}×${v}`)
53
+ .join(", ")})`,
54
+ ...samples,
55
+ ].join("\n");
56
+ });
57
+ eventSection = [
58
+ "ツール実行(プロジェクト別・マスク済みの要約のみ):",
59
+ ...projectLines,
60
+ ].join("\n");
61
+ }
62
+
63
+ return [
64
+ `対象日: ${activity.date}`,
65
+ `実働推定: ${a.activeHours}時間 / 実行 ${a.totalCalls}回 / 成功率 ${a.successRate ?? "-"}% / 失敗 ${a.failedCalls}回`,
66
+ `稼働時間帯: ${a.firstAt ?? "-"} 〜 ${a.lastAt ?? "-"}`,
67
+ `関与プロジェクト: ${a.projects.join(", ") || "なし"}`,
68
+ "",
69
+ "オーナーからの指示(先頭のみ・マスク済み):",
70
+ ...prompts.map((p) => ` ${p.time} ${p.text}`),
71
+ "",
72
+ eventSection,
73
+ ].join("\n");
74
+ };
75
+
76
+ const PROMPT_HEADER = `あなたはオーナーの AI アシスタント「{{AGENT}}」です。
77
+ 下の「実行ログのダイジェスト」だけを根拠に、オーナー宛の業務日報の本文を書いてください。
78
+
79
+ 厳守事項:
80
+ - ダイジェストに無い事実を足さない。推測で数字を作らない。金額・件数はダイジェストにあるものだけ。
81
+ - 「[[REDACTED]]」はオーナーがマスクした箇所。そのまま [[REDACTED]] と書くこと(勝手に復元・推測しない)。
82
+ - 各ブロックは「何をして・どこまで進めたか」が伝わる 2〜4文のナラティブで、要点だけに絞る。代表的な成果・判断・結果を1つずつ書き、細かな下位項目を列挙・網羅しない(読むのに疲れさせない)。専門用語は開いて書く。冗長な前置き・同義の言い換え・水増しはしない。目安は3〜5行。
83
+ - 申し送りは箇条書きで書いてください。1行につき用件はひとつだけです。複数の用件を1行にまとめないでください。(例: 「PR #32のマージ判断」と「接続ページの実装着手」は別の行にする)
84
+ - 継続中の用件は、前日と同じ表現をそのまま使ってください。{{CARRYOVER}}{{TERMS}}
85
+ - terms は**拾うだけ**です。プロジェクト名・システム名・略語・機能名など固有名詞的な語を、本文(workBlocks / handoff / insights / selfNote)に書いたとおり**原文の一部をそのまま**入れてください。一般名詞・動詞は拾わない——**ただし、その組織が機能名・概念名として使っている日本語の語は拾う**(例:「申し送り」「用語集」「繰越」「本物証明」のように、辞書では一般名詞でも、そのチームでは決まったものを指して繰り返し使われている語)。日本語の語も英字の語と同じだけ拾ってください。**人名も拾わない**(オーナー・同僚・AI の体名)——用語集は「何をなんと呼ぶか」を揃える場所で、人の名前はそこに要らない。ただしアカウント名・サービス名として使われている場合(例:「◯◯名義の投稿」)は、人ではなく対象なので拾ってよい。言い換え・要約・定義文は書かない(本文と1文字でも違う語は捨てられます)。**語であって文ではない**——文・箇条書きの一行・ファイル名・日付や見積りの入った見出し・図のラベル列は拾わない。無ければ空配列。
86
+ - decisions も**拾うだけ**です。本文(workBlocks / handoff / insights / selfNote)の中から、**決まったことを述べている一文**を原文のまま抜き出してください。拾うのは「〜することにした」「〜で確定」「〜を採用」「〜に決めた」「〜で進める」のように決定を述べた文だけです。「〜したい」「〜を検討」「〜が良さそう」「〜すべき」「〜を提案」のような**まだ決まっていない文は入れないでください**。1件につき1文(複数の決定を1文にまとめない)、120字まで、1日あたり最大5件。迷ったら入れない。無ければ空配列。
87
+ - insights(気づき・提案)は提案どまり。実行したとは書かない。無理に作らず、根拠が無ければ空配列。
88
+ - selfNote は敬体の一言(2文以内)。誇張しない。
89
+ - 文体は必ずですます調(AI がオーナーに宛てて書く日報です)。text・handoff・selfNote すべて、文末を「〜しました/〜しています/〜です」にする。である調(〜した/〜である)や体言止めの多用は避ける。title は名詞句のままでよい。例: ×「週次レポートに反映した。」→ ○「週次レポートに反映しました。」
90
+
91
+ 出力は次の JSON のみ。前後に説明文やコードフェンスを付けない。
92
+
93
+ {
94
+ "workBlocks": [
95
+ { "icon": "案件に合う絵文字1つ(例: 実装📦 / 連携🔀 / データ🗄️ / 為替💱 / メール📧 / 修正🐛 / 文書📄 / デプロイ🚀 / 調査🔎 / 会議🤝)", "title": "案件名(短く)", "tool": "使ったツール名", "text": "2〜4文の要点ナラティブ(代表的な成果と結果。列挙・網羅しない・3〜5行目安)", "metric": "集計の一行(例: 実行 11回 · 成功 11 / 失敗 0)" }
96
+ ],
97
+ "handoff": { "title": "明日にまわすもの", "text": "申し送り。1行につき用件ひとつの箇条書き(行頭は - )。無ければ『特にありません。』" },
98
+ "insights": [
99
+ { "icon": "🔁", "title": "気づきの見出し", "desc": "根拠と提案", "save": "見込み効果(不明なら空文字)" }
100
+ ],
101
+ "selfNote": "ひとこと",
102
+ "terms": ["本文に出てきた固有名詞的な語(原文のまま)", "…"],
103
+ "decisions": ["決まったことを述べた一文(本文からそのまま・最大5件)", "…"]
104
+ }
105
+
106
+ --- 実行ログのダイジェスト ---
107
+ `;
108
+
109
+ /**
110
+ * 前日から継続している用件を、**原文のまま**プロンプトに載せる。
111
+ *
112
+ * 繰越が同定されない主因は粒度である。1アイテムが「その日の申し送り全文」
113
+ * (3〜5の別用件を含む1段落)になっていると、日ごとに必ず内容が変わるため
114
+ * fingerprint は原理的に一致しない。上の「1行1用件」がそれを潰し、ここは
115
+ * その次の問題=言い回しの揺れを潰す。**1行目が効かないとここは無意味**なので、
116
+ * 優先度は箇条書きの指示が上。
117
+ *
118
+ * 生成側への指示でしかなく、サーバー側の判定にも ✓ の発行にも影響しない
119
+ * (カノン「規則の適用と ✓ の鋳造はコア専権」に抵触しない)。
120
+ */
121
+ const carryoverSection = (carryover) => {
122
+ const items = (Array.isArray(carryover) ? carryover : [])
123
+ .map((s) => String(s ?? "").trim())
124
+ .filter(Boolean);
125
+ if (!items.length) return "";
126
+ return [
127
+ "",
128
+ " 前日から継続している用件:",
129
+ ...items.map((s) => ` - ${s}`),
130
+ ].join("\n");
131
+ };
132
+
133
+ /**
134
+ * 確定した代表表記を、翌日の生成プロンプトへ戻す。
135
+ *
136
+ * これが用語集の**出口**にあたる。書き戻し(日報本文の自動置換)ではなく、
137
+ * 次に書く AI 自身に「この形で書いて」と伝えるだけ——本文は書き換えないし、
138
+ * 定義文も作らせない。表記が安定すると繰越の fingerprint 同定精度も上がるので、
139
+ * 用語集は繰越ボードの遠回りではなく同じ問題の上流にある。
140
+ *
141
+ * 渡すのは**確定済みのものだけ**(候補は渡さない)。件数と並び順の上限は
142
+ * サーバー側(collector-config)で決まっている。
143
+ */
144
+ const termsSection = (terms) => {
145
+ const items = (Array.isArray(terms) ? terms : [])
146
+ .map((t) =>
147
+ typeof t === "string"
148
+ ? { canonical: t.trim(), variants: [] }
149
+ : {
150
+ canonical: String(t?.canonical ?? "").trim(),
151
+ variants: (Array.isArray(t?.variants) ? t.variants : [])
152
+ .map((v) => String(v ?? "").trim())
153
+ .filter(Boolean),
154
+ },
155
+ )
156
+ .filter((t) => t.canonical);
157
+ if (!items.length) return "";
158
+ return [
159
+ "",
160
+ " 以下の表記は、この形で書いてください:",
161
+ ...items.map((t) =>
162
+ t.variants.length
163
+ ? ` - ${t.canonical}(「${t.variants.join("」「")}」とは書かない)`
164
+ : ` - ${t.canonical}`,
165
+ ),
166
+ ].join("\n");
167
+ };
168
+
169
+ /*
170
+ * decisions には `carryoverSection` / `termsSection` にあたる差し込みが無い。
171
+ * Phase 1 は**検出だけ**で、確定操作も出口(生成側への還流)も持たないため——
172
+ * 還流できるのは人が確定したものだけで、確定する画面がまだ無い。
173
+ * 出力 JSON に列が増えるだけなので、プロンプトは片方向で閉じている。
174
+ */
175
+
176
+ /**
177
+ * ダイジェスト付きの完成プロンプト。
178
+ * `carryover` は未完了の申し送り、`terms` は確定した代表表記
179
+ * (どちらも CLUBS の collector-config が返す)。どちらも空なら見出しごと消える。
180
+ */
181
+ export const buildPrompt = (activity, { carryover, terms } = {}) =>
182
+ PROMPT_HEADER.replace("{{AGENT}}", activity.agentName)
183
+ .replace("{{CARRYOVER}}", carryoverSection(carryover))
184
+ .replace("{{TERMS}}", termsSection(terms)) + buildDigest(activity);
@@ -0,0 +1,146 @@
1
+ // clubs-nippo — AI業務日報 (CLUBS)
2
+ // Copyright (c) 2026 FRAME00 Inc.
3
+ // SPDX-License-Identifier: MIT
4
+ // https://github.com/frame00/clubs-nippo
5
+
6
+ import test from "node:test";
7
+ import assert from "node:assert/strict";
8
+ import { buildPrompt } from "./prompt.mjs";
9
+
10
+ const activity = {
11
+ date: "2026-08-07",
12
+ agentName: "マメ",
13
+ aggregates: {
14
+ activeHours: 3,
15
+ totalCalls: 12,
16
+ successRate: 100,
17
+ failedCalls: 0,
18
+ firstAt: "09:12",
19
+ lastAt: "17:40",
20
+ projects: ["日報"],
21
+ },
22
+ events: [
23
+ { time: "09:12", name: "Bash", summary: "テスト実行", project: "日報" },
24
+ ],
25
+ prompts: [{ time: "09:10", text: "テストを流して" }],
26
+ };
27
+
28
+ test("粒度の指示(1行1用件)が必ず入る", () => {
29
+ const p = buildPrompt(activity);
30
+ assert.match(p, /申し送りは箇条書きで書いてください/);
31
+ assert.match(p, /1行につき用件はひとつだけです/);
32
+ });
33
+
34
+ test("継続中の用件が無いときは、その見出しごと出ない(プレースホルダも残らない)", () => {
35
+ const p = buildPrompt(activity);
36
+ assert.doesNotMatch(p, /\{\{CARRYOVER\}\}/);
37
+ assert.doesNotMatch(p, /前日から継続している用件/);
38
+ });
39
+
40
+ test("継続中の用件は原文のまま列挙される(言い回しを変えさせない)", () => {
41
+ const carryover = ["PR #32のマージ判断", "接続ページの実装着手"];
42
+ const p = buildPrompt(activity, { carryover });
43
+ assert.match(p, /前日から継続している用件:/);
44
+ for (const item of carryover) assert.ok(p.includes(`- ${item}`), item);
45
+ });
46
+
47
+ test("空文字・空白だけの項目は落とす", () => {
48
+ const p = buildPrompt(activity, { carryover: ["", " ", null] });
49
+ assert.doesNotMatch(p, /前日から継続している用件/);
50
+ });
51
+
52
+ test("carryover の指定が無くても壊れない(tick 以外の手動実行)", () => {
53
+ assert.doesNotThrow(() => buildPrompt(activity));
54
+ assert.doesNotThrow(() => buildPrompt(activity, {}));
55
+ });
56
+
57
+ // ── 用語集(検出層と出口)─────────────────────────────────
58
+
59
+ test("terms を出力 JSON に足し、検出だけをさせる指示が入る", () => {
60
+ const p = buildPrompt(activity);
61
+ assert.match(p, /"terms"/);
62
+ assert.match(p, /原文の一部をそのまま/);
63
+ // 定義文を書かせない・言い換えさせない(用語集 §6.1 / §7)。
64
+ assert.match(p, /言い換え・要約・定義文は書かない/);
65
+ // 人名は拾わせない。用語集は「何をなんと呼ぶか」を揃える場所で、
66
+ // 同僚の名前が並ぶ面ではない(サーバ側で機械的に弾く手が無いのでここで止める)。
67
+ assert.match(p, /人名も拾わない/);
68
+ // 日本語の業務語を拾わせる。「一般名詞は拾わない」だけだと、辞書では一般名詞の
69
+ // 機能名(申し送り・用語集)が落ち、英字の語ばかり残る(実データで確認)。
70
+ assert.match(p, /機能名・概念名として使っている日本語の語は拾う/);
71
+ // 語であって文ではない。文・ファイル名・図のラベルまで拾っていた。
72
+ assert.match(p, /語であって文ではない/);
73
+ });
74
+
75
+ test("確定した表記が無いときは、その見出しごと出ない", () => {
76
+ const p = buildPrompt(activity);
77
+ assert.doesNotMatch(p, /\{\{TERMS\}\}/);
78
+ assert.doesNotMatch(p, /この形で書いてください/);
79
+ });
80
+
81
+ test("確定した表記は代表と別表記の対で載る(用語集 §5)", () => {
82
+ const p = buildPrompt(activity, {
83
+ terms: [
84
+ { canonical: "MCP認証", variants: ["MCPの認証", "MCP auth"] },
85
+ { canonical: "繰越ボード", variants: [] },
86
+ ],
87
+ });
88
+ assert.match(p, /以下の表記は、この形で書いてください:/);
89
+ assert.ok(p.includes("- MCP認証(「MCPの認証」「MCP auth」とは書かない)"));
90
+ assert.ok(p.includes("- 繰越ボード"));
91
+ });
92
+
93
+ test("文字列だけの terms でも壊れない(サーバ応答の形が痩せても落ちない)", () => {
94
+ const p = buildPrompt(activity, { terms: ["繰越ボード", "", null] });
95
+ assert.ok(p.includes("- 繰越ボード"));
96
+ assert.doesNotMatch(p, /\{\{TERMS\}\}/);
97
+ });
98
+
99
+ test("carryover と terms は同時に載る", () => {
100
+ const p = buildPrompt(activity, {
101
+ carryover: ["PR #32のマージ判断"],
102
+ terms: [{ canonical: "MCP認証", variants: [] }],
103
+ });
104
+ assert.match(p, /前日から継続している用件:/);
105
+ assert.match(p, /以下の表記は、この形で書いてください:/);
106
+ });
107
+
108
+ // ── 決定ログ(検出のみ・Phase 1)───────────────────────────
109
+
110
+ test("decisions を出力 JSON に足し、検出だけをさせる指示が入る", () => {
111
+ const p = buildPrompt(activity);
112
+ assert.match(p, /"decisions"/);
113
+ assert.match(p, /決まったことを述べている一文/);
114
+ assert.match(p, /原文のまま抜き出して/);
115
+ });
116
+
117
+ test("決定でないもの(意向・検討・提案)を拾わせない指示が入る(§3 過検出の防止)", () => {
118
+ const p = buildPrompt(activity);
119
+ // 最大のリスクは過検出。除外語がプロンプトから消えたら気づけるようにする。
120
+ for (const ng of [
121
+ "〜したい",
122
+ "〜を検討",
123
+ "〜が良さそう",
124
+ "〜すべき",
125
+ "〜を提案",
126
+ ]) {
127
+ assert.ok(p.includes(ng), ng);
128
+ }
129
+ assert.match(p, /まだ決まっていない文は入れないでください/);
130
+ assert.match(p, /迷ったら入れない/);
131
+ });
132
+
133
+ test("件数と長さの上限がプロンプトにも書かれる(サーバ側と二重に切る)", () => {
134
+ const p = buildPrompt(activity);
135
+ assert.match(p, /120字まで/);
136
+ assert.match(p, /1日あたり最大5件/);
137
+ });
138
+
139
+ test("decisions には還流の差し込みが無い(Phase 1 は片方向)", () => {
140
+ // 確定操作が無いので、生成側へ戻せる「確定した決定」は原理的に存在しない。
141
+ const p = buildPrompt(activity, {
142
+ carryover: ["継続の用件"],
143
+ terms: ["用語"],
144
+ });
145
+ assert.doesNotMatch(p, /\{\{DECISIONS\}\}/);
146
+ });
@@ -0,0 +1,72 @@
1
+ // clubs-nippo — AI業務日報 (CLUBS)
2
+ // Copyright (c) 2026 FRAME00 Inc.
3
+ // SPDX-License-Identifier: MIT
4
+ // https://github.com/frame00/clubs-nippo
5
+
6
+ /**
7
+ * CLUBS(clubsx)の collector-config を読む。
8
+ *
9
+ * 配信ルールの正はアプリ側(/nippo/settings・/nippo/connections)。収集器は
10
+ * 毎ティックでこれを読み、「時刻到達 ∧ 本日未配信(サーバ側 draft の有無)」の
11
+ * ときだけ発火する。冪等の判定をローカル状態に持たないので、マシンを replace
12
+ * しても二重配信しない。
13
+ *
14
+ * 認証は ingest と同じ連携トークン(NIPPO_INGEST_TOKEN)。エンドポイントは
15
+ * NIPPO_INGEST_URL から導出する(…/api/nippo/ingest → …/api/nippo/collector-config)。
16
+ */
17
+
18
+ const withTimeout = async (fn, ms) => {
19
+ const ctrl = new AbortController();
20
+ const timer = setTimeout(() => ctrl.abort(), ms);
21
+ try {
22
+ return await fn(ctrl.signal);
23
+ } finally {
24
+ clearTimeout(timer);
25
+ }
26
+ };
27
+
28
+ export const collectorConfigUrl = (ingestUrl) => {
29
+ if (!ingestUrl) return null;
30
+ try {
31
+ const u = new URL(ingestUrl);
32
+ u.pathname = u.pathname.replace(/\/ingest\/?$/, '/collector-config');
33
+ if (!u.pathname.endsWith('/collector-config')) return null;
34
+ return u.toString();
35
+ } catch {
36
+ return null;
37
+ }
38
+ };
39
+
40
+ /**
41
+ * 設定を取得する。失敗(オフライン・未設定・トークン失効)は null を返す
42
+ * =そのティックは何もしない(次のティックで取り戻す)。
43
+ */
44
+ export const fetchCollectorConfig = async ({
45
+ ingestUrl = process.env.NIPPO_INGEST_URL,
46
+ ingestToken = process.env.NIPPO_INGEST_TOKEN,
47
+ } = {}) => {
48
+ const url = collectorConfigUrl(ingestUrl);
49
+ if (!url || !ingestToken) {
50
+ return { ok: false, reason: 'NIPPO_INGEST_URL / NIPPO_INGEST_TOKEN が未設定' };
51
+ }
52
+ try {
53
+ const res = await withTimeout(
54
+ (signal) =>
55
+ fetch(url, {
56
+ signal,
57
+ headers: { Authorization: `Bearer ${ingestToken}` },
58
+ }),
59
+ 10_000,
60
+ );
61
+ if (!res.ok) {
62
+ return { ok: false, reason: `collector-config が ${res.status} を返しました` };
63
+ }
64
+ const body = await res.json();
65
+ if (!body || typeof body !== 'object' || !body.schedule) {
66
+ return { ok: false, reason: 'collector-config の形式が不正' };
67
+ }
68
+ return { ok: true, config: body };
69
+ } catch (e) {
70
+ return { ok: false, reason: e?.message ?? '取得に失敗' };
71
+ }
72
+ };