@tyhld/conductor 0.12.91 → 0.12.93
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.js +69 -4
- package/dist/headless.js +38 -9
- package/dist/realtime-parse.js +17 -0
- package/dist/realtime.js +47 -13
- package/dist/selfcheck.js +136 -24
- package/package.json +1 -1
- package/sales-template/setup.sh +52 -4
- package/scripts/hooks/policy.py +54 -1
- package/scripts/hooks/test_discipline.py +20 -0
- package/skills/conductor-craftsman/SKILL.md +6 -1
package/dist/autoupdate.js
CHANGED
|
@@ -36,7 +36,7 @@ import os from 'node:os';
|
|
|
36
36
|
import path from 'node:path';
|
|
37
37
|
import { fileURLToPath } from 'node:url';
|
|
38
38
|
import { CLAUDE_MIN_VERSION, compareVersions, isProcessAlive, parseClaudeVersion, resolveClaudeBin, } from './headless.js';
|
|
39
|
-
import { defaultExec, fetchClaudeStable, npmBesideNode, pickCandidates } from './selfcheck.js';
|
|
39
|
+
import { defaultExec, fetchClaudeStable, npmBesideNode, pickCandidates, selfcheckDir } from './selfcheck.js';
|
|
40
40
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
41
41
|
// 定数(★時間の値は設計 3ed9e2b2 の推奨値・管制の ROLLOUT_TIMES と同じ考え方)
|
|
42
42
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -55,6 +55,12 @@ export const RESTART_WAIT_MS = 120_000;
|
|
|
55
55
|
export const RESTART_POLL_MS = 5_000;
|
|
56
56
|
/** 戻しても直らなかった機で、自己診断を測り直す間隔。 */
|
|
57
57
|
export const HELD_RECHECK_MS = 60 * 60 * 1000;
|
|
58
|
+
/**
|
|
59
|
+
* 自己診断が「人にしかできない操作(🔵)」だけで落ちているとき、測り直す間隔(便 4cf71dde)。
|
|
60
|
+
* ★人は直したあと何も打たない。この間隔で更新係が自己診断をやり直し、通れば先へ進む。
|
|
61
|
+
* 1回 約0.04ドルなので、待っているあいだも1時間に1回まで(戻しても直らない機の測り直しと同じ間隔)。
|
|
62
|
+
*/
|
|
63
|
+
export const HUMAN_RECHECK_MS = 60 * 60 * 1000;
|
|
58
64
|
/** 候補を届けられなかったとき、もう一度届けに行くまでの間隔。 */
|
|
59
65
|
export const CANDIDATE_RETRY_MS = 24 * 60 * 60 * 1000;
|
|
60
66
|
const SEMVER = /^\d+\.\d+\.\d+$/;
|
|
@@ -352,7 +358,10 @@ async function afterSwap(d, s) {
|
|
|
352
358
|
const failed = r.failedKeys.join(',') || '不明';
|
|
353
359
|
if (!s.rollback) {
|
|
354
360
|
// ★前の版へ戻す(そのPCだけ)。
|
|
355
|
-
|
|
361
|
+
// ★人にしかできない操作だけで落ちたときは、版の不具合と決めつけない(落ちた版の印を付けない)。
|
|
362
|
+
// 人が直した後、管制がまだその版を返していれば、もう一度入れ替えて確かめられる(便 4cf71dde)。
|
|
363
|
+
if (!r.humanOnly)
|
|
364
|
+
addFailed(s, comp, s.to);
|
|
356
365
|
const back = s.from ?? null;
|
|
357
366
|
if (comp === 'claude-code' && back && compareVersions(back, CLAUDE_MIN_VERSION) < 0) {
|
|
358
367
|
s.phase = 'held';
|
|
@@ -409,6 +418,49 @@ async function rollBack(d, s, back) {
|
|
|
409
418
|
removeDrain(d, s);
|
|
410
419
|
return afterSwap(d, s);
|
|
411
420
|
}
|
|
421
|
+
/** 手元の自己診断の控え(last.json)のうち、更新係が読む物。読めなければ null。 */
|
|
422
|
+
export function readLastSelfcheck(home) {
|
|
423
|
+
try {
|
|
424
|
+
const j = JSON.parse(readFileSync(path.join(selfcheckDir(home), 'last.json'), 'utf8'));
|
|
425
|
+
if (j.result !== 'PASS' && j.result !== 'FAIL')
|
|
426
|
+
return null;
|
|
427
|
+
const failed = (j.items ?? []).filter((i) => i.ok === false);
|
|
428
|
+
const at = typeof j.finishedAt === 'string' ? Date.parse(j.finishedAt) : NaN;
|
|
429
|
+
return {
|
|
430
|
+
pass: j.result === 'PASS',
|
|
431
|
+
humanOnly: failed.length > 0 && failed.every((i) => i.failKind === 'human'),
|
|
432
|
+
finishedAt: Number.isFinite(at) ? at : null,
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
catch {
|
|
436
|
+
return null;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
/**
|
|
440
|
+
* 最後の自己診断が「人にしかできない操作」だけで落ちているあいだは、人の手番を待つ(便 4cf71dde)。
|
|
441
|
+
* ★人が直しても何も打たなくてよいように、HUMAN_RECHECK_MS ごとに自己診断をやり直す。通れば待ちは終わり。
|
|
442
|
+
* 返り値 waiting=true =まだ待つ(入れ替えない・候補の自己診断を流さない)。
|
|
443
|
+
* ★仕組みの異常(fault)が混ざる FAIL は、ここでは待たない(今までどおり入れ替え・戻しで確かめる)。
|
|
444
|
+
*/
|
|
445
|
+
function waitingForHuman(d, now) {
|
|
446
|
+
const last = readLastSelfcheck(d.home);
|
|
447
|
+
if (!last || last.pass || !last.humanOnly)
|
|
448
|
+
return { waiting: false, detail: '' };
|
|
449
|
+
const age = last.finishedAt === null ? Infinity : now.getTime() - last.finishedAt;
|
|
450
|
+
if (age < HUMAN_RECHECK_MS) {
|
|
451
|
+
const next = new Date((last.finishedAt ?? now.getTime()) + HUMAN_RECHECK_MS).toISOString();
|
|
452
|
+
return { waiting: true, detail: `自己診断が人の操作待ち(🔵)で落ちたままです。次の測り直し ${next}` };
|
|
453
|
+
}
|
|
454
|
+
const r = d.runSelfcheck('daily');
|
|
455
|
+
if (r.pass) {
|
|
456
|
+
d.log('人の操作待ちだった自己診断が通りました。先へ進みます');
|
|
457
|
+
return { waiting: false, detail: '' };
|
|
458
|
+
}
|
|
459
|
+
return {
|
|
460
|
+
waiting: true,
|
|
461
|
+
detail: `自己診断を測り直しましたが、まだ通りません(${r.failedKeys.join(',') || '不明'}${r.humanOnly ? '・人の操作待ち' : ''})`,
|
|
462
|
+
};
|
|
463
|
+
}
|
|
412
464
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
413
465
|
// 1回ぶん
|
|
414
466
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -465,6 +517,9 @@ export async function runUpdater(d) {
|
|
|
465
517
|
const why = !target.ready ? '管制はまだ準備中です' : target.halted ? '管制で自動更新が止められています' : 'この機は自動更新の対象ではありません';
|
|
466
518
|
return done(d, s, 'skip', `${why}(${target.reason ?? '理由なし'})。何もしません`);
|
|
467
519
|
}
|
|
520
|
+
// ③-2 人の操作待ち(🔵)で自己診断が落ちたままなら、測り直してから先へ進む(便 4cf71dde)。
|
|
521
|
+
// ★落ちたまま入れ替えると、同じ理由で落ちて戻し、版に疑いが付く(実物 2026-10-03 02:43Z・ノートPC)。
|
|
522
|
+
const human = waitingForHuman(d, now);
|
|
468
523
|
// ④ どの部品を動かすか(★conductor の目標が今と違うあいだは Claude Code を動かさない)。
|
|
469
524
|
const claudeNow = d.resolveClaude();
|
|
470
525
|
let comp = null;
|
|
@@ -495,11 +550,18 @@ export async function runUpdater(d) {
|
|
|
495
550
|
if (!comp || !want) {
|
|
496
551
|
removeDrain(d, s);
|
|
497
552
|
s.busySince = null;
|
|
553
|
+
if (human.waiting)
|
|
554
|
+
return done(d, s, 'wait-human', human.detail);
|
|
498
555
|
// ★後続の機は候補を探さない・候補の自己診断を流さない(役は管制が決める・欄が無ければ今までどおり)。
|
|
499
556
|
if (target.role !== 'follower')
|
|
500
557
|
await reportCandidates(d, s, installed, claudeNow?.version ?? null);
|
|
501
558
|
return done(d, s, 'none', `目標の版と同じです(conductor ${installed ?? '不明'}・Claude Code ${claudeNow?.version ?? '不明'})`);
|
|
502
559
|
}
|
|
560
|
+
if (human.waiting) {
|
|
561
|
+
removeDrain(d, s);
|
|
562
|
+
s.busySince = null;
|
|
563
|
+
return done(d, s, 'wait-human', `${comp} ${want} へは入れ替えません。${human.detail}`);
|
|
564
|
+
}
|
|
503
565
|
// ⑤ 全現場が空きか。
|
|
504
566
|
const busy = busySites(d.home, d.isAlive);
|
|
505
567
|
if (busy.length > 0) {
|
|
@@ -603,11 +665,14 @@ export function selfcheckViaEntry(home, exec, env) {
|
|
|
603
665
|
catch {
|
|
604
666
|
// 読めない=FAIL として扱う(迷ったら戻す側)
|
|
605
667
|
}
|
|
606
|
-
const
|
|
668
|
+
const failedItems = (parsed.items ?? []).filter((x) => x.ok === false);
|
|
669
|
+
const failedKeys = failedItems.map((x) => String(x.key));
|
|
607
670
|
const pass = r.status === 0 && parsed.result === 'PASS';
|
|
671
|
+
// ★読めなかった FAIL(項目が無い)は人の操作待ちとは言わない(迷ったら版を疑う側)。
|
|
672
|
+
const humanOnly = !pass && failedItems.length > 0 && failedItems.every((x) => x.failKind === 'human');
|
|
608
673
|
if (!pass && failedKeys.length === 0)
|
|
609
674
|
failedKeys.push(`selfcheck(終了 ${r.status})`);
|
|
610
|
-
return { pass, failedKeys, sent: typeof parsed.sent?.sent === 'boolean' ? parsed.sent.sent : null };
|
|
675
|
+
return { pass, failedKeys, sent: typeof parsed.sent?.sent === 'boolean' ? parsed.sent.sent : null, humanOnly };
|
|
611
676
|
};
|
|
612
677
|
}
|
|
613
678
|
/** `conductor autoupdate [--boot]` の入口。返り値は終了コード(★失敗でも 0=タイマーを止めない。使い方の誤りだけ 2)。 */
|
package/dist/headless.js
CHANGED
|
@@ -996,6 +996,41 @@ function closeLog(ws) {
|
|
|
996
996
|
* 失敗の理由は lastSpawnError に入る。
|
|
997
997
|
*/
|
|
998
998
|
export let lastSpawnError = '';
|
|
999
|
+
/** 職人の PATH で「古い node が先に来ない」ように見る道具の名前。 */
|
|
1000
|
+
const NODE_TOOLS = ['node', 'npm', 'npx'];
|
|
1001
|
+
/**
|
|
1002
|
+
* 職人(claude -p)の子プロセスへ渡す PATH を作る(便 24c9b6b6・e9276b86)。
|
|
1003
|
+
*
|
|
1004
|
+
* 【なぜ要るか(実物 2026-10-03・TYHLD-OTA 便 f770cd9d/ノートPC 便 4cf71dde)】
|
|
1005
|
+
* systemd の常駐はシェルの PATH を継承しないので、nvm の bin が職人の PATH に無い。
|
|
1006
|
+
* 以前は claude が nvm の bin に居たので、claude の bin を足すだけで npm・npx も一緒に見えていた。
|
|
1007
|
+
* Claude Code を公式の入れ方(~/.local/bin/claude)にそろえた後は、足されるのが ~/.local/bin だけになり、
|
|
1008
|
+
* 職人の bash で `npm: command not found`。node だけは /usr/local/bin/node(nvm への近道)で見えていた。
|
|
1009
|
+
* ★/usr/bin/node(v18)が別に在る機もあるので、PATH の順が崩れると古い node が動く。
|
|
1010
|
+
*
|
|
1011
|
+
* 【決め方】
|
|
1012
|
+
* - 中継を動かしている node(process.execPath=conductor-run が選んだ配布物の node)の bin を必ず足す。
|
|
1013
|
+
* =職人の node・npm・npx は、中継と同じ版になる(その bin に npm・npx が同居する入れ方=nvm 等)。
|
|
1014
|
+
* - claude の bin も足す(便 24c9b6b6)。ただし claude の bin に node・npm・npx のどれかが在るとき
|
|
1015
|
+
* (/usr/bin・nvm の別の版など)は、中継の node の bin をその前に置く=古い node が先に来ない。
|
|
1016
|
+
* claude の bin に node 類が無いとき(~/.local/bin などの公式の入れ方)は、claude の bin を先に置く
|
|
1017
|
+
* =職人の bash の `claude` は中継が選んだ claude のまま。
|
|
1018
|
+
* - 2つは既に PATH に在っても先頭へ寄せる(後ろに残っていると、先に在る古い node が勝つため)。重複させない。
|
|
1019
|
+
* - PATH が空でも動く。
|
|
1020
|
+
*/
|
|
1021
|
+
export function buildWorkerPath(currentPath, claudeBin, nodeExecPath = process.execPath, hasTool = (dir, name) => existsSync(path.join(dir, name))) {
|
|
1022
|
+
const nodeDir = path.dirname(nodeExecPath);
|
|
1023
|
+
const claudeDir = path.dirname(claudeBin);
|
|
1024
|
+
let front;
|
|
1025
|
+
if (nodeDir === claudeDir)
|
|
1026
|
+
front = [nodeDir];
|
|
1027
|
+
else if (NODE_TOOLS.some((t) => hasTool(claudeDir, t)))
|
|
1028
|
+
front = [nodeDir, claudeDir];
|
|
1029
|
+
else
|
|
1030
|
+
front = [claudeDir, nodeDir];
|
|
1031
|
+
const rest = (currentPath ?? '').split(':').filter((d) => d !== '' && !front.includes(d));
|
|
1032
|
+
return [...front, ...rest].join(':');
|
|
1033
|
+
}
|
|
999
1034
|
export function spawnClaudeProcess(cfg, cmd, sessionId, attempt, isResume) {
|
|
1000
1035
|
// 前の失敗の理由を持ち越さない(理由の無い失敗に古い理由が付かないように)
|
|
1001
1036
|
lastSpawnError = '';
|
|
@@ -1025,15 +1060,9 @@ export function spawnClaudeProcess(cfg, cmd, sessionId, attempt, isResume) {
|
|
|
1025
1060
|
env.CONDUCTOR_SITE = cfg.site;
|
|
1026
1061
|
env.CONDUCTOR_MACHINE = cfg.machine;
|
|
1027
1062
|
env.CONDUCTOR_WORK_DIR = cfg.workDir;
|
|
1028
|
-
//
|
|
1029
|
-
//
|
|
1030
|
-
|
|
1031
|
-
// 信頼したディレクトリだけを足す。重複させない・PATH が空でも動く。
|
|
1032
|
-
const claudeBinDir = path.dirname(claudeBin);
|
|
1033
|
-
const currentPath = env.PATH ?? '';
|
|
1034
|
-
if (!currentPath.split(':').includes(claudeBinDir)) {
|
|
1035
|
-
env.PATH = currentPath ? `${claudeBinDir}:${currentPath}` : claudeBinDir;
|
|
1036
|
-
}
|
|
1063
|
+
// ★職人の PATH の先頭に、中継を動かしている node の bin と claude の bin を足す
|
|
1064
|
+
// (便 24c9b6b6・e9276b86)。決め方は buildWorkerPath の説明を見ること。
|
|
1065
|
+
env.PATH = buildWorkerPath(env.PATH, claudeBin);
|
|
1037
1066
|
// ★テスト用: CONDUCTOR_CLAUDE_CONFIG_DIR を設定すると、claude の設定フォルダを差し替える。
|
|
1038
1067
|
// 本物のログインを壊さずに認証エラーを再現できる(空のフォルダを指す → not logged in)。
|
|
1039
1068
|
// 本番では設定しない(claude は既定の ~/.claude を使う)。
|
package/dist/realtime-parse.js
CHANGED
|
@@ -22,6 +22,14 @@ export const EAR_REFRESH_FALLBACK_MS = 8 * 60 * 60 * 1000;
|
|
|
22
22
|
/** 再接続の待ち(指数バックオフ)の初期値と上限。 */
|
|
23
23
|
export const EAR_RETRY_BASE_MS = 5 * 1000;
|
|
24
24
|
export const EAR_RETRY_MAX_MS = 5 * 60 * 1000;
|
|
25
|
+
/**
|
|
26
|
+
* 鍵が通らない(401・403)ときの聞き直しの待ち(指数バックオフ)の初期値と上限(便 e9276b86)。
|
|
27
|
+
* 【実物 2026-10-03 03:04〜03:24Z】鍵が通らない機から realtime-token が 2〜4秒おきに聞き直され、20分で 401 が189件。
|
|
28
|
+
* 鍵は待っても直らない(人が入れ直すまで同じ答え)ので、ふつうの切断(5秒から)より長く空ける。
|
|
29
|
+
* ★1分 → 2分 → 4分 …と広げ、30分で頭打ち。つながっているときの速さ(合図・取り直し)は変えない。
|
|
30
|
+
*/
|
|
31
|
+
export const EAR_AUTH_RETRY_BASE_MS = 60 * 1000;
|
|
32
|
+
export const EAR_AUTH_RETRY_MAX_MS = 30 * 60 * 1000;
|
|
25
33
|
/** 購読設定が無い(unsupported)ときに問い直す間隔(ms)。中央の設定投入に自然に追従するため。 */
|
|
26
34
|
export const EAR_UNSUPPORTED_RETRY_MS = 30 * 60 * 1000;
|
|
27
35
|
/**
|
|
@@ -108,6 +116,15 @@ export function computeRefreshDelayMs(expiresAt, nowMs) {
|
|
|
108
116
|
return clamp(EAR_REFRESH_FALLBACK_MS);
|
|
109
117
|
return clamp(at - nowMs - EAR_REFRESH_MARGIN_MS);
|
|
110
118
|
}
|
|
119
|
+
/** 中央の答えが「鍵が通らない」か(401・403)。 */
|
|
120
|
+
export function isAuthRejected(r) {
|
|
121
|
+
return r.kind === 'error' && (r.status === 401 || r.status === 403);
|
|
122
|
+
}
|
|
123
|
+
/** 鍵が通らないときの待ち(ms)。続けて断られた回数に応じて 1分 → 2分 → 4分 …と広げ、30分で頭打ち。 */
|
|
124
|
+
export function computeAuthRetryDelayMs(failures) {
|
|
125
|
+
const n = failures < 0 ? 0 : failures;
|
|
126
|
+
return Math.min(EAR_AUTH_RETRY_MAX_MS, EAR_AUTH_RETRY_BASE_MS * 2 ** Math.min(n, 20));
|
|
127
|
+
}
|
|
111
128
|
/** 再接続の待ち(ms)。試行回数に応じて 5s → 10s → 20s …と広げ、5分で頭打ちにする。 */
|
|
112
129
|
export function computeRetryDelayMs(attempt) {
|
|
113
130
|
const n = attempt < 0 ? 0 : attempt;
|
package/dist/realtime.js
CHANGED
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
* 読めなければ「耳なし=各relayのポーリングのみ」で静かに動き続ける。
|
|
31
31
|
*/
|
|
32
32
|
import { describeTransport, resolveWebSocketTransport } from './websocket-transport.js';
|
|
33
|
-
import { EAR_NO_SITE_RETRY_MS, EAR_NUDGE_MIN_GAP_MS, EAR_TOKEN_FETCH_TIMEOUT_MS, EAR_UNSUPPORTED_RETRY_MS, computeRefreshDelayMs, computeRetryDelayMs, parseRealtimeConnection, readSignalSite, } from './realtime-parse.js';
|
|
33
|
+
import { EAR_NO_SITE_RETRY_MS, EAR_NUDGE_MIN_GAP_MS, EAR_TOKEN_FETCH_TIMEOUT_MS, EAR_UNSUPPORTED_RETRY_MS, computeAuthRetryDelayMs, computeRefreshDelayMs, computeRetryDelayMs, isAuthRejected, parseRealtimeConnection, readSignalSite, } from './realtime-parse.js';
|
|
34
34
|
// 読み取りと間合いの計算は src/realtime-parse.ts に分けてある(外部I/Oなし=単体テスト可能)。
|
|
35
35
|
// 従来どおり `./realtime.js` からも使えるよう、ここで通しておく。
|
|
36
36
|
export * from './realtime-parse.js';
|
|
@@ -60,7 +60,7 @@ export async function fetchRealtimeConnection(cfg, site) {
|
|
|
60
60
|
signal: AbortSignal.timeout(EAR_TOKEN_FETCH_TIMEOUT_MS),
|
|
61
61
|
});
|
|
62
62
|
if (!res.ok)
|
|
63
|
-
return { kind: 'error', detail: `HTTP ${res.status}
|
|
63
|
+
return { kind: 'error', detail: `HTTP ${res.status}`, status: res.status };
|
|
64
64
|
const data = await res.json().catch(() => null);
|
|
65
65
|
return parseRealtimeConnection(data);
|
|
66
66
|
}
|
|
@@ -78,12 +78,17 @@ async function loadCreateClient() {
|
|
|
78
78
|
return null;
|
|
79
79
|
}
|
|
80
80
|
}
|
|
81
|
+
const DEFAULT_IO = {
|
|
82
|
+
fetchConnection: fetchRealtimeConnection,
|
|
83
|
+
loadCreateClient,
|
|
84
|
+
resolveTransport: resolveWebSocketTransport,
|
|
85
|
+
};
|
|
81
86
|
/**
|
|
82
87
|
* 指示の着信合図を受け取る耳を1本張る(★このPCに1本だけ=耳プロセスからのみ呼ぶ)。
|
|
83
88
|
*
|
|
84
89
|
* 呼び出し側は各relayのポーリングを止めないこと——耳が死んでいる間はポーリングが肩代わりする。
|
|
85
90
|
*/
|
|
86
|
-
export function startRealtimeEar(cfg, handlers) {
|
|
91
|
+
export function startRealtimeEar(cfg, handlers, io = DEFAULT_IO) {
|
|
87
92
|
const { siteForToken, onSignal } = handlers;
|
|
88
93
|
let stopped = false;
|
|
89
94
|
let client = null;
|
|
@@ -96,6 +101,18 @@ export function startRealtimeEar(cfg, handlers) {
|
|
|
96
101
|
let lastNudgeSite = null;
|
|
97
102
|
/** 再接続の予約が二重に積まれないようにする(切断イベントは連続で飛んでくる)。 */
|
|
98
103
|
let reconnectArmed = false;
|
|
104
|
+
/**
|
|
105
|
+
* 鍵が通らない(401・403)と続けて断られた回数(便 e9276b86)。★接続情報が取れたときだけ 0 に戻す
|
|
106
|
+
* (購読の SUBSCRIBED では戻さない=別の線の知らせで待ちが縮まない)。
|
|
107
|
+
*/
|
|
108
|
+
let authFailures = 0;
|
|
109
|
+
/**
|
|
110
|
+
* いま張っている線の世代(便 e9276b86)。片付けるたびに1つ進める。
|
|
111
|
+
* ★片付けた古い線から遅れて届く CLOSED などの知らせは、世代が違うので無視する。
|
|
112
|
+
* (以前は、期限の手前の張り直しで古い線を片付けると、その CLOSED で2本目の張り直しが走り、
|
|
113
|
+
* 聞き直しが重なっていた。)
|
|
114
|
+
*/
|
|
115
|
+
let generation = 0;
|
|
99
116
|
const clearTimers = () => {
|
|
100
117
|
if (retryTimer) {
|
|
101
118
|
clearTimeout(retryTimer);
|
|
@@ -122,16 +139,28 @@ export function startRealtimeEar(cfg, handlers) {
|
|
|
122
139
|
}
|
|
123
140
|
channel = null;
|
|
124
141
|
client = null;
|
|
142
|
+
generation += 1;
|
|
125
143
|
};
|
|
126
|
-
/**
|
|
127
|
-
|
|
144
|
+
/**
|
|
145
|
+
* 一拍おいて張り直す。理由はログに残す(凝らない=最悪はポーリングが拾う)。
|
|
146
|
+
* authRejected=鍵が通らない(401・403)ときは、ふつうの切断より長く空ける(便 e9276b86)。
|
|
147
|
+
*/
|
|
148
|
+
const scheduleReconnect = (reason, authRejected = false) => {
|
|
128
149
|
if (stopped || reconnectArmed)
|
|
129
150
|
return;
|
|
130
151
|
reconnectArmed = true;
|
|
131
152
|
teardown();
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
153
|
+
let delay;
|
|
154
|
+
if (authRejected) {
|
|
155
|
+
delay = computeAuthRetryDelayMs(authFailures);
|
|
156
|
+
authFailures += 1;
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
delay = computeRetryDelayMs(attempt);
|
|
160
|
+
attempt += 1;
|
|
161
|
+
}
|
|
162
|
+
log(`${reason} → ${Math.round(delay / 1000)}秒後に張り直します(この間は各relayの15秒ポーリングが肩代わり)` +
|
|
163
|
+
(authRejected ? '。★鍵が通りません(~/.conductor.env の合言葉を確かめてください)' : ''));
|
|
135
164
|
clearTimers();
|
|
136
165
|
retryTimer = setTimeout(() => {
|
|
137
166
|
reconnectArmed = false;
|
|
@@ -168,7 +197,7 @@ export function startRealtimeEar(cfg, handlers) {
|
|
|
168
197
|
const connect = async () => {
|
|
169
198
|
if (stopped)
|
|
170
199
|
return;
|
|
171
|
-
const createClient = await loadCreateClient();
|
|
200
|
+
const createClient = await io.loadCreateClient();
|
|
172
201
|
if (createClient === null) {
|
|
173
202
|
log('@supabase/supabase-js が読めないため耳は張りません(各relayの15秒ポーリングでこれまでどおり動きます)');
|
|
174
203
|
return; // 依存は実行中に生えないので再試行しない。
|
|
@@ -176,7 +205,7 @@ export function startRealtimeEar(cfg, handlers) {
|
|
|
176
205
|
// ★どの Node(18/20/22) でも耳が張れるようにする(実機 Node 20 の「native WebSocket not found」根治)。
|
|
177
206
|
// 標準の WebSocket が無い機では同梱の ws を supabase-js の transport へ渡す。
|
|
178
207
|
// どちらも用意できなければ耳は張らない——が、プロセスは落とさない(ポーリングが肩代わりする)。
|
|
179
|
-
const transport = await
|
|
208
|
+
const transport = await io.resolveTransport();
|
|
180
209
|
if (transport === null) {
|
|
181
210
|
log('この機には標準のWebSocketが無く、同梱のWebSocket部品(ws)も読めないため耳は張りません' +
|
|
182
211
|
'(npm install で解消します。それまでも各relayの15秒ポーリングでこれまでどおり動きます)');
|
|
@@ -189,7 +218,9 @@ export function startRealtimeEar(cfg, handlers) {
|
|
|
189
218
|
retryTimer = setTimeout(() => void connect(), EAR_NO_SITE_RETRY_MS);
|
|
190
219
|
return;
|
|
191
220
|
}
|
|
192
|
-
const result = await
|
|
221
|
+
const result = await io.fetchConnection(cfg, site);
|
|
222
|
+
if (stopped)
|
|
223
|
+
return;
|
|
193
224
|
if (result.kind === 'unsupported') {
|
|
194
225
|
log(`中央から購読設定が返らないため耳は張りません(${result.detail})。` +
|
|
195
226
|
`各relayの15秒ポーリングで動き続け、${Math.round(EAR_UNSUPPORTED_RETRY_MS / 60000)}分後にもう一度問い合わせます`);
|
|
@@ -198,10 +229,12 @@ export function startRealtimeEar(cfg, handlers) {
|
|
|
198
229
|
return;
|
|
199
230
|
}
|
|
200
231
|
if (result.kind === 'error') {
|
|
201
|
-
scheduleReconnect(`接続情報を取得できません(${result.detail}
|
|
232
|
+
scheduleReconnect(`接続情報を取得できません(${result.detail})`, isAuthRejected(result));
|
|
202
233
|
return;
|
|
203
234
|
}
|
|
235
|
+
authFailures = 0;
|
|
204
236
|
const conn = result.conn;
|
|
237
|
+
const myGeneration = generation;
|
|
205
238
|
try {
|
|
206
239
|
client = createClient(conn.supabaseUrl, conn.supabaseAnonKey, {
|
|
207
240
|
auth: { persistSession: false, autoRefreshToken: false },
|
|
@@ -221,7 +254,8 @@ export function startRealtimeEar(cfg, handlers) {
|
|
|
221
254
|
.channel(spec.channel)
|
|
222
255
|
.on('postgres_changes', filterOpt, handleSignal)
|
|
223
256
|
.subscribe((status, err) => {
|
|
224
|
-
|
|
257
|
+
// ★片付けた古い線の知らせは無視する(張り直しを重ねない・便 e9276b86)。
|
|
258
|
+
if (stopped || myGeneration !== generation)
|
|
225
259
|
return;
|
|
226
260
|
if (status === 'SUBSCRIBED') {
|
|
227
261
|
attempt = 0;
|
package/dist/selfcheck.js
CHANGED
|
@@ -27,7 +27,7 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync
|
|
|
27
27
|
import os from 'node:os';
|
|
28
28
|
import path from 'node:path';
|
|
29
29
|
import { fileURLToPath } from 'node:url';
|
|
30
|
-
import { CLAUDE_MIN_VERSION, claudeUpdateHint, compareVersions, lastClaudePinStatus, resolveClaudeBin, } from './headless.js';
|
|
30
|
+
import { buildWorkerPath, CLAUDE_MIN_VERSION, claudeUpdateHint, compareVersions, lastClaudePinStatus, resolveClaudeBin, } from './headless.js';
|
|
31
31
|
import { fetchControl } from './relay.js';
|
|
32
32
|
import { readDrain } from './autoupdate-flags.js';
|
|
33
33
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -164,8 +164,9 @@ export function summarizeStream(stdout) {
|
|
|
164
164
|
* init 行には `mcp_servers: [{ name, status }]` が載る。status は connected / pending /
|
|
165
165
|
* needs-auth / failed など。このPCでは「claude.ai ai-kana」(アカウントのコネクタ)と、
|
|
166
166
|
* 使っていない手元の登録「ai-kana」(いつも needs-auth)の2つが並ぶ。
|
|
167
|
-
* ★1つでも connected
|
|
168
|
-
*
|
|
167
|
+
* ★1つでも connected なら接続済み(便が完了の知らせに使えるのは、どれか1つがつながっていれば足りる)。
|
|
168
|
+
* pending(つなぎかけ)・読めない状態が在れば確定できない(=要認証とは言わない)。
|
|
169
|
+
* connected も pending も無く needs-auth が在るときだけ要認証。
|
|
169
170
|
*/
|
|
170
171
|
export function aiKanaMcpState(mcp) {
|
|
171
172
|
if (mcp === null)
|
|
@@ -175,12 +176,88 @@ export function aiKanaMcpState(mcp) {
|
|
|
175
176
|
return 'missing';
|
|
176
177
|
if (mine.some((s) => s.status === 'connected'))
|
|
177
178
|
return 'connected';
|
|
178
|
-
if (mine.some((s) => s.status === 'pending'))
|
|
179
|
+
if (mine.some((s) => s.status === 'pending' || s.status === 'unknown'))
|
|
179
180
|
return 'pending';
|
|
180
181
|
if (mine.some((s) => /auth/i.test(s.status)))
|
|
181
182
|
return 'needs-auth';
|
|
182
183
|
return 'failed';
|
|
183
184
|
}
|
|
185
|
+
/**
|
|
186
|
+
* MCP の状態が落ち着くのを待つ上限(★回数と時間の両方・便 4cf71dde)。
|
|
187
|
+
* ふつうは `claude mcp list` 1回(数秒)で落ち着く。上限まで落ち着かなければ PASS にも 🔵 にもしない(🟡)。
|
|
188
|
+
*/
|
|
189
|
+
export const MCP_SETTLE_TRIES = 3;
|
|
190
|
+
export const MCP_SETTLE_WAIT_MS = 5_000;
|
|
191
|
+
export const MCP_LIST_TIMEOUT_MS = 45_000;
|
|
192
|
+
/**
|
|
193
|
+
* `claude mcp list` の出力を読む(★実機 2026-10-03・Claude Code 2.1.287 の形)。
|
|
194
|
+
* `claude.ai ai-kana: https://… - ✔ Connected` / `ai-kana: https://… (HTTP) - ! Needs authentication` /
|
|
195
|
+
* `名前: … - ✗ Failed to connect`。★知らない形の行は「読めない(unknown)」=落ち着いていない側に倒す。
|
|
196
|
+
*/
|
|
197
|
+
export function parseMcpList(stdout) {
|
|
198
|
+
const out = [];
|
|
199
|
+
for (const raw of stdout.split('\n')) {
|
|
200
|
+
const line = raw.trim();
|
|
201
|
+
const m = line.match(/^(.+?): .* - (.+)$/);
|
|
202
|
+
if (!m)
|
|
203
|
+
continue;
|
|
204
|
+
const word = m[2];
|
|
205
|
+
const status = /connected/i.test(word) && !/not connected|disconnected/i.test(word)
|
|
206
|
+
? 'connected'
|
|
207
|
+
: /auth/i.test(word)
|
|
208
|
+
? 'needs-auth'
|
|
209
|
+
: /fail|error/i.test(word)
|
|
210
|
+
? 'failed'
|
|
211
|
+
: /pending|connecting/i.test(word)
|
|
212
|
+
? 'pending'
|
|
213
|
+
: 'unknown';
|
|
214
|
+
out.push({ name: m[1], status });
|
|
215
|
+
}
|
|
216
|
+
return out;
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* 便が実際に使う MCP の状態を、落ち着くまで確かめる(★便 4cf71dde の根治)。
|
|
220
|
+
*
|
|
221
|
+
* 【なぜ init 行だけでは足りないか(実物 2026-10-03・ノートPC)】
|
|
222
|
+
* 便(claude -p)が完了の知らせ・開発履歴に使っているのはアカウントのコネクタ「claude.ai ai-kana」
|
|
223
|
+
* (道具の名前 mcp__claude_ai_ai-kana__*)で、手元の登録「ai-kana」(User config・要認証)は使っていない。
|
|
224
|
+
* ところが常駐(systemd)から起こした claude -p の init 行には、コネクタがまだ1つも載らない
|
|
225
|
+
* (便の記録でも init は [ai-kana:needs-auth] だけ。コネクタは後から読み込まれ、便の中では使えている)。
|
|
226
|
+
* 人が端末から流すと載るが pending のまま。=init 行は「始まった瞬間」の写しで、落ち着いた状態ではない。
|
|
227
|
+
* そのため同じPC・同じ日に「つなぎかけ→PASS」と「要認証→🔵」に割れた。
|
|
228
|
+
* 【どう確かめるか】init 行で接続済みなら、それで足りる(追加の所要なし)。そうでなければ、同じ claude・
|
|
229
|
+
* 同じ環境で `claude mcp list`(コネクタも含めて1つずつつないで確かめる・型は使わない=費用なし)を流し、
|
|
230
|
+
* 落ち着いた状態(つながった/要認証/失敗)が出るまで、回数と時間の上限つきで待つ。
|
|
231
|
+
*/
|
|
232
|
+
export async function settleMcp(d, claudeBin, cwd, init) {
|
|
233
|
+
const first = aiKanaMcpState(init);
|
|
234
|
+
if (first === 'connected')
|
|
235
|
+
return { state: 'connected', list: init, source: 'init', tries: 0, why: null };
|
|
236
|
+
if (!claudeBin) {
|
|
237
|
+
return { state: first === 'pending' ? 'unsettled' : first, list: init, source: init ? 'init' : 'none', tries: 0, why: 'claude が無いので確かめ直せません' };
|
|
238
|
+
}
|
|
239
|
+
const sleep = d.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
240
|
+
const env = probeEnv(d, claudeBin);
|
|
241
|
+
let last = null;
|
|
242
|
+
let why = null;
|
|
243
|
+
for (let i = 1; i <= MCP_SETTLE_TRIES; i++) {
|
|
244
|
+
const r = d.exec(claudeBin, ['mcp', 'list'], { cwd, env, timeoutMs: MCP_LIST_TIMEOUT_MS });
|
|
245
|
+
const list = parseMcpList(r.stdout);
|
|
246
|
+
if (list.length > 0)
|
|
247
|
+
last = list;
|
|
248
|
+
const st = list.length > 0 ? aiKanaMcpState(list) : 'unknown';
|
|
249
|
+
if (st === 'connected' || st === 'needs-auth' || st === 'failed' || st === 'missing') {
|
|
250
|
+
return { state: st, list, source: 'mcp-list', tries: i, why: null };
|
|
251
|
+
}
|
|
252
|
+
why =
|
|
253
|
+
list.length === 0
|
|
254
|
+
? `claude mcp list の一覧を読めません(終了 ${r.status}${r.error ? `・${r.error.slice(0, 80)}` : ''})`
|
|
255
|
+
: 'ai-kana の MCP が つなぎかけのまま';
|
|
256
|
+
if (i < MCP_SETTLE_TRIES)
|
|
257
|
+
await sleep(MCP_SETTLE_WAIT_MS);
|
|
258
|
+
}
|
|
259
|
+
return { state: 'unsettled', list: last ?? init, source: last ? 'mcp-list' : init ? 'init' : 'none', tries: MCP_SETTLE_TRIES, why };
|
|
260
|
+
}
|
|
184
261
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
185
262
|
// 本体
|
|
186
263
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -456,6 +533,14 @@ function siteSettingsFor(distRoot) {
|
|
|
456
533
|
return null;
|
|
457
534
|
}
|
|
458
535
|
}
|
|
536
|
+
/** 小さな試験と MCP の確かめで claude に渡す環境(★中継と同じ形・src/headless.ts)。 */
|
|
537
|
+
function probeEnv(d, claudeBin) {
|
|
538
|
+
const env = { ...d.env };
|
|
539
|
+
delete env.CLAUDECODE;
|
|
540
|
+
delete env.ANTHROPIC_API_KEY;
|
|
541
|
+
env.PATH = buildWorkerPath(env.PATH, claudeBin);
|
|
542
|
+
return env;
|
|
543
|
+
}
|
|
459
544
|
/** ⑥ 小さな headless 試験を1回流す。 */
|
|
460
545
|
function runHeadlessProbe(d, claudeBin, runDir) {
|
|
461
546
|
const res = { summary: null, exitCode: null, outsideWrite: 'unknown', net: {}, probeRan: false, error: null };
|
|
@@ -475,12 +560,7 @@ function runHeadlessProbe(d, claudeBin, runDir) {
|
|
|
475
560
|
}
|
|
476
561
|
writeFileSync(path.join(work, '.claude', 'settings.json'), settings);
|
|
477
562
|
writeFileSync(path.join(work, 'probe.sh'), probeScript(outsideFile), { mode: 0o755 });
|
|
478
|
-
const env =
|
|
479
|
-
delete env.CLAUDECODE;
|
|
480
|
-
delete env.ANTHROPIC_API_KEY;
|
|
481
|
-
const binDir = path.dirname(claudeBin);
|
|
482
|
-
if (!(env.PATH ?? '').split(':').includes(binDir))
|
|
483
|
-
env.PATH = env.PATH ? `${binDir}:${env.PATH}` : binDir;
|
|
563
|
+
const env = probeEnv(d, claudeBin);
|
|
484
564
|
const r = d.exec(claudeBin, [
|
|
485
565
|
'-p', PROBE_PROMPT,
|
|
486
566
|
'--output-format', 'stream-json',
|
|
@@ -683,23 +763,32 @@ export async function runSelfcheck(d, trigger) {
|
|
|
683
763
|
else
|
|
684
764
|
fail(headless, 'fault', 'tool の結果に台本の目印がありません(Bash が動いていない)');
|
|
685
765
|
}
|
|
686
|
-
// ③ MCP
|
|
687
|
-
const
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
766
|
+
// ③ MCP(★落ち着くまで確かめる・便が実際に使う登録を見る=settleMcp)
|
|
767
|
+
const mcp = await settleMcp(d, claude.bin, path.join(runDir, 'work'), sum?.mcp ?? null);
|
|
768
|
+
const seen = mcp.source === 'mcp-list' ? `claude mcp list で確かめた・${mcp.tries}回目` : mcp.source === 'init' ? '小さな試験の最初の行' : '一覧なし';
|
|
769
|
+
const mineOf = (st) => (mcp.list ?? []).filter((s) => /ai-kana/i.test(s.name) && s.status === st).map((s) => s.name);
|
|
770
|
+
if (mcp.state === 'connected') {
|
|
771
|
+
pass(central, `ai-kana の MCP が接続済み(${mineOf('connected').join('・')}/${seen})`);
|
|
772
|
+
const unused = mineOf('needs-auth');
|
|
773
|
+
if (unused.length > 0)
|
|
774
|
+
note(central, `要認証の登録 ${unused.join('・')} は、つながっている登録があるので便は使いません(直さなくて構いません)`);
|
|
775
|
+
}
|
|
776
|
+
else if (mcp.state === 'needs-auth') {
|
|
777
|
+
const names = mineOf('needs-auth');
|
|
778
|
+
fail(central, 'human', `ai-kana の MCP が要認証です(${names.join('・')}/${seen}・つながっている登録が無いので完了の知らせを出せません)`, {
|
|
692
779
|
machine: d.machine,
|
|
693
|
-
what:
|
|
780
|
+
what: `Ubuntu で claude を開き、/mcp → ${names[0] ?? 'ai-kana'} → Authenticate を選んでブラウザでログインする`,
|
|
694
781
|
command: 'claude',
|
|
695
782
|
});
|
|
696
783
|
}
|
|
697
|
-
else if (
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
else if (
|
|
702
|
-
fail(central, 'fault',
|
|
784
|
+
else if (mcp.state === 'unsettled' || mcp.state === 'pending') {
|
|
785
|
+
// ★PASS にも 🔵 にもしない(確かめきれていない=🟡)。
|
|
786
|
+
fail(central, 'fault', `ai-kana の MCP の状態が、上限(${MCP_SETTLE_TRIES}回)まで待っても落ち着きませんでした(${mcp.why ?? 'つなぎかけのまま'})`);
|
|
787
|
+
}
|
|
788
|
+
else if (mcp.state === 'missing')
|
|
789
|
+
fail(central, 'fault', `ai-kana の MCP が一覧にありません(${seen})`);
|
|
790
|
+
else if (mcp.state === 'failed')
|
|
791
|
+
fail(central, 'fault', `ai-kana の MCP につながりません(failed・${seen})`);
|
|
703
792
|
else
|
|
704
793
|
note(central, 'MCP の状態を読めませんでした(小さな試験の最初の行が無い)');
|
|
705
794
|
// ④ 書き込み
|
|
@@ -731,7 +820,7 @@ export async function runSelfcheck(d, trigger) {
|
|
|
731
820
|
result: items.some((i) => i.ok === false) ? 'FAIL' : 'PASS',
|
|
732
821
|
conductorVersion: version,
|
|
733
822
|
claude: { bin: claude.bin, version: claude.version, loggedIn: claude.loggedIn, pin: lastClaudePinStatus() },
|
|
734
|
-
mcp:
|
|
823
|
+
mcp: mcp.list,
|
|
735
824
|
drain: readDrain().active,
|
|
736
825
|
items,
|
|
737
826
|
humanActions,
|
|
@@ -750,8 +839,31 @@ export async function runSelfcheck(d, trigger) {
|
|
|
750
839
|
finally {
|
|
751
840
|
// ★一時の作業場所は必ず消す(外に書けてしまった物も含めて)。
|
|
752
841
|
rmSync(runDir, { recursive: true, force: true });
|
|
842
|
+
// ★道具(Claude Code)が作業場所ごとに /tmp/claude-<uid>/ の下へ作る記録も消す(便 e9276b86)。
|
|
843
|
+
// 作業場所は毎回名前が違うので、消さないと自己診断のたびに1つずつ溜まる(ノートPCで9つ)。
|
|
844
|
+
for (const dir of [runDir, path.join(runDir, 'work')]) {
|
|
845
|
+
const trace = claudeTraceDir(dir, d.claudeTmpRoot);
|
|
846
|
+
if (trace)
|
|
847
|
+
rmSync(trace, { recursive: true, force: true });
|
|
848
|
+
}
|
|
753
849
|
}
|
|
754
850
|
}
|
|
851
|
+
/**
|
|
852
|
+
* Claude Code が作業場所ごとに作る記録の置き場(★実物 2026-10-03・Claude Code 2.1.287)。
|
|
853
|
+
* `/tmp/claude-<uid>/` の下に、作業場所のパスの英数字以外を `-` に替えた名前で作る
|
|
854
|
+
* (例 /home/u/.local/state/conductor/selfcheck/run-…/work → -home-u--local-state-conductor-selfcheck-run-…-work)。
|
|
855
|
+
* ★消してよいのは自己診断の作業場所の分だけ。名前に selfcheck の run- が入らなければ null(他人の物に触らない)。
|
|
856
|
+
*/
|
|
857
|
+
export function claudeTraceDir(workDir, root = defaultClaudeTmpRoot()) {
|
|
858
|
+
const name = workDir.replace(/[^a-zA-Z0-9]/g, '-');
|
|
859
|
+
if (!/-selfcheck-run-/.test(name))
|
|
860
|
+
return null;
|
|
861
|
+
return path.join(root, name);
|
|
862
|
+
}
|
|
863
|
+
function defaultClaudeTmpRoot() {
|
|
864
|
+
const uid = typeof process.getuid === 'function' ? process.getuid() : 0;
|
|
865
|
+
return path.join('/tmp', `claude-${uid}`);
|
|
866
|
+
}
|
|
755
867
|
/** 画面に出す形(人が読む)。 */
|
|
756
868
|
export function formatSelfcheck(r) {
|
|
757
869
|
const lines = [
|
package/package.json
CHANGED
package/sales-template/setup.sh
CHANGED
|
@@ -1290,6 +1290,49 @@ else
|
|
|
1290
1290
|
cleanup_stale_units || warn "古いユニットの掃除に失敗しました(インストールは続行します)。"
|
|
1291
1291
|
fi
|
|
1292
1292
|
|
|
1293
|
+
# --- 画面なし方式(headless)の機では、足す現場も画面なし方式で立てる(便 e9276b86) ---
|
|
1294
|
+
# 【なぜ要るか(実物 2026-10-03・TYHLD-GOTO・TYHLD-OTA)】
|
|
1295
|
+
# 1行インストーラ(install.sh)は最初の現場に drop-in(headless.conf)を置いて画面なし方式にする。
|
|
1296
|
+
# ところが2つ目の現場を `conductor setup 現場名` で足すと drop-in が無く、古い方式(tmux)で立った
|
|
1297
|
+
# (管制で「AI担当は休止中」。人が drop-in を手で置いて直した)。⑧の案内も tmux の3行を出していた。
|
|
1298
|
+
# 【決め方】次のどれかなら、この機は画面なし方式=この現場にも install.sh と同じ drop-in を置く。
|
|
1299
|
+
# ① CONDUCTOR_RUNNER_MODE=headless で呼ばれた(install.sh から)。★=tmux と明示されたら置かない。
|
|
1300
|
+
# ② この機のほかの現場(またはテンプレート全体)の drop-in に画面なし方式の指定が在る。
|
|
1301
|
+
# ★どれにも当たらない機(もとから tmux 方式の機)は、1つも変えない。Mac(launchd)も変えない。
|
|
1302
|
+
HEADLESS_LINE='Environment=CONDUCTOR_RUNNER_MODE=headless'
|
|
1303
|
+
HEADLESS_DROPIN_DIR="$HOME/.config/systemd/user/conductor@${SITE}.service.d"
|
|
1304
|
+
HEADLESS_DROPIN="$HEADLESS_DROPIN_DIR/headless.conf"
|
|
1305
|
+
machine_is_headless() {
|
|
1306
|
+
case "${CONDUCTOR_RUNNER_MODE:-}" in
|
|
1307
|
+
headless) return 0 ;;
|
|
1308
|
+
tmux) return 1 ;;
|
|
1309
|
+
esac
|
|
1310
|
+
local f
|
|
1311
|
+
for f in "$HOME"/.config/systemd/user/conductor@*.service.d/*.conf; do
|
|
1312
|
+
[[ -f "$f" ]] || continue
|
|
1313
|
+
grep -qxF "$HEADLESS_LINE" "$f" && return 0
|
|
1314
|
+
done
|
|
1315
|
+
return 1
|
|
1316
|
+
}
|
|
1317
|
+
HEADLESS_ADDED=0
|
|
1318
|
+
if [[ "$OS_KIND" != "mac" ]] && machine_is_headless; then
|
|
1319
|
+
if [[ -f "$HEADLESS_DROPIN" ]] && grep -qxF "$HEADLESS_LINE" "$HEADLESS_DROPIN"; then
|
|
1320
|
+
echo "[setup] 画面なし方式: この現場には設定済みです(${HEADLESS_DROPIN})"
|
|
1321
|
+
else
|
|
1322
|
+
mkdir -p "$HEADLESS_DROPIN_DIR"
|
|
1323
|
+
cat > "$HEADLESS_DROPIN" <<DROPIN
|
|
1324
|
+
# setup.sh が設定: この機は画面なし方式(headless)なので、この現場も同じ方式で動かす。
|
|
1325
|
+
# ★tmux に切り替えたいときはこのファイルを消して systemctl --user daemon-reload。
|
|
1326
|
+
[Service]
|
|
1327
|
+
${HEADLESS_LINE}
|
|
1328
|
+
DROPIN
|
|
1329
|
+
HEADLESS_ADDED=1
|
|
1330
|
+
echo "[setup] 画面なし方式を設定しました(drop-in: ${HEADLESS_DROPIN})"
|
|
1331
|
+
fi
|
|
1332
|
+
# ⑧ の案内を画面なし方式に切り替える。
|
|
1333
|
+
CONDUCTOR_RUNNER_MODE=headless
|
|
1334
|
+
fi
|
|
1335
|
+
|
|
1293
1336
|
# ★常駐の設置だけが OS で分かれる(ADR-019・案C)。ここより上も下も1本のまま。
|
|
1294
1337
|
if [[ "$OS_KIND" == "mac" ]]; then
|
|
1295
1338
|
SVC_INSTALL="$SCRIPT_DIR/launchd/install.sh"
|
|
@@ -1298,6 +1341,10 @@ else
|
|
|
1298
1341
|
fi
|
|
1299
1342
|
if [[ -f "$SVC_INSTALL" ]]; then
|
|
1300
1343
|
bash "$SVC_INSTALL" "$SITE" "$PROJECT_DIR"
|
|
1344
|
+
# ★もう動いていた現場(setup のやり直し)は enable --now では読み直さないので、入れ直して効かせる。
|
|
1345
|
+
if [[ "$HEADLESS_ADDED" == "1" ]]; then
|
|
1346
|
+
systemctl --user restart "conductor@${SITE}.service" 2>/dev/null || true
|
|
1347
|
+
fi
|
|
1301
1348
|
else
|
|
1302
1349
|
warn "常駐の設置スクリプトが見つかりません: $SVC_INSTALL(スキップ)"
|
|
1303
1350
|
echo "[setup] 手動で設定する場合は sales-template/${SVC_KIND}/ を参照してください。"
|
|
@@ -1452,12 +1499,13 @@ if [[ "${CONDUCTOR_RUNNER_MODE:-}" == "headless" ]]; then
|
|
|
1452
1499
|
合言葉を入れ直す: rm ~/.conductor.env → そのあと setup.sh をもう一度実行
|
|
1453
1500
|
|
|
1454
1501
|
──────────────────────────────────────────────────────────
|
|
1455
|
-
|
|
1502
|
+
職人の画面を手で開きたいとき(見るだけ・補助)
|
|
1456
1503
|
──────────────────────────────────────────────────────────
|
|
1457
1504
|
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1505
|
+
画面なし方式では、職人の画面を手で立ち上げません(立ち上げると、便と同じ現場で2人目の職人が動きます)。
|
|
1506
|
+
職人がいま何をしているかは、管制の画面か、次の1行で見られます(見るだけ・入力はできません):
|
|
1507
|
+
|
|
1508
|
+
kc ${SITE} (kc が無いときは conductor view ${SITE})
|
|
1461
1509
|
|
|
1462
1510
|
GUIDE
|
|
1463
1511
|
else
|
package/scripts/hooks/policy.py
CHANGED
|
@@ -3586,6 +3586,7 @@ def _gate_command(command: str, depth: int = 0):
|
|
|
3586
3586
|
# 【何のため】次の3件は、毎便の本文に「やらない」と書いてきたが、番人は通していた(棚卸し 8ceae57d の
|
|
3587
3587
|
# 実測で allow)。番人で止められれば、便の本文からその行を外せる(書かなくても守られる)。
|
|
3588
3588
|
# ① git add -A(--all・-A を含む短い旗のまとめ・git add . ・git add :/)
|
|
3589
|
+
# と git commit -a(--all・-am などのまとめ・便 4cf71dde で足した)
|
|
3589
3590
|
# ② cd を && でほかの命令とつなげる/cd とリダイレクトを同じ命令の行に混ぜる
|
|
3590
3591
|
# ③ リポ全体・ホーム全体を指す再帰の検索(grep -r・rg・ag・ack・find)
|
|
3591
3592
|
# 【★確実にその形と言える物だけを止める】命令の1行目を、引用符を守って字句に割ってから見る
|
|
@@ -3600,6 +3601,8 @@ _DISCIPLINE_REDIRECTS = frozenset({'>', '>>', '<', '>&', '&>', '>|', '<>', '&>>'
|
|
|
3600
3601
|
|
|
3601
3602
|
_DISC_GIT_ADD = ('git add はまとめて足さない(-A・--all・. は秘密や無関係の変更まで入る)。'
|
|
3602
3603
|
'足すファイルを名指しで git add path/to/file(鉄則1)')
|
|
3604
|
+
_DISC_GIT_COMMIT_ALL = ('git commit -a・-am・--all はまとめて記録に入れない(着手前から変わっていた設定ファイルまで入る)。'
|
|
3605
|
+
'ファイルを名指しで git add path/to/file してから git commit(鉄則1)')
|
|
3603
3606
|
_DISC_CD = ('cd を && でほかの命令とつなげない・cd とリダイレクトを同じ行に混ぜない(許可の確認で止まる)。'
|
|
3604
3607
|
'cd は作業のはじめに1回だけ単独で打ち、以後は絶対パスか git -C を使う(鉄則2)')
|
|
3605
3608
|
_DISC_SEARCH = ('リポ全体・ホーム全体を再帰で検索しない(重く、秘密の置き場にも当たる)。'
|
|
@@ -3678,6 +3681,56 @@ def _disc_git_add(words):
|
|
|
3678
3681
|
return None
|
|
3679
3682
|
|
|
3680
3683
|
|
|
3684
|
+
# git commit の短い旗のうち、値を取る物(-m 説明・-F ファイル・-C/-c 記録・-t 型)と、
|
|
3685
|
+
# 値を続けて書ける物(-u 形・-S 鍵)。★これらより後ろの字は旗ではなく値(`-ma` は説明 "a")。
|
|
3686
|
+
_COMMIT_VALUE_SHORT = frozenset('mFCct')
|
|
3687
|
+
_COMMIT_OPTVALUE_SHORT = frozenset('uS')
|
|
3688
|
+
_COMMIT_VALUE_LONG = frozenset({'--message', '--file', '--reuse-message', '--reedit-message',
|
|
3689
|
+
'--template', '--author', '--date', '--fixup', '--squash',
|
|
3690
|
+
'--cleanup', '--trailer', '--pathspec-from-file'})
|
|
3691
|
+
|
|
3692
|
+
|
|
3693
|
+
def _disc_git_commit_all(words):
|
|
3694
|
+
"""git commit -a/--all/-am(追跡しているファイルの変更を全部まとめて記録に入れる形)。
|
|
3695
|
+
|
|
3696
|
+
★便 4cf71dde:着手前から M だった .claude/settings.json が、空振り確認の `git commit -a` で
|
|
3697
|
+
手元の記録に入った(ai-kana の2便が続けて踏んだ)。`git add -A` と同じ「まとめて入れる」形なので、
|
|
3698
|
+
同じ扱いで止める(止める物を足すだけ)。
|
|
3699
|
+
"""
|
|
3700
|
+
if not words or words[0].split('/')[-1] != 'git':
|
|
3701
|
+
return None
|
|
3702
|
+
i = 1
|
|
3703
|
+
while i < len(words) and words[i].startswith('-'):
|
|
3704
|
+
i += 2 if words[i] in ('-C', '-c') else 1
|
|
3705
|
+
if i >= len(words) or words[i] != 'commit':
|
|
3706
|
+
return None
|
|
3707
|
+
rest = words[i + 1:]
|
|
3708
|
+
k = 0
|
|
3709
|
+
while k < len(rest):
|
|
3710
|
+
a = rest[k]
|
|
3711
|
+
k += 1
|
|
3712
|
+
if a == '--':
|
|
3713
|
+
break
|
|
3714
|
+
if a.startswith('--'):
|
|
3715
|
+
if a == '--all':
|
|
3716
|
+
return _DISC_GIT_COMMIT_ALL
|
|
3717
|
+
if a in _COMMIT_VALUE_LONG:
|
|
3718
|
+
k += 1 # 値は次の語(`--message=…` の形は1語で終わる)
|
|
3719
|
+
continue
|
|
3720
|
+
if not a.startswith('-') or a == '-':
|
|
3721
|
+
continue
|
|
3722
|
+
for j, ch in enumerate(a[1:], 1):
|
|
3723
|
+
if ch == 'a':
|
|
3724
|
+
return _DISC_GIT_COMMIT_ALL
|
|
3725
|
+
if ch in _COMMIT_VALUE_SHORT:
|
|
3726
|
+
if j == len(a) - 1:
|
|
3727
|
+
k += 1 # `-m 説明` … 値は次の語
|
|
3728
|
+
break # `-mabc` … 残りは値
|
|
3729
|
+
if ch in _COMMIT_OPTVALUE_SHORT:
|
|
3730
|
+
break
|
|
3731
|
+
return None
|
|
3732
|
+
|
|
3733
|
+
|
|
3681
3734
|
def _disc_repo_top(cwd):
|
|
3682
3735
|
d = os.path.normpath(cwd) if cwd and os.path.isabs(cwd) else None
|
|
3683
3736
|
while d:
|
|
@@ -3808,7 +3861,7 @@ def _discipline_scan(command: str, cwd=None):
|
|
|
3808
3861
|
while k < len(words) and (_ASSIGN_RE.match(words[k]) or words[k].split('/')[-1] in _WRAPPERS):
|
|
3809
3862
|
k += 1
|
|
3810
3863
|
core = words[k:]
|
|
3811
|
-
hit = _disc_git_add(core) or _disc_search(core, cwd)
|
|
3864
|
+
hit = _disc_git_add(core) or _disc_git_commit_all(core) or _disc_search(core, cwd)
|
|
3812
3865
|
if hit:
|
|
3813
3866
|
return hit
|
|
3814
3867
|
return None
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
【何を守っているか】
|
|
8
8
|
(n) ★止まる形:git add -A/--all/./-Av/git -C 場所 add -A、
|
|
9
|
+
git commit -a/-am/--all/-qam/git -C 場所 commit -av(便 4cf71dde)、
|
|
9
10
|
cd を && でつなぐ(前でも後ろでも・括弧の中でも)・cd とリダイレクトを同じ行に混ぜる、
|
|
10
11
|
リポ全体・ホーム全体・/ を指す再帰の検索(grep -r・rg・find、行き先を省いた形も)
|
|
11
12
|
(p) 止まってはいけない似た形:git add ファイル名・git add -- ファイル名・cd 単独・cd X; 命令・
|
|
@@ -44,6 +45,7 @@ os.mkdir(os.path.join(REPO, 'src'))
|
|
|
44
45
|
SUB = os.path.join(REPO, 'src')
|
|
45
46
|
HOME = os.path.expanduser('~')
|
|
46
47
|
ADD = 'git ' + 'add'
|
|
48
|
+
COMMIT = 'git ' + 'commit'
|
|
47
49
|
|
|
48
50
|
|
|
49
51
|
def decide(cmd, cwd=REPO):
|
|
@@ -76,6 +78,14 @@ NEGATIVE = [
|
|
|
76
78
|
('n22 リポの一番上を名指しした find', f'find {REPO} -name x'),
|
|
77
79
|
('n23 リポより広い所(リポの親)', f'find {os.path.dirname(REPO)} -name x'),
|
|
78
80
|
('n24 env 変数=… を前に付けても', f'GIT_TRACE=1 {ADD} -A'),
|
|
81
|
+
# ★便 4cf71dde:git commit -a は git add -A と同じ「まとめて入れる」形(ai-kana の2便が踏んだ)。
|
|
82
|
+
('n25 git commit -a', f'{COMMIT} -a'),
|
|
83
|
+
('n26 git commit -am 説明', f'{COMMIT} -am "x"'),
|
|
84
|
+
('n27 git commit --all', f'{COMMIT} --all -m x'),
|
|
85
|
+
('n28 git commit -m 説明 -a(後ろに置いても)', f'{COMMIT} -m "x" -a'),
|
|
86
|
+
('n29 git -C 場所 commit -av', f'git -C /x commit -av'),
|
|
87
|
+
('n30 git commit -qam 説明(短い旗のまとめ)', f'{COMMIT} -qam x'),
|
|
88
|
+
('n31 命令 && git commit -am 説明', f'git status && {COMMIT} -am x'),
|
|
79
89
|
]
|
|
80
90
|
for label, cmd in NEGATIVE:
|
|
81
91
|
r = decide(cmd)
|
|
@@ -101,6 +111,14 @@ POSITIVE = [
|
|
|
101
111
|
('p16 下の場所から find .(リポ全体ではない)', 'find . -name x'),
|
|
102
112
|
('p17 grep(再帰でない)', 'grep -n foo src/a.ts'),
|
|
103
113
|
('p18 リダイレクトだけ(cd なし)', 'ls > /tmp/claude-1000/o'),
|
|
114
|
+
('p19 git commit -m 説明(名指しで add した後)', f'{COMMIT} -m "fix: 直す"'),
|
|
115
|
+
('p20 説明の中の -a は旗ではない', f'{COMMIT} -m "-a を止める"'),
|
|
116
|
+
('p21 -ma は説明 "a"(-a ではない)', f'{COMMIT} -ma'),
|
|
117
|
+
('p22 git commit --amend --no-edit', f'{COMMIT} --amend --no-edit'),
|
|
118
|
+
('p23 git commit -F ファイル', f'{COMMIT} -F /tmp/claude-1000/msg.txt'),
|
|
119
|
+
('p24 git commit --author 名前', f'{COMMIT} --author "-a <a@b>" -m x'),
|
|
120
|
+
('p25 git commit -- ファイル名', f'{COMMIT} -m x -- src/a.ts'),
|
|
121
|
+
('p26 git log --all(commit ではない)', 'git log --all --oneline'),
|
|
104
122
|
]
|
|
105
123
|
for label, cmd in POSITIVE:
|
|
106
124
|
cwd = SUB if label.startswith('p16') else REPO
|
|
@@ -108,6 +126,8 @@ for label, cmd in POSITIVE:
|
|
|
108
126
|
|
|
109
127
|
print('── (m) 止めたときの文 ' + '─' * 44)
|
|
110
128
|
check('(m) git add は名指しを案内', 'git add path/to/file' in decide(f'{ADD} -A')['reason'], True)
|
|
129
|
+
check('(m) git commit -a は「名指しで add してから commit」を案内',
|
|
130
|
+
all(w in decide(f'{COMMIT} -am x')['reason'] for w in ('git add path/to/file', 'してから git commit')), True)
|
|
111
131
|
check('(m) cd は絶対パスか git -C を案内', 'git -C' in decide('cd x && ls')['reason'], True)
|
|
112
132
|
check('(m) 検索は名指しの場所を案内', '名指し' in decide('find . -name x')['reason'], True)
|
|
113
133
|
for label, cmd in [('git add', f'{ADD} -A'), ('cd', 'cd x && ls'), ('検索', 'find / -name x')]:
|
|
@@ -22,7 +22,7 @@ description: 采配くんの便(プロンプト末尾に CONDUCTOR_JOB:<id>
|
|
|
22
22
|
- ① そのファイルを**触らない・開かない・`git diff` の対象にしない**。「読めるようにする」方向へ回避しない(鉄則4)。
|
|
23
23
|
- ② 差分は**対象を名指しして**見る。例 `git diff -- src docs`。全体で見たいときは除いて見る=`git diff -- . ':(exclude).npmrc'`。
|
|
24
24
|
- ③ `M` と出ていても、**自分が触っていなければ触っていない**。★職人には本当に変わったかを確かめられない。`git status --porcelain -- <そのファイル>` の**表示だけ**を報告に書き、**中身は1文字も書かない**。
|
|
25
|
-
- ④ **コミットに含めない**(`git add` は対象を名指しする=`git add -A`
|
|
25
|
+
- ④ **コミットに含めない**(`git add` は対象を名指しする=`git add -A` を使わない)。★`git commit -a`・`-am`・`--all` も同じ「まとめて全部を入れる」形なので使わない(番人が止める)。**ファイルを名指しで `git add` してから `git commit -m …`**(便 4cf71dde:着手前から `M` だった `.claude/settings.json` が、空振り確認の `git commit -a` で手元の記録に入った)。
|
|
26
26
|
- ⑤ それで進めないときだけ、回避せず**人の手番として報告して止まる**。
|
|
27
27
|
2. **cd は1回だけ** — cd は作業開始時に1回だけ実行する。以後 cd を `&&` で他コマンドと連結しない・リダイレクトと混ぜない。
|
|
28
28
|
3. **持ち場の外に出ない** — 指示された作業ディレクトリの外を変更しない。
|
|
@@ -132,6 +132,10 @@ description: 采配くんの便(プロンプト末尾に CONDUCTOR_JOB:<id>
|
|
|
132
132
|
- **なぜ(置き場の話)。** 実機 2026-09-03: ai-saku の画面便(PR#258・PR#269)で、検品用の使い捨てページ `_layout-check.html` をリポジトリ直下に作り、`rm -rf` で消そうとして止められ、人が2回消した。**禁止の一覧と番人の制限は正しい(緩めない)。悪いのは置き場。**
|
|
133
133
|
- **なぜ(後片付けの話を改めた理由)。** 旧い文言は「リポジトリ直下は番人が `rm` を止めるので自分で消さず報告」だった。実測(便 fb248f6e)では、`-f` を付けない `rm -r` と実在のパスの名指しなら、リポジトリ直下の自分の作業ファイルも通る。止まるのは `-f` 付きと変数・`*` 入りだけ。**止まらない形を「止まる」と書いていると、職人が自分の残骸を残したまま便を終える**ので、実物に合わせた(番人・禁止の一覧は1行も変えていない)。
|
|
134
134
|
- 置き場を `/tmp/claude-1000/` にすれば、止められることも、残骸がコミットに混ざることも起きない(根治=鉄則10)。Playwright の検品ページも同じ(鉄則8-④)。
|
|
135
|
+
- **空振り確認(直しをわざと外して、試験が落ちるのを見る)も、使い捨ての複製の中だけで行う(えふさん確定 2026-10-03・便 4cf71dde の要判断②)。**
|
|
136
|
+
- 形: `git clone --no-hardlinks <リポの実在のパス> /tmp/claude-1000/karaburi-<便IDの頭8文字>` → その中で直しを外す → 試験を流す → 終わったら `rm -r /tmp/claude-1000/karaburi-<便IDの頭8文字>`(`-f` を付けない・名指し)。
|
|
137
|
+
- ★**作業ツリーの中で直しを外さない**(戻し忘れがそのまま PR に入る)。★**`git worktree` を使わない**:囲いが `.git/worktrees` を読み取り専用で重ねるので、`git worktree remove` が「Device or resource busy」で止まり、職人が片付けられない(便 4cf71dde で再現)。複製なら囲いにも番人にも止められずに片付く。
|
|
138
|
+
- 複製の中でも記録に入れるときは名指しで `git add <ファイル>`(鉄則1-④)。複製は GitHub へ push しない。
|
|
135
139
|
14. **`.env` 類(`.env`・`.env.local`・`.env.example` などの雛形も含む)は職人が書き換えない(えふさん確定 2026-09-18・便 3d11f913)**
|
|
136
140
|
- **番人は `.env` 本体も `.env.example` などの雛形も、書き込みを止める**(Write/Edit の設定 deny と、Bash の `echo`/`tee`/`sed -i`/`cp`/`mv` などを、経路を問わず同じ結果にそろえた)。読み込みは今までどおり(雛形は読める・本物は読めない)。
|
|
137
141
|
- **なぜ。** 雛形(`.env.example`)は普通は秘密値を置かないが、「exampleだから常に安全」と番人へ例外を作ると、将来誤って実値が書かれたときの穴になる(利便性より安全側・えふさん確定)。
|
|
@@ -164,6 +168,7 @@ description: 采配くんの便(プロンプト末尾に CONDUCTOR_JOB:<id>
|
|
|
164
168
|
- 通る例: `rm -r /tmp/claude-1000/作業名` / `rm -r node_modules/.tmp-作業名`(★`-f` を付けない・自分で作った物だけ)
|
|
165
169
|
- 止まる例: `rm -rf /tmp/claude-1000/作業名`(禁止の一覧)/ `rm -r "$DIR/x"`・`rm -r build/*`(番人)/ `rm -r .git`・`rm -r .claude/hooks`・`rm -r .tyhld`(番人=相対パスでも消せない)
|
|
166
170
|
- ★**探すだけの道具の「探す言葉」に `rm …` と書いても、もう止まらない**(便 915694ac で直した)。鉄則や台本を調べるときの `grep -rn 'rm -rf .git' skills`・`git grep -n 'rm -r' -- skills`・`rg 'rm -rf /' docs` は通る。★囲みの外に書いた `rm` は今までどおり測るので、`grep -rn 'x' docs; rm -rf .git` のように**後ろで消す形は止まる**。
|
|
171
|
+
- **まとめて全部を記録に入れる `git add`・`git commit`**(`git add -A`・`git add .`・`git commit -a`・`git commit -am 説明`・`git commit --all`)→ 止まる。着手前から変わっていたファイル(例 `.claude/settings.json`)まで入り、番人がそれを記録から外す操作も止めるので取り返せない(便 4cf71dde)。**ファイルを名指しで `git add <ファイル>` してから `git commit -m 説明`**(鉄則1-④)。
|
|
167
172
|
- **作業場所が変数のままの `git push`**(`git -C "$DIR" push`・行き先のブランチを確かめられない形)→ 止まる。**リポの中で、ブランチを名指しして** `git push origin <作業ブランチ名>`(★`-u` は付けない=鉄則7-⓪。上流の設定を書くと、囲いが `.git/config` を塞いでいるPCで止まる)。
|
|
168
173
|
|
|
169
174
|
## 便の終え方
|