@tyhld/conductor 0.12.83 → 0.12.85
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/dist/autoupdate-flags.js +62 -0
- package/dist/cli.js +17 -1
- package/dist/headless.js +84 -1
- package/dist/relay.js +51 -0
- package/dist/selfcheck.js +743 -0
- package/dist/view-stream.js +33 -8
- package/package.json +2 -1
- package/scripts/dist-source.sh +60 -10
- package/scripts/hooks/skill_sync_notify.py +8 -1
- package/scripts/hooks/test_skill_sync_notify.py +8 -0
- package/scripts/tyhld-selfcheck.sh +58 -0
- package/scripts/update-guard.sh +2 -1
- package/systemd/tyhld-selfcheck.service +23 -0
- package/systemd/tyhld-selfcheck.timer +19 -0
|
@@ -0,0 +1,743 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @tyhld/conductor — 自己診断(`conductor selfcheck`・便 b7bd5e43・設計 3ed9e2b2 の A1)。
|
|
3
|
+
*
|
|
4
|
+
* 【何のためか】自社PCの自動更新(えふさん確定 2026-10-02)で、
|
|
5
|
+
* 「conductor 更新 → 自己診断 → PASS → Claude Code 更新 → 自己診断 → PASS」の判定に使う。
|
|
6
|
+
* 毎日1回(ADR-026 の9)と更新の直後を【同じ道具】で行い、きっかけの名前だけを変える。
|
|
7
|
+
*
|
|
8
|
+
* 【見る物(えふさん確定の6項目+番人)】
|
|
9
|
+
* ① conductor 起動 … 常駐(conductor@*・耳)が動いているか/常駐が動かしている版
|
|
10
|
+
* ② Claude Code 起動 … 中継と同じ選び方(指名を含む)で選ばれた実体と版・ログイン済みか
|
|
11
|
+
* ③ 管制との通信 … 緊急停止の読み取りが通るか・ai-kana の MCP が「接続済み」か「要認証」か
|
|
12
|
+
* ④ 囲い … bwrap・socat が在るか・囲いの中の書き込みが作業場所の外で止まるか
|
|
13
|
+
* ⑤ GitHub・npm … 外側から gh・npm、囲いの内側から両方へ1回ずつ
|
|
14
|
+
* ⑥ 小さな headless 試験 … 小さい型(haiku)・許可は中継と同じ(番人がそのまま効く)。
|
|
15
|
+
* 合否は職人の言葉でなく、tool の結果と終了コードで機械判定する
|
|
16
|
+
* +番人 … 入っている番人に、止めるべき物と通すべき物を1つずつ判定させる
|
|
17
|
+
*
|
|
18
|
+
* 【★副作用なし】commit・push・PR・合流・配備・npm 公開をしない。作業場所は一時フォルダ
|
|
19
|
+
* (~/.local/state/conductor/selfcheck/run-<時刻>・git の中ではない)で、終わったら消す。
|
|
20
|
+
*
|
|
21
|
+
* 【結果】手元の控え(~/.local/state/conductor/selfcheck/last.json と history/)に必ず残す。
|
|
22
|
+
* 管制の口が在れば送る(★口が無い・届かないときも落ちない=送れなかったことを控えに書くだけ)。
|
|
23
|
+
* 送る中身は buildSelfcheckPayload の1か所(口の形は ai-kana の便 2f5eb92c が決める)。
|
|
24
|
+
*/
|
|
25
|
+
import { spawnSync } from 'node:child_process';
|
|
26
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync, } from 'node:fs';
|
|
27
|
+
import os from 'node:os';
|
|
28
|
+
import path from 'node:path';
|
|
29
|
+
import { fileURLToPath } from 'node:url';
|
|
30
|
+
import { CLAUDE_MIN_VERSION, claudeUpdateHint, compareVersions, lastClaudePinStatus, resolveClaudeBin, } from './headless.js';
|
|
31
|
+
import { fetchControl } from './relay.js';
|
|
32
|
+
import { readDrain } from './autoupdate-flags.js';
|
|
33
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
34
|
+
// 形
|
|
35
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
36
|
+
export const SELFCHECK_TRIGGERS = ['daily', 'after-conductor', 'after-claude', 'after-rollback', 'manual'];
|
|
37
|
+
export const defaultExec = (cmd, args, opts = {}) => {
|
|
38
|
+
const r = spawnSync(cmd, args, {
|
|
39
|
+
cwd: opts.cwd,
|
|
40
|
+
env: opts.env,
|
|
41
|
+
input: opts.input,
|
|
42
|
+
timeout: opts.timeoutMs ?? 30_000,
|
|
43
|
+
encoding: 'utf8',
|
|
44
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
45
|
+
});
|
|
46
|
+
return {
|
|
47
|
+
status: r.status,
|
|
48
|
+
stdout: r.stdout ?? '',
|
|
49
|
+
stderr: r.stderr ?? '',
|
|
50
|
+
...(r.error ? { error: r.error.message } : {}),
|
|
51
|
+
};
|
|
52
|
+
};
|
|
53
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
54
|
+
// 置き場
|
|
55
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
56
|
+
/** 自己診断の控えと一時の作業場所の置き場。 */
|
|
57
|
+
export function selfcheckDir(home) {
|
|
58
|
+
return path.join(home, '.local', 'state', 'conductor', 'selfcheck');
|
|
59
|
+
}
|
|
60
|
+
/** 管制の受け口(★1か所。口の形は ai-kana の便 2f5eb92c に合わせてここだけ直す)。 */
|
|
61
|
+
export const SELFCHECK_ENDPOINT = '/api/conductor/selfcheck';
|
|
62
|
+
/** 控えの数の上限(古い物から消す)。 */
|
|
63
|
+
export const SELFCHECK_HISTORY_MAX = 30;
|
|
64
|
+
/** 小さな試験の上限時間。 */
|
|
65
|
+
export const SELFCHECK_HEADLESS_TIMEOUT_MS = 180_000;
|
|
66
|
+
/** 小さな試験で使う型(設計 3ed9e2b2 の要判断6・推奨 haiku)。 */
|
|
67
|
+
export const SELFCHECK_MODEL = 'haiku';
|
|
68
|
+
/** 小さな試験の最後の答えを示す目印(★職人の言葉ではなく、tool の結果の中で探す)。 */
|
|
69
|
+
export const PROBE_OK_MARK = 'SELFCHECK_PROBE_OK';
|
|
70
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
71
|
+
// 小さな試験(⑥)の中身と読み方
|
|
72
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
73
|
+
/**
|
|
74
|
+
* 囲いの中で流す台本。1回の Bash で ④(外への書き込み)と ⑤(内側の通信)までまとめて見る。
|
|
75
|
+
* ★書き込み先は作業場所の【外】の使い捨ての場所(自己診断の一時フォルダの中)。
|
|
76
|
+
* ★秘密の置き場は1つも触らない。
|
|
77
|
+
*/
|
|
78
|
+
export function probeScript(outsideFile) {
|
|
79
|
+
return [
|
|
80
|
+
'#!/usr/bin/env bash',
|
|
81
|
+
`OUT='${outsideFile.replace(/'/g, "'\\''")}'`,
|
|
82
|
+
`echo "${PROBE_OK_MARK}"`,
|
|
83
|
+
'if ( printf x > "$OUT" ) 2>/dev/null; then echo "OUTSIDE_WRITE=open"; else echo "OUTSIDE_WRITE=blocked"; fi',
|
|
84
|
+
'for u in https://registry.npmjs.org/ https://github.com/; do',
|
|
85
|
+
' if command -v curl >/dev/null 2>&1; then',
|
|
86
|
+
' c="$(curl -sS -o /dev/null -I -m 20 -w "%{http_code}" "$u" 2>/dev/null)"',
|
|
87
|
+
' else c="no-curl"; fi',
|
|
88
|
+
' echo "NET $u ${c:-000}"',
|
|
89
|
+
'done',
|
|
90
|
+
'',
|
|
91
|
+
].join('\n');
|
|
92
|
+
}
|
|
93
|
+
export const PROBE_PROMPT = '作業場所の probe.sh を Bash で1回だけ実行してください(コマンドは `bash probe.sh` だけ)。' +
|
|
94
|
+
'ほかの道具は使わず、ファイルも作らないでください。出力をそのまま返して終えてください。';
|
|
95
|
+
/** stream-json の出力を読む(★知らない行は読み飛ばす)。 */
|
|
96
|
+
export function summarizeStream(stdout) {
|
|
97
|
+
const out = { mcp: null, toolResults: [], result: null };
|
|
98
|
+
for (const line of stdout.split('\n')) {
|
|
99
|
+
const t = line.trim();
|
|
100
|
+
if (!t.startsWith('{'))
|
|
101
|
+
continue;
|
|
102
|
+
let ev;
|
|
103
|
+
try {
|
|
104
|
+
ev = JSON.parse(t);
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (ev.type === 'system' && ev.subtype === 'init' && Array.isArray(ev.mcp_servers)) {
|
|
110
|
+
out.mcp = ev.mcp_servers
|
|
111
|
+
.map((s) => s)
|
|
112
|
+
.filter((s) => typeof s.name === 'string')
|
|
113
|
+
.map((s) => ({ name: String(s.name), status: typeof s.status === 'string' ? s.status : 'unknown' }));
|
|
114
|
+
}
|
|
115
|
+
if (ev.type === 'user') {
|
|
116
|
+
const content = ev.message?.content;
|
|
117
|
+
if (Array.isArray(content)) {
|
|
118
|
+
for (const c of content) {
|
|
119
|
+
if (c?.type !== 'tool_result')
|
|
120
|
+
continue;
|
|
121
|
+
const body = c.content;
|
|
122
|
+
if (typeof body === 'string')
|
|
123
|
+
out.toolResults.push(body);
|
|
124
|
+
else if (Array.isArray(body)) {
|
|
125
|
+
out.toolResults.push(body.map((b) => (typeof b?.text === 'string' ? b.text : '')).join('\n'));
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (ev.type === 'result') {
|
|
131
|
+
const usage = (ev.usage ?? {});
|
|
132
|
+
const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
|
|
133
|
+
out.result = {
|
|
134
|
+
subtype: typeof ev.subtype === 'string' ? ev.subtype : null,
|
|
135
|
+
isError: ev.is_error === true,
|
|
136
|
+
costUsd: num(ev.total_cost_usd),
|
|
137
|
+
inputTokens: num(usage.input_tokens) === null
|
|
138
|
+
? null
|
|
139
|
+
: (num(usage.input_tokens) ?? 0) +
|
|
140
|
+
(num(usage.cache_read_input_tokens) ?? 0) +
|
|
141
|
+
(num(usage.cache_creation_input_tokens) ?? 0),
|
|
142
|
+
outputTokens: num(usage.output_tokens),
|
|
143
|
+
durationMs: num(ev.duration_ms),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* ai-kana の MCP の状態を1つに決める(★実機 2026-10-02・このPCの init 行で確かめた形)。
|
|
151
|
+
* init 行には `mcp_servers: [{ name, status }]` が載る。status は connected / pending /
|
|
152
|
+
* needs-auth / failed など。このPCでは「claude.ai ai-kana」(アカウントのコネクタ)と、
|
|
153
|
+
* 使っていない手元の登録「ai-kana」(いつも needs-auth)の2つが並ぶ。
|
|
154
|
+
* ★1つでも connected なら接続済み。pending(つなぎかけで始まった)が在れば確定できない
|
|
155
|
+
* (=要認証とは言わない)。connected も pending も無く needs-auth が在るときだけ要認証。
|
|
156
|
+
*/
|
|
157
|
+
export function aiKanaMcpState(mcp) {
|
|
158
|
+
if (mcp === null)
|
|
159
|
+
return 'unknown';
|
|
160
|
+
const mine = mcp.filter((s) => /ai-kana/i.test(s.name));
|
|
161
|
+
if (mine.length === 0)
|
|
162
|
+
return 'missing';
|
|
163
|
+
if (mine.some((s) => s.status === 'connected'))
|
|
164
|
+
return 'connected';
|
|
165
|
+
if (mine.some((s) => s.status === 'pending'))
|
|
166
|
+
return 'pending';
|
|
167
|
+
if (mine.some((s) => /auth/i.test(s.status)))
|
|
168
|
+
return 'needs-auth';
|
|
169
|
+
return 'failed';
|
|
170
|
+
}
|
|
171
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
172
|
+
// 本体
|
|
173
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
174
|
+
function readJsonVersion(file) {
|
|
175
|
+
try {
|
|
176
|
+
const v = JSON.parse(readFileSync(file, 'utf8')).version;
|
|
177
|
+
return typeof v === 'string' ? v : null;
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
/** 控えの1行(`<時刻> key=value …`)を読む。 */
|
|
184
|
+
function readKvLine(file) {
|
|
185
|
+
try {
|
|
186
|
+
const line = (readFileSync(file, 'utf8').split('\n')[0] ?? '').trim();
|
|
187
|
+
const out = {};
|
|
188
|
+
for (const p of line.split(' ').slice(1)) {
|
|
189
|
+
const i = p.indexOf('=');
|
|
190
|
+
if (i > 0)
|
|
191
|
+
out[p.slice(0, i)] = p.slice(i + 1);
|
|
192
|
+
}
|
|
193
|
+
return out;
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
return {};
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
const fail = (item, kind, msg, human) => {
|
|
200
|
+
item.ok = false;
|
|
201
|
+
item.detail.push(`✗ ${msg}`);
|
|
202
|
+
if (kind === 'fault' || item.failKind === 'fault')
|
|
203
|
+
item.failKind = 'fault';
|
|
204
|
+
else
|
|
205
|
+
item.failKind = 'human';
|
|
206
|
+
if (human)
|
|
207
|
+
(item.human ??= []).push(human);
|
|
208
|
+
};
|
|
209
|
+
const pass = (item, msg) => {
|
|
210
|
+
if (item.ok === null)
|
|
211
|
+
item.ok = true;
|
|
212
|
+
item.detail.push(`✓ ${msg}`);
|
|
213
|
+
};
|
|
214
|
+
const note = (item, msg) => {
|
|
215
|
+
item.detail.push(`・${msg}`);
|
|
216
|
+
};
|
|
217
|
+
/** ① conductor 起動 */
|
|
218
|
+
function checkConductor(d, version) {
|
|
219
|
+
const item = { key: 'conductor', label: 'conductor 起動', ok: null, detail: [] };
|
|
220
|
+
if (version)
|
|
221
|
+
pass(item, `この道具の版 ${version}`);
|
|
222
|
+
else
|
|
223
|
+
fail(item, 'fault', '配布物の版を読めません');
|
|
224
|
+
if (d.platform !== 'linux') {
|
|
225
|
+
note(item, 'systemd の無い機なので常駐は見ていません');
|
|
226
|
+
return item;
|
|
227
|
+
}
|
|
228
|
+
const r = d.exec('systemctl', ['--user', 'list-units', 'conductor@*.service', 'conductor-ear.service', '--all', '--no-legend', '--plain'], {
|
|
229
|
+
timeoutMs: 15_000,
|
|
230
|
+
});
|
|
231
|
+
if (r.status !== 0 && !r.stdout.trim()) {
|
|
232
|
+
note(item, `常駐の一覧を読めません(${(r.error ?? r.stderr).trim().slice(0, 120) || `終了 ${r.status}`})`);
|
|
233
|
+
}
|
|
234
|
+
else {
|
|
235
|
+
const units = r.stdout
|
|
236
|
+
.split('\n')
|
|
237
|
+
.map((l) => l.trim().split(/\s+/))
|
|
238
|
+
.filter((f) => f[0]?.endsWith('.service'));
|
|
239
|
+
const relays = units.filter((f) => f[0].startsWith('conductor@'));
|
|
240
|
+
const down = units.filter((f) => f[2] !== 'active');
|
|
241
|
+
if (relays.length === 0)
|
|
242
|
+
note(item, '常駐(conductor@*)がこの機にありません');
|
|
243
|
+
else if (down.length === 0)
|
|
244
|
+
pass(item, `常駐 ${units.length} 本すべて動いています`);
|
|
245
|
+
else
|
|
246
|
+
fail(item, 'fault', `動いていない常駐: ${down.map((f) => `${f[0]}(${f[2]}/${f[3] ?? ''})`).join(' ')}`);
|
|
247
|
+
}
|
|
248
|
+
// 常駐が最後に動かせた配布物の版(conductor-run の控え・★~/.tyhld/hooks の下=職人は書けない)。
|
|
249
|
+
const rt = readKvLine(path.join(d.home, '.tyhld', 'hooks', 'runtime-source.last'));
|
|
250
|
+
if (rt.status === 'ok' && rt.version) {
|
|
251
|
+
if (version && rt.version !== version) {
|
|
252
|
+
fail(item, 'fault', `常駐が動かしている版 ${rt.version} が、この道具の版 ${version} と違います(起こし直しが済んでいない)`);
|
|
253
|
+
}
|
|
254
|
+
else
|
|
255
|
+
pass(item, `常駐が動かしている版 ${rt.version}`);
|
|
256
|
+
}
|
|
257
|
+
else if (rt.status) {
|
|
258
|
+
fail(item, 'fault', `常駐の起動の控えが失敗です(${rt.reason ?? '理由不明'})`);
|
|
259
|
+
}
|
|
260
|
+
else
|
|
261
|
+
note(item, '常駐の起動の控え(runtime-source.last)がありません');
|
|
262
|
+
return item;
|
|
263
|
+
}
|
|
264
|
+
/** ② Claude Code 起動 */
|
|
265
|
+
function checkClaude(d) {
|
|
266
|
+
const item = { key: 'claude', label: 'Claude Code 起動', ok: null, detail: [] };
|
|
267
|
+
const got = d.resolveClaude();
|
|
268
|
+
const pin = lastClaudePinStatus();
|
|
269
|
+
if (pin.state === 'used')
|
|
270
|
+
note(item, `指名どおりの実体を選びました(${pin.pinned})`);
|
|
271
|
+
if (pin.state === 'fallback')
|
|
272
|
+
fail(item, 'fault', `Claude Code の指名を使えません: ${pin.reason}`);
|
|
273
|
+
if (!got) {
|
|
274
|
+
fail(item, 'human', 'claude が見つかりません', {
|
|
275
|
+
machine: d.machine,
|
|
276
|
+
what: 'Ubuntu で Claude Code を入れる',
|
|
277
|
+
command: 'curl -fsSL https://claude.ai/install.sh | bash',
|
|
278
|
+
});
|
|
279
|
+
return { item, bin: null, version: null, loggedIn: null };
|
|
280
|
+
}
|
|
281
|
+
if (!got.version)
|
|
282
|
+
fail(item, 'fault', `claude の版を読めません(${got.bin})`);
|
|
283
|
+
else if (compareVersions(got.version, CLAUDE_MIN_VERSION) < 0) {
|
|
284
|
+
const hint = claudeUpdateHint(got.bin);
|
|
285
|
+
fail(item, 'human', `Claude Code が最低版 ${CLAUDE_MIN_VERSION} より古い(${got.bin} = ${got.version})`, {
|
|
286
|
+
machine: d.machine,
|
|
287
|
+
what: 'Ubuntu で Claude Code を上げる',
|
|
288
|
+
command: hint.replace(/^[^:]+:\s*/, ''),
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
else
|
|
292
|
+
pass(item, `${got.bin} = ${got.version}`);
|
|
293
|
+
let loggedIn = null;
|
|
294
|
+
const a = d.exec(got.bin, ['auth', 'status', '--json'], { timeoutMs: 20_000, env: d.env });
|
|
295
|
+
try {
|
|
296
|
+
const j = JSON.parse(a.stdout);
|
|
297
|
+
if (typeof j.loggedIn === 'boolean')
|
|
298
|
+
loggedIn = j.loggedIn;
|
|
299
|
+
}
|
|
300
|
+
catch {
|
|
301
|
+
// 読めない形(古い版など)は下で扱う
|
|
302
|
+
}
|
|
303
|
+
if (loggedIn === true)
|
|
304
|
+
pass(item, 'ログイン済み');
|
|
305
|
+
else if (loggedIn === false) {
|
|
306
|
+
fail(item, 'human', 'Claude Code がログインしていません', {
|
|
307
|
+
machine: d.machine,
|
|
308
|
+
what: 'Ubuntu で Claude Code にログインし直す',
|
|
309
|
+
command: `"${got.bin}" auth login`,
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
else
|
|
313
|
+
note(item, `ログインの状態を読めません(auth status の終了 ${a.status})。小さな試験の結果で判断します`);
|
|
314
|
+
return { item, bin: got.bin, version: got.version, loggedIn };
|
|
315
|
+
}
|
|
316
|
+
/** ③ 管制との通信(MCP の状態は小さな試験の後で足す) */
|
|
317
|
+
async function checkCentral(d) {
|
|
318
|
+
const item = { key: 'central', label: '管制との通信', ok: null, detail: [] };
|
|
319
|
+
const url = d.env.CONDUCTOR_URL;
|
|
320
|
+
const token = d.env.CONDUCTOR_TOKEN;
|
|
321
|
+
if (!url || !token) {
|
|
322
|
+
fail(item, 'fault', '接続設定(CONDUCTOR_URL・CONDUCTOR_TOKEN)がありません');
|
|
323
|
+
return item;
|
|
324
|
+
}
|
|
325
|
+
const site = d.env.CONDUCTOR_SITE || 'selfcheck';
|
|
326
|
+
const ctl = await withFetch(d, () => fetchControl({ url, token, machine: d.machine, site }));
|
|
327
|
+
if (ctl.ok)
|
|
328
|
+
pass(item, `緊急停止の読み取りが通りました(停止中=${ctl.relayHalted ? 'はい' : 'いいえ'})`);
|
|
329
|
+
else
|
|
330
|
+
fail(item, 'fault', `緊急停止の読み取りが通りません(${ctl.detail})`);
|
|
331
|
+
return item;
|
|
332
|
+
}
|
|
333
|
+
/** relay.ts の fetch を試験で差し替えるための包み(globalThis.fetch を一時的に置き換える)。 */
|
|
334
|
+
async function withFetch(d, fn) {
|
|
335
|
+
if (d.fetchImpl === globalThis.fetch)
|
|
336
|
+
return fn();
|
|
337
|
+
const orig = globalThis.fetch;
|
|
338
|
+
globalThis.fetch = d.fetchImpl;
|
|
339
|
+
try {
|
|
340
|
+
return await fn();
|
|
341
|
+
}
|
|
342
|
+
finally {
|
|
343
|
+
globalThis.fetch = orig;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
/** ④ 囲い(部品の有無。書き込みの止まり方は小さな試験の後で足す) */
|
|
347
|
+
function checkSandboxParts(d) {
|
|
348
|
+
const item = { key: 'sandbox', label: '囲い(sandbox)', ok: null, detail: [] };
|
|
349
|
+
if (d.platform !== 'linux') {
|
|
350
|
+
note(item, 'Linux 以外の機なので部品(bwrap・socat)は求めません');
|
|
351
|
+
return item;
|
|
352
|
+
}
|
|
353
|
+
const missing = ['bwrap', 'socat'].filter((b) => d.exec('bash', ['-c', `command -v ${b}`], { env: d.env }).status !== 0);
|
|
354
|
+
if (missing.length === 0)
|
|
355
|
+
pass(item, 'bwrap・socat が在ります');
|
|
356
|
+
else {
|
|
357
|
+
fail(item, 'human', `囲いの部品がありません: ${missing.join('・')}`, {
|
|
358
|
+
machine: d.machine,
|
|
359
|
+
what: 'Ubuntu で囲いの部品を入れる(sudo のパスワードが分からないときは Windows の PowerShell で wsl.exe -u root apt-get install -y bubblewrap socat)',
|
|
360
|
+
command: 'sudo apt-get install -y bubblewrap socat',
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
return item;
|
|
364
|
+
}
|
|
365
|
+
/** 配布物の node の隣の npm(★毎時同期と同じ考え方=PATH の裸の npm に頼らない)。 */
|
|
366
|
+
export function npmBesideNode(execPath = process.execPath) {
|
|
367
|
+
const beside = path.join(path.dirname(execPath), 'npm');
|
|
368
|
+
return existsSync(beside) ? beside : null;
|
|
369
|
+
}
|
|
370
|
+
/** ⑤ GitHub・npm(外側。内側は小さな試験の後で足す) */
|
|
371
|
+
function checkNetworkOutside(d, version) {
|
|
372
|
+
const item = { key: 'network', label: 'GitHub・npm への通信', ok: null, detail: [] };
|
|
373
|
+
const npm = d.findNpm();
|
|
374
|
+
if (!npm)
|
|
375
|
+
fail(item, 'fault', `配布物の node の隣に npm がありません(${path.dirname(process.execPath)})`);
|
|
376
|
+
else {
|
|
377
|
+
const env = { ...d.env, PATH: `${path.dirname(npm)}:${d.env.PATH ?? ''}` };
|
|
378
|
+
const r = d.exec(npm, ['view', '@tyhld/conductor', 'version', '--@tyhld:registry=https://registry.npmjs.org'], { env, timeoutMs: 60_000 });
|
|
379
|
+
const v = r.stdout.trim().match(/^\d+\.\d+\.\d+\S*/)?.[0];
|
|
380
|
+
if (r.status === 0 && v)
|
|
381
|
+
pass(item, `公開 npm の @tyhld/conductor = ${v}${version && v !== version ? `(この機は ${version})` : ''}`);
|
|
382
|
+
else
|
|
383
|
+
fail(item, 'fault', `公開 npm に問い合わせられません(${npm}・終了 ${r.status})`);
|
|
384
|
+
}
|
|
385
|
+
const gh = d.exec('gh', ['auth', 'status'], { env: d.env, timeoutMs: 30_000 });
|
|
386
|
+
if (gh.error && /ENOENT/.test(gh.error)) {
|
|
387
|
+
fail(item, 'human', 'gh が入っていません', { machine: d.machine, what: 'Ubuntu で GitHub の道具を入れる', command: 'sudo apt-get install -y gh' });
|
|
388
|
+
}
|
|
389
|
+
else if (gh.status !== 0) {
|
|
390
|
+
fail(item, 'human', 'GitHub にログインしていません(gh auth status が失敗)', {
|
|
391
|
+
machine: d.machine,
|
|
392
|
+
what: 'Ubuntu で GitHub にログインし直す',
|
|
393
|
+
command: 'gh auth login',
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
else {
|
|
397
|
+
const api = d.exec('gh', ['api', 'rate_limit', '--jq', '.rate.limit'], { env: d.env, timeoutMs: 30_000 });
|
|
398
|
+
if (api.status === 0)
|
|
399
|
+
pass(item, 'GitHub にログイン済み・API へ届きます');
|
|
400
|
+
else
|
|
401
|
+
fail(item, 'fault', `GitHub の API へ届きません(終了 ${api.status})`);
|
|
402
|
+
}
|
|
403
|
+
return item;
|
|
404
|
+
}
|
|
405
|
+
/** 囲いの設定だけを配布物の雛形から取り出す(★作業場所が現場と同じ囲いになるように)。 */
|
|
406
|
+
function siteSettingsFor(distRoot) {
|
|
407
|
+
try {
|
|
408
|
+
const t = JSON.parse(readFileSync(path.join(distRoot, 'sales-template', 'settings.json'), 'utf8'));
|
|
409
|
+
return `${JSON.stringify({ permissions: t.permissions, sandbox: t.sandbox }, null, 2)}\n`;
|
|
410
|
+
}
|
|
411
|
+
catch {
|
|
412
|
+
return null;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
/** ⑥ 小さな headless 試験を1回流す。 */
|
|
416
|
+
function runHeadlessProbe(d, claudeBin, runDir) {
|
|
417
|
+
const res = { summary: null, exitCode: null, outsideWrite: 'unknown', net: {}, probeRan: false, error: null };
|
|
418
|
+
if (!claudeBin) {
|
|
419
|
+
res.error = 'claude が無いので流していません';
|
|
420
|
+
return res;
|
|
421
|
+
}
|
|
422
|
+
const work = path.join(runDir, 'work');
|
|
423
|
+
const outside = path.join(runDir, 'outside');
|
|
424
|
+
const outsideFile = path.join(outside, 'probe.txt');
|
|
425
|
+
mkdirSync(path.join(work, '.claude'), { recursive: true });
|
|
426
|
+
mkdirSync(outside, { recursive: true });
|
|
427
|
+
const settings = siteSettingsFor(d.distRoot);
|
|
428
|
+
if (!settings) {
|
|
429
|
+
res.error = '配布物の囲いの雛形(sales-template/settings.json)を読めません';
|
|
430
|
+
return res;
|
|
431
|
+
}
|
|
432
|
+
writeFileSync(path.join(work, '.claude', 'settings.json'), settings);
|
|
433
|
+
writeFileSync(path.join(work, 'probe.sh'), probeScript(outsideFile), { mode: 0o755 });
|
|
434
|
+
const env = { ...d.env };
|
|
435
|
+
delete env.CLAUDECODE;
|
|
436
|
+
delete env.ANTHROPIC_API_KEY;
|
|
437
|
+
const binDir = path.dirname(claudeBin);
|
|
438
|
+
if (!(env.PATH ?? '').split(':').includes(binDir))
|
|
439
|
+
env.PATH = env.PATH ? `${binDir}:${env.PATH}` : binDir;
|
|
440
|
+
const r = d.exec(claudeBin, [
|
|
441
|
+
'-p', PROBE_PROMPT,
|
|
442
|
+
'--output-format', 'stream-json',
|
|
443
|
+
'--verbose',
|
|
444
|
+
'--max-turns', '3',
|
|
445
|
+
'--model', SELFCHECK_MODEL,
|
|
446
|
+
// ★中継と同じ許可(src/headless.ts の既定)=番人がそのまま効く。迂回の旗は付けない。
|
|
447
|
+
'--permission-mode', 'acceptEdits',
|
|
448
|
+
], { cwd: work, env, timeoutMs: SELFCHECK_HEADLESS_TIMEOUT_MS });
|
|
449
|
+
res.exitCode = r.status;
|
|
450
|
+
if (r.error)
|
|
451
|
+
res.error = r.error;
|
|
452
|
+
res.summary = summarizeStream(r.stdout);
|
|
453
|
+
const tool = res.summary.toolResults.join('\n');
|
|
454
|
+
res.probeRan = tool.includes(PROBE_OK_MARK);
|
|
455
|
+
// ★外に書けたかは【実物】で決める(ファイルが在るか)。台本の言葉は補助。
|
|
456
|
+
if (existsSync(outsideFile))
|
|
457
|
+
res.outsideWrite = 'open';
|
|
458
|
+
else if (/OUTSIDE_WRITE=blocked/.test(tool))
|
|
459
|
+
res.outsideWrite = 'blocked';
|
|
460
|
+
for (const m of tool.matchAll(/^NET (\S+) (\S+)$/gm))
|
|
461
|
+
res.net[m[1]] = m[2];
|
|
462
|
+
return res;
|
|
463
|
+
}
|
|
464
|
+
/** +番人:入っている番人に、止めるべき物と通すべき物を1つずつ判定させる。 */
|
|
465
|
+
function checkGuard(d, work) {
|
|
466
|
+
const item = { key: 'guard', label: '番人', ok: null, detail: [] };
|
|
467
|
+
const guard = path.join(d.home, '.tyhld', 'hooks', 'cc2_guard.py');
|
|
468
|
+
const resync = 'APPLY=1 bash "$(npm root -g)/@tyhld/conductor/scripts/update-guard.sh"';
|
|
469
|
+
if (!existsSync(guard)) {
|
|
470
|
+
fail(item, 'human', `番人が入っていません(${guard})`, { machine: d.machine, what: 'Ubuntu で番人を配り直す', command: resync });
|
|
471
|
+
return item;
|
|
472
|
+
}
|
|
473
|
+
const cases = [
|
|
474
|
+
{ label: '番人の置き場を消す', command: 'rm -rf ~/.tyhld/hooks', want: 'deny' },
|
|
475
|
+
{ label: '鍵を読み出す', command: 'cat ~/.ssh/id_rsa', want: 'deny' },
|
|
476
|
+
{ label: 'ふつうの一覧', command: 'ls', want: 'allow' },
|
|
477
|
+
];
|
|
478
|
+
for (const c of cases) {
|
|
479
|
+
const input = JSON.stringify({
|
|
480
|
+
hook_event_name: 'PreToolUse',
|
|
481
|
+
tool_name: 'Bash',
|
|
482
|
+
tool_input: { command: c.command },
|
|
483
|
+
cwd: work,
|
|
484
|
+
session_id: 'selfcheck',
|
|
485
|
+
});
|
|
486
|
+
const r = d.exec('python3', [guard], { input, env: { ...d.env, PYTHONDONTWRITEBYTECODE: '1' }, timeoutMs: 30_000 });
|
|
487
|
+
let got = 'none';
|
|
488
|
+
try {
|
|
489
|
+
got = JSON.parse(r.stdout).hookSpecificOutput?.permissionDecision ?? 'none';
|
|
490
|
+
}
|
|
491
|
+
catch {
|
|
492
|
+
// 何も返さない=委譲(allow ではない)
|
|
493
|
+
}
|
|
494
|
+
const ok = c.want === 'deny' ? got === 'deny' : got === 'allow' || got === 'none';
|
|
495
|
+
if (ok)
|
|
496
|
+
pass(item, `${c.label} → ${got}`);
|
|
497
|
+
else
|
|
498
|
+
fail(item, 'fault', `${c.label} → ${got}(期待 ${c.want})`);
|
|
499
|
+
}
|
|
500
|
+
return item;
|
|
501
|
+
}
|
|
502
|
+
/** 管制へ送る中身(★1か所)。口の形が決まったらここだけ合わせる。 */
|
|
503
|
+
export function buildSelfcheckPayload(r) {
|
|
504
|
+
return {
|
|
505
|
+
schema: r.schema,
|
|
506
|
+
machine: r.machine,
|
|
507
|
+
trigger: r.trigger,
|
|
508
|
+
result: r.result,
|
|
509
|
+
startedAt: r.startedAt,
|
|
510
|
+
finishedAt: r.finishedAt,
|
|
511
|
+
conductorVersion: r.conductorVersion,
|
|
512
|
+
claudeVersion: r.claude.version,
|
|
513
|
+
claudeBin: r.claude.bin,
|
|
514
|
+
claudePin: r.claude.pin.state === 'none' ? null : r.claude.pin,
|
|
515
|
+
drain: r.drain,
|
|
516
|
+
mcp: r.mcp,
|
|
517
|
+
items: r.items.map((i) => ({ key: i.key, label: i.label, ok: i.ok, failKind: i.failKind ?? null, detail: i.detail })),
|
|
518
|
+
humanActions: r.humanActions,
|
|
519
|
+
usage: r.usage,
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
/** 手元の控えに書く(last.json と history/)。★失敗しても落ちない。 */
|
|
523
|
+
export function saveSelfcheck(home, r, sent) {
|
|
524
|
+
try {
|
|
525
|
+
const dir = selfcheckDir(home);
|
|
526
|
+
const hist = path.join(dir, 'history');
|
|
527
|
+
mkdirSync(hist, { recursive: true });
|
|
528
|
+
const body = `${JSON.stringify({ ...r, sent }, null, 2)}\n`;
|
|
529
|
+
const stamp = r.startedAt.replace(/[:.]/g, '-');
|
|
530
|
+
writeFileSync(path.join(hist, `${stamp}-${r.trigger}.json`), body);
|
|
531
|
+
writeFileSync(path.join(dir, 'last.json'), body);
|
|
532
|
+
const files = readdirSync(hist).filter((f) => f.endsWith('.json')).sort();
|
|
533
|
+
for (const f of files.slice(0, Math.max(0, files.length - SELFCHECK_HISTORY_MAX))) {
|
|
534
|
+
rmSync(path.join(hist, f), { force: true });
|
|
535
|
+
}
|
|
536
|
+
return path.join(dir, 'last.json');
|
|
537
|
+
}
|
|
538
|
+
catch {
|
|
539
|
+
return null;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
/** 管制へ送る。★口が無い・届かないときも落ちない(結果を返すだけ)。 */
|
|
543
|
+
export async function sendSelfcheck(d, r) {
|
|
544
|
+
const url = d.env.CONDUCTOR_URL;
|
|
545
|
+
const token = d.env.CONDUCTOR_TOKEN;
|
|
546
|
+
if (!url || !token)
|
|
547
|
+
return { sent: false, detail: '接続設定が無いので送っていません' };
|
|
548
|
+
try {
|
|
549
|
+
const res = await d.fetchImpl(`${url}${SELFCHECK_ENDPOINT}`, {
|
|
550
|
+
method: 'POST',
|
|
551
|
+
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
552
|
+
body: JSON.stringify(buildSelfcheckPayload(r)),
|
|
553
|
+
// ★口が無いと、管制の入口がログイン画面へ回して 200 を返す(実機 2026-10-02)。
|
|
554
|
+
// 回り道を辿らず、JSON で答えたときだけ「受け取られた」と数える。
|
|
555
|
+
redirect: 'manual',
|
|
556
|
+
signal: AbortSignal.timeout(20_000),
|
|
557
|
+
});
|
|
558
|
+
const json = (res.headers.get('content-type') ?? '').includes('application/json');
|
|
559
|
+
if (res.ok && json)
|
|
560
|
+
return { sent: true, detail: `HTTP ${res.status}` };
|
|
561
|
+
if (res.ok || (res.status >= 300 && res.status < 400)) {
|
|
562
|
+
return { sent: false, detail: `HTTP ${res.status}(JSON の答えではない=管制にまだ受け口がありません)` };
|
|
563
|
+
}
|
|
564
|
+
return { sent: false, detail: res.status === 404 ? 'HTTP 404(管制にまだ受け口がありません)' : `HTTP ${res.status}` };
|
|
565
|
+
}
|
|
566
|
+
catch (err) {
|
|
567
|
+
return { sent: false, detail: err instanceof Error ? err.message : String(err) };
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
/** 自己診断を1回流す(送る・控えるは呼び出し側)。 */
|
|
571
|
+
export async function runSelfcheck(d, trigger) {
|
|
572
|
+
const startedAt = d.now().toISOString();
|
|
573
|
+
const version = readJsonVersion(path.join(d.distRoot, 'package.json'));
|
|
574
|
+
const runDir = path.join(selfcheckDir(d.home), `run-${startedAt.replace(/[:.]/g, '-')}`);
|
|
575
|
+
mkdirSync(runDir, { recursive: true });
|
|
576
|
+
try {
|
|
577
|
+
const conductor = checkConductor(d, version);
|
|
578
|
+
const claude = checkClaude(d);
|
|
579
|
+
const central = await checkCentral(d);
|
|
580
|
+
const sandbox = checkSandboxParts(d);
|
|
581
|
+
const network = checkNetworkOutside(d, version);
|
|
582
|
+
const guard = checkGuard(d, path.join(runDir, 'work'));
|
|
583
|
+
// ⑥ 小さな試験(③の MCP・④の書き込み・⑤の内側の通信もここで見る)
|
|
584
|
+
const headless = { key: 'headless', label: '小さな headless 試験', ok: null, detail: [] };
|
|
585
|
+
const h = runHeadlessProbe(d, claude.bin, runDir);
|
|
586
|
+
const sum = h.summary;
|
|
587
|
+
if (h.error)
|
|
588
|
+
note(headless, h.error);
|
|
589
|
+
if (!sum || !sum.result)
|
|
590
|
+
fail(headless, 'fault', `結果の行がありません(終了 ${h.exitCode})`);
|
|
591
|
+
else {
|
|
592
|
+
if (h.exitCode === 0)
|
|
593
|
+
pass(headless, '終了コード 0');
|
|
594
|
+
else
|
|
595
|
+
fail(headless, 'fault', `終了コード ${h.exitCode}`);
|
|
596
|
+
if (sum.result.subtype === 'success' && !sum.result.isError)
|
|
597
|
+
pass(headless, '結果の行が success');
|
|
598
|
+
else
|
|
599
|
+
fail(headless, 'fault', `結果の行が ${sum.result.subtype ?? '不明'}(is_error=${sum.result.isError})`);
|
|
600
|
+
if (h.probeRan)
|
|
601
|
+
pass(headless, '囲いの中で Bash の台本が動きました(tool の結果で確認)');
|
|
602
|
+
else
|
|
603
|
+
fail(headless, 'fault', 'tool の結果に台本の目印がありません(Bash が動いていない)');
|
|
604
|
+
}
|
|
605
|
+
// ③ MCP
|
|
606
|
+
const mcpState = aiKanaMcpState(sum?.mcp ?? null);
|
|
607
|
+
if (mcpState === 'connected')
|
|
608
|
+
pass(central, 'ai-kana の MCP が接続済み');
|
|
609
|
+
else if (mcpState === 'needs-auth') {
|
|
610
|
+
fail(central, 'human', 'ai-kana の MCP が要認証です(完了の知らせを出せません)', {
|
|
611
|
+
machine: d.machine,
|
|
612
|
+
what: 'Ubuntu で claude を開き、/mcp → ai-kana → Authenticate を選んでブラウザでログインする',
|
|
613
|
+
command: 'claude',
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
else if (mcpState === 'pending')
|
|
617
|
+
note(central, 'ai-kana の MCP は つなぎかけのまま試験が始まりました(接続済みか要認証かは確定できません)');
|
|
618
|
+
else if (mcpState === 'missing')
|
|
619
|
+
fail(central, 'fault', 'ai-kana の MCP が一覧にありません');
|
|
620
|
+
else if (mcpState === 'failed')
|
|
621
|
+
fail(central, 'fault', 'ai-kana の MCP につながりません(failed)');
|
|
622
|
+
else
|
|
623
|
+
note(central, 'MCP の状態を読めませんでした(小さな試験の最初の行が無い)');
|
|
624
|
+
// ④ 書き込み
|
|
625
|
+
if (h.outsideWrite === 'blocked')
|
|
626
|
+
pass(sandbox, '囲いの中から作業場所の外への書き込みが止まりました');
|
|
627
|
+
else if (h.outsideWrite === 'open')
|
|
628
|
+
fail(sandbox, 'fault', '★囲いの中から作業場所の外へ書けました(囲いが効いていない)');
|
|
629
|
+
else
|
|
630
|
+
fail(sandbox, 'fault', '囲いの中の書き込みを確かめられませんでした');
|
|
631
|
+
// ⑤ 内側の通信
|
|
632
|
+
const codes = Object.entries(h.net);
|
|
633
|
+
if (codes.length === 0)
|
|
634
|
+
fail(network, 'fault', '囲いの内側からの通信を確かめられませんでした');
|
|
635
|
+
for (const [u, c] of codes) {
|
|
636
|
+
if (/^[23]\d\d$/.test(c))
|
|
637
|
+
pass(network, `囲いの内側から ${u} → ${c}`);
|
|
638
|
+
else
|
|
639
|
+
fail(network, 'fault', `囲いの内側から ${u} → ${c}`);
|
|
640
|
+
}
|
|
641
|
+
const items = [conductor, claude.item, central, sandbox, network, headless, guard];
|
|
642
|
+
const humanActions = items.flatMap((i) => i.human ?? []);
|
|
643
|
+
return {
|
|
644
|
+
schema: 'conductor-selfcheck/1',
|
|
645
|
+
machine: d.machine,
|
|
646
|
+
trigger,
|
|
647
|
+
startedAt,
|
|
648
|
+
finishedAt: d.now().toISOString(),
|
|
649
|
+
result: items.some((i) => i.ok === false) ? 'FAIL' : 'PASS',
|
|
650
|
+
conductorVersion: version,
|
|
651
|
+
claude: { bin: claude.bin, version: claude.version, loggedIn: claude.loggedIn, pin: lastClaudePinStatus() },
|
|
652
|
+
mcp: sum?.mcp ?? null,
|
|
653
|
+
drain: readDrain().active,
|
|
654
|
+
items,
|
|
655
|
+
humanActions,
|
|
656
|
+
usage: sum?.result
|
|
657
|
+
? {
|
|
658
|
+
costUsd: sum.result.costUsd,
|
|
659
|
+
inputTokens: sum.result.inputTokens,
|
|
660
|
+
outputTokens: sum.result.outputTokens,
|
|
661
|
+
durationMs: sum.result.durationMs,
|
|
662
|
+
}
|
|
663
|
+
: null,
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
finally {
|
|
667
|
+
// ★一時の作業場所は必ず消す(外に書けてしまった物も含めて)。
|
|
668
|
+
rmSync(runDir, { recursive: true, force: true });
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
/** 画面に出す形(人が読む)。 */
|
|
672
|
+
export function formatSelfcheck(r) {
|
|
673
|
+
const lines = [
|
|
674
|
+
`自己診断 ${r.result}(${r.machine}・きっかけ ${r.trigger}・${r.startedAt})`,
|
|
675
|
+
` conductor ${r.conductorVersion ?? '不明'} / Claude Code ${r.claude.version ?? '不明'}(${r.claude.bin ?? '無し'})`,
|
|
676
|
+
];
|
|
677
|
+
for (const i of r.items) {
|
|
678
|
+
const mark = i.ok === true ? 'PASS' : i.ok === false ? `FAIL${i.failKind === 'human' ? '(人の操作)' : ''}` : '----';
|
|
679
|
+
lines.push(`[${mark}] ${i.label}`);
|
|
680
|
+
for (const dl of i.detail)
|
|
681
|
+
lines.push(` ${dl}`);
|
|
682
|
+
}
|
|
683
|
+
if (r.humanActions.length > 0) {
|
|
684
|
+
lines.push('', '人にしかできない操作:');
|
|
685
|
+
for (const h of r.humanActions)
|
|
686
|
+
lines.push(` 🔵 ${h.machine}: ${h.what}`, ` ${h.command}`);
|
|
687
|
+
}
|
|
688
|
+
if (r.usage) {
|
|
689
|
+
lines.push('', `小さな試験の使用量: 入力 ${r.usage.inputTokens ?? '?'} トークン・出力 ${r.usage.outputTokens ?? '?'} トークン・` +
|
|
690
|
+
`${r.usage.costUsd ?? '?'} ドル相当・${r.usage.durationMs ?? '?'} ms`);
|
|
691
|
+
}
|
|
692
|
+
return lines.join('\n');
|
|
693
|
+
}
|
|
694
|
+
/** `conductor selfcheck` の入口。返り値は終了コード(0=PASS / 1=FAIL / 2=使い方の誤り)。 */
|
|
695
|
+
export async function runSelfcheckCommand(argv) {
|
|
696
|
+
let trigger = 'manual';
|
|
697
|
+
let json = false;
|
|
698
|
+
let send = true;
|
|
699
|
+
for (let i = 0; i < argv.length; i++) {
|
|
700
|
+
const a = argv[i];
|
|
701
|
+
if (a === '--trigger') {
|
|
702
|
+
const v = argv[++i];
|
|
703
|
+
if (!SELFCHECK_TRIGGERS.includes(v)) {
|
|
704
|
+
console.error(`[selfcheck] きっかけの名前が違います: ${v ?? '(なし)'}(${SELFCHECK_TRIGGERS.join(' / ')})`);
|
|
705
|
+
return 2;
|
|
706
|
+
}
|
|
707
|
+
trigger = v;
|
|
708
|
+
}
|
|
709
|
+
else if (a === '--json')
|
|
710
|
+
json = true;
|
|
711
|
+
else if (a === '--no-send')
|
|
712
|
+
send = false;
|
|
713
|
+
else if (a === '--now')
|
|
714
|
+
trigger = 'manual';
|
|
715
|
+
else {
|
|
716
|
+
console.error(`[selfcheck] 知らない引数: ${a}`);
|
|
717
|
+
return 2;
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
const d = {
|
|
721
|
+
exec: defaultExec,
|
|
722
|
+
fetchImpl: globalThis.fetch,
|
|
723
|
+
env: process.env,
|
|
724
|
+
home: os.homedir(),
|
|
725
|
+
machine: process.env.CONDUCTOR_MACHINE || os.hostname(),
|
|
726
|
+
now: () => new Date(),
|
|
727
|
+
distRoot: path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'),
|
|
728
|
+
resolveClaude: resolveClaudeBin,
|
|
729
|
+
platform: process.platform,
|
|
730
|
+
findNpm: () => npmBesideNode(),
|
|
731
|
+
};
|
|
732
|
+
const r = await runSelfcheck(d, trigger);
|
|
733
|
+
const sent = send ? await sendSelfcheck(d, r) : { sent: false, detail: '--no-send' };
|
|
734
|
+
const saved = saveSelfcheck(d.home, r, sent);
|
|
735
|
+
if (json)
|
|
736
|
+
console.log(JSON.stringify({ ...r, sent, saved }, null, 2));
|
|
737
|
+
else {
|
|
738
|
+
console.log(formatSelfcheck(r));
|
|
739
|
+
console.log(`\n管制へ: ${sent.sent ? '送りました' : '送れませんでした'}(${sent.detail})`);
|
|
740
|
+
console.log(`控え: ${saved ?? '書けませんでした'}`);
|
|
741
|
+
}
|
|
742
|
+
return r.result === 'PASS' ? 0 : 1;
|
|
743
|
+
}
|