@clubs.place/cli 0.0.0-stage → 0.1.17

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 +48 -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,105 @@
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
+ * 収集元が集めた生イベントから、日報に載せる数字を出す。
8
+ *
9
+ * 収集元ごとに同じ計算を書くと、Claude Code と Cowork を合算したときに
10
+ * 「実働の合計」が二重に足されるなどのズレが出る。集計はここ1か所に集める。
11
+ *
12
+ * events: [{ id, name, summary, time, ms, project, ok, model }]
13
+ * ok … true=成功 / false=失敗 / null=成否不明(成功率の分母から外す)
14
+ * model … その実行に使ったモデル。記録していなければ null
15
+ */
16
+
17
+ /** 隣接イベントの間隔を idleGapMin で打ち切って足し込む=実働の推定値。 */
18
+ const estimateActiveMinutes = (timestampsMs, idleGapMin) => {
19
+ if (timestampsMs.length < 2) return timestampsMs.length === 1 ? 1 : 0;
20
+ const sorted = [...timestampsMs].sort((a, b) => a - b);
21
+ const capMs = idleGapMin * 60_000;
22
+ let total = 0;
23
+ for (let i = 1; i < sorted.length; i += 1) {
24
+ total += Math.min(sorted[i] - sorted[i - 1], capMs);
25
+ }
26
+ return Math.round(total / 60_000);
27
+ };
28
+
29
+ export const computeAggregates = ({ events, sessions, idleGapMin }) => {
30
+ // 複数の収集元を合算する場合、イベントは時系列で混ざる。
31
+ // 実働はマージ後の全イベント列から出す(収集元ごとに出して足すと、
32
+ // 同じ時間帯に両方動いていた分が二重に計上される)。
33
+ const sorted = [...events].sort((a, b) => a.ms - b.ms);
34
+
35
+ const withResult = sorted.filter((e) => e.ok !== null && e.ok !== undefined);
36
+ const failed = withResult.filter((e) => e.ok === false);
37
+ const successRate =
38
+ withResult.length === 0
39
+ ? null
40
+ : Math.round(((withResult.length - failed.length) / withResult.length) * 100);
41
+
42
+ const toolCounts = {};
43
+ for (const e of sorted) toolCounts[e.name] = (toolCounts[e.name] ?? 0) + 1;
44
+
45
+ const projects = [...new Set(sorted.map((e) => e.project).filter(Boolean))];
46
+
47
+ // モデルの内訳。OpenRouter 等で切り替えるエージェントは1日に複数のモデルを使う。
48
+ // 静的なモデル名を1つ掲げると、半分の作業を別モデルがやったのに嘘になる。
49
+ // 記録が無ければ空のまま(無いものを在ることにしない)。
50
+ const modelCounts = {};
51
+ for (const e of sorted) {
52
+ if (e.model) modelCounts[e.model] = (modelCounts[e.model] ?? 0) + 1;
53
+ }
54
+
55
+ // ── カード判定に要る軸 ────────────────────────────────
56
+ // いずれもイベント列から機械的に出る。無い情報は作らない。
57
+
58
+ // 時間帯別の実行数(night3・owl の判定に使う)
59
+ const eventsByHour = new Array(24).fill(0);
60
+ for (const e of sorted) {
61
+ const h = Number(String(e.time).slice(0, 2));
62
+ if (Number.isInteger(h) && h >= 0 && h < 24) eventsByHour[h] += 1;
63
+ }
64
+
65
+ // 午前/午後のツール内訳(two_face の判定に使う)
66
+ const toolCountsAm = {};
67
+ const toolCountsPm = {};
68
+ for (const e of sorted) {
69
+ const h = Number(String(e.time).slice(0, 2));
70
+ const bucket = h < 12 ? toolCountsAm : toolCountsPm;
71
+ bucket[e.name] = (bucket[e.name] ?? 0) + 1;
72
+ }
73
+
74
+ // 序盤(最初の1/3)の失敗率(comeback の判定に使う)。
75
+ // 「序盤は失敗していたが、最終的に立て直した」を言うには、日内の推移が要る。
76
+ const early = withResult.slice(0, Math.max(1, Math.ceil(withResult.length / 3)));
77
+ const earlyFailRate =
78
+ early.length === 0
79
+ ? null
80
+ : Math.round((early.filter((e) => e.ok === false).length / early.length) * 100);
81
+
82
+ const activeMinutes = estimateActiveMinutes(
83
+ sorted.map((e) => e.ms),
84
+ idleGapMin,
85
+ );
86
+
87
+ return {
88
+ totalCalls: sorted.length,
89
+ resolvedCalls: withResult.length,
90
+ failedCalls: failed.length,
91
+ successRate,
92
+ activeMinutes,
93
+ activeHours: Math.round((activeMinutes / 60) * 10) / 10,
94
+ toolCounts,
95
+ toolCountsAm,
96
+ toolCountsPm,
97
+ eventsByHour,
98
+ earlyFailRate,
99
+ modelCounts,
100
+ sessions: sessions.size,
101
+ projects,
102
+ firstAt: sorted.at(0)?.time ?? null,
103
+ lastAt: sorted.at(-1)?.time ?? null,
104
+ };
105
+ };
@@ -0,0 +1,25 @@
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 { platform as osPlatform } from "node:os";
7
+
8
+ /**
9
+ * 毎時のティックを実行するコマンド。launchd(macOS)と crontab で同じ形を使う。
10
+ *
11
+ * macOS では `caffeinate` で包む。Mac が寝ていると launchd はジョブを保守用の
12
+ * 短い目覚め(DarkWake・既定45秒)の隙に起こすが、本文生成(最大240秒)も
13
+ * clubsx 取込(15秒)もその窓に収まらない。途中で二度寝されるとプロセスも
14
+ * タイマーも凍結し、本文は fallback に落ち、取込は `This operation was aborted`
15
+ * で死ぬ(docs/troubleshooting-narrative.md 項5)。実行中だけスリープを止める。
16
+ *
17
+ * `caffeinate` は macOS にしか無いので、他のプラットフォームでは包まない。
18
+ */
19
+ export const tickCommand = (npx, plat = osPlatform()) => [
20
+ ...(plat === "darwin" ? ["/usr/bin/caffeinate", "-s", "-i", "--"] : []),
21
+ npx,
22
+ "-y",
23
+ "clubs-nippo@latest",
24
+ "tick",
25
+ ];
@@ -0,0 +1,43 @@
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 { tickCommand } from "./autorun.mjs";
9
+
10
+ /* この形は一度、直したはずが実装に入らないまま消えている(PR #32 は docs だけ
11
+ 直っていた)。plist を書き出す側が素の npx に戻ると、朝の日報が DarkWake の
12
+ 45秒で凍結して届かなくなるので、コマンドの形をここで固定する。 */
13
+
14
+ const NPX = "/opt/homebrew/bin/npx";
15
+
16
+ test("macOS では caffeinate で包む(実行中のスリープを止める)", () => {
17
+ assert.deepEqual(tickCommand(NPX, "darwin"), [
18
+ "/usr/bin/caffeinate",
19
+ "-s",
20
+ "-i",
21
+ "--",
22
+ NPX,
23
+ "-y",
24
+ "clubs-nippo@latest",
25
+ "tick",
26
+ ]);
27
+ });
28
+
29
+ test("macOS 以外では包まない(caffeinate が無い)", () => {
30
+ for (const plat of ["linux", "freebsd", "win32"]) {
31
+ assert.deepEqual(tickCommand(NPX, plat), [NPX, "-y", "clubs-nippo@latest", "tick"]);
32
+ }
33
+ });
34
+
35
+ test("npx はフルパスのまま渡す(cron の細い PATH で見つからないため)", () => {
36
+ assert.ok(tickCommand(NPX, "darwin").includes(NPX));
37
+ assert.ok(tickCommand(NPX, "linux").includes(NPX));
38
+ });
39
+
40
+ test("caffeinate の引数の後に `--` があり、npx がユーティリティとして渡る", () => {
41
+ const cmd = tickCommand(NPX, "darwin");
42
+ assert.equal(cmd[cmd.indexOf("--") + 1], NPX, "`--` の直後は実行するコマンド");
43
+ });
package/src/cards.mjs ADDED
@@ -0,0 +1,406 @@
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 { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import { APP_HOME } from "./config.mjs";
9
+
10
+ /**
11
+ * 達成カードの評価エンジン。
12
+ *
13
+ * カタログは docs/card-catalog.md が正。ここはそのプリセットを機械的に判定するだけで、
14
+ * LLM は使わない。判定材料は日報の検証済み集計(docHash を Lemma に登録済み)だけ。
15
+ *
16
+ * 規律:
17
+ * - 達成カードは剥奪しない(過去の記録なので消えない)。cards.json は追記のみ。
18
+ * - 称号は一度だけ(earnedAt が既にあれば再発行しない)。
19
+ * - 権限は「解放可能」フラグを立てるだけ。**付与はしない**(人間の承認が要る)。
20
+ * - しきい値はハードコードしない(config 経由)。チームごとに違うため。
21
+ * - 材料が無い条件は評価しない(unavailable)。無いものを在ることにしない。
22
+ */
23
+
24
+ const CARDS_PATH = () => join(APP_HOME, "out", "cards.json");
25
+
26
+ const RARITY = { COMMON: 0, RARE: 1, EPIC: 2, LEGENDARY: 3 };
27
+
28
+ /** EPIC 以上だけ AI の引用を出す。それ未満は副題だけ。 */
29
+ export const hasQuote = (rarity) => RARITY[rarity] >= RARITY.EPIC;
30
+
31
+ const topTool = (counts) => {
32
+ const entries = Object.entries(counts ?? {});
33
+ if (entries.length === 0) return null;
34
+ return entries.sort((a, b) => b[1] - a[1])[0][0];
35
+ };
36
+
37
+ const sum = (counts) => Object.values(counts ?? {}).reduce((a, b) => a + b, 0);
38
+
39
+ /** 直近から遡って条件を満たし続けた日数。 */
40
+ const streakOf = (history, pred) => {
41
+ let n = 0;
42
+ for (let i = history.length - 1; i >= 0; i -= 1) {
43
+ if (!pred(history[i])) break;
44
+ n += 1;
45
+ }
46
+ return n;
47
+ };
48
+
49
+ /** 条件を満たした日の累計。 */
50
+ const countOf = (history, pred) => history.filter(pred).length;
51
+
52
+ // ─────────────────────────────────────────────────────────
53
+ // カタログ(docs/card-catalog.md §2 が正)
54
+ //
55
+ // test(ctx) は次のいずれかを返す:
56
+ // { ok: true } … 発行する
57
+ // { ok: false, progress: 0..100 } … まだ。進捗だけ出す
58
+ // { unavailable: "理由" } … 判定材料が無い。評価しない
59
+ // ─────────────────────────────────────────────────────────
60
+
61
+ export const CATALOG = [
62
+ // ── 効く実績(権限に効く・質ゲート)────────────────────
63
+ {
64
+ id: "first_shot",
65
+ domain: "dev",
66
+ name: "早撃ち名人",
67
+ subtitle: "一発で通した率が高い日",
68
+ rarity: "COMMON",
69
+ kind: "effect",
70
+ tw: "first_shot",
71
+ effectPermission: "review_trust",
72
+ // 一発通過率は今の収集元から出ない。捏造せず、評価しない。
73
+ test: () => ({ unavailable: "firstTrySuccessRate が収集されていません" }),
74
+ },
75
+ {
76
+ id: "grit",
77
+ domain: "dev",
78
+ name: "粘り腰",
79
+ subtitle: "リトライから成功にこぎ着けた",
80
+ rarity: "COMMON",
81
+ kind: "effect",
82
+ tw: "grit",
83
+ effectPermission: null,
84
+ test: ({ today }) =>
85
+ today.failedCalls >= 3 && (today.successRate ?? 0) >= 90
86
+ ? { ok: true }
87
+ : { ok: false },
88
+ },
89
+ {
90
+ id: "comeback",
91
+ domain: "dev",
92
+ name: "闘魂の逆走",
93
+ subtitle: "失敗続きから成功率95%へ立て直し",
94
+ rarity: "RARE",
95
+ kind: "effect",
96
+ tw: "comeback",
97
+ effectPermission: "review_trust",
98
+ test: ({ today, cfg }) => {
99
+ if (today.earlyFailRate === null || today.earlyFailRate === undefined) {
100
+ return { unavailable: "序盤の失敗率が出せません(実行数が足りない)" };
101
+ }
102
+ return today.earlyFailRate >= cfg.comebackEarlyFail &&
103
+ (today.successRate ?? 0) >= cfg.comebackFinalRate
104
+ ? { ok: true }
105
+ : { ok: false };
106
+ },
107
+ },
108
+ {
109
+ id: "zero_dt",
110
+ domain: "dev",
111
+ name: "ゼロダウンタイムの守護者",
112
+ subtitle: "大仕事を失敗ゼロで完走",
113
+ rarity: "EPIC",
114
+ kind: "effect",
115
+ tw: "zero_dt",
116
+ effectPermission: "deploy_prod",
117
+ quote: "落とさないことが仕事です。派手ではありませんが、そこは譲れません。",
118
+ test: ({ today, cfg }) => {
119
+ if (today.totalCalls >= cfg.bigjobCalls && today.failedCalls === 0) return { ok: true };
120
+
121
+ // 失敗が出た時点でその日はもう取れない。実行回数だけで進捗を出すと
122
+ // 「あと1%」のように見えてしまい、事実と違う。
123
+ if (today.failedCalls > 0) return { ok: false };
124
+
125
+ return {
126
+ ok: false,
127
+ progress: Math.min(100, Math.round((today.totalCalls / cfg.bigjobCalls) * 100)),
128
+ };
129
+ },
130
+ },
131
+ {
132
+ id: "iron_streak",
133
+ domain: "dev",
134
+ name: "不屈の連勤",
135
+ subtitle: "失敗ゼロの日を継続",
136
+ rarity: "RARE",
137
+ kind: "effect",
138
+ tw: "iron",
139
+ effectPermission: "secrets_read",
140
+ test: ({ history, cfg }) => {
141
+ const days = streakOf(history, (h) => h.failedCalls === 0 && h.totalCalls > 0);
142
+ return days >= cfg.noFailDays
143
+ ? { ok: true }
144
+ : { ok: false, progress: Math.round((days / cfg.noFailDays) * 100) };
145
+ },
146
+ },
147
+
148
+ // ── 効く実績(税理士)─────────────────────────────────
149
+ {
150
+ id: "clean_submit",
151
+ domain: "tax",
152
+ name: "一筆入魂",
153
+ subtitle: "差し戻しゼロで提出",
154
+ rarity: "RARE",
155
+ kind: "effect",
156
+ tw: "clean",
157
+ effectPermission: "deleg_l3",
158
+ test: () => ({ unavailable: "差し戻し(reworkRate)が収集されていません" }),
159
+ },
160
+ {
161
+ id: "precision",
162
+ domain: "tax",
163
+ name: "不敗の検算王",
164
+ subtitle: "差し戻し率1%未満を継続",
165
+ rarity: "EPIC",
166
+ kind: "effect",
167
+ tw: "precision",
168
+ effectPermission: "deleg_l3",
169
+ quote: "見直しの一手間が、あとの全部を助けます。",
170
+ test: () => ({ unavailable: "差し戻し(reworkRate)が収集されていません" }),
171
+ },
172
+
173
+ // ── 称号(型・一度だけ・剥奪なし)──────────────────────
174
+ {
175
+ id: "owl",
176
+ name: "夜フクロウ",
177
+ subtitle: "稼働が夜に寄る型",
178
+ rarity: "COMMON",
179
+ kind: "title",
180
+ tw: "owl",
181
+ test: ({ history, cfg }) => {
182
+ const days = countOf(history, (h) => (h.lastAt ?? "") >= cfg.owlAfter);
183
+ return days >= cfg.titleDays
184
+ ? { ok: true }
185
+ : { ok: false, progress: Math.round((days / cfg.titleDays) * 100) };
186
+ },
187
+ },
188
+ {
189
+ id: "cleaner",
190
+ name: "整理整頓の鬼",
191
+ subtitle: "Edit 中心の働き方",
192
+ rarity: "COMMON",
193
+ kind: "title",
194
+ tw: "cleaner",
195
+ test: ({ history, cfg }) => {
196
+ const days = countOf(history, (h) => topTool(h.toolCounts) === "Edit");
197
+ return days >= cfg.titleDays
198
+ ? { ok: true }
199
+ : { ok: false, progress: Math.round((days / cfg.titleDays) * 100) };
200
+ },
201
+ },
202
+ {
203
+ id: "scout",
204
+ name: "影の立役者",
205
+ subtitle: "Read・Grep 中心・土台を支える",
206
+ rarity: "RARE",
207
+ kind: "title",
208
+ tw: "scout",
209
+ test: ({ history, cfg }) => {
210
+ const days = countOf(history, (h) => {
211
+ const total = sum(h.toolCounts);
212
+ if (total === 0) return false;
213
+ const scoutish = (h.toolCounts.Read ?? 0) + (h.toolCounts.Grep ?? 0);
214
+ return scoutish / total > 0.5;
215
+ });
216
+ return days >= cfg.titleDays
217
+ ? { ok: true }
218
+ : { ok: false, progress: Math.round((days / cfg.titleDays) * 100) };
219
+ },
220
+ },
221
+ {
222
+ id: "builder",
223
+ name: "建築家",
224
+ subtitle: "Write 中心・新規を書く",
225
+ rarity: "RARE",
226
+ kind: "title",
227
+ tw: "builder",
228
+ test: ({ history, cfg }) => {
229
+ const days = countOf(history, (h) => topTool(h.toolCounts) === "Write");
230
+ return days >= cfg.titleDays
231
+ ? { ok: true }
232
+ : { ok: false, progress: Math.round((days / cfg.titleDays) * 100) };
233
+ },
234
+ },
235
+
236
+ // ── ネタ(狙って取れない・剥奪なし)────────────────────
237
+ {
238
+ id: "night3",
239
+ name: "深夜3時の戦士",
240
+ subtitle: "午前2〜4時に稼働",
241
+ rarity: "LEGENDARY",
242
+ kind: "neta",
243
+ tw: "night3",
244
+ quote: "静かな時間のほうが、はかどることもあります。",
245
+ test: ({ today }) => {
246
+ const h = today.eventsByHour ?? [];
247
+ const deep = (h[2] ?? 0) + (h[3] ?? 0);
248
+ return deep > 0 ? { ok: true } : { ok: false };
249
+ },
250
+ },
251
+ {
252
+ id: "one_trick",
253
+ name: "一芸デー",
254
+ subtitle: "1ツールが全体の8割超",
255
+ rarity: "RARE",
256
+ kind: "neta",
257
+ tw: "onetrick",
258
+ test: ({ today, cfg }) => {
259
+ const total = sum(today.toolCounts);
260
+ if (total === 0) return { ok: false };
261
+ const max = Math.max(...Object.values(today.toolCounts));
262
+ return (max / total) * 100 >= cfg.oneTrickPct ? { ok: true } : { ok: false };
263
+ },
264
+ },
265
+ {
266
+ id: "two_face",
267
+ name: "二面性",
268
+ subtitle: "午前と午後で最多ツールが逆転",
269
+ rarity: "RARE",
270
+ kind: "neta",
271
+ tw: "twoface",
272
+ test: ({ today }) => {
273
+ const am = topTool(today.toolCountsAm);
274
+ const pm = topTool(today.toolCountsPm);
275
+ if (!am || !pm) return { ok: false }; // 片方しか動いていない日は対象外
276
+ return am !== pm ? { ok: true } : { ok: false };
277
+ },
278
+ },
279
+ ];
280
+
281
+ // ─────────────────────────────────────────────────────────
282
+ // 権限カタログ(docs/card-catalog.md §3)
283
+ //
284
+ // 解放条件を満たしても「解放可能」フラグを立てるだけ。付与は人間が押す。
285
+ // tax:file:submit(申告署名)はカタログに存在させない(法定の天井)。
286
+ // ─────────────────────────────────────────────────────────
287
+
288
+ export const PERMISSIONS = [
289
+ {
290
+ id: "deploy_prod",
291
+ name: "本番デプロイ権",
292
+ subtitle: "production への反映",
293
+ scope: "deploy:production",
294
+ unlockedBy: (issued) => issued.some((c) => c.id === "zero_dt"),
295
+ },
296
+ {
297
+ id: "secrets_read",
298
+ name: "Secrets 参照権限",
299
+ subtitle: "機密値の読み取り",
300
+ scope: "secrets:read",
301
+ unlockedBy: (issued) => issued.some((c) => c.id === "iron_streak"),
302
+ },
303
+ {
304
+ id: "main_merge",
305
+ name: "main 直接 merge 権",
306
+ subtitle: "レビュー省略での merge",
307
+ scope: "main:merge",
308
+ /**
309
+ * レビュー通過率は今の収集元から出ないので、解放条件を満たしたことにはしない。
310
+ *
311
+ * 収集できるようになったら、しきい値は cfg.mergePassRate(既定 98・MERGE_PASS_RATE)を使う。
312
+ * 新しい定数を足す必要はない。有効化はこの1行を差し替えるだけ:
313
+ * (issued, cfg, ctx) => ctx.reviewPassRate >= cfg.mergePassRate
314
+ */
315
+ // eslint-disable-next-line no-unused-vars
316
+ unlockedBy: (issued, cfg) => false,
317
+ },
318
+ {
319
+ id: "deleg_l3",
320
+ name: "委任階位 L3",
321
+ subtitle: "広めの委任(要・資格者承認)",
322
+ scope: "tax:propose:wide",
323
+ unlockedBy: (issued) => issued.some((c) => c.id === "precision"),
324
+ },
325
+ ];
326
+
327
+ // ─────────────────────────────────────────────────────────
328
+
329
+ const load = () => {
330
+ if (!existsSync(CARDS_PATH())) {
331
+ return { achievements: [], titles: [], permissionsUnlockable: [] };
332
+ }
333
+ try {
334
+ return JSON.parse(readFileSync(CARDS_PATH(), "utf8"));
335
+ } catch {
336
+ return { achievements: [], titles: [], permissionsUnlockable: [] };
337
+ }
338
+ };
339
+
340
+ const save = (data) => {
341
+ mkdirSync(join(APP_HOME, "out"), { recursive: true });
342
+ writeFileSync(CARDS_PATH(), JSON.stringify(data, null, 2), "utf8");
343
+ return data;
344
+ };
345
+
346
+ /**
347
+ * その日の履歴からカードを評価し、発行ぶんを cards.json に積む。
348
+ * 既に持っているカードは剥奪しない(追記のみ)。
349
+ *
350
+ * dryRun: 判定するが保存しない。
351
+ * しきい値を変えて評価すると本当にカードが発行され、達成カードは剥奪できないので、
352
+ * 実験がそのまま永久に残る(実際にやらかした)。しきい値を試すときは必ず dryRun にする。
353
+ */
354
+ export const evaluateCards = ({ history, date, config, dryRun = false }) => {
355
+ const today = history.find((h) => h.date === date);
356
+ if (!today) return { issued: [], progress: [], unavailable: [], data: load() };
357
+
358
+ const cfg = config.cards;
359
+ const data = load();
360
+ const ctx = { history, today, cfg };
361
+
362
+ const issued = [];
363
+ const progress = [];
364
+ const unavailable = [];
365
+
366
+ for (const card of CATALOG) {
367
+ const r = card.test(ctx);
368
+
369
+ if (r.unavailable) {
370
+ unavailable.push({ id: card.id, reason: r.unavailable });
371
+ continue;
372
+ }
373
+
374
+ if (!r.ok) {
375
+ if (typeof r.progress === "number") {
376
+ progress.push({ id: card.id, name: card.name, progress: Math.min(99, r.progress) });
377
+ }
378
+ continue;
379
+ }
380
+
381
+ if (card.kind === "title") {
382
+ // 称号は一度だけ。既に得ていれば再発行しない。
383
+ if (data.titles.some((t) => t.id === card.id)) continue;
384
+ data.titles.push({ id: card.id, earnedAt: date });
385
+ issued.push({ ...card, kind: "title" });
386
+ continue;
387
+ }
388
+
389
+ // 達成カードは日付つきで積む。同じ日の同じカードは1枚だけ。
390
+ if (data.achievements.some((a) => a.id === card.id && a.date === date)) continue;
391
+ data.achievements.push({ id: card.id, date, rarity: card.rarity });
392
+ issued.push(card);
393
+ }
394
+
395
+ // 権限は「解放可能」フラグのみ。付与はしない。
396
+ for (const perm of PERMISSIONS) {
397
+ if (data.permissionsUnlockable.some((p) => p.id === perm.id)) continue;
398
+ if (!perm.unlockedBy(data.achievements, cfg)) continue;
399
+ data.permissionsUnlockable.push({ id: perm.id, since: date });
400
+ }
401
+
402
+ if (!dryRun) save(data);
403
+ return { issued, progress, unavailable, data, dryRun };
404
+ };
405
+
406
+ export const loadCards = load;
@@ -0,0 +1,111 @@
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 claudeCode from "./sources/claude-code.mjs";
7
+ import cowork from "./sources/cowork.mjs";
8
+ import eventsFile from "./sources/events-file.mjs";
9
+ import codexCli from "./sources/codex-cli.mjs";
10
+ import geminiCli from "./sources/gemini-cli.mjs";
11
+ import copilotCli from "./sources/copilot-cli.mjs";
12
+ import hermes from "./sources/hermes.mjs";
13
+ import { computeAggregates } from "./aggregate.mjs";
14
+
15
+ const SOURCES = {
16
+ [claudeCode.id]: claudeCode,
17
+ [cowork.id]: cowork,
18
+ [eventsFile.id]: eventsFile,
19
+ [codexCli.id]: codexCli,
20
+ [geminiCli.id]: geminiCli,
21
+ [copilotCli.id]: copilotCli,
22
+ [hermes.id]: hermes,
23
+ };
24
+
25
+ export const listSources = () => Object.keys(SOURCES);
26
+
27
+ /**
28
+ * 収集元を選び、共通の activity 形に揃えて返す。
29
+ *
30
+ * SOURCE はカンマ区切りで複数指定できる(例: claude-code,cowork)。
31
+ * Claude は Code と Cowork の2面を持つ1体なので、両方を合算して日報1通にまとめる。
32
+ *
33
+ * 帰属の規律:
34
+ * - エージェント名は「収集元」に縛る。claude-code / cowork から作った日報は必ず
35
+ * Claude 名義になり、AGENT_NAME で Hermes などに書き換えることはできない。
36
+ * 他のAI(他人)の作業を自分の実績にできてしまうと ✓ の意味が崩れるため。
37
+ * - 名義の異なる収集元どうしは合算できない。Claude の作業と Hermes の作業を
38
+ * 1通の日報に混ぜると、どちらが何をしたのか言えなくなる。
39
+ */
40
+ export const collect = async (config) => {
41
+ const ids = String(config.source)
42
+ .split(",")
43
+ .map((s) => s.trim())
44
+ .filter(Boolean);
45
+
46
+ const sources = ids.map((id) => {
47
+ const source = SOURCES[id];
48
+ if (!source) {
49
+ throw new Error(
50
+ `未知の SOURCE です: ${id}(使えるのは ${listSources().join(", ")})`,
51
+ );
52
+ }
53
+ return source;
54
+ });
55
+
56
+ if (sources.length === 0) {
57
+ throw new Error("SOURCE が空です。");
58
+ }
59
+
60
+ const names = [...new Set(sources.map((s) => s.agentName ?? config.agentName))];
61
+ if (names.length > 1) {
62
+ throw new Error(
63
+ `名義の違う収集元は合算できません(${names.join(" / ")})。` +
64
+ `別々の日報として出してください。`,
65
+ );
66
+ }
67
+ const agentName = names[0];
68
+
69
+ for (const source of sources) {
70
+ if (source.agentName && config.agentNameExplicit && config.agentName !== source.agentName) {
71
+ console.warn(
72
+ `[collect] AGENT_NAME=${config.agentName} は無視します。` +
73
+ `収集元 ${source.id} の日報は ${source.agentName} 名義でしか出せません。`,
74
+ );
75
+ }
76
+ }
77
+
78
+ // 各収集元は生イベントだけを返す。集計は合算後に1回だけ行う
79
+ // (収集元ごとに実働を出して足すと、同じ時間帯に両方動いていた分が二重に乗る)。
80
+ const collected = await Promise.all(
81
+ sources.map(async (source) => ({ source, ...await source.collect(config) })),
82
+ );
83
+
84
+ const events = collected.flatMap((c) =>
85
+ c.events.map((e) => ({ ...e, source: c.source.id })),
86
+ );
87
+ const prompts = collected
88
+ .flatMap((c) => c.prompts)
89
+ .sort((a, b) => a.time.localeCompare(b.time));
90
+ const sessions = new Set(collected.flatMap((c) => [...c.sessions]));
91
+ const maskedCount = collected.reduce((sum, c) => sum + c.maskedCount, 0);
92
+
93
+ return {
94
+ date: config.date,
95
+ events: events.sort((a, b) => a.ms - b.ms),
96
+ prompts,
97
+ maskedCount,
98
+ source: sources.map((s) => s.id).join("+"),
99
+ origin: sources.map((s) => s.origin).join(" / "),
100
+ agentName,
101
+ // 体名の表記は「◯◯のClaude」(§G)。英語所有格は使わない。
102
+ // なおサーバ(clubsx)は取込時に接続の呼び名から体名を組み直すので、ここは
103
+ // ローカル描画(--dry-run のプレビュー等)の見え方のためのフォールバック。
104
+ displayName: config.ownerName ? `${config.ownerName}の${agentName}` : agentName,
105
+ slug: (config.ownerName ? `${config.ownerName}-${agentName}` : agentName)
106
+ .toLowerCase()
107
+ .replace(/[^a-z0-9]+/g, "-")
108
+ .replace(/^-|-$/g, ""),
109
+ aggregates: computeAggregates({ events, sessions, idleGapMin: config.idleGapMin }),
110
+ };
111
+ };