@clubs.place/cli 0.1.25 → 0.1.26
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.
- package/.env.example +8 -2
- package/PROVENANCE.json +28 -13
- package/bin/clubs.mjs +196 -16
- package/package.json +1 -1
- package/src/config.mjs +34 -11
- package/src/config.test.mjs +83 -0
- package/src/generator-env.mjs +42 -0
- package/src/generator-env.test.mjs +66 -0
- package/src/generators/hermes.mjs +188 -0
- package/src/generators/hermes.test.mjs +287 -0
- package/src/hermes-cron-script.test.mjs +105 -0
- package/src/hermes-cron.live.test.mjs +97 -0
- package/src/hermes-cron.mjs +421 -0
- package/src/hermes-cron.test.mjs +430 -0
- package/src/init.pipeline.test.mjs +130 -0
- package/src/narrative.hermes.test.mjs +74 -0
- package/src/narrative.mjs +30 -5
- package/src/narrative.targets.test.mjs +26 -0
- package/src/pack.test.mjs +23 -0
- package/src/profiles.mjs +25 -1
- package/src/profiles.test.mjs +15 -0
- package/src/prompt.mjs +1 -0
- package/src/prompt.test.mjs +5 -0
- package/src/sources/hermes.mjs +18 -9
- package/src/sources/hermes.test.mjs +129 -0
- package/src/tick.pipeline.test.mjs +166 -0
- package/src/tick.profiles.test.mjs +131 -0
package/src/narrative.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import claudeGenerator from "./generators/claude.mjs";
|
|
|
3
3
|
import codexGenerator from "./generators/codex.mjs";
|
|
4
4
|
import geminiGenerator from "./generators/gemini.mjs";
|
|
5
5
|
import copilotGenerator from "./generators/copilot.mjs";
|
|
6
|
+
import hermesGenerator from "./generators/hermes.mjs";
|
|
6
7
|
import openrouterGenerator from "./generators/openrouter.mjs";
|
|
7
8
|
import openaiCompatGenerator from "./generators/openai-compat.mjs";
|
|
8
9
|
|
|
@@ -11,6 +12,7 @@ const GENERATORS = {
|
|
|
11
12
|
[codexGenerator.id]: codexGenerator,
|
|
12
13
|
[geminiGenerator.id]: geminiGenerator,
|
|
13
14
|
[copilotGenerator.id]: copilotGenerator,
|
|
15
|
+
[hermesGenerator.id]: hermesGenerator,
|
|
14
16
|
[openrouterGenerator.id]: openrouterGenerator,
|
|
15
17
|
[openaiCompatGenerator.id]: openaiCompatGenerator,
|
|
16
18
|
};
|
|
@@ -30,14 +32,32 @@ const extractJson = (raw) => {
|
|
|
30
32
|
|
|
31
33
|
const UNSET_PROJECT = "(案件未設定)";
|
|
32
34
|
|
|
35
|
+
/**
|
|
36
|
+
* 日報の本文に出してよい「触れた対象」か(2026-10-28:シェルのコマンド
|
|
37
|
+
* 「CAP=$(cat /root/…」がそのまま日報と BOARD に出ていた)。日報はチームが読む
|
|
38
|
+
* ので、コマンド・パス・URL・環境変数は出さない。ファイルは名前だけにする。
|
|
39
|
+
*/
|
|
40
|
+
const SHELL_TOOL = /^(bash|shell|sh|zsh|exec|exec_command|run_shell|terminal|powershell)/i;
|
|
41
|
+
const COMMANDISH =
|
|
42
|
+
/\$\(|`|&&|\|\||[;|]\s|^\s*[A-Za-z_][A-Za-z0-9_]*=|https?:\/\/|(?:^|\s)(?:sudo|cd|cat|ls|rm|cp|mv|echo|export|curl|wget|ssh|git|npm|npx|yarn|pnpm|node|python3?|pip|docker|kubectl|grep|sed|awk)\s/;
|
|
43
|
+
export const readableTarget = (e) => {
|
|
44
|
+
if (SHELL_TOOL.test(String(e?.name ?? ""))) return "";
|
|
45
|
+
let s = String(e?.summary ?? "")
|
|
46
|
+
.replace(/\s+/g, " ")
|
|
47
|
+
.trim();
|
|
48
|
+
if (!s || COMMANDISH.test(s)) return "";
|
|
49
|
+
// 空白の無いパスはファイル名だけ(/Users/… や /root/… の場所を出さない)。
|
|
50
|
+
if (!s.includes(" ") && s.includes("/")) s = s.split("/").filter(Boolean).at(-1) ?? "";
|
|
51
|
+
if (/(?:^|\s)(?:~|\/(?:root|home|Users|tmp|var|etc|opt|private))\//.test(s)) return "";
|
|
52
|
+
return s;
|
|
53
|
+
};
|
|
54
|
+
|
|
33
55
|
/** イベント列から「何に触れたか」を数件ひろう。要点推測の手がかりになる。 */
|
|
34
56
|
const representativeTargets = (list, limit = 4) => {
|
|
35
57
|
const seen = new Set();
|
|
36
58
|
const out = [];
|
|
37
59
|
for (const e of list) {
|
|
38
|
-
const s =
|
|
39
|
-
.replace(/\s+/g, " ")
|
|
40
|
-
.trim();
|
|
60
|
+
const s = readableTarget(e);
|
|
41
61
|
if (s && !seen.has(s)) {
|
|
42
62
|
seen.add(s);
|
|
43
63
|
// コードポイント単位で丸める(.slice は UTF-16 単位でサロゲートを割り、
|
|
@@ -69,6 +89,8 @@ const setupHelp = (generatorId) => {
|
|
|
69
89
|
return "【直し方】お使いの端末に Gemini CLI がインストールされているか確認してください。CLUBS の接続設定画面で Generator を変更することもできます。";
|
|
70
90
|
case "copilot":
|
|
71
91
|
return "【直し方】お使いの端末に Copilot CLI がインストールされているか確認してください。CLUBS の接続設定画面で Generator を変更することもできます。";
|
|
92
|
+
case "hermes":
|
|
93
|
+
return "【直し方】お使いの端末の Hermes の既定モデルで生成します(追加の API キーは要りません)。ターミナルで `hermes model` と打って既定モデルが選ばれているか確認してください。CLUBS の接続設定画面で Generator を変更することもできます。";
|
|
72
94
|
case "openrouter":
|
|
73
95
|
return "【直し方】OpenRouter の API キーが .env に正しく設定されているか確認してください。キーは https://openrouter.ai/keys から取得できます。";
|
|
74
96
|
case "openai-compat":
|
|
@@ -178,12 +200,15 @@ export const withBlockRepos = (narrative, activity, fallbackRepos = []) => {
|
|
|
178
200
|
* 本文生成。
|
|
179
201
|
*
|
|
180
202
|
* 生成器は差し替え可能:
|
|
181
|
-
* claude/codex/gemini/copilot … その端末の AI CLI をヘッドレスで叩く。
|
|
203
|
+
* claude/codex/gemini/copilot/hermes … その端末の AI CLI をヘッドレスで叩く。
|
|
182
204
|
* 収集元と揃えれば1つのサブスクで日報まで完結する
|
|
183
|
-
* openrouter … OpenRouter の API
|
|
205
|
+
* openrouter … OpenRouter の API。モデルを切り替えるエージェント向け
|
|
184
206
|
* openai-compat … OpenAI 互換のカスタムエンドポイント(Sakana Fugu 等)
|
|
185
207
|
* none … LLM を使わず、集計値だけで決定的に組む
|
|
186
208
|
*
|
|
209
|
+
* hermes は `hermes chat` を既定モデルで呼ぶ(-m/--provider なし=pin なし・
|
|
210
|
+
* 追加の API キーなし。実際のモデルは発火時の Hermes 設定で決まる)。
|
|
211
|
+
*
|
|
187
212
|
* どの生成器が落ちても throw しない。fallback(集計値だけの本文)に落として完走する。
|
|
188
213
|
* 日報が届かないより、機械的でも届くほうがよい。ただし黙って落ちない——
|
|
189
214
|
* 意図しない fallback は理由つきで本文と標準出力の両方に出す。
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { fallbackNarrative, readableTarget } from "./narrative.mjs";
|
|
4
|
+
|
|
5
|
+
test("コマンド・パス・URL は日報の本文に出さない(2026-10-28)", () => {
|
|
6
|
+
assert.equal(readableTarget({ name: "Bash", summary: "CAP=$(cat /root/.hermes/cap)" }), "");
|
|
7
|
+
assert.equal(readableTarget({ name: "Bash", summary: "npm run check" }), "");
|
|
8
|
+
assert.equal(readableTarget({ name: "Read", summary: "git log --oneline" }), "");
|
|
9
|
+
assert.equal(readableTarget({ name: "WebFetch", summary: "https://example.com/a?b=c" }), "");
|
|
10
|
+
assert.equal(readableTarget({ name: "Edit", summary: "/Users/me/github/app/src/board.ts" }), "board.ts");
|
|
11
|
+
assert.equal(readableTarget({ name: "Edit", summary: "LP の見出しを直す" }), "LP の見出しを直す");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("予備の本文:触れた対象が全部コマンドなら、ツールの内訳だけ", () => {
|
|
15
|
+
const n = fallbackNarrative({
|
|
16
|
+
date: "2026-10-27",
|
|
17
|
+
aggregates: { totalCalls: 2, failedCalls: 0, activeHours: 1, projects: [] },
|
|
18
|
+
events: [
|
|
19
|
+
{ time: "00:00", name: "Bash", project: "", summary: "CAP=$(cat /root/.hermes/cap)" },
|
|
20
|
+
{ time: "23:47", name: "Bash", project: "", summary: "ls /root" },
|
|
21
|
+
],
|
|
22
|
+
});
|
|
23
|
+
const text = n.workBlocks[0].text;
|
|
24
|
+
assert.doesNotMatch(text, /\/root|CAP=/);
|
|
25
|
+
assert.match(text, /内訳は Bash×2。/);
|
|
26
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
|
|
5
|
+
/* 配布物に必要なモジュールが入ること(npm pack --dry-run)。
|
|
6
|
+
* hermes-cron.mjs・hermes 生成器・bin が files 収録なのを確認する。 */
|
|
7
|
+
|
|
8
|
+
const REPO = new URL("../", import.meta.url).pathname;
|
|
9
|
+
|
|
10
|
+
test("npm pack --dry-run に必須モジュールが含まれる", () => {
|
|
11
|
+
const r = spawnSync("npm", ["pack", "--dry-run"], { cwd: REPO, encoding: "utf8", timeout: 120_000 });
|
|
12
|
+
assert.equal(r.status, 0, `npm pack --dry-run: ${r.stderr.slice(0, 300)}`);
|
|
13
|
+
const out = `${r.stdout}${r.stderr}`;
|
|
14
|
+
for (const need of [
|
|
15
|
+
"src/hermes-cron.mjs",
|
|
16
|
+
"src/generator-env.mjs",
|
|
17
|
+
"src/generators/hermes.mjs",
|
|
18
|
+
"src/profiles.mjs",
|
|
19
|
+
"bin/clubs.mjs",
|
|
20
|
+
]) {
|
|
21
|
+
assert.ok(out.includes(need), `${need} が配布物に要る`);
|
|
22
|
+
}
|
|
23
|
+
});
|
package/src/profiles.mjs
CHANGED
|
@@ -44,6 +44,21 @@ export const listProfiles = (home) => {
|
|
|
44
44
|
.sort();
|
|
45
45
|
};
|
|
46
46
|
|
|
47
|
+
/**
|
|
48
|
+
* --only=<収集元> の振り分け。その設定の SOURCE に含まれるものだけ通す。
|
|
49
|
+
* tick(毎時起動)と手動実行の両方で使う。only が無ければ全部通す。
|
|
50
|
+
* .env が無い・SOURCE が無い設定は通さない(何を読むか分からないため)。
|
|
51
|
+
*/
|
|
52
|
+
export const profileMatchesOnly = (dir, only) => {
|
|
53
|
+
if (!only) return true;
|
|
54
|
+
const source = readEnvFile(join(dir, ".env")).SOURCE;
|
|
55
|
+
if (!source) return false;
|
|
56
|
+
return String(source)
|
|
57
|
+
.split(",")
|
|
58
|
+
.map((s) => s.trim())
|
|
59
|
+
.includes(only);
|
|
60
|
+
};
|
|
61
|
+
|
|
47
62
|
/** .env を読む(コメント・空行は飛ばす)。無ければ空。 */
|
|
48
63
|
export const readEnvFile = (path) => {
|
|
49
64
|
if (!existsSync(path)) return {};
|
|
@@ -102,8 +117,17 @@ export const childEnv = (env, loadedKeys, home) => {
|
|
|
102
117
|
};
|
|
103
118
|
|
|
104
119
|
/** 並べた AI を1つずつ動かす(同じ引数で・出力はそのまま流す)。 */
|
|
105
|
-
export const runProfiles = async ({ home, args, loadedKeys, script, node = process.execPath }) => {
|
|
120
|
+
export const runProfiles = async ({ home, args, loadedKeys, script, node = process.execPath, only, shouldSkip }) => {
|
|
106
121
|
for (const dir of listProfiles(home)) {
|
|
122
|
+
if (!profileMatchesOnly(dir, only)) {
|
|
123
|
+
console.log(`\n[agents] --only=${only} のため飛ばします(${dir})`);
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
const skipped = shouldSkip?.(dir);
|
|
127
|
+
if (skipped) {
|
|
128
|
+
console.log(`\n[agents] 飛ばします(${dir}): ${skipped}`);
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
107
131
|
const name = readEnvFile(join(dir, ".env")).SOURCE || dir.split("/").pop();
|
|
108
132
|
console.log(`\n[agents] ${name} の分(${dir})`);
|
|
109
133
|
await new Promise((resolve) => {
|
package/src/profiles.test.mjs
CHANGED
|
@@ -71,3 +71,18 @@ test("最後に書いた .env の場所を APP_HOME/.last-init に残す(並
|
|
|
71
71
|
writeLastInit(h, env);
|
|
72
72
|
assert.equal(readFileSync(join(h, LAST_INIT_FILE), "utf8"), env + "\n");
|
|
73
73
|
});
|
|
74
|
+
|
|
75
|
+
test("--only=hermes は hermes の収集元だけ通す(tick の振り分け用)", async () => {
|
|
76
|
+
const { profileMatchesOnly } = await import("./profiles.mjs");
|
|
77
|
+
const h = home();
|
|
78
|
+
const hermesDir = join(h, "agents", "H");
|
|
79
|
+
mkdirSync(hermesDir, { recursive: true });
|
|
80
|
+
writeFileSync(join(hermesDir, ".env"), "SOURCE=hermes\n");
|
|
81
|
+
const claudeDir = join(h, "agents", "C");
|
|
82
|
+
mkdirSync(claudeDir, { recursive: true });
|
|
83
|
+
writeFileSync(join(claudeDir, ".env"), "SOURCE=claude-code,cowork\n");
|
|
84
|
+
assert.equal(profileMatchesOnly(hermesDir, "hermes"), true);
|
|
85
|
+
assert.equal(profileMatchesOnly(claudeDir, "hermes"), false);
|
|
86
|
+
assert.equal(profileMatchesOnly(claudeDir, undefined), true);
|
|
87
|
+
assert.equal(profileMatchesOnly(join(h, "agents", "N"), "hermes"), false);
|
|
88
|
+
});
|
package/src/prompt.mjs
CHANGED
|
@@ -75,6 +75,7 @@ const PROMPT_HEADER = `あなたはオーナーの AI アシスタント「{{AGE
|
|
|
75
75
|
- ダイジェストに無い事実を足さない。推測で数字を作らない。金額・件数はダイジェストにあるものだけ。
|
|
76
76
|
- 「[[REDACTED]]」はオーナーがマスクした箇所。そのまま [[REDACTED]] と書くこと(勝手に復元・推測しない)。
|
|
77
77
|
- 各ブロックは「何をして・どこまで進めたか」が伝わる 2〜4文のナラティブで、要点だけに絞る。代表的な成果・判断・結果を1つずつ書き、細かな下位項目を列挙・網羅しない(読むのに疲れさせない)。専門用語は開いて書く。冗長な前置き・同義の言い換え・水増しはしない。目安は3〜5行。
|
|
78
|
+
- 日報はチームが読みます。シェルのコマンド・コードの断片・ファイルの場所(/Users/… /root/… など)・環境変数・URL・キーやトークンは本文に書かないでください。何をしたかを言葉で書き、ファイルは名前だけにします。
|
|
78
79
|
- 申し送りは箇条書きで書いてください。1行につき用件はひとつだけです。複数の用件を1行にまとめないでください。(例: 「PR #32のマージ判断」と「接続ページの実装着手」は別の行にする)
|
|
79
80
|
- 申し送りに書くのは、今回はじめて出てきた用件だけです。すでに申し送った用件は、まだ済んでいなくても**もう一度書かないでください**(オーナーのタスクページに残っています)。新しい用件が無ければ『特にありません。』。{{HANDED_OFF}}{{TERMS}}
|
|
80
81
|
- terms は**拾うだけ**です。プロジェクト名・システム名・略語・機能名など固有名詞的な語を、本文(workBlocks / handoff / insights / selfNote)に書いたとおり**原文の一部をそのまま**入れてください。一般名詞・動詞は拾わない——**ただし、その組織が機能名・概念名として使っている日本語の語は拾う**(例:「申し送り」「用語集」「繰越」「本物証明」のように、辞書では一般名詞でも、そのチームでは決まったものを指して繰り返し使われている語)。日本語の語も英字の語と同じだけ拾ってください。**人名も拾わない**(オーナー・同僚・AI の体名)——用語集は「何をなんと呼ぶか」を揃える場所で、人の名前はそこに要らない。ただしアカウント名・サービス名として使われている場合(例:「◯◯名義の投稿」)は、人ではなく対象なので拾ってよい。言い換え・要約・定義文は書かない(本文と1文字でも違う語は捨てられます)。**語であって文ではない**——文・箇条書きの一行・ファイル名・日付や見積りの入った見出し・図のラベル列は拾わない。無ければ空配列。
|
package/src/prompt.test.mjs
CHANGED
package/src/sources/hermes.mjs
CHANGED
|
@@ -25,10 +25,15 @@ import { maskText } from "../mask.mjs";
|
|
|
25
25
|
* このソースが名義れるのは Hermes だけ(帰属の規律)。
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
28
|
+
/* HERMES_HOME を尊重する(明示の HERMES_SESSIONS_DIR/HERMES_STATE_DB が勝つ)。
|
|
29
|
+
* 収集時に毎回引く——モジュール読込時の固定値にすると HERMES_HOME 切替と
|
|
30
|
+
* テストの隔離が効かない。 */
|
|
31
|
+
export const hermesHomeDir = () =>
|
|
32
|
+
process.env.HERMES_HOME || join(homedir(), ".hermes");
|
|
33
|
+
export const sessionsDir = () =>
|
|
34
|
+
process.env.HERMES_SESSIONS_DIR || join(hermesHomeDir(), "sessions");
|
|
35
|
+
export const stateDbPath = () =>
|
|
36
|
+
process.env.HERMES_STATE_DB || join(hermesHomeDir(), "state.db");
|
|
32
37
|
|
|
33
38
|
/** ISO(UTC) → ローカルの YYYY-MM-DD / HH:MM(tzOffsetMin 分ずらして判定)。 */
|
|
34
39
|
const localParts = (iso, tzOffsetMin) => {
|
|
@@ -110,7 +115,7 @@ const collectFromJsonFiles = (config) => {
|
|
|
110
115
|
|
|
111
116
|
let sessionNames;
|
|
112
117
|
try {
|
|
113
|
-
sessionNames = readdirSync(
|
|
118
|
+
sessionNames = readdirSync(sessionsDir())
|
|
114
119
|
.filter((f) => f.startsWith("session_") && f.endsWith(".json"))
|
|
115
120
|
.map((f) => f.replace(".json", ""));
|
|
116
121
|
} catch {
|
|
@@ -129,7 +134,7 @@ const collectFromJsonFiles = (config) => {
|
|
|
129
134
|
let maskedCount = 0;
|
|
130
135
|
|
|
131
136
|
for (const sid of sessionNames) {
|
|
132
|
-
const fpath = join(
|
|
137
|
+
const fpath = join(sessionsDir(), `${sid}.json`);
|
|
133
138
|
|
|
134
139
|
try {
|
|
135
140
|
const mtime = statSync(fpath).mtimeMs;
|
|
@@ -146,6 +151,9 @@ const collectFromJsonFiles = (config) => {
|
|
|
146
151
|
continue;
|
|
147
152
|
}
|
|
148
153
|
|
|
154
|
+
// --source tool の日報生成セッションは集めない(自己再帰防止)。他は残す。
|
|
155
|
+
if (data.source === "tool") continue;
|
|
156
|
+
|
|
149
157
|
const updated = data.last_updated ?? data.session_start;
|
|
150
158
|
const parts = localParts(updated, tzOffsetMin);
|
|
151
159
|
if (!parts || parts.date !== date) continue;
|
|
@@ -230,7 +238,7 @@ const collectFromJsonFiles = (config) => {
|
|
|
230
238
|
const collectFromStateDb = async (config) => {
|
|
231
239
|
const { date, tzOffsetMin, maskPatterns, includePrompts } = config;
|
|
232
240
|
|
|
233
|
-
if (!existsSync(
|
|
241
|
+
if (!existsSync(stateDbPath())) {
|
|
234
242
|
return { events: [], prompts: [], sessions: new Set(), maskedCount: 0 };
|
|
235
243
|
}
|
|
236
244
|
|
|
@@ -249,7 +257,7 @@ const collectFromStateDb = async (config) => {
|
|
|
249
257
|
|
|
250
258
|
let db;
|
|
251
259
|
try {
|
|
252
|
-
db = new DatabaseSync(
|
|
260
|
+
db = new DatabaseSync(stateDbPath(), { readOnly: true });
|
|
253
261
|
} catch {
|
|
254
262
|
return { events: [], prompts: [], sessions: new Set(), maskedCount: 0 };
|
|
255
263
|
}
|
|
@@ -262,11 +270,12 @@ const collectFromStateDb = async (config) => {
|
|
|
262
270
|
// 対象日に「活動した」セッションを取得(開始日ではなく活動日で選ぶ)。
|
|
263
271
|
// 長く続くスレッド(例: 数日前に開始して当日も進めたセッション)を
|
|
264
272
|
// 開始日で弾くと、当日の作業が丸ごと日報から消える。
|
|
273
|
+
// --source tool の日報生成セッションは集めない(自己再帰防止)。他は残す。
|
|
265
274
|
const sessionRows = db
|
|
266
275
|
.prepare(
|
|
267
276
|
`SELECT DISTINCT s.id, s.started_at, s.source FROM sessions s
|
|
268
277
|
JOIN messages m ON m.session_id = s.id
|
|
269
|
-
WHERE m.timestamp >= ? AND m.timestamp < ?
|
|
278
|
+
WHERE m.timestamp >= ? AND m.timestamp < ? AND s.source != 'tool'
|
|
270
279
|
ORDER BY s.started_at`
|
|
271
280
|
)
|
|
272
281
|
.all(dayStartUtc, dayEndUtc);
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { spawn } from "node:child_process";
|
|
7
|
+
|
|
8
|
+
// Hermes collector: HERMES_HOME 尊重 + source=tool 除外。
|
|
9
|
+
// モジュール定数の import キャッシュを避けるため subprocess で検証する。
|
|
10
|
+
const REPO = new URL("../../", import.meta.url).pathname;
|
|
11
|
+
const DATE = "2026-10-28";
|
|
12
|
+
const NOON = Date.parse(`${DATE}T12:00:00Z`) / 1000;
|
|
13
|
+
|
|
14
|
+
const runCollect = (env) =>
|
|
15
|
+
new Promise((resolve, reject) => {
|
|
16
|
+
const childEnv = { ...process.env, ...env };
|
|
17
|
+
// 明示指定が無い場合は親環境の漏れを消し、HERMES_HOME 既定の導出にする
|
|
18
|
+
if (env.HERMES_HOME !== undefined) {
|
|
19
|
+
if (env.HERMES_SESSIONS_DIR === undefined) delete childEnv.HERMES_SESSIONS_DIR;
|
|
20
|
+
if (env.HERMES_STATE_DB === undefined) delete childEnv.HERMES_STATE_DB;
|
|
21
|
+
}
|
|
22
|
+
const code = [
|
|
23
|
+
`const m = await import(${JSON.stringify(join(REPO, "src/sources/hermes.mjs"))});`,
|
|
24
|
+
`const r = await m.default.collect({ date: ${JSON.stringify(DATE)}, tzOffsetMin: 0, maskPatterns: [], includePrompts: true });`,
|
|
25
|
+
`console.log(JSON.stringify({ events: r.events.map((e) => e.summary), sessions: [...r.sessions] }));`,
|
|
26
|
+
].join("\n");
|
|
27
|
+
const child = spawn(process.execPath, ["--input-type=module", "-e", code], {
|
|
28
|
+
env: childEnv,
|
|
29
|
+
});
|
|
30
|
+
let out = "";
|
|
31
|
+
let err = "";
|
|
32
|
+
child.stdout.on("data", (d) => (out += d));
|
|
33
|
+
child.stderr.on("data", (d) => (err += d));
|
|
34
|
+
const timer = setTimeout(() => {
|
|
35
|
+
child.kill("SIGKILL");
|
|
36
|
+
reject(new Error("collect timeout"));
|
|
37
|
+
}, 60_000);
|
|
38
|
+
child.on("close", (st) => {
|
|
39
|
+
clearTimeout(timer);
|
|
40
|
+
if (st !== 0) reject(new Error(`exit ${st}: ${err}`.slice(0, 500)));
|
|
41
|
+
else resolve(JSON.parse(out));
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const jsonSession = (source, marker) => ({
|
|
46
|
+
session_id: "s",
|
|
47
|
+
session_start: `${DATE}T12:00:00Z`,
|
|
48
|
+
last_updated: `${DATE}T12:00:00Z`,
|
|
49
|
+
...(source ? { source } : {}),
|
|
50
|
+
messages: [
|
|
51
|
+
{ role: "assistant", tool_calls: [{ id: "c1", function: { name: "terminal", arguments: JSON.stringify({ command: marker }) } }] },
|
|
52
|
+
{ role: "tool", tool_call_id: "c1", name: "terminal", content: '{"ok":true}' },
|
|
53
|
+
],
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const writeDb = (dbPath, rows) =>
|
|
57
|
+
new Promise((resolve, reject) => {
|
|
58
|
+
const setupFile = join(mkdtempSync(join(tmpdir(), "nippo-dbsetup-")), "setup.mjs");
|
|
59
|
+
writeFileSync(
|
|
60
|
+
setupFile,
|
|
61
|
+
[
|
|
62
|
+
`import { DatabaseSync } from "node:sqlite";`,
|
|
63
|
+
`const [dbPath, rowsJson] = process.argv.slice(2);`,
|
|
64
|
+
`const rows = JSON.parse(rowsJson);`,
|
|
65
|
+
`const OK_JSON = JSON.stringify({ ok: true });`,
|
|
66
|
+
`const db = new DatabaseSync(dbPath);`,
|
|
67
|
+
`db.exec("CREATE TABLE sessions (id TEXT PRIMARY KEY, started_at REAL, source TEXT)");`,
|
|
68
|
+
`db.exec("CREATE TABLE messages (id INTEGER PRIMARY KEY, session_id TEXT, role TEXT, tool_name TEXT, tool_calls TEXT, content TEXT, timestamp REAL, tool_call_id TEXT)");`,
|
|
69
|
+
`for (const [sid, source, marker, cid, ts] of rows) {`,
|
|
70
|
+
` db.prepare("INSERT INTO sessions (id, started_at, source) VALUES (?, ?, ?)").run(sid, ts, source);`,
|
|
71
|
+
` const tc = JSON.stringify([{ id: cid, function: { name: "terminal", arguments: JSON.stringify({ command: marker }) } }]);`,
|
|
72
|
+
` db.prepare("INSERT INTO messages (session_id, role, tool_calls, timestamp) VALUES (?, 'assistant', ?, ?)").run(sid, tc, ts);`,
|
|
73
|
+
` db.prepare("INSERT INTO messages (session_id, role, tool_name, content, timestamp, tool_call_id) VALUES (?, 'tool', 'terminal', ?, ?, ?)").run(sid, OK_JSON, ts + 60, cid);`,
|
|
74
|
+
`}`,
|
|
75
|
+
`db.close();`,
|
|
76
|
+
].join("\n"),
|
|
77
|
+
"utf8",
|
|
78
|
+
);
|
|
79
|
+
const child = spawn(process.execPath, [setupFile, dbPath, JSON.stringify(rows.map((r) => [...r, NOON]))], { env: process.env });
|
|
80
|
+
let err = "";
|
|
81
|
+
child.stderr.on("data", (d) => (err += d));
|
|
82
|
+
child.on("close", (st) => (st === 0 ? resolve() : reject(new Error(`db setup exit ${st}: ${err}`.slice(0, 500)))));
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("HERMES_HOME 下の sessions/state.db を読む", async () => {
|
|
86
|
+
const hhome = mkdtempSync(join(tmpdir(), "nippo-hhome-"));
|
|
87
|
+
mkdirSync(join(hhome, "sessions"), { recursive: true });
|
|
88
|
+
writeFileSync(join(hhome, "sessions", "session_20261028_120000.json"), JSON.stringify(jsonSession("cli", "HHOME_MARKER_CMD")));
|
|
89
|
+
const r = await runCollect({ HERMES_HOME: hhome });
|
|
90
|
+
assert.ok(r.events.some((s) => String(s).includes("HHOME_MARKER_CMD")), `HERMES_HOME配下を読む: ${JSON.stringify(r)}`);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("明示の HERMES_SESSIONS_DIR/HERMES_STATE_DB が HERMES_HOME に勝つ", async () => {
|
|
94
|
+
const hhome = mkdtempSync(join(tmpdir(), "nippo-hhome2-"));
|
|
95
|
+
mkdirSync(join(hhome, "sessions"), { recursive: true });
|
|
96
|
+
writeFileSync(join(hhome, "sessions", "session_20261028_120000.json"), JSON.stringify(jsonSession("cli", "HOME_LOSE_MARKER")));
|
|
97
|
+
const other = mkdtempSync(join(tmpdir(), "nippo-explicit-"));
|
|
98
|
+
mkdirSync(join(other, "sessions"), { recursive: true });
|
|
99
|
+
writeFileSync(join(other, "sessions", "session_20261028_120000.json"), JSON.stringify(jsonSession("cli", "EXPLICIT_WIN_MARKER")));
|
|
100
|
+
const r = await runCollect({
|
|
101
|
+
HERMES_HOME: hhome,
|
|
102
|
+
HERMES_SESSIONS_DIR: join(other, "sessions"),
|
|
103
|
+
HERMES_STATE_DB: join(other, "no-state.db"),
|
|
104
|
+
});
|
|
105
|
+
assert.ok(r.events.some((s) => String(s).includes("EXPLICIT_WIN_MARKER")), `明示パスを読む: ${JSON.stringify(r)}`);
|
|
106
|
+
assert.ok(!r.events.some((s) => String(s).includes("HOME_LOSE_MARKER")), `HERMES_HOME側は読まない: ${JSON.stringify(r)}`);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("source=tool の JSON セッションを除外する(他は残す)", async () => {
|
|
110
|
+
const hhome = mkdtempSync(join(tmpdir(), "nippo-tooljson-"));
|
|
111
|
+
mkdirSync(join(hhome, "sessions"), { recursive: true });
|
|
112
|
+
writeFileSync(join(hhome, "sessions", "session_20261028_110000.json"), JSON.stringify(jsonSession("tool", "TOOL_JSON_DROP")));
|
|
113
|
+
writeFileSync(join(hhome, "sessions", "session_20261028_120000.json"), JSON.stringify(jsonSession("cli", "USER_JSON_KEEP")));
|
|
114
|
+
const r = await runCollect({ HERMES_HOME: hhome, HERMES_STATE_DB: join(hhome, "no-state.db") });
|
|
115
|
+
assert.ok(!r.events.some((s) => String(s).includes("TOOL_JSON_DROP")), `toolは除外: ${JSON.stringify(r)}`);
|
|
116
|
+
assert.ok(r.events.some((s) => String(s).includes("USER_JSON_KEEP")), `非toolは残す: ${JSON.stringify(r)}`);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("source=tool の state.db セッションを除外する(他は残す)", async () => {
|
|
120
|
+
const hhome = mkdtempSync(join(tmpdir(), "nippo-tooldb-"));
|
|
121
|
+
mkdirSync(join(hhome, "sessions"), { recursive: true });
|
|
122
|
+
await writeDb(join(hhome, "state.db"), [
|
|
123
|
+
["sess-tool", "tool", "TOOL_DB_DROP", "call-tool"],
|
|
124
|
+
["sess-user", "cli", "USER_DB_KEEP", "call-user"],
|
|
125
|
+
]);
|
|
126
|
+
const r = await runCollect({ HERMES_HOME: hhome });
|
|
127
|
+
assert.ok(!r.events.some((s) => String(s).includes("TOOL_DB_DROP")), `toolは除外: ${JSON.stringify(r)}`);
|
|
128
|
+
assert.ok(r.events.some((s) => String(s).includes("USER_DB_KEEP")), `非toolは残す: ${JSON.stringify(r)}`);
|
|
129
|
+
});
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, chmodSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { spawn } from "node:child_process";
|
|
7
|
+
import http from "node:http";
|
|
8
|
+
|
|
9
|
+
/* tick の実エントリポイント(bin/clubs.mjs)を、スタブの collector-config
|
|
10
|
+
* サーバ+スタブ hermes で動かす。旧来の設定値(generator.backend の汎用既定)
|
|
11
|
+
* が hermes 収集元を claude に戻さないこと、--only=hermes が fetch 前に絞って
|
|
12
|
+
* root の Claude 設定で取りに行かないことを、実パイプラインで証明する。
|
|
13
|
+
* 実 Hermes・実 CLUBS・実 cron には触らない(HERMES_SESSIONS_DIR/STATE_DB も
|
|
14
|
+
* 隔離し、会話一覧の汚染も無い)。 */
|
|
15
|
+
|
|
16
|
+
const REPO = new URL("../", import.meta.url).pathname;
|
|
17
|
+
const BIN = join(REPO, "bin", "clubs.mjs");
|
|
18
|
+
|
|
19
|
+
const collectorConfig = (over = {}) => ({
|
|
20
|
+
schedule: { enabled: true, time: "00:00", tz: "JST" },
|
|
21
|
+
due: true,
|
|
22
|
+
delivered: false,
|
|
23
|
+
reportDate: "2026-10-28",
|
|
24
|
+
now: { time: "01:00" },
|
|
25
|
+
sources: ["hermes"],
|
|
26
|
+
generator: { backend: "claude-cli" },
|
|
27
|
+
...over,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
/** スタブ CLUBS。collector-config を返し、ingest を受けて数える。 */
|
|
31
|
+
const startStubClubs = (config) =>
|
|
32
|
+
new Promise((resolve) => {
|
|
33
|
+
const hits = { config: 0, ingest: 0 };
|
|
34
|
+
const server = http.createServer((req, res) => {
|
|
35
|
+
if (req.url.endsWith("/collector-config")) {
|
|
36
|
+
hits.config += 1;
|
|
37
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
38
|
+
res.end(JSON.stringify(typeof config === "function" ? config(req) : config));
|
|
39
|
+
} else if (req.url.endsWith("/ingest")) {
|
|
40
|
+
hits.ingest += 1;
|
|
41
|
+
req.resume();
|
|
42
|
+
req.on("end", () => {
|
|
43
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
44
|
+
res.end("{}");
|
|
45
|
+
});
|
|
46
|
+
} else {
|
|
47
|
+
res.writeHead(404);
|
|
48
|
+
res.end();
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
server.listen(0, "127.0.0.1", () => {
|
|
52
|
+
resolve({ server, hits, url: `http://127.0.0.1:${server.address().port}` });
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
/** 呼ばれたら記録する hermes スタブ(呼ばれないはずの場面で使う)。 */
|
|
57
|
+
const recordingHermes = (dir) => {
|
|
58
|
+
const p = join(dir, "hermes-stub.sh");
|
|
59
|
+
writeFileSync(p, `#!/bin/sh\necho "$@" > ${dir}/argv.txt\ncat > ${dir}/stdin.txt\nprintf '{}'\n`, "utf8");
|
|
60
|
+
chmodSync(p, 0o755);
|
|
61
|
+
return p;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/* spawnSync は親のイベントループを止めてスタブサーバが応答できなくなるため、
|
|
65
|
+
* 必ず非同期 spawn(イベントループを生かしたまま子の終了を待つ)。 */
|
|
66
|
+
const runTick = (home, server, { extraEnv = {}, args = ["tick", "--only=hermes"] } = {}) =>
|
|
67
|
+
new Promise((resolve, reject) => {
|
|
68
|
+
const env = { ...process.env, ...extraEnv };
|
|
69
|
+
env.CLUBS_NIPPO_HOME = home;
|
|
70
|
+
env.NIPPO_INGEST_URL = `${server.url}/api/nippo/ingest`;
|
|
71
|
+
env.NIPPO_INGEST_TOKEN = "test-token";
|
|
72
|
+
env.HERMES_SESSIONS_DIR = join(home, "empty-sessions");
|
|
73
|
+
env.HERMES_STATE_DB = join(home, "no-state.db");
|
|
74
|
+
mkdirSync(env.HERMES_SESSIONS_DIR, { recursive: true });
|
|
75
|
+
for (const k of [
|
|
76
|
+
"GENERATOR",
|
|
77
|
+
"SOURCE",
|
|
78
|
+
"EVENTS_FILE",
|
|
79
|
+
"AGENT_NAME",
|
|
80
|
+
"OPENROUTER_MODEL",
|
|
81
|
+
"BACKEND_BASE_URL",
|
|
82
|
+
"BACKEND_MODEL",
|
|
83
|
+
"CLUBS_NIPPO_CHILD",
|
|
84
|
+
"CLUBS_NIPPO_SCHEDULER",
|
|
85
|
+
]) {
|
|
86
|
+
if (!(k in extraEnv)) delete env[k];
|
|
87
|
+
}
|
|
88
|
+
const child = spawn(process.execPath, [BIN, ...args], { env });
|
|
89
|
+
let stdout = "";
|
|
90
|
+
let stderr = "";
|
|
91
|
+
child.stdout.on("data", (d) => (stdout += d));
|
|
92
|
+
child.stderr.on("data", (d) => (stderr += d));
|
|
93
|
+
const timer = setTimeout(() => {
|
|
94
|
+
child.kill("SIGKILL");
|
|
95
|
+
reject(new Error(`tick が終わらない: ${stdout}${stderr}`.slice(0, 500)));
|
|
96
|
+
}, 120_000);
|
|
97
|
+
child.on("error", (e) => {
|
|
98
|
+
clearTimeout(timer);
|
|
99
|
+
reject(e);
|
|
100
|
+
});
|
|
101
|
+
child.on("close", (status) => {
|
|
102
|
+
clearTimeout(timer);
|
|
103
|
+
resolve({ status, stdout, stderr });
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const writeHome = (home, lines) => {
|
|
108
|
+
mkdirSync(join(home, "out"), { recursive: true });
|
|
109
|
+
writeFileSync(join(home, ".env"), lines + "\n", "utf8");
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
for (const legacy of [{ backend: "claude-cli" }, { backend: "openrouter" }]) {
|
|
113
|
+
test(`旧来の汎用既定 ${legacy.backend}(モデルなし)でも hermes 収集元は hermes 生成のまま`, async () => {
|
|
114
|
+
const { server, hits, url } = await startStubClubs(collectorConfig({ generator: legacy }));
|
|
115
|
+
const home = mkdtempSync(join(tmpdir(), "nippo-tick-"));
|
|
116
|
+
writeHome(home, `SOURCE=hermes\nNIPPO_INGEST_URL=${url}/api/nippo/ingest\nNIPPO_INGEST_TOKEN=test-token`);
|
|
117
|
+
const stubDir = mkdtempSync(join(tmpdir(), "nippo-tick-bin-"));
|
|
118
|
+
try {
|
|
119
|
+
const r = await runTick(home, { url }, { extraEnv: { HERMES_BIN: recordingHermes(stubDir) } });
|
|
120
|
+
assert.equal(r.status, 0, `exit 0 のはず: ${r.stderr}${r.stdout}`);
|
|
121
|
+
assert.match(r.stdout, /\[tick\] 発火/);
|
|
122
|
+
assert.match(r.stdout, /generator=hermes/);
|
|
123
|
+
assert.ok(!r.stdout.includes("GENERATOR=claude"), "claude に戻さない");
|
|
124
|
+
assert.ok(hits.config >= 1, "設定を取りに行った");
|
|
125
|
+
assert.equal(hits.ingest, 1, "取込まで完走する");
|
|
126
|
+
assert.ok(existsSync(join(home, "out", "nippo-2026-10-28.html")), "描画まで進む");
|
|
127
|
+
assert.equal(existsSync(join(stubDir, "argv.txt")), false, "集計だけの日は生成器を呼ばない");
|
|
128
|
+
} finally {
|
|
129
|
+
server.close();
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
test("root が Claude でも tick --only=hermes は取りに行かない(子の fanout は別)", async () => {
|
|
135
|
+
const { server, hits, url } = await startStubClubs(collectorConfig({ due: true, sources: ["claude-code"] }));
|
|
136
|
+
const home = mkdtempSync(join(tmpdir(), "nippo-tick-"));
|
|
137
|
+
writeHome(home, `SOURCE=claude-code\nNIPPO_INGEST_URL=${url}/api/nippo/ingest\nNIPPO_INGEST_TOKEN=test-token`);
|
|
138
|
+
try {
|
|
139
|
+
const r = await runTick(home, { url });
|
|
140
|
+
assert.equal(r.status, 0, `exit 0 のはず: ${r.stderr}${r.stdout}`);
|
|
141
|
+
assert.match(r.stdout, /--only=hermes/);
|
|
142
|
+
assert.equal(hits.config, 0, "fetch しない");
|
|
143
|
+
assert.equal(hits.ingest, 0, "発火しない");
|
|
144
|
+
assert.ok(!existsSync(join(home, "out", "nippo-2026-10-28.html")));
|
|
145
|
+
} finally {
|
|
146
|
+
server.close();
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("ローカル明示の GENERATOR=none はアプリ設定より勝つ(上書きしない)", async () => {
|
|
151
|
+
const { server, hits, url } = await startStubClubs(collectorConfig({ generator: { backend: "hermes-cli" } }));
|
|
152
|
+
const home = mkdtempSync(join(tmpdir(), "nippo-tick-"));
|
|
153
|
+
writeHome(
|
|
154
|
+
home,
|
|
155
|
+
`SOURCE=hermes\nGENERATOR=none\nNIPPO_INGEST_URL=${url}/api/nippo/ingest\nNIPPO_INGEST_TOKEN=test-token`,
|
|
156
|
+
);
|
|
157
|
+
try {
|
|
158
|
+
const r = await runTick(home, { url });
|
|
159
|
+
assert.equal(r.status, 0, `exit 0 のはず: ${r.stderr}${r.stdout}`);
|
|
160
|
+
assert.match(r.stdout, /generator=none/);
|
|
161
|
+
assert.equal(hits.ingest, 1, "取込まで完走する");
|
|
162
|
+
assert.ok(readFileSync(join(home, "out", "nippo-2026-10-28.html"), "utf8").length > 0);
|
|
163
|
+
} finally {
|
|
164
|
+
server.close();
|
|
165
|
+
}
|
|
166
|
+
});
|