@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,97 @@
|
|
|
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 } from "node:os";
|
|
6
|
+
import { spawnSync, execSync } from "node:child_process";
|
|
7
|
+
import {
|
|
8
|
+
HERMES_CRON_JOB_NAME,
|
|
9
|
+
HERMES_CRON_SCHEDULE,
|
|
10
|
+
cronScriptPath,
|
|
11
|
+
markerPath,
|
|
12
|
+
installHermesCron,
|
|
13
|
+
removeHermesCron,
|
|
14
|
+
statusHermesCron,
|
|
15
|
+
} from "./hermes-cron.mjs";
|
|
16
|
+
|
|
17
|
+
/* 実 `hermes cron` API を隔離 HERMES_HOME で一度だけ通す。実運用の
|
|
18
|
+
* ~/.hermes には触らない(HERMES_HOME を temp に向けた実バイナリ呼び出し)。
|
|
19
|
+
* ジョブ格納(jobs.json)だけを使い、実スケジューラの起動は要らない。
|
|
20
|
+
* install→install(冪等・1件)→status→remove→読み返し、を実 API で確認する。
|
|
21
|
+
* hermes バイナリが無ければ skip(壊さない)。 */
|
|
22
|
+
|
|
23
|
+
const hermesBin = () => process.env.HERMES_BIN || "hermes";
|
|
24
|
+
|
|
25
|
+
const haveHermes = (() => {
|
|
26
|
+
try {
|
|
27
|
+
execSync(`command -v ${hermesBin()} 2>/dev/null`);
|
|
28
|
+
return true;
|
|
29
|
+
} catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
})();
|
|
33
|
+
|
|
34
|
+
const realRunner = (hhome) => (args) => {
|
|
35
|
+
try {
|
|
36
|
+
const r = spawnSync(hermesBin(), args, {
|
|
37
|
+
env: { ...process.env, HERMES_HOME: hhome },
|
|
38
|
+
encoding: "utf8",
|
|
39
|
+
timeout: 180_000,
|
|
40
|
+
});
|
|
41
|
+
return { status: r.status ?? 1, stdout: r.stdout ?? "", stderr: r.stderr ?? "" };
|
|
42
|
+
} catch (e) {
|
|
43
|
+
return { status: 1, stdout: "", stderr: e.message };
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const sameNameJobs = (hhome) => {
|
|
48
|
+
const d = JSON.parse(readFileSync(join(hhome, "cron", "jobs.json"), "utf8"));
|
|
49
|
+
return (d.jobs ?? []).filter((j) => j?.name === HERMES_CRON_JOB_NAME);
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
test("実 cron API: install→二度目→status→remove(隔離 HERMES_HOME)", { skip: !haveHermes }, () => {
|
|
53
|
+
const home = mkdtempSync(join(tmpdir(), "nippo-live-home-"));
|
|
54
|
+
writeFileSync(join(home, ".env"), "SOURCE=hermes\n");
|
|
55
|
+
const hhome = mkdtempSync(join(tmpdir(), "nippo-live-hermes-"));
|
|
56
|
+
const runner = realRunner(hhome);
|
|
57
|
+
|
|
58
|
+
const r1 = installHermesCron({
|
|
59
|
+
home,
|
|
60
|
+
hermesHome: hhome,
|
|
61
|
+
npxPath: "/usr/bin/npx",
|
|
62
|
+
hermesBinDir: "/usr/bin",
|
|
63
|
+
runner,
|
|
64
|
+
});
|
|
65
|
+
assert.equal(r1.ok, true, `初回 install: ${r1.reason ?? ""}`);
|
|
66
|
+
assert.equal(r1.created, true);
|
|
67
|
+
let jobs = sameNameJobs(hhome);
|
|
68
|
+
assert.equal(jobs.length, 1, "同名ジョブは1件");
|
|
69
|
+
assert.equal(jobs[0].no_agent, true);
|
|
70
|
+
assert.equal(jobs[0].script, "clubs-nippo-hermes-tick.sh");
|
|
71
|
+
assert.equal(jobs[0].deliver, "local");
|
|
72
|
+
assert.equal(jobs[0].enabled, true);
|
|
73
|
+
assert.equal(jobs[0].schedule?.expr, HERMES_CRON_SCHEDULE);
|
|
74
|
+
assert.ok(existsSync(cronScriptPath(hhome)), "script が実 HERMES_HOME に書かれる");
|
|
75
|
+
assert.match(readFileSync(cronScriptPath(hhome), "utf8"), /tick --only=hermes/);
|
|
76
|
+
|
|
77
|
+
const r2 = installHermesCron({
|
|
78
|
+
home,
|
|
79
|
+
hermesHome: hhome,
|
|
80
|
+
npxPath: "/usr/bin/npx",
|
|
81
|
+
hermesBinDir: "/usr/bin",
|
|
82
|
+
runner,
|
|
83
|
+
});
|
|
84
|
+
assert.equal(r2.ok, true, `二度目 install: ${r2.reason ?? ""}`);
|
|
85
|
+
assert.equal(r2.created, false);
|
|
86
|
+
jobs = sameNameJobs(hhome);
|
|
87
|
+
assert.equal(jobs.length, 1, "二度目も1件のまま(重複なし)");
|
|
88
|
+
|
|
89
|
+
const st = statusHermesCron({ home, hermesHome: hhome });
|
|
90
|
+
assert.equal(st.state, "ok", `status: ${st.hint ?? ""}`);
|
|
91
|
+
|
|
92
|
+
const rm = removeHermesCron({ home, hermesHome: hhome, runner });
|
|
93
|
+
assert.equal(rm.ok, true, `remove: ${rm.reason ?? ""}`);
|
|
94
|
+
assert.equal(sameNameJobs(hhome).length, 0, "読み返して消えている");
|
|
95
|
+
assert.equal(existsSync(markerPath(home)), false, "印も消える");
|
|
96
|
+
assert.equal(statusHermesCron({ home, hermesHome: hhome }).state, "unmanaged");
|
|
97
|
+
});
|
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync, unlinkSync } from "node:fs";
|
|
3
|
+
import { join, dirname, basename } from "node:path";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { listProfiles, readEnvFile } from "./profiles.mjs";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Hermes ネイティブ cron(script-only tick)の管理。
|
|
9
|
+
*
|
|
10
|
+
* 考え方: Hermes cron のジョブは LLM 抜き(--no-agent)で毎時スクリプトを
|
|
11
|
+
* 走らせるだけ。スクリプトが `clubs-nippo tick --only=hermes` を呼び、tick
|
|
12
|
+
* の中で `hermes chat`(既定モデル・pin なし)が本文を書く。LLM 呼び出しは
|
|
13
|
+
* 生成の1回だけ——エージェント実行と生成の二重 LLM は無い。
|
|
14
|
+
*
|
|
15
|
+
* 使うモデルは発火時の Hermes main 設定。中で呼ぶ `hermes chat` に -m/--model
|
|
16
|
+
* を付けないので、cron 側の model pin(per-job pin・cron.model)は中の生成
|
|
17
|
+
* には効かない。main の既定に追従する。冪等の正は CLUBS サーバ側の
|
|
18
|
+
* due/reportDate/delivered(collector-config)が持つ。
|
|
19
|
+
*
|
|
20
|
+
* 変更は公式 `hermes cron` CLI 経由だけ。jobs.json には書かない(読むだけ)。
|
|
21
|
+
* `hermes cron` の終了符号は信用しない(失敗でも exit 0 のことがある)ので、
|
|
22
|
+
* jobs.json を読み返して確認する。
|
|
23
|
+
* hermes CLI が無い環境では {ok:false} を返す(黙って Claude に落とさない)。
|
|
24
|
+
*
|
|
25
|
+
* ジョブ名・スクリプト名は HERMES_HOME ごとに1つ(シングルトン)。複数の
|
|
26
|
+
* CLUBS 設定(root+並べた分)は tick 側の fanout(--only と子プロセス)で
|
|
27
|
+
* 担い、スクリプトはつねに基底 home(agents/ を含む側)に束縛する。子の
|
|
28
|
+
* home で install しても子の home には束縛しない(resolveBaseHome)。
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
export const HERMES_CRON_JOB_NAME = "clubs-nippo-hermes";
|
|
32
|
+
export const HERMES_CRON_SCHEDULE = "0 * * * *";
|
|
33
|
+
export const HERMES_CRON_DELIVER = "local";
|
|
34
|
+
export const MARKER_FILE = ".hermes-cron.json";
|
|
35
|
+
export const SCRIPT_REL = "clubs-nippo-hermes-tick.sh";
|
|
36
|
+
export const SCHEDULER_ENV = "CLUBS_NIPPO_SCHEDULER";
|
|
37
|
+
export const SCHEDULER_HERMES_CRON = "hermes-cron";
|
|
38
|
+
export const CLUBS_HOME_ENV = "CLUBS_NIPPO_HOME";
|
|
39
|
+
export const HERMES_HOME_ENV = "HERMES_HOME";
|
|
40
|
+
|
|
41
|
+
export const hermesHomeDir = (env = process.env) => env.HERMES_HOME || join(homedir(), ".hermes");
|
|
42
|
+
export const cronScriptRel = () => SCRIPT_REL;
|
|
43
|
+
export const cronScriptPath = (hermesHome) => join(hermesHome, "scripts", SCRIPT_REL);
|
|
44
|
+
export const markerPath = (home) => join(home, MARKER_FILE);
|
|
45
|
+
|
|
46
|
+
export const readMarker = (home) => {
|
|
47
|
+
try {
|
|
48
|
+
return JSON.parse(readFileSync(markerPath(home), "utf8"));
|
|
49
|
+
} catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/** シェル1語を一重引用符で固める(空白・'・$・`・改行も無害化)。 */
|
|
55
|
+
export const shQuote = (s) => `'${String(s ?? "").replace(/'/g, `'\\''`)}'`;
|
|
56
|
+
|
|
57
|
+
const homeSources = (dir) => {
|
|
58
|
+
const raw = readEnvFile(join(dir, ".env")).SOURCE ?? "claude-code,cowork";
|
|
59
|
+
return String(raw).split(",").map((s) => s.trim()).filter(Boolean);
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const writeMarker = (dir, job = "hermes-cron") => {
|
|
63
|
+
writeFileSync(markerPath(dir), JSON.stringify({ job, script: SCRIPT_REL, installedAt: new Date().toISOString() }) + "\n", "utf8");
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* 子の home(<base>/agents/<id>/.env のある階層)で呼ばれたら基底 home に
|
|
68
|
+
* 戻す。基底に .env が無い形では何もしない(独自 home の明示指定を壊さない)。
|
|
69
|
+
* スクリプトの束縛も印もつねに基底だけ——子に束縛すると基底の hermes が
|
|
70
|
+
* 動かなくなる。
|
|
71
|
+
*/
|
|
72
|
+
export const resolveBaseHome = (home) => {
|
|
73
|
+
if (basename(dirname(home)) === "agents") {
|
|
74
|
+
const base = dirname(dirname(home));
|
|
75
|
+
if (existsSync(join(base, ".env"))) return base;
|
|
76
|
+
}
|
|
77
|
+
return home;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* OS 側 tick(crontab/launchd)がこの設定を飛ばすべきか。印(hermes-cron
|
|
82
|
+
* 管理)があり、かつ今回の実行が hermes-cron 経由でないときだけ飛ばす。
|
|
83
|
+
* 同じ設定への二重 tick(OS + Hermes)を避けるためのもの。
|
|
84
|
+
*/
|
|
85
|
+
export const shouldSkipTick = (home, { scheduler } = {}) => {
|
|
86
|
+
if (!existsSync(markerPath(home))) return { skip: false };
|
|
87
|
+
if (scheduler === SCHEDULER_HERMES_CRON) return { skip: false };
|
|
88
|
+
return {
|
|
89
|
+
skip: true,
|
|
90
|
+
reason: `hermes-cron 管理のため OS 側 tick では飛ばします(${markerPath(home)})`,
|
|
91
|
+
};
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Hermes cron が毎時走らせるスクリプト。tick の出力を日誌に残し、
|
|
96
|
+
* - 失敗(tick 非ゼロ)→ 出して非ゼロ終了(Hermes が失敗として記録)
|
|
97
|
+
* - 発火(配信した)→ 出してゼロ終了(配信として届く)
|
|
98
|
+
* - それ以外(発火せず)→ 黙ってゼロ終了(--no-agent は空出力=静寂)
|
|
99
|
+
*
|
|
100
|
+
* home とスケジューラ所有者の hermesHome を束縛する。収集・生成では .env が優先。
|
|
101
|
+
* tickCmd 以外の埋め込みはすべて一重引用符で固める。
|
|
102
|
+
*/
|
|
103
|
+
export const buildTickScript = ({
|
|
104
|
+
home,
|
|
105
|
+
hermesHome,
|
|
106
|
+
npxPath,
|
|
107
|
+
tickCmd = npxPath ? defaultTickCmd(npxPath) : "",
|
|
108
|
+
hermesBinDir = "",
|
|
109
|
+
logPath,
|
|
110
|
+
}) => `#!/usr/bin/env bash
|
|
111
|
+
# CLUBS 日報 tick(Hermes ネイティブ cron 用・${HERMES_CRON_JOB_NAME} が毎時実行)。
|
|
112
|
+
# 生成は中の \`hermes chat\`(既定モデル・pin なし)が1回だけ行う。
|
|
113
|
+
set -u
|
|
114
|
+
export ${SCHEDULER_ENV}=${SCHEDULER_HERMES_CRON}
|
|
115
|
+
${home ? `export ${CLUBS_HOME_ENV}=${shQuote(home)}\n` : ""}${hermesHome ? `export CLUBS_NIPPO_SCHEDULER_HERMES_HOME=${shQuote(hermesHome)}\nunset HERMES_HOME\n` : ""}${hermesBinDir ? `export PATH=${shQuote(hermesBinDir)}:"$PATH"\n` : ""}OUT=$(${tickCmd} 2>&1)
|
|
116
|
+
CODE=$?
|
|
117
|
+
mkdir -p "$(dirname ${shQuote(logPath)})" 2>/dev/null || true
|
|
118
|
+
printf '%s\\n' "$OUT" >> ${shQuote(logPath)} 2>/dev/null || true
|
|
119
|
+
if [ "$CODE" -ne 0 ]; then
|
|
120
|
+
printf '%s\\n' "$OUT" | tail -n 20
|
|
121
|
+
exit "$CODE"
|
|
122
|
+
fi
|
|
123
|
+
if printf '%s\\n' "$OUT" | grep -q '\\[tick\\] 発火:'; then
|
|
124
|
+
printf '%s\\n' "$OUT"
|
|
125
|
+
fi
|
|
126
|
+
exit 0
|
|
127
|
+
`;
|
|
128
|
+
|
|
129
|
+
const defaultRunner = (hermesBin) => (args) => {
|
|
130
|
+
try {
|
|
131
|
+
const r = spawnSync(hermesBin, args, { encoding: "utf8", timeout: 30_000 });
|
|
132
|
+
return { status: r.status ?? 1, stdout: r.stdout ?? "", stderr: r.stderr ?? "" };
|
|
133
|
+
} catch (e) {
|
|
134
|
+
return { status: 1, stdout: "", stderr: e.message };
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const readJobs = (hermesHome) => {
|
|
139
|
+
try {
|
|
140
|
+
const d = JSON.parse(readFileSync(join(hermesHome, "cron", "jobs.json"), "utf8"));
|
|
141
|
+
return Array.isArray(d?.jobs) ? d.jobs : [];
|
|
142
|
+
} catch {
|
|
143
|
+
return [];
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const findJobById = (hermesHome, id) => readJobs(hermesHome).find((j) => j?.id === id) ?? null;
|
|
148
|
+
|
|
149
|
+
const findJobsByName = (hermesHome, name = HERMES_CRON_JOB_NAME) =>
|
|
150
|
+
readJobs(hermesHome).filter((j) => j?.name === name);
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* 望むジョブ像: 同名・script-only(--no-agent)・毎時・deliver local・有効。
|
|
154
|
+
* 名前だけの一致では受け入れない(停止中・別スクリプト・agent 付きの同名
|
|
155
|
+
* ジョブに印を付けて OS 側を止めると、日報が黙って止まる)。
|
|
156
|
+
*/
|
|
157
|
+
export const jobSpecMatches = (job) => {
|
|
158
|
+
if (!job || job.name !== HERMES_CRON_JOB_NAME) return false;
|
|
159
|
+
if (job.no_agent !== true) return false;
|
|
160
|
+
if (job.script !== SCRIPT_REL) return false;
|
|
161
|
+
if (job.deliver !== HERMES_CRON_DELIVER) return false;
|
|
162
|
+
if (job.enabled !== true) return false;
|
|
163
|
+
const expr = job.schedule?.expr ?? (typeof job.schedule === "string" ? job.schedule : "");
|
|
164
|
+
if (expr !== HERMES_CRON_SCHEDULE) return false;
|
|
165
|
+
return true;
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
/** 印(marker.job=ジョブ ID)が今の実ジョブを指しているか。 */
|
|
169
|
+
export const resolveMarkedJob = (hermesHome, marker) => {
|
|
170
|
+
if (!marker?.job) return null;
|
|
171
|
+
const byId = findJobById(hermesHome, marker.job);
|
|
172
|
+
if (byId) return byId;
|
|
173
|
+
// 旧い印(名前だけ): 同名が1つだけならそれとみなす。複数・不一致は drift。
|
|
174
|
+
if (marker.job === HERMES_CRON_JOB_NAME) {
|
|
175
|
+
const same = findJobsByName(hermesHome);
|
|
176
|
+
if (same.length === 1) return same[0];
|
|
177
|
+
}
|
|
178
|
+
return null;
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const defaultTickCmd = (npxPath) => `${shQuote(npxPath)} -y @clubs.place/cli@latest tick --only=hermes`;
|
|
182
|
+
const defaultLogPath = (home) => join(home, "out", "hermes-cron.log");
|
|
183
|
+
|
|
184
|
+
const REPAIR_HINT = "`clubs-nippo hermes-cron install` で直すか、`remove` で印を消してください。";
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Hermes ネイティブ cron を入れる(冪等)。同名ジョブの扱い:
|
|
188
|
+
* - 無い → 公式 create → 読み返して像を確認(exit 0 だけでは信じない)
|
|
189
|
+
* - 1つ+像どおり → 作り直さない
|
|
190
|
+
* - 1つ+像ちがい(停止中・別スケジュール等)→ 公式 edit/resume で直して読み返し確認。
|
|
191
|
+
* prompt 付き(利用者の agent ジョブが同名)は触らず失敗にする
|
|
192
|
+
* - 複数 → どれが自分のものか分からないので触らず失敗にする
|
|
193
|
+
* 失敗したら印は付けない(OS 側を止めない)。
|
|
194
|
+
* hermes CLI が無い環境では失敗を返す(OS cron への黙った切替はしない)。
|
|
195
|
+
*/
|
|
196
|
+
export const installHermesCron = ({
|
|
197
|
+
home,
|
|
198
|
+
hermesHome = hermesHomeDir(),
|
|
199
|
+
hermesBin = process.env.HERMES_BIN || "hermes",
|
|
200
|
+
npxPath,
|
|
201
|
+
hermesBinDir = "",
|
|
202
|
+
runner = defaultRunner(hermesBin),
|
|
203
|
+
tickCmd,
|
|
204
|
+
logPath,
|
|
205
|
+
} = {}) => {
|
|
206
|
+
if (!home) return { ok: false, reason: "home(CLUBS_NIPPO_HOME)が必要です" };
|
|
207
|
+
home = resolveBaseHome(home);
|
|
208
|
+
if (!logPath) logPath = defaultLogPath(home);
|
|
209
|
+
if (!npxPath) return { ok: false, reason: "npx のフルパスが必要です(cron の細い PATH 対策)" };
|
|
210
|
+
const scriptFile = cronScriptPath(hermesHome);
|
|
211
|
+
mkdirSync(dirname(scriptFile), { recursive: true });
|
|
212
|
+
writeFileSync(
|
|
213
|
+
scriptFile,
|
|
214
|
+
buildTickScript({ home, hermesHome, npxPath, tickCmd, hermesBinDir, logPath }),
|
|
215
|
+
"utf8",
|
|
216
|
+
);
|
|
217
|
+
chmodSync(scriptFile, 0o755);
|
|
218
|
+
|
|
219
|
+
const same = findJobsByName(hermesHome);
|
|
220
|
+
let jobId;
|
|
221
|
+
let created = false;
|
|
222
|
+
let repaired = false;
|
|
223
|
+
if (same.length === 0) {
|
|
224
|
+
const made = runner([
|
|
225
|
+
"cron",
|
|
226
|
+
"create",
|
|
227
|
+
HERMES_CRON_SCHEDULE,
|
|
228
|
+
"--name",
|
|
229
|
+
HERMES_CRON_JOB_NAME,
|
|
230
|
+
"--deliver",
|
|
231
|
+
HERMES_CRON_DELIVER,
|
|
232
|
+
"--script",
|
|
233
|
+
SCRIPT_REL,
|
|
234
|
+
"--no-agent",
|
|
235
|
+
]);
|
|
236
|
+
if (made.status !== 0) {
|
|
237
|
+
return { ok: false, reason: `hermes cron create に失敗しました(${(made.stderr || made.stdout || "").trim().slice(0, 300)})` };
|
|
238
|
+
}
|
|
239
|
+
const job = findJobsByName(hermesHome)[0] ?? null;
|
|
240
|
+
if (!job || !jobSpecMatches(job)) {
|
|
241
|
+
return { ok: false, reason: "hermes cron create は終わりましたが望むジョブ像を確認できません(jobs.json を読み返しました)" };
|
|
242
|
+
}
|
|
243
|
+
jobId = job.id;
|
|
244
|
+
created = true;
|
|
245
|
+
} else if (same.length === 1 && jobSpecMatches(same[0])) {
|
|
246
|
+
jobId = same[0].id;
|
|
247
|
+
} else if (same.length === 1) {
|
|
248
|
+
const target = same[0];
|
|
249
|
+
if (target.prompt) {
|
|
250
|
+
return {
|
|
251
|
+
ok: false,
|
|
252
|
+
reason: `同名のジョブ(${target.id})は利用者の agent ジョブの疑いがあるため触りません(prompt 付き)。名前を変えるか手で直してください。`,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
const edit = runner([
|
|
256
|
+
"cron",
|
|
257
|
+
"edit",
|
|
258
|
+
target.id,
|
|
259
|
+
"--schedule",
|
|
260
|
+
HERMES_CRON_SCHEDULE,
|
|
261
|
+
"--script",
|
|
262
|
+
SCRIPT_REL,
|
|
263
|
+
"--no-agent",
|
|
264
|
+
"--deliver",
|
|
265
|
+
HERMES_CRON_DELIVER,
|
|
266
|
+
]);
|
|
267
|
+
if (edit.status !== 0) {
|
|
268
|
+
return { ok: false, reason: `hermes cron edit に失敗しました(${(edit.stderr || edit.stdout || "").trim().slice(0, 300)})` };
|
|
269
|
+
}
|
|
270
|
+
if (!target.enabled) {
|
|
271
|
+
const resumed = runner(["cron", "resume", target.id]);
|
|
272
|
+
if (resumed.status !== 0) {
|
|
273
|
+
return { ok: false, reason: `hermes cron resume に失敗しました(${(resumed.stderr || resumed.stdout || "").trim().slice(0, 300)})` };
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
const job = findJobById(hermesHome, target.id);
|
|
277
|
+
if (!job || !jobSpecMatches(job)) {
|
|
278
|
+
return { ok: false, reason: "hermes cron edit は終わりましたが望むジョブ像を確認できません(jobs.json を読み返しました)" };
|
|
279
|
+
}
|
|
280
|
+
jobId = job.id;
|
|
281
|
+
repaired = true;
|
|
282
|
+
} else {
|
|
283
|
+
return {
|
|
284
|
+
ok: false,
|
|
285
|
+
reason: `同名のジョブが ${same.length} 件あります(${same.map((j) => j.id).join(", ")})。どれとも決められないので触りません。不要なものを手で消してから入れ直してください。`,
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// 印は hermes 収集元の家だけに付ける(他 AI の OS 実行を止めない)。
|
|
290
|
+
if (homeSources(home).includes("hermes")) writeMarker(home, jobId);
|
|
291
|
+
return { ok: true, created, ...(repaired ? { repaired: true } : {}), job: jobId };
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
/** Hermes ネイティブ cron を外す(印を消す・script は残す=入れ直しが冪等)。 */
|
|
295
|
+
export const removeHermesCron = ({
|
|
296
|
+
home,
|
|
297
|
+
hermesHome = hermesHomeDir(),
|
|
298
|
+
hermesBin = process.env.HERMES_BIN || "hermes",
|
|
299
|
+
runner = defaultRunner(hermesBin),
|
|
300
|
+
} = {}) => {
|
|
301
|
+
const marker = readMarker(home);
|
|
302
|
+
let targetId = null;
|
|
303
|
+
if (marker?.job) {
|
|
304
|
+
const job = resolveMarkedJob(hermesHome, marker);
|
|
305
|
+
if (!job) {
|
|
306
|
+
// 正確な ID と script を持つ所有印だけ回復する。読めない jobs.json は
|
|
307
|
+
// 「削除済み」の証拠にならない。旧い名前だけの印も曖昧なので残す。
|
|
308
|
+
let jobs;
|
|
309
|
+
try {
|
|
310
|
+
const data = JSON.parse(readFileSync(join(hermesHome, "cron", "jobs.json"), "utf8"));
|
|
311
|
+
jobs = Array.isArray(data) ? data : data.jobs;
|
|
312
|
+
} catch { /* 確認不能 */ }
|
|
313
|
+
if (marker.script === SCRIPT_REL && typeof marker.job === "string" &&
|
|
314
|
+
marker.job !== HERMES_CRON_JOB_NAME &&
|
|
315
|
+
Array.isArray(jobs) && !jobs.some((j) => j?.id === marker.job)) {
|
|
316
|
+
unlinkSync(markerPath(home));
|
|
317
|
+
return { ok: true, removed: false };
|
|
318
|
+
}
|
|
319
|
+
return {
|
|
320
|
+
ok: false,
|
|
321
|
+
reason: `印のジョブ(${marker.job})が Hermes 側にありません。${REPAIR_HINT}`,
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
targetId = job.id;
|
|
325
|
+
} else {
|
|
326
|
+
const same = findJobsByName(hermesHome);
|
|
327
|
+
if (same.length === 0) return { ok: true, removed: false };
|
|
328
|
+
if (same.length > 1) {
|
|
329
|
+
return {
|
|
330
|
+
ok: false,
|
|
331
|
+
reason: `印が無く同名のジョブが ${same.length} 件あります(${same.map((j) => j.id).join(", ")})。手で消してください。`,
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
targetId = same[0].id;
|
|
335
|
+
}
|
|
336
|
+
const target = findJobById(hermesHome, targetId);
|
|
337
|
+
if (!target || target.name !== HERMES_CRON_JOB_NAME ||
|
|
338
|
+
target.script !== SCRIPT_REL || target.no_agent !== true || target.prompt) {
|
|
339
|
+
return { ok: false, reason: "所有する script-only ジョブと確認できないため触りません。" };
|
|
340
|
+
}
|
|
341
|
+
const removed = runner(["cron", "remove", targetId]);
|
|
342
|
+
if (removed.status !== 0) {
|
|
343
|
+
return { ok: false, reason: `hermes cron remove に失敗しました(${(removed.stderr || removed.stdout || "").trim().slice(0, 300)})` };
|
|
344
|
+
}
|
|
345
|
+
if (findJobById(hermesHome, targetId)) {
|
|
346
|
+
return { ok: false, reason: `hermes cron remove は終わりましたがジョブ(${targetId})が残っています(jobs.json を読み返しました)` };
|
|
347
|
+
}
|
|
348
|
+
try {
|
|
349
|
+
unlinkSync(markerPath(home));
|
|
350
|
+
} catch {
|
|
351
|
+
/* 印が無いのは正常 */
|
|
352
|
+
}
|
|
353
|
+
return { ok: true, removed: true };
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
/** ok(管理中・job 在り・像どおり)/ drift(印はあるが job が無い・像ちがい)/ unmanaged。 */
|
|
357
|
+
export const statusHermesCron = ({ home, hermesHome = hermesHomeDir() } = {}) => {
|
|
358
|
+
const marker = readMarker(home);
|
|
359
|
+
if (!marker) return { state: "unmanaged" };
|
|
360
|
+
const job = resolveMarkedJob(hermesHome, marker);
|
|
361
|
+
if (!job) {
|
|
362
|
+
return {
|
|
363
|
+
state: "drift",
|
|
364
|
+
hint: `印のジョブ(${marker.job})が Hermes 側にありません。${REPAIR_HINT}`,
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
if (!jobSpecMatches(job)) {
|
|
368
|
+
const why = !job.enabled
|
|
369
|
+
? "停止中(paused)"
|
|
370
|
+
: job.no_agent !== true
|
|
371
|
+
? "agent 付き(--no-agent ではない)"
|
|
372
|
+
: job.script !== SCRIPT_REL
|
|
373
|
+
? `別スクリプト(${job.script})`
|
|
374
|
+
: `別スケジュール・配信先(${job.schedule?.expr ?? job.schedule}・${job.deliver})`;
|
|
375
|
+
return {
|
|
376
|
+
state: "drift",
|
|
377
|
+
job: job.id,
|
|
378
|
+
hint: `ジョブ(${job.id})は${why}です。${REPAIR_HINT}`,
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
return { state: "ok", job: job.id };
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* init 時のスケジューラ割り振り。hermes だけの設定は hermes-cron に一本化し
|
|
386
|
+
* OS 側と重ねない。混成は両方(OS が全体・hermes-cron が --only=hermes を担い、
|
|
387
|
+
* 印で OS 側の二重実行を止める)。hermes CLI が無ければ OS に倒して警告する
|
|
388
|
+
* (黙って Claude に落とさない=生成器の既定は hermes のまま)。
|
|
389
|
+
*/
|
|
390
|
+
export const planScheduler = ({ sources = [], hermesAvailable = false } = {}) => {
|
|
391
|
+
const list = sources.map((s) => String(s).trim()).filter(Boolean);
|
|
392
|
+
const hasHermes = list.includes("hermes");
|
|
393
|
+
const onlyHermes = hasHermes && list.every((s) => s === "hermes");
|
|
394
|
+
if (onlyHermes && hermesAvailable) return { os: false, hermesCron: true };
|
|
395
|
+
return { os: true, hermesCron: hasHermes && hermesAvailable };
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* APP_HOME 配下の hermes 収集元の設定すべてに印を付ける(root+並べた分)。
|
|
400
|
+
* 他 AI の設定には触らない。戻り値は印を付けた home の一覧。
|
|
401
|
+
*/
|
|
402
|
+
export const markHermesManagedHomes = (appHome, job = "hermes-cron") => {
|
|
403
|
+
const marked = [];
|
|
404
|
+
for (const dir of [appHome, ...listProfiles(appHome)]) {
|
|
405
|
+
if (!homeSources(dir).includes("hermes")) continue;
|
|
406
|
+
writeMarker(dir, job);
|
|
407
|
+
marked.push(dir);
|
|
408
|
+
}
|
|
409
|
+
return marked;
|
|
410
|
+
};
|
|
411
|
+
|
|
412
|
+
/** APP_HOME 配下の印をすべて消す(.env には触らない)。 */
|
|
413
|
+
export const clearHermesManagedHomes = (appHome) => {
|
|
414
|
+
for (const dir of [appHome, ...listProfiles(appHome)]) {
|
|
415
|
+
try {
|
|
416
|
+
unlinkSync(markerPath(dir));
|
|
417
|
+
} catch {
|
|
418
|
+
/* 印が無いのは正常 */
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
};
|