@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
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { tmpdir, homedir } from "node:os";
|
|
6
|
+
import {
|
|
7
|
+
HERMES_CRON_JOB_NAME,
|
|
8
|
+
HERMES_CRON_SCHEDULE,
|
|
9
|
+
MARKER_FILE,
|
|
10
|
+
hermesHomeDir,
|
|
11
|
+
cronScriptRel,
|
|
12
|
+
cronScriptPath,
|
|
13
|
+
markerPath,
|
|
14
|
+
readMarker,
|
|
15
|
+
buildTickScript,
|
|
16
|
+
shouldSkipTick,
|
|
17
|
+
installHermesCron,
|
|
18
|
+
removeHermesCron,
|
|
19
|
+
statusHermesCron,
|
|
20
|
+
} from "./hermes-cron.mjs";
|
|
21
|
+
|
|
22
|
+
/* Hermes ネイティブ cron(script-only tick)の管理。公式 `hermes cron` CLI
|
|
23
|
+
だけを使い、jobs.json は読むだけ(書かない)。モデル pin なし・追加の
|
|
24
|
+
LLM 資格情報なし。テストは runner/jobsReader を差し替える(実登録しない)。 */
|
|
25
|
+
|
|
26
|
+
const tmp = () => mkdtempSync(join(tmpdir(), "nippo-hcron-"));
|
|
27
|
+
|
|
28
|
+
test("定数: 名前・毎時・印ファイル", () => {
|
|
29
|
+
assert.equal(HERMES_CRON_JOB_NAME, "clubs-nippo-hermes");
|
|
30
|
+
assert.equal(HERMES_CRON_SCHEDULE, "0 * * * *");
|
|
31
|
+
assert.equal(MARKER_FILE, ".hermes-cron.json");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("HERMES_HOME を尊重する(未設定なら ~/.hermes)", () => {
|
|
35
|
+
const old = process.env.HERMES_HOME;
|
|
36
|
+
try {
|
|
37
|
+
process.env.HERMES_HOME = "/tmp/x-hermes-home";
|
|
38
|
+
assert.equal(hermesHomeDir(), "/tmp/x-hermes-home");
|
|
39
|
+
delete process.env.HERMES_HOME;
|
|
40
|
+
assert.equal(hermesHomeDir(), join(homedir(), ".hermes"));
|
|
41
|
+
} finally {
|
|
42
|
+
if (old === undefined) delete process.env.HERMES_HOME;
|
|
43
|
+
else process.env.HERMES_HOME = old;
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("スクリプトは HERMES_HOME/scripts 下の相対名", () => {
|
|
48
|
+
const h = tmp();
|
|
49
|
+
assert.equal(cronScriptRel(), "clubs-nippo-hermes-tick.sh");
|
|
50
|
+
assert.equal(cronScriptPath(h), join(h, "scripts", "clubs-nippo-hermes-tick.sh"));
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("buildTickScript: hermes-cron 印 + tick --only=hermes + モデル指定なし", () => {
|
|
54
|
+
const s = buildTickScript({ npxPath: "/usr/bin/npx", hermesBinDir: "/usr/bin", logPath: "/tmp/x.log" });
|
|
55
|
+
assert.match(s, /CLUBS_NIPPO_SCHEDULER=hermes-cron/);
|
|
56
|
+
assert.match(s, /tick --only=hermes/);
|
|
57
|
+
for (const flag of ["--model", "--pin", "--provider", "OPENROUTER", "GENERATOR="]) {
|
|
58
|
+
assert.ok(!s.includes(flag), `${flag} を含んではならない`);
|
|
59
|
+
}
|
|
60
|
+
assert.match(s, /\/tmp\/x\.log/);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("印のある設定は OS 側 tick を飛ばす(hermes-cron の実行だけ通す)", () => {
|
|
64
|
+
const home = tmp();
|
|
65
|
+
assert.equal(shouldSkipTick(home, {}).skip, false);
|
|
66
|
+
writeFileSync(join(home, MARKER_FILE), JSON.stringify({ job: "abc" }));
|
|
67
|
+
assert.equal(shouldSkipTick(home, {}).skip, true);
|
|
68
|
+
assert.match(shouldSkipTick(home, {}).reason, /hermes-cron/);
|
|
69
|
+
assert.equal(shouldSkipTick(home, { scheduler: "hermes-cron" }).skip, false);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("install: 初回は script 書込 + hermes cron create(pin なし)+ 印", () => {
|
|
73
|
+
const home = tmp();
|
|
74
|
+
writeFileSync(join(home, ".env"), "SOURCE=hermes\n");
|
|
75
|
+
const hhome = tmp();
|
|
76
|
+
mkdirSync(join(hhome, "cron"), { recursive: true });
|
|
77
|
+
writeFileSync(join(hhome, "cron", "jobs.json"), JSON.stringify({ jobs: [], updated_at: "" }));
|
|
78
|
+
const calls = [];
|
|
79
|
+
const runner = (args) => {
|
|
80
|
+
calls.push(args);
|
|
81
|
+
if (args[1] === "list") return { status: 0, stdout: "", stderr: "" };
|
|
82
|
+
if (args[1] === "create") {
|
|
83
|
+
const jobs = JSON.parse(readFileSync(join(hhome, "cron", "jobs.json"), "utf8"));
|
|
84
|
+
jobs.jobs.push({
|
|
85
|
+
id: "job1",
|
|
86
|
+
name: HERMES_CRON_JOB_NAME,
|
|
87
|
+
script: cronScriptRel(),
|
|
88
|
+
no_agent: true,
|
|
89
|
+
deliver: "local",
|
|
90
|
+
enabled: true,
|
|
91
|
+
schedule: { kind: "cron", expr: HERMES_CRON_SCHEDULE },
|
|
92
|
+
model: null,
|
|
93
|
+
});
|
|
94
|
+
writeFileSync(join(hhome, "cron", "jobs.json"), JSON.stringify(jobs));
|
|
95
|
+
return { status: 0, stdout: "created job1", stderr: "" };
|
|
96
|
+
}
|
|
97
|
+
throw new Error(`unexpected: ${args}`);
|
|
98
|
+
};
|
|
99
|
+
const r = installHermesCron({
|
|
100
|
+
home,
|
|
101
|
+
hermesHome: hhome,
|
|
102
|
+
npxPath: "/usr/bin/npx",
|
|
103
|
+
hermesBinDir: "/usr/bin",
|
|
104
|
+
runner,
|
|
105
|
+
});
|
|
106
|
+
assert.equal(r.ok, true);
|
|
107
|
+
assert.equal(r.created, true);
|
|
108
|
+
assert.equal(r.job, "job1");
|
|
109
|
+
const create = calls.find((a) => a[1] === "create");
|
|
110
|
+
assert.ok(create.includes(HERMES_CRON_SCHEDULE));
|
|
111
|
+
assert.ok(create.includes("--no-agent") && create.includes("--deliver") && create.includes("local"));
|
|
112
|
+
assert.ok(create.includes("--script") && create.includes(cronScriptRel()));
|
|
113
|
+
assert.ok(!create.includes("--model") && !create.includes("--pin") && !create.includes("--provider"));
|
|
114
|
+
assert.ok(existsSync(cronScriptPath(hhome)), "script が書かれる");
|
|
115
|
+
const script = readFileSync(cronScriptPath(hhome), "utf8");
|
|
116
|
+
assert.ok(script.includes(`CLUBS_NIPPO_HOME='${home}'`), "基底 home に束縛する");
|
|
117
|
+
assert.ok(script.includes(`CLUBS_NIPPO_SCHEDULER_HERMES_HOME='${hhome}'`), "所有者 home を束縛する");
|
|
118
|
+
assert.ok(!script.includes("export HERMES_HOME="), "収集用 home をグローバルに固定しない");
|
|
119
|
+
assert.equal(readMarker(home).job, "job1");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("install: 子の home で呼んでも基底に束縛する(子に上書きしない)", async () => {
|
|
123
|
+
const { resolveBaseHome } = await import("./hermes-cron.mjs");
|
|
124
|
+
const app = tmp();
|
|
125
|
+
writeFileSync(join(app, ".env"), "SOURCE=hermes\n");
|
|
126
|
+
mkdirSync(join(app, "agents", "AG2"), { recursive: true });
|
|
127
|
+
writeFileSync(join(app, "agents", "AG2", ".env"), "SOURCE=hermes\n");
|
|
128
|
+
assert.equal(resolveBaseHome(join(app, "agents", "AG2")), app);
|
|
129
|
+
assert.equal(resolveBaseHome(app), app);
|
|
130
|
+
const lone = tmp();
|
|
131
|
+
assert.equal(resolveBaseHome(join(lone, "agents", "AG9")), join(lone, "agents", "AG9"));
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
const specJob = (id) => ({
|
|
135
|
+
id,
|
|
136
|
+
name: HERMES_CRON_JOB_NAME,
|
|
137
|
+
script: cronScriptRel(),
|
|
138
|
+
no_agent: true,
|
|
139
|
+
deliver: "local",
|
|
140
|
+
enabled: true,
|
|
141
|
+
schedule: { kind: "cron", expr: HERMES_CRON_SCHEDULE },
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("install: 二度目は作り直さない(冪等・重複なし)", () => {
|
|
145
|
+
const home = tmp();
|
|
146
|
+
writeFileSync(join(home, ".env"), "SOURCE=hermes\n");
|
|
147
|
+
const hhome = tmp();
|
|
148
|
+
mkdirSync(join(hhome, "cron"), { recursive: true });
|
|
149
|
+
writeFileSync(join(hhome, "cron", "jobs.json"), JSON.stringify({ jobs: [specJob("job1")] }));
|
|
150
|
+
writeFileSync(join(home, MARKER_FILE), JSON.stringify({ job: "job1" }));
|
|
151
|
+
let creates = 0;
|
|
152
|
+
const r = installHermesCron({
|
|
153
|
+
home,
|
|
154
|
+
hermesHome: hhome,
|
|
155
|
+
npxPath: "/usr/bin/npx",
|
|
156
|
+
hermesBinDir: "/usr/bin",
|
|
157
|
+
runner: (args) => {
|
|
158
|
+
if (args[1] === "create") creates += 1;
|
|
159
|
+
return { status: 0, stdout: "", stderr: "" };
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
assert.equal(r.ok, true);
|
|
163
|
+
assert.equal(r.created, false);
|
|
164
|
+
assert.equal(creates, 0);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("install: 停止中の同名ジョブは edit+resume で直す(作り直さない)", () => {
|
|
168
|
+
const home = tmp();
|
|
169
|
+
writeFileSync(join(home, ".env"), "SOURCE=hermes\n");
|
|
170
|
+
const hhome = tmp();
|
|
171
|
+
mkdirSync(join(hhome, "cron"), { recursive: true });
|
|
172
|
+
writeFileSync(
|
|
173
|
+
join(hhome, "cron", "jobs.json"),
|
|
174
|
+
JSON.stringify({ jobs: [{ ...specJob("job1"), enabled: false, schedule: { kind: "cron", expr: "5 * * * *" } }] }),
|
|
175
|
+
);
|
|
176
|
+
const calls = [];
|
|
177
|
+
const r = installHermesCron({
|
|
178
|
+
home,
|
|
179
|
+
hermesHome: hhome,
|
|
180
|
+
npxPath: "/usr/bin/npx",
|
|
181
|
+
runner: (args) => {
|
|
182
|
+
calls.push(args);
|
|
183
|
+
const jobs = JSON.parse(readFileSync(join(hhome, "cron", "jobs.json"), "utf8"));
|
|
184
|
+
const j = jobs.jobs.find((x) => x.id === "job1");
|
|
185
|
+
if (args[1] === "edit") {
|
|
186
|
+
j.schedule = { kind: "cron", expr: HERMES_CRON_SCHEDULE };
|
|
187
|
+
j.script = cronScriptRel();
|
|
188
|
+
j.no_agent = true;
|
|
189
|
+
j.deliver = "local";
|
|
190
|
+
}
|
|
191
|
+
if (args[1] === "resume") j.enabled = true;
|
|
192
|
+
writeFileSync(join(hhome, "cron", "jobs.json"), JSON.stringify(jobs));
|
|
193
|
+
return { status: 0, stdout: "ok", stderr: "" };
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
assert.equal(r.ok, true);
|
|
197
|
+
assert.equal(r.repaired, true);
|
|
198
|
+
assert.equal(r.job, "job1");
|
|
199
|
+
assert.ok(calls.some((a) => a[1] === "edit" && a.includes("job1")));
|
|
200
|
+
assert.ok(calls.some((a) => a[1] === "resume" && a.includes("job1")));
|
|
201
|
+
assert.ok(!calls.some((a) => a[1] === "create"), "作り直さない");
|
|
202
|
+
assert.equal(readMarker(home).job, "job1");
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("install: 同名が複数・prompt 付き同名は触らず失敗(印も付けない)", () => {
|
|
206
|
+
for (const jobs of [
|
|
207
|
+
[specJob("a"), specJob("b")],
|
|
208
|
+
[{ ...specJob("c"), prompt: "利用者の仕事", no_agent: false }],
|
|
209
|
+
]) {
|
|
210
|
+
const home = tmp();
|
|
211
|
+
writeFileSync(join(home, ".env"), "SOURCE=hermes\n");
|
|
212
|
+
const hhome = tmp();
|
|
213
|
+
mkdirSync(join(hhome, "cron"), { recursive: true });
|
|
214
|
+
writeFileSync(join(hhome, "cron", "jobs.json"), JSON.stringify({ jobs }));
|
|
215
|
+
let touched = 0;
|
|
216
|
+
const r = installHermesCron({
|
|
217
|
+
home,
|
|
218
|
+
hermesHome: hhome,
|
|
219
|
+
npxPath: "/usr/bin/npx",
|
|
220
|
+
runner: () => {
|
|
221
|
+
touched += 1;
|
|
222
|
+
return { status: 0, stdout: "", stderr: "" };
|
|
223
|
+
},
|
|
224
|
+
});
|
|
225
|
+
assert.equal(r.ok, false);
|
|
226
|
+
assert.equal(touched, 0, "直しも作り直しもしない");
|
|
227
|
+
assert.equal(existsSync(markerPath(home)), false, "印を付けない");
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test("remove: hermes cron remove + 印を消す(読み返し確認)", () => {
|
|
232
|
+
const home = tmp();
|
|
233
|
+
const hhome = tmp();
|
|
234
|
+
mkdirSync(join(hhome, "cron"), { recursive: true });
|
|
235
|
+
const jobsFile = join(hhome, "cron", "jobs.json");
|
|
236
|
+
writeFileSync(jobsFile, JSON.stringify({ jobs: [specJob("job9")] }));
|
|
237
|
+
writeFileSync(join(home, MARKER_FILE), JSON.stringify({ job: "job9" }));
|
|
238
|
+
const calls = [];
|
|
239
|
+
const r = removeHermesCron({
|
|
240
|
+
home,
|
|
241
|
+
hermesHome: hhome,
|
|
242
|
+
runner: (args) => {
|
|
243
|
+
calls.push(args);
|
|
244
|
+
const jobs = JSON.parse(readFileSync(jobsFile, "utf8"));
|
|
245
|
+
jobs.jobs = jobs.jobs.filter((j) => j.id !== "job9");
|
|
246
|
+
writeFileSync(jobsFile, JSON.stringify(jobs));
|
|
247
|
+
return { status: 0, stdout: "", stderr: "" };
|
|
248
|
+
},
|
|
249
|
+
});
|
|
250
|
+
assert.equal(r.ok, true);
|
|
251
|
+
assert.deepEqual(calls[0].slice(0, 2), ["cron", "remove"]);
|
|
252
|
+
assert.ok(calls[0].includes("job9"));
|
|
253
|
+
assert.equal(existsSync(markerPath(home)), false);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test("remove: exit 0 でもジョブが残っていたら失敗(印は残す)", () => {
|
|
257
|
+
const home = tmp();
|
|
258
|
+
const hhome = tmp();
|
|
259
|
+
mkdirSync(join(hhome, "cron"), { recursive: true });
|
|
260
|
+
writeFileSync(join(hhome, "cron", "jobs.json"), JSON.stringify({ jobs: [specJob("job9")] }));
|
|
261
|
+
writeFileSync(join(home, MARKER_FILE), JSON.stringify({ job: "job9" }));
|
|
262
|
+
const r = removeHermesCron({
|
|
263
|
+
home,
|
|
264
|
+
hermesHome: hhome,
|
|
265
|
+
runner: () => ({ status: 0, stdout: "removed", stderr: "" }),
|
|
266
|
+
});
|
|
267
|
+
assert.equal(r.ok, false);
|
|
268
|
+
assert.equal(existsSync(markerPath(home)), true);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test("remove: 印の指すジョブが無い・同名複数は失敗(消さない)", () => {
|
|
272
|
+
const home = tmp();
|
|
273
|
+
writeFileSync(join(home, MARKER_FILE), JSON.stringify({ job: "gone" }));
|
|
274
|
+
const hhome = tmp();
|
|
275
|
+
mkdirSync(join(hhome, "cron"), { recursive: true });
|
|
276
|
+
writeFileSync(join(hhome, "cron", "jobs.json"), JSON.stringify({ jobs: [specJob("other")] }));
|
|
277
|
+
let touched = 0;
|
|
278
|
+
const r = removeHermesCron({
|
|
279
|
+
home,
|
|
280
|
+
hermesHome: hhome,
|
|
281
|
+
runner: () => {
|
|
282
|
+
touched += 1;
|
|
283
|
+
return { status: 0, stdout: "", stderr: "" };
|
|
284
|
+
},
|
|
285
|
+
});
|
|
286
|
+
assert.equal(r.ok, false);
|
|
287
|
+
assert.equal(touched, 0);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test("status: 正常・ drift(印はあるが job が無い)・未管理を見分ける", () => {
|
|
291
|
+
const home = tmp();
|
|
292
|
+
const hhome = tmp();
|
|
293
|
+
mkdirSync(join(hhome, "cron"), { recursive: true });
|
|
294
|
+
const jobsFile = join(hhome, "cron", "jobs.json");
|
|
295
|
+
writeFileSync(jobsFile, JSON.stringify({ jobs: [] }));
|
|
296
|
+
assert.equal(statusHermesCron({ home, hermesHome: hhome }).state, "unmanaged");
|
|
297
|
+
writeFileSync(join(home, MARKER_FILE), JSON.stringify({ job: "gone" }));
|
|
298
|
+
const drift = statusHermesCron({ home, hermesHome: hhome });
|
|
299
|
+
assert.equal(drift.state, "drift");
|
|
300
|
+
assert.match(drift.hint, /install|repair|作り直/i);
|
|
301
|
+
writeFileSync(jobsFile, JSON.stringify({ jobs: [specJob("gone")] }));
|
|
302
|
+
assert.equal(statusHermesCron({ home, hermesHome: hhome }).state, "ok");
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test("status: 印の ID と実ジョブの ID が違う・停止中・像ちがいは drift", () => {
|
|
306
|
+
const hhome = tmp();
|
|
307
|
+
mkdirSync(join(hhome, "cron"), { recursive: true });
|
|
308
|
+
const jobsFile = join(hhome, "cron", "jobs.json");
|
|
309
|
+
writeFileSync(jobsFile, JSON.stringify({ jobs: [specJob("live")] }));
|
|
310
|
+
const home = tmp();
|
|
311
|
+
writeFileSync(join(home, MARKER_FILE), JSON.stringify({ job: "old" }));
|
|
312
|
+
assert.equal(statusHermesCron({ home, hermesHome: hhome }).state, "drift");
|
|
313
|
+
writeFileSync(join(home, MARKER_FILE), JSON.stringify({ job: "live" }));
|
|
314
|
+
assert.equal(statusHermesCron({ home, hermesHome: hhome }).state, "ok");
|
|
315
|
+
writeFileSync(jobsFile, JSON.stringify({ jobs: [{ ...specJob("live"), enabled: false }] }));
|
|
316
|
+
const paused = statusHermesCron({ home, hermesHome: hhome });
|
|
317
|
+
assert.equal(paused.state, "drift");
|
|
318
|
+
assert.match(paused.hint, /停止/);
|
|
319
|
+
writeFileSync(
|
|
320
|
+
jobsFile,
|
|
321
|
+
JSON.stringify({ jobs: [{ ...specJob("live"), schedule: { kind: "cron", expr: "5 * * * *" } }] }),
|
|
322
|
+
);
|
|
323
|
+
assert.equal(statusHermesCron({ home, hermesHome: hhome }).state, "drift");
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
test("install 失敗(create 非ゼロ)は印を残さず理由を返す", () => {
|
|
327
|
+
const home = tmp();
|
|
328
|
+
const hhome = tmp();
|
|
329
|
+
mkdirSync(join(hhome, "cron"), { recursive: true });
|
|
330
|
+
writeFileSync(join(hhome, "cron", "jobs.json"), JSON.stringify({ jobs: [] }));
|
|
331
|
+
const r = installHermesCron({
|
|
332
|
+
home,
|
|
333
|
+
hermesHome: hhome,
|
|
334
|
+
npxPath: "/usr/bin/npx",
|
|
335
|
+
hermesBinDir: "/usr/bin",
|
|
336
|
+
runner: () => ({ status: 1, stdout: "", stderr: "boom" }),
|
|
337
|
+
});
|
|
338
|
+
assert.equal(r.ok, false);
|
|
339
|
+
assert.match(r.reason, /boom/);
|
|
340
|
+
assert.equal(existsSync(markerPath(home)), false);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
test("planScheduler: hermes だけ+hermes 有りは hermes-cron のみ(OS 重複なし)", async () => {
|
|
344
|
+
const { planScheduler } = await import("./hermes-cron.mjs");
|
|
345
|
+
assert.deepEqual(planScheduler({ sources: ["hermes"], hermesAvailable: true }), { os: false, hermesCron: true });
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
test("planScheduler: 混成は両方・hermes 無しは OS のみ・hermes 無 CLI は OS", async () => {
|
|
349
|
+
const { planScheduler } = await import("./hermes-cron.mjs");
|
|
350
|
+
assert.deepEqual(planScheduler({ sources: ["hermes", "claude-code"], hermesAvailable: true }), { os: true, hermesCron: true });
|
|
351
|
+
assert.deepEqual(planScheduler({ sources: ["claude-code"], hermesAvailable: true }), { os: true, hermesCron: false });
|
|
352
|
+
assert.deepEqual(planScheduler({ sources: ["hermes"], hermesAvailable: false }), { os: true, hermesCron: false });
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
test("markHermesManagedHomes: hermes の設定だけに印(他 AI に触らない)", async () => {
|
|
356
|
+
const { markHermesManagedHomes } = await import("./hermes-cron.mjs");
|
|
357
|
+
const { mkdtempSync: mk, mkdirSync: md, writeFileSync: wr } = await import("node:fs");
|
|
358
|
+
const { join: j } = await import("node:path");
|
|
359
|
+
const { tmpdir: td } = await import("node:os");
|
|
360
|
+
const app = mk(j(td(), "nippo-mark-"));
|
|
361
|
+
wr(j(app, ".env"), "SOURCE=hermes\n");
|
|
362
|
+
md(j(app, "agents", "H"), { recursive: true });
|
|
363
|
+
wr(j(app, "agents", "H", ".env"), "SOURCE=hermes\n");
|
|
364
|
+
md(j(app, "agents", "C"), { recursive: true });
|
|
365
|
+
wr(j(app, "agents", "C", ".env"), "SOURCE=claude-code\n");
|
|
366
|
+
const marked = markHermesManagedHomes(app);
|
|
367
|
+
assert.deepEqual(marked.sort(), [app, j(app, "agents", "H")].sort());
|
|
368
|
+
const { existsSync: ex } = await import("node:fs");
|
|
369
|
+
assert.equal(ex(j(app, MARKER_FILE)), true);
|
|
370
|
+
assert.equal(ex(j(app, "agents", "C", MARKER_FILE)), false);
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
test("clearHermesManagedHomes: 印だけ消す(.env は触らない)", async () => {
|
|
374
|
+
const { markHermesManagedHomes, clearHermesManagedHomes } = await import("./hermes-cron.mjs");
|
|
375
|
+
const { mkdtempSync: mk, writeFileSync: wr, readFileSync: rr, existsSync: ex } = await import("node:fs");
|
|
376
|
+
const { join: j } = await import("node:path");
|
|
377
|
+
const { tmpdir: td } = await import("node:os");
|
|
378
|
+
const app = mk(j(td(), "nippo-unmark-"));
|
|
379
|
+
wr(j(app, ".env"), "SOURCE=hermes\n");
|
|
380
|
+
markHermesManagedHomes(app);
|
|
381
|
+
assert.equal(ex(j(app, MARKER_FILE)), true);
|
|
382
|
+
clearHermesManagedHomes(app);
|
|
383
|
+
assert.equal(ex(j(app, MARKER_FILE)), false);
|
|
384
|
+
assert.equal(rr(j(app, ".env"), "utf8"), "SOURCE=hermes\n");
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
test("remove: deleted-job の所有印を status drift 後に回復(空 jobs・無関係ジョブを維持)", () => {
|
|
388
|
+
for (const jobs of [[], [specJob("unrelated")]]) {
|
|
389
|
+
const home = tmp();
|
|
390
|
+
const hermesHome = tmp();
|
|
391
|
+
mkdirSync(join(hermesHome, "cron"));
|
|
392
|
+
const jobsFile = join(hermesHome, "cron", "jobs.json");
|
|
393
|
+
const original = JSON.stringify({ jobs });
|
|
394
|
+
writeFileSync(jobsFile, original);
|
|
395
|
+
writeFileSync(markerPath(home), JSON.stringify({ job: "deleted-job", script: cronScriptRel() }));
|
|
396
|
+
assert.equal(statusHermesCron({ home, hermesHome }).state, "drift");
|
|
397
|
+
assert.equal(shouldSkipTick(home).skip, true);
|
|
398
|
+
const r = removeHermesCron({
|
|
399
|
+
home, hermesHome,
|
|
400
|
+
runner: () => { assert.fail("消失済み ID で CLI は呼ばない"); },
|
|
401
|
+
});
|
|
402
|
+
assert.deepEqual(r, { ok: true, removed: false });
|
|
403
|
+
assert.equal(existsSync(markerPath(home)), false);
|
|
404
|
+
assert.equal(shouldSkipTick(home).skip, false);
|
|
405
|
+
assert.equal(statusHermesCron({ home, hermesHome }).state, "unmanaged");
|
|
406
|
+
assert.equal(readFileSync(jobsFile, "utf8"), original);
|
|
407
|
+
}
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
test("remove: wrong-owned・名前だけの重複・読めない jobs は触らず印を残す", () => {
|
|
411
|
+
for (const [marker, jobs] of [
|
|
412
|
+
[{ job: "a", script: cronScriptRel() }, [{ ...specJob("a"), name: "other-owner" }]],
|
|
413
|
+
[{ job: "a", script: cronScriptRel() }, [{ ...specJob("a"), script: "other.sh" }]],
|
|
414
|
+
[{ job: "a", script: cronScriptRel() }, [{ ...specJob("a"), prompt: "user job" }]],
|
|
415
|
+
[{ job: HERMES_CRON_JOB_NAME, script: cronScriptRel() }, [specJob("a"), specJob("b")]],
|
|
416
|
+
[null, [specJob("a"), specJob("b")]],
|
|
417
|
+
[{ job: "gone", script: cronScriptRel() }, null],
|
|
418
|
+
[{ job: "gone", script: "other.sh" }, []],
|
|
419
|
+
]) {
|
|
420
|
+
const home = tmp();
|
|
421
|
+
const hermesHome = tmp();
|
|
422
|
+
mkdirSync(join(hermesHome, "cron"));
|
|
423
|
+
writeFileSync(join(hermesHome, "cron", "jobs.json"), jobs ? JSON.stringify({ jobs }) : "broken");
|
|
424
|
+
if (marker) writeFileSync(markerPath(home), JSON.stringify(marker));
|
|
425
|
+
assert.equal(removeHermesCron({
|
|
426
|
+
home, hermesHome, runner: () => { assert.fail("所有を確認できない job は触らない"); },
|
|
427
|
+
}).ok, false);
|
|
428
|
+
if (marker) assert.deepEqual(readMarker(home), marker);
|
|
429
|
+
}
|
|
430
|
+
});
|
|
@@ -0,0 +1,130 @@
|
|
|
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
|
+
/* init の実エントリポイント(bin/clubs.mjs)を、スタブの CLUBS サーバ+
|
|
10
|
+
* スタブ crontab で動かす。実 crontab・実 Hermes・実 CLUBS には触らない。
|
|
11
|
+
* - root の init は .env を書いて OS 登録(crontab 行)まで進む
|
|
12
|
+
* - あとから別の AI(Claude)を足すと子の .env が作られ、hermes だけの家に
|
|
13
|
+
* OS 登録が無くても OS 側の登録に進む(既存登録がある決め打ちをしない)
|
|
14
|
+
* 非対話(パイプ)のため確認は既定 Yes で進む。hermes CLI は PATH に置かない
|
|
15
|
+
* (hermes-cron 経路に入らず OS 経路の振る舞いを見る)。 */
|
|
16
|
+
|
|
17
|
+
const REPO = new URL("../", import.meta.url).pathname;
|
|
18
|
+
const BIN = join(REPO, "bin", "clubs.mjs");
|
|
19
|
+
|
|
20
|
+
/* spawnSync は親のイベントループを止めてスタブサーバが応答できなくなるため、
|
|
21
|
+
* 必ず非同期 spawn。 */
|
|
22
|
+
const runInit = (home, { args, serverUrl, stubs }) =>
|
|
23
|
+
new Promise((resolve, reject) => {
|
|
24
|
+
const env = { ...process.env };
|
|
25
|
+
env.CLUBS_NIPPO_HOME = home;
|
|
26
|
+
env.PATH = `${stubs}:/usr/bin:/bin`;
|
|
27
|
+
for (const k of ["GENERATOR", "SOURCE", "CLUBS_NIPPO_CHILD", "CLUBS_NIPPO_SCHEDULER", "HERMES_BIN"]) {
|
|
28
|
+
delete env[k];
|
|
29
|
+
}
|
|
30
|
+
void serverUrl;
|
|
31
|
+
const child = spawn(process.execPath, [BIN, ...args], { env, stdio: ["ignore", "pipe", "pipe"] });
|
|
32
|
+
let stdout = "";
|
|
33
|
+
let stderr = "";
|
|
34
|
+
child.stdout.on("data", (d) => (stdout += d));
|
|
35
|
+
child.stderr.on("data", (d) => (stderr += d));
|
|
36
|
+
const timer = setTimeout(() => {
|
|
37
|
+
child.kill("SIGKILL");
|
|
38
|
+
reject(new Error(`init が終わらない: ${stdout}${stderr}`.slice(0, 500)));
|
|
39
|
+
}, 60_000);
|
|
40
|
+
child.on("error", (e) => {
|
|
41
|
+
clearTimeout(timer);
|
|
42
|
+
reject(e);
|
|
43
|
+
});
|
|
44
|
+
child.on("close", (status) => {
|
|
45
|
+
clearTimeout(timer);
|
|
46
|
+
resolve({ status, stdout, stderr });
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
/** crontab -l(空)と crontab -(入力を保存)のスタブ。 */
|
|
51
|
+
const stubBin = () => {
|
|
52
|
+
const dir = mkdtempSync(join(tmpdir(), "nippo-init-bin-"));
|
|
53
|
+
const capture = join(dir, "crontab.txt");
|
|
54
|
+
writeFileSync(
|
|
55
|
+
join(dir, "crontab"),
|
|
56
|
+
`#!/bin/sh\nif [ "$1" = "-l" ]; then exit 0; fi\ncat > ${capture}\n`,
|
|
57
|
+
"utf8",
|
|
58
|
+
);
|
|
59
|
+
chmodSync(join(dir, "crontab"), 0o755);
|
|
60
|
+
return { dir, capture };
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
test("root の init は .env を書いて OS 登録(crontab 行)まで進む", async () => {
|
|
64
|
+
const home = mkdtempSync(join(tmpdir(), "nippo-init-"));
|
|
65
|
+
const { dir, capture } = stubBin();
|
|
66
|
+
const r = await runInit(home, {
|
|
67
|
+
stubs: dir,
|
|
68
|
+
args: ["init", "--url=http://127.0.0.1:9/api/nippo/ingest", "--token=test-token", "--source=hermes"],
|
|
69
|
+
});
|
|
70
|
+
assert.equal(r.status, 0, `exit 0 のはず: ${r.stderr}${r.stdout}`);
|
|
71
|
+
const env = readFileSync(join(home, ".env"), "utf8");
|
|
72
|
+
assert.match(env, /SOURCE=hermes/);
|
|
73
|
+
assert.match(env, /NIPPO_INGEST_TOKEN=test-token/);
|
|
74
|
+
assert.ok(existsSync(capture), "crontab に登録しに行く");
|
|
75
|
+
const line = readFileSync(capture, "utf8");
|
|
76
|
+
assert.match(line, /@clubs\.place\/cli/);
|
|
77
|
+
assert.match(line, /\btick\b/);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("あとから Claude を足すと子の設定が作られ OS 登録に進む(既存決め打ちなし)", async () => {
|
|
81
|
+
const home = mkdtempSync(join(tmpdir(), "nippo-init-"));
|
|
82
|
+
mkdirSync(join(home, "out"), { recursive: true });
|
|
83
|
+
const hits = { redeem: 0 };
|
|
84
|
+
const server = http.createServer((req, res) => {
|
|
85
|
+
let body = "";
|
|
86
|
+
req.on("data", (d) => (body += d));
|
|
87
|
+
req.on("end", () => {
|
|
88
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
89
|
+
if (req.url.endsWith("/agents/redeem")) {
|
|
90
|
+
hits.redeem += 1;
|
|
91
|
+
res.end(JSON.stringify({ token: "new-token", agentId: "AG2" }));
|
|
92
|
+
} else if (req.url.endsWith("/collector-config")) {
|
|
93
|
+
const auth = req.headers.authorization ?? "";
|
|
94
|
+
if (auth.includes("new-token")) {
|
|
95
|
+
res.end(JSON.stringify({ schedule: { enabled: true }, agentId: "AG2", sources: ["claude-code"] }));
|
|
96
|
+
} else {
|
|
97
|
+
res.end(JSON.stringify({ schedule: { enabled: true }, agentId: "AG1", sources: ["hermes"] }));
|
|
98
|
+
}
|
|
99
|
+
} else {
|
|
100
|
+
res.end("{}");
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
await new Promise((r) => server.listen(0, "127.0.0.1", r));
|
|
105
|
+
const url = `http://127.0.0.1:${server.address().port}`;
|
|
106
|
+
writeFileSync(
|
|
107
|
+
join(home, ".env"),
|
|
108
|
+
`NIPPO_INGEST_URL=${url}/api/nippo/ingest\nNIPPO_INGEST_TOKEN=old-token\nAGENT_ID=AG1\nSOURCE=hermes\n`,
|
|
109
|
+
"utf8",
|
|
110
|
+
);
|
|
111
|
+
const { dir, capture } = stubBin();
|
|
112
|
+
try {
|
|
113
|
+
const r = await runInit(home, { stubs: dir, args: ["init", `--url=${url}/api/nippo/ingest`, "--code=CODE123"] });
|
|
114
|
+
assert.equal(r.status, 0, `exit 0 のはず: ${r.stderr}${r.stdout}`);
|
|
115
|
+
assert.equal(hits.redeem, 1);
|
|
116
|
+
assert.equal(
|
|
117
|
+
readFileSync(join(home, ".env"), "utf8").includes("AGENT_ID=AG2"),
|
|
118
|
+
false,
|
|
119
|
+
"root の鍵を上書きしない",
|
|
120
|
+
);
|
|
121
|
+
const childEnv = join(home, "agents", "AG2", ".env");
|
|
122
|
+
assert.ok(existsSync(childEnv), "子の設定が作られる");
|
|
123
|
+
assert.match(readFileSync(childEnv, "utf8"), /AGENT_ID=AG2/);
|
|
124
|
+
assert.ok(existsSync(capture), "OS 登録に進む(hermes だけの家に OS 登録は無いため)");
|
|
125
|
+
assert.match(readFileSync(capture, "utf8"), /tick/);
|
|
126
|
+
assert.match(readFileSync(capture, "utf8"), /place/);
|
|
127
|
+
} finally {
|
|
128
|
+
server.close();
|
|
129
|
+
}
|
|
130
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { mkdtempSync, writeFileSync, chmodSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { generateNarrative, fallbackNarrative } from "./narrative.mjs";
|
|
7
|
+
|
|
8
|
+
/* hermes 生成器の結線。Hermes 既定モデルで書かせ、落ちたら理由つき fallback。 */
|
|
9
|
+
|
|
10
|
+
const activity = {
|
|
11
|
+
date: "2026-10-28",
|
|
12
|
+
agentName: "test's Hermes",
|
|
13
|
+
source: "hermes",
|
|
14
|
+
aggregates: {
|
|
15
|
+
totalCalls: 3,
|
|
16
|
+
failedCalls: 0,
|
|
17
|
+
successRate: 100,
|
|
18
|
+
activeHours: 1.2,
|
|
19
|
+
projects: ["clubsx"],
|
|
20
|
+
},
|
|
21
|
+
events: [
|
|
22
|
+
{ time: "09:00", name: "terminal.exec", summary: "テスト実行", project: "clubsx", ok: true },
|
|
23
|
+
{ time: "10:00", name: "file.read", summary: "設定確認", project: "clubsx", ok: true },
|
|
24
|
+
{ time: "11:00", name: "terminal.exec", summary: "再実行", project: "clubsx", ok: true },
|
|
25
|
+
],
|
|
26
|
+
prompts: [],
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const stubBin = (script) => {
|
|
30
|
+
const dir = mkdtempSync(join(tmpdir(), "nippo-narr-"));
|
|
31
|
+
const p = join(dir, "hermes-stub.sh");
|
|
32
|
+
writeFileSync(p, `#!/bin/sh\n${script}\n`, "utf8");
|
|
33
|
+
chmodSync(p, 0o755);
|
|
34
|
+
return p;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const withHermesBin = (p) => {
|
|
38
|
+
const old = process.env.HERMES_BIN;
|
|
39
|
+
process.env.HERMES_BIN = p;
|
|
40
|
+
return () => (old === undefined ? delete process.env.HERMES_BIN : (process.env.HERMES_BIN = old));
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
test("GENERATOR=hermes で hermes 生成器が動く(stdout の JSON を採用)", async () => {
|
|
44
|
+
const restore = withHermesBin(
|
|
45
|
+
stubBin(`cat >/dev/null\nprintf '{"workBlocks":[{"title":"clubsx","text":"本文","metric":"実行 3回"}],"handoff":{"title":"t","text":"x"},"insights":[],"selfNote":"s"}'`),
|
|
46
|
+
);
|
|
47
|
+
try {
|
|
48
|
+
const n = await generateNarrative(activity, { generator: "hermes", useLlm: true });
|
|
49
|
+
assert.equal(n.generator, "hermes");
|
|
50
|
+
assert.equal(n.workBlocks.length, 1);
|
|
51
|
+
assert.equal(n.workBlocks[0].title, "clubsx");
|
|
52
|
+
assert.ok(!("repos" in (n.workBlocks[0] ?? {})), "repos は行に残さない(blockRepos に移す)");
|
|
53
|
+
} finally {
|
|
54
|
+
restore();
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("hermes が落ちたら理由つき fallback(黙って集計値だけにしない)", async () => {
|
|
59
|
+
const restore = withHermesBin(stubBin(`echo boom >&2\nexit 1`));
|
|
60
|
+
try {
|
|
61
|
+
const n = await generateNarrative(activity, { generator: "hermes", useLlm: true });
|
|
62
|
+
assert.match(n.generator, /fallback/);
|
|
63
|
+
assert.match(n.generator, /hermes/);
|
|
64
|
+
assert.match(n.selfNote, /自動生成ができませんでした/);
|
|
65
|
+
} finally {
|
|
66
|
+
restore();
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("hermes の直し方は API キー不要(hermes model・既定設定の確認)", () => {
|
|
71
|
+
const n = fallbackNarrative(activity, "hermes での生成に失敗(boom)", "hermes");
|
|
72
|
+
assert.match(n.selfNote, /hermes model/);
|
|
73
|
+
assert.match(n.selfNote, /API キーは要りません/);
|
|
74
|
+
});
|