@tyhld/conductor 0.11.10 → 0.11.12
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/relay.js +122 -67
- package/package.json +1 -1
- package/scripts/hooks/hook_datalink.py +5 -63
- package/scripts/hooks/permission_request_hook.py +9 -56
- package/scripts/hooks/test_autorun.py +35 -115
package/dist/relay.js
CHANGED
|
@@ -200,13 +200,6 @@ export const BUSY_MARKERS = [
|
|
|
200
200
|
* '·' のような普通の出力に頻出する記号も入れない。)
|
|
201
201
|
*/
|
|
202
202
|
export const BUSY_GLYPHS = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
203
|
-
/**
|
|
204
|
-
* CC2 の作業報告を囲ませる行マーカー(ADR-029 報告集約・送信側)。
|
|
205
|
-
* CC2 はこの2行で報告本文を挟んで出力し、relay が間の行群を抜き出して中央へ送る。
|
|
206
|
-
* BUSY_MARKERS / BUSY_GLYPHS / `DONE:` のいずれとも文字列が衝突しない(アイドル判定・完了判定を汚さない)。
|
|
207
|
-
*/
|
|
208
|
-
export const REPORT_START = 'REPORT_START';
|
|
209
|
-
export const REPORT_END = 'REPORT_END';
|
|
210
203
|
/** 設定から職人の種類を読む。未指定・知らない値は 'claude'(後退させない)。 */
|
|
211
204
|
export function kindOf(cfg) {
|
|
212
205
|
return cfg.agentKind ?? 'claude';
|
|
@@ -940,11 +933,13 @@ export function selectOrphanToRestore(orphans, currentPending) {
|
|
|
940
933
|
* CC2 の途中送信を防ぐため、改行はすべて空白に潰して「1行=1メッセージ」にする
|
|
941
934
|
* (本文内に生 Enter を含めない=送信は末尾の Enter 1回だけにするため)。
|
|
942
935
|
*
|
|
943
|
-
* 完了時に CC2
|
|
944
|
-
* 1)
|
|
945
|
-
*
|
|
946
|
-
*
|
|
947
|
-
*
|
|
936
|
+
* 完了時に CC2 へ求めること(★2026-09-07 に「画面から読む」をやめ、ファイル経由にした):
|
|
937
|
+
* 1) 報告を【ファイル】に書く(置き場と名前は relayReportDirs / reportFileName が決める)。
|
|
938
|
+
* ★画面に長い報告を出す方式は窓が 16〜34 行しかなく、9/5 以降 162 回すべて失敗していた
|
|
939
|
+
* (調査 d1d16a56)。ファイルなら長さの制限が無く、1文字も落ちない。
|
|
940
|
+
* 2) その後の最終行に必ず DONE:{site} とだけ出力(完了の合図。★これは今までどおり画面で見る)。
|
|
941
|
+
* 3) さらに devlog MCP の conductor_complete を commandId=<id> で1回呼ぶ(relay非依存の確実な通知)。
|
|
942
|
+
* ★(3) が先に通っていれば relay は二重に送らない(completeWithReport が中央へ確かめる)。
|
|
948
943
|
*
|
|
949
944
|
* anchor: この送信に固有のアンカートークン(buildAnchor(id)=`CONDUCTOR_JOB:<id>`)。本文の「最後」に置くことで、
|
|
950
945
|
* 画面では送信エコーの末尾に出る=完了検出(hasDoneBelowAnchor)の境界になる。
|
|
@@ -972,8 +967,11 @@ export function buildSendText(body, site, anchor) {
|
|
|
972
967
|
`英語ではなく日本語で、「プルリクエストを作成する」「テストを実行する」のように` +
|
|
973
968
|
`「何をするか」が一目で分かる簡潔な形(〜を作成する/実行する/削除する 等)で書いてください` +
|
|
974
969
|
`(この説明は管制の確認カードにそのまま表示され、非エンジニアのお客さまが読んで許可を判断します)。\n\n` +
|
|
975
|
-
|
|
976
|
-
|
|
970
|
+
// ★報告はファイルに書いてもらう(2026-09-07)。画面に出す方式は窓が小さすぎて
|
|
971
|
+
// 長い報告の先頭が流れ、9/5 以降 162 回すべて失敗していた(調査 d1d16a56)。
|
|
972
|
+
`作業がすべて完了したら、まず報告を次のファイルに書いてください(変更ファイル・要点・` +
|
|
973
|
+
`チェック結果を簡潔に): ${reportFileCandidates(commandId)[0]} ` +
|
|
974
|
+
`(フォルダが無ければ作ってください。長さの制限はありません。画面に出す必要はありません)。` +
|
|
977
975
|
`その後の最終行に必ず ${marker} とだけ出力してください。\n\n` +
|
|
978
976
|
// ★案D(relay非依存の確実な完了通知): 完了したら必ず devlog MCP の conductor_complete を呼ぶ。
|
|
979
977
|
`そして完了したら必ず、devlog の MCP ツール conductor_complete を commandId="${commandId}" で1回だけ呼んでください` +
|
|
@@ -995,53 +993,6 @@ export function buildSendText(body, site, anchor) {
|
|
|
995
993
|
// 改行(と連続改行)を空白1個に潰し、前後を整える。
|
|
996
994
|
return full.replace(/\r?\n+/g, ' ').replace(/\s+/g, ' ').trim();
|
|
997
995
|
}
|
|
998
|
-
/**
|
|
999
|
-
* 画面テキストから CC2 の報告本文を抜き出す純粋関数(ADR-029 報告集約・送信側)。
|
|
1000
|
-
*
|
|
1001
|
-
* 方針:
|
|
1002
|
-
* - 行単位で「装飾を除くと ${REPORT_START} / ${REPORT_END} だけが残る行」をマーカー行とみなす。
|
|
1003
|
-
* 送信本文のエコー行は1行に両マーカー+指示文が同居するため、装飾除去後に空にならず
|
|
1004
|
-
* マーカー行と判定されない(=誤抽出しない)。
|
|
1005
|
-
* - マーカーが複数あれば「最後のペア」(最後の END と、その直前の最も近い START)を採用する。
|
|
1006
|
-
* CC2 の実報告は送信本文エコーより後(画面下側)に出るため、最後のペア採用で実報告を拾える。
|
|
1007
|
-
* - START/END が無い・対応が壊れている・中身が空のときは null(報告なし)。done化は妨げない。
|
|
1008
|
-
*
|
|
1009
|
-
* 限界(今回スコープ外): tmux capture-pane は画面に見えている範囲しか取れないため、
|
|
1010
|
-
* 報告がスクロールで流れると全文は取れない(取れた範囲を report とする)。
|
|
1011
|
-
*/
|
|
1012
|
-
export function extractReport(text) {
|
|
1013
|
-
const lines = text.replace(/\r/g, '').split('\n');
|
|
1014
|
-
// 装飾(ボックス罫線・行頭バレット・空白)を除いた中身を返す。
|
|
1015
|
-
const stripped = (line) => line.replace(/[│|>⏺●○•*·]/g, '').trim();
|
|
1016
|
-
// 最後の END マーカー行を探す。
|
|
1017
|
-
let endIdx = -1;
|
|
1018
|
-
for (let i = lines.length - 1; i >= 0; i--) {
|
|
1019
|
-
if (stripped(lines[i]) === REPORT_END) {
|
|
1020
|
-
endIdx = i;
|
|
1021
|
-
break;
|
|
1022
|
-
}
|
|
1023
|
-
}
|
|
1024
|
-
if (endIdx < 0)
|
|
1025
|
-
return null;
|
|
1026
|
-
// その直前にある最も近い START マーカー行を探す。
|
|
1027
|
-
let startIdx = -1;
|
|
1028
|
-
for (let i = endIdx - 1; i >= 0; i--) {
|
|
1029
|
-
if (stripped(lines[i]) === REPORT_START) {
|
|
1030
|
-
startIdx = i;
|
|
1031
|
-
break;
|
|
1032
|
-
}
|
|
1033
|
-
}
|
|
1034
|
-
if (startIdx < 0)
|
|
1035
|
-
return null;
|
|
1036
|
-
// 間の行群を取り出し、前後の空行を trim。
|
|
1037
|
-
const inner = lines.slice(startIdx + 1, endIdx);
|
|
1038
|
-
while (inner.length > 0 && inner[0].trim() === '')
|
|
1039
|
-
inner.shift();
|
|
1040
|
-
while (inner.length > 0 && inner[inner.length - 1].trim() === '')
|
|
1041
|
-
inner.pop();
|
|
1042
|
-
const report = inner.join('\n').trim();
|
|
1043
|
-
return report.length > 0 ? report : null;
|
|
1044
|
-
}
|
|
1045
996
|
// ───────────────────────────────────────────────────────────────────────────
|
|
1046
997
|
// tmux I/O(失敗は握りつぶして null/false。送らない側に倒すため)
|
|
1047
998
|
// ───────────────────────────────────────────────────────────────────────────
|
|
@@ -1102,6 +1053,76 @@ export function relaySendTmpDirs() {
|
|
|
1102
1053
|
}
|
|
1103
1054
|
/** tmux バッファ名の接頭辞(他人のバッファと衝突させないため現場・PID・時刻で一意にする)。 */
|
|
1104
1055
|
export const RELAY_SEND_BUFFER_PREFIX = 'conductor-send';
|
|
1056
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
1057
|
+
// 報告の受け渡し(★ファイル経由・えふさん決定 2026-09-07)
|
|
1058
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
1059
|
+
/**
|
|
1060
|
+
* 職人が報告を書く場所(1指示につき1ファイル)。★relay はここだけを見る。
|
|
1061
|
+
*
|
|
1062
|
+
* 【なぜ画面から読むのをやめたか(調査 d1d16a56 で確定した事実)】
|
|
1063
|
+
* 職人は全画面表示で動くので tmux から見える行は 16〜34 行しかない(実測: 代替画面=1・
|
|
1064
|
+
* 履歴の行数 0〜222 行/上限 2000 行)。報告が数十行あると、終わりの目印と DONE 行が
|
|
1065
|
+
* 下に残った時点で【始まりの目印はもう窓の外】になり、対が見つからず報告が null になる。
|
|
1066
|
+
* ★9/5 以降の実測: 報告を添えた完了 0 件 / 報告なしの完了 162 件。
|
|
1067
|
+
* =画面から報告を読む保険は一度も動いたことがなかった。窓の大きさの問題なので、
|
|
1068
|
+
* 目印の書き方をどう工夫しても直らない。だから【画面を読むのをやめて】ファイルにする。
|
|
1069
|
+
*
|
|
1070
|
+
* 置き場(前から順に試す。理由は writeSendTmpFile と同じ):
|
|
1071
|
+
* 第1候補 /tmp/claude-<uid>/conductor-report … 職人の使い捨て置き場と同じ根(鉄則13)
|
|
1072
|
+
* 第2候補 os.tmpdir()/conductor-report-<uid> … /tmp が使えない機体の保険
|
|
1073
|
+
* 環境変数 CONDUCTOR_REPORT_DIR で1つに固定できる(テスト・特殊な機体向け)。
|
|
1074
|
+
*/
|
|
1075
|
+
export function relayReportDirs(env = process.env) {
|
|
1076
|
+
const explicit = (env.CONDUCTOR_REPORT_DIR ?? '').trim();
|
|
1077
|
+
if (explicit)
|
|
1078
|
+
return [explicit];
|
|
1079
|
+
const uid = typeof process.getuid === 'function' ? process.getuid() : 0;
|
|
1080
|
+
return [
|
|
1081
|
+
path.join('/tmp', `claude-${uid}`, 'conductor-report'),
|
|
1082
|
+
path.join(os.tmpdir(), `conductor-report-${uid}`),
|
|
1083
|
+
];
|
|
1084
|
+
}
|
|
1085
|
+
/**
|
|
1086
|
+
* その指示の報告ファイル名。★指示ID で引ける形にする(どの便の報告かを取り違えない)。
|
|
1087
|
+
* 指示ID に紛れ込みうる区切り文字は落とす(別の場所を指す名前を作らせない)。
|
|
1088
|
+
*/
|
|
1089
|
+
export function reportFileName(commandId) {
|
|
1090
|
+
const safe = String(commandId ?? '').replace(/[^A-Za-z0-9_-]/g, '').slice(0, 128);
|
|
1091
|
+
return `${safe || 'unknown'}.md`;
|
|
1092
|
+
}
|
|
1093
|
+
/** 報告ファイルの置き場の候補(前から順に探す)。 */
|
|
1094
|
+
function reportFileCandidates(commandId, env) {
|
|
1095
|
+
return relayReportDirs(env).map((dir) => path.join(dir, reportFileName(commandId)));
|
|
1096
|
+
}
|
|
1097
|
+
/**
|
|
1098
|
+
* その指示の報告を読む(消さない)。無ければ null。
|
|
1099
|
+
* ★読むだけで消さないのは、管制へ書けたことを確かめてから消すため
|
|
1100
|
+
* (書けなかったのに消すと報告が永久に失われる)。
|
|
1101
|
+
*/
|
|
1102
|
+
export function readReport(commandId, env) {
|
|
1103
|
+
for (const file of reportFileCandidates(commandId, env)) {
|
|
1104
|
+
try {
|
|
1105
|
+
const text = readFileSync(file, 'utf8').trim();
|
|
1106
|
+
if (text.length > 0)
|
|
1107
|
+
return text;
|
|
1108
|
+
}
|
|
1109
|
+
catch {
|
|
1110
|
+
// 無い・読めない → 次の候補へ(報告が無いこと自体は異常ではない)
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
return null;
|
|
1114
|
+
}
|
|
1115
|
+
/** その指示の報告ファイルを片付ける(★同じ報告を二度送らないため)。 */
|
|
1116
|
+
export function discardReport(commandId, env) {
|
|
1117
|
+
for (const file of reportFileCandidates(commandId, env)) {
|
|
1118
|
+
try {
|
|
1119
|
+
unlinkSync(file);
|
|
1120
|
+
}
|
|
1121
|
+
catch {
|
|
1122
|
+
// 無ければ何もしない
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1105
1126
|
/** 本文を書いた一時ファイルを作ってパスを返す。どこにも書けなければ例外。 */
|
|
1106
1127
|
function writeSendTmpFile(text) {
|
|
1107
1128
|
let lastErr = new Error('一時ファイルの置き場が1つも作れませんでした');
|
|
@@ -1341,6 +1362,39 @@ export async function patchStatus(cfg, id, status, report) {
|
|
|
1341
1362
|
return false;
|
|
1342
1363
|
}
|
|
1343
1364
|
}
|
|
1365
|
+
/**
|
|
1366
|
+
* 完了を1回で送る(★報告と完了を同じ1回に載せる・えふさん決定 2026-09-07)。
|
|
1367
|
+
*
|
|
1368
|
+
* 【なぜ1回にするか】以前は「報告なしの完了」を先に送り、報告はあとから追いかけていた。
|
|
1369
|
+
* そのため管制の開発履歴が【本文なしの記録】と【あとから来た本文】の2回に分かれ、
|
|
1370
|
+
* 本文が来ないまま終わると「完了したが報告なし」だけが残った。報告はファイルにあるので、
|
|
1371
|
+
* 完了を送るその1回に必ず載せられる。
|
|
1372
|
+
*
|
|
1373
|
+
* 【★二重に送らない】職人が conductor_complete で先に完了させていたら、relay は何も送らない。
|
|
1374
|
+
* 中央へ1回だけ状態を尋ねて確かめる。尋ねられない(不通)ときは送る側に倒す
|
|
1375
|
+
* =完了が落ちるより、同じ報告が2度書かれるほうが害が小さい(中央は上書き保存する)。
|
|
1376
|
+
*
|
|
1377
|
+
* 【後片付け】管制へ書けたときだけ報告ファイルを消す。書けなければ残す=次tickでやり直せる。
|
|
1378
|
+
* 返り: 追跡を畳んでよいか(true = 完了が確定した)。
|
|
1379
|
+
*/
|
|
1380
|
+
export async function completeWithReport(cfg, id, logLine = log) {
|
|
1381
|
+
const already = await fetchCommandStatus(cfg, id);
|
|
1382
|
+
if (isTerminalCommandStatus(already)) {
|
|
1383
|
+
// 職人が自分で完了させていた。★relay は二重に送らない(報告も上書きしない)。
|
|
1384
|
+
discardReport(id);
|
|
1385
|
+
logLine(`site=${cfg.site} id=${id} は職人が自分で完了させていた(status=${already})` +
|
|
1386
|
+
'→relayからは送りません(報告の置き場だけ片付けました)');
|
|
1387
|
+
return true;
|
|
1388
|
+
}
|
|
1389
|
+
const report = readReport(id);
|
|
1390
|
+
if (!(await patchStatus(cfg, id, 'done', report)))
|
|
1391
|
+
return false;
|
|
1392
|
+
if (report !== null)
|
|
1393
|
+
discardReport(id); // ★書けたら片付ける(同じ報告を二度送らない)
|
|
1394
|
+
logLine(`site=${cfg.site} id=${id} done` +
|
|
1395
|
+
(report !== null ? `(報告添付・${report.length}文字)` : '(報告ファイルなし=完了のみ)'));
|
|
1396
|
+
return true;
|
|
1397
|
+
}
|
|
1344
1398
|
/**
|
|
1345
1399
|
* 指示 {id} 単体の現在 status を中央へ問う(外部完了検出の根治プローブ)。
|
|
1346
1400
|
* GET /api/conductor/commands/{id}(テナントトークン Bearer)。応答 JSON から status 文字列を拾う。
|
|
@@ -1650,9 +1704,11 @@ export async function keepaliveTick(cfg, state, deps = {}) {
|
|
|
1650
1704
|
return;
|
|
1651
1705
|
}
|
|
1652
1706
|
if (action === 'done') {
|
|
1653
|
-
// 完了は従来の経路(画面の DONE
|
|
1654
|
-
const report =
|
|
1707
|
+
// 完了は従来の経路(画面の DONE 行)にそのまま乗せる。★報告は職人が書いたファイルから読む。
|
|
1708
|
+
const report = readReport(ka.id);
|
|
1655
1709
|
if (await complete(cfg, ka.id, report)) {
|
|
1710
|
+
if (report !== null)
|
|
1711
|
+
discardReport(ka.id); // ★書けたら片付ける(二度送らない)
|
|
1656
1712
|
logLine(`site=${cfg.site} id=${ka.id} 見張り終了: 画面のDONEを検出し完了を報告` +
|
|
1657
1713
|
`${report != null ? '(報告添付)' : ''}`);
|
|
1658
1714
|
// ★終わったら必ず手を下ろす(挙げっぱなしにすると現場が長くふさがる)。
|
|
@@ -1828,11 +1884,10 @@ export async function relayTick(cfg, state, hooks) {
|
|
|
1828
1884
|
}
|
|
1829
1885
|
const below = sliceForDoneScan(cap, state.pending.anchor, state.pending.screenDoneWide);
|
|
1830
1886
|
if (below !== null && hasStandaloneDone(below, cfg.site)) {
|
|
1831
|
-
//
|
|
1832
|
-
|
|
1833
|
-
const ok = await
|
|
1887
|
+
// ★報告は画面から読まない。職人が書いたファイルを読む(relayReportDirs / readReport)。
|
|
1888
|
+
// 画面を読む方式は窓が 16〜34 行しかなく、9/5 以降 162 回すべて失敗していた(調査 d1d16a56)。
|
|
1889
|
+
const ok = await completeWithReport(cfg, state.pending.id, log);
|
|
1834
1890
|
if (ok) {
|
|
1835
|
-
log(`site=${cfg.site} id=${state.pending.id} done${report != null ? '(報告添付)' : ''}`);
|
|
1836
1891
|
state.pending = null;
|
|
1837
1892
|
}
|
|
1838
1893
|
return; // sent 中は新規取得しない
|
package/package.json
CHANGED
|
@@ -483,73 +483,15 @@ def autorun_enabled(env=None):
|
|
|
483
483
|
return not _AUTORUN_OFF_RE.match(str(raw).strip())
|
|
484
484
|
|
|
485
485
|
|
|
486
|
-
|
|
487
|
-
# これは「あとで人が読む記録」であって、職人を待たせてよい理由が1つも無い。
|
|
488
|
-
# 管制が遅い・落ちているときに職人の手が止まらないよう、通常の 35 秒ではなくここで切る。
|
|
489
|
-
AUTORUN_SEND_TIMEOUT_SEC = 4
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
def record_autorun_local(tool_name, gate=None, sent=True, reason='', env=None):
|
|
486
|
+
def record_autorun_local(tool_name, gate=None, env=None):
|
|
493
487
|
"""「聞かずに通した(自走)」1回を、この機の記録に1行残す。
|
|
494
488
|
|
|
495
|
-
形: `<ISO時刻> Autorun <tool> -> allow[ gate=<種別>]
|
|
496
|
-
|
|
497
|
-
|
|
489
|
+
形: `<ISO時刻> Autorun <tool> -> allow[ gate=<種別>]`
|
|
490
|
+
★管制へは何も送らない(えふさん決定 2026-09-07・この記録は不要)。残すのはこの1行だけで、
|
|
491
|
+
「黙って通したことにならない」状態は保つ(後から何を通したか数えられる)。
|
|
492
|
+
★秘密もコマンド全文も残さない: ツール名と関門種別だけ。
|
|
498
493
|
"""
|
|
499
494
|
safe_tool = str(tool_name or 'unknown').split()[0][:40]
|
|
500
495
|
suffix = f' gate={str(gate)[:16]}' if gate else ''
|
|
501
|
-
if not sent:
|
|
502
|
-
suffix += f' 管制へ送れず({str(reason)[:40]})'
|
|
503
496
|
line = f'{time.strftime("%Y-%m-%dT%H:%M:%S%z")} Autorun {safe_tool} -> allow{suffix}\n'
|
|
504
497
|
return _append_line(decision_log_path(env), line)
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
def record_autorun(cfg, payload):
|
|
508
|
-
"""「聞かずに通した(自走)」1件を管制へ【履歴として】送る。返り: True/False。
|
|
509
|
-
|
|
510
|
-
【1手で済むようになった(管制 PR#276・2026-09-06)】
|
|
511
|
-
管制が kind='auto' を受け付け、その行は status='recorded' で生まれる=
|
|
512
|
-
「確認待ち」を1度も通らないので、カードとして人の前に出ない。
|
|
513
|
-
★以前は「登録してすぐ閉じる(POST→DELETE)」でカードを消していた。管制が
|
|
514
|
-
『人に聞くカード』しか受け取らなかった頃の逃げ方で、登録から DELETE までの
|
|
515
|
-
一瞬だけカードが出得た。もう要らないので【丸ごと消した】。
|
|
516
|
-
|
|
517
|
-
【古い管制への互換(★止まらないための逃げ道)】
|
|
518
|
-
kind='auto' を知らない版は 400(こちらの送信内容の不備)で弾く。そのときだけ、
|
|
519
|
-
昔の形(kind='message' + 登録してすぐ閉じる)で送り直す=記録は必ず残る。
|
|
520
|
-
★400 以外(不通・401・5xx)では送り直さない。相手が受け取れない理由が違うので、
|
|
521
|
-
同じものを2回投げても増えるのは負荷だけ。
|
|
522
|
-
|
|
523
|
-
★何が起きても職人は止めない(例外は握る)。送れなかった事実は _request が1行残す。
|
|
524
|
-
"""
|
|
525
|
-
try:
|
|
526
|
-
reg = _request(cfg, 'POST', '/api/conductor/hook-requests', payload,
|
|
527
|
-
timeout=AUTORUN_SEND_TIMEOUT_SEC)
|
|
528
|
-
if reg and reg.get('id'):
|
|
529
|
-
return True
|
|
530
|
-
if last_failure_kind() != FAIL_CLIENT:
|
|
531
|
-
return False # 不通・認証・管制側異常 … 送り直しても同じ。次の機会に任せる。
|
|
532
|
-
return _record_autorun_legacy(cfg, payload)
|
|
533
|
-
except Exception: # noqa: BLE001 記録のために職人を止めない
|
|
534
|
-
return False
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
def _record_autorun_legacy(cfg, payload):
|
|
538
|
-
"""★古い管制(kind='auto' を知らない版)への送り直し。返り: True/False。
|
|
539
|
-
|
|
540
|
-
昔の形=「人に聞くカード」として登録し、その場で閉じる(status='abandoned')。
|
|
541
|
-
一覧は『確認待ち』の行しか出さないのでカードにはならず、行は残るので後から読める。
|
|
542
|
-
★この道は互換のためだけに残している。管制が新しければ1手目で終わる(ここへ来ない)。
|
|
543
|
-
"""
|
|
544
|
-
legacy = dict(payload)
|
|
545
|
-
legacy['kind'] = 'message'
|
|
546
|
-
# 古い管制の kind='message' は question が必須。人が読む1行をそのまま入れる。
|
|
547
|
-
legacy['question'] = payload.get('summary') or '(自走で通しました)'
|
|
548
|
-
legacy['waitMs'] = 0
|
|
549
|
-
reg = _request(cfg, 'POST', '/api/conductor/hook-requests', legacy,
|
|
550
|
-
timeout=AUTORUN_SEND_TIMEOUT_SEC)
|
|
551
|
-
if not reg or not reg.get('id'):
|
|
552
|
-
return False
|
|
553
|
-
_request(cfg, 'DELETE', f"/api/conductor/hook-requests/{reg['id']}",
|
|
554
|
-
timeout=AUTORUN_SEND_TIMEOUT_SEC)
|
|
555
|
-
return True
|
|
@@ -24,8 +24,9 @@
|
|
|
24
24
|
【★自走モード(既定・えふさん決定 2026-09-06「すべてを自走できるようにして」)】
|
|
25
25
|
上の1〜4は【聞く】ための手順で、自走モードでは通らない。自走モードでは:
|
|
26
26
|
・人には一切聞かない。その場で allow を返す(★Claude Code 自身の確認画面にも落とさない)。
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
・★管制へは何も送らない(えふさん決定 2026-09-07)。2026-09-06 の自走化では「聞かずに
|
|
28
|
+
通した」を管制へ1件ずつ送っていたが、要らないと決まったので送る仕組みごと消した。
|
|
29
|
+
残すのはこの機の記録1行だけ($HOME/.conductor-guard.log の `Autorun …`)。
|
|
29
30
|
・★deny(持ち出し・秘密・番人の設定への書き込み等)は1ミリも変えない。自走でも通さない。
|
|
30
31
|
切り替えは【1か所】: 環境変数 CC2_AUTORUN、または $HOME/.conductor.env の CC2_AUTORUN。
|
|
31
32
|
未設定=自走。`CC2_AUTORUN=0`(0/false/no/off)で【従来どおり3ボタンで聞く】動きに戻る。
|
|
@@ -35,7 +36,6 @@
|
|
|
35
36
|
"""
|
|
36
37
|
import json
|
|
37
38
|
import os
|
|
38
|
-
import re
|
|
39
39
|
import sys
|
|
40
40
|
|
|
41
41
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
@@ -49,33 +49,6 @@ import summary_ja # noqa: E402
|
|
|
49
49
|
# localSettings に恒久 allow が書かれ、関門が静かに消える経路になっていた。塞ぐため固定する。
|
|
50
50
|
ALLOW_ALWAYS_DESTINATION = 'session'
|
|
51
51
|
|
|
52
|
-
# 自走の記録として管制へ送るときの種別(管制 PR#276 が 'recorded' で受ける=カードにしない)。
|
|
53
|
-
# ★古い管制はこれを知らず 400 で弾く。そのときは hook_datalink が昔の形で送り直す(互換)。
|
|
54
|
-
AUTORUN_KIND = 'auto'
|
|
55
|
-
|
|
56
|
-
# 履歴の1行説明の頭に付ける印(人が読んで「判断を求められていない」と分かるように)。
|
|
57
|
-
AUTORUN_SUMMARY_PREFIX = '【自走で通しました】'
|
|
58
|
-
# 聞くときの見出し「【お客さまの確認:本番へ反映】」を、履歴の見出しへ置き換えるための形。
|
|
59
|
-
_ASK_HEAD_RE = re.compile(r'^【お客さまの確認:([^】]*)】')
|
|
60
|
-
# 「…してよいですか」を「…しています」に直す(履歴なのに問いかけのままだと人が迷う)。
|
|
61
|
-
_ASK_TAIL_RE = re.compile(r'([てで])よいですか\s*$')
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
def autorun_summary(summary):
|
|
65
|
-
"""聞くための1行説明を、読むための1行説明(履歴)に直す。
|
|
66
|
-
|
|
67
|
-
例) 【お客さまの確認:本番へ反映】…を実行してよいですか
|
|
68
|
-
→ 【自走で通しました:本番へ反映】…を実行しています
|
|
69
|
-
★形が想定と違っても必ず印は付ける(読んだ人が「判断を求められていない」と分かることが第一)。
|
|
70
|
-
"""
|
|
71
|
-
text = (summary or '').strip()
|
|
72
|
-
head = _ASK_HEAD_RE.match(text)
|
|
73
|
-
if head:
|
|
74
|
-
text = f'【自走で通しました:{head.group(1)}】' + _ASK_HEAD_RE.sub('', text)
|
|
75
|
-
else:
|
|
76
|
-
text = AUTORUN_SUMMARY_PREFIX + text
|
|
77
|
-
return _ASK_TAIL_RE.sub(r'\1います', text)
|
|
78
|
-
|
|
79
52
|
|
|
80
53
|
def build_choices(gate=None):
|
|
81
54
|
"""管制のカードに出す選択肢。端末の確認と同じ数にする(確定仕様)。
|
|
@@ -189,22 +162,6 @@ def autorun_enabled(env=None, datalink=hook_datalink):
|
|
|
189
162
|
return False
|
|
190
163
|
|
|
191
164
|
|
|
192
|
-
def build_autorun_payload(data, gate=None):
|
|
193
|
-
"""管制へ【履歴として】送る値。★種別は 'auto'(人に聞くカードではない・管制 PR#276)。
|
|
194
|
-
|
|
195
|
-
★主役は summary(人がそのまま読む1行)。管制はこれを「自走で通した」の一覧に出す。
|
|
196
|
-
例) 【自走で通しました:本番へ反映】部品を公開(配布)しています
|
|
197
|
-
★toolName / toolInput は「実行された中身」として今までどおり添える(あとで追える)。
|
|
198
|
-
★聞くための持ち物(待ち時間・選択肢)は付けない。誰も待っていないし、押させないため。
|
|
199
|
-
"""
|
|
200
|
-
payload = build_payload(data, gate)
|
|
201
|
-
payload['kind'] = AUTORUN_KIND
|
|
202
|
-
payload['summary'] = autorun_summary(payload.get('summary'))
|
|
203
|
-
payload.pop('waitMs', None)
|
|
204
|
-
payload.pop('choices', None)
|
|
205
|
-
return payload
|
|
206
|
-
|
|
207
|
-
|
|
208
165
|
def decide_autorun(data, verdict, gate, cfg=None, datalink=hook_datalink, codex=False):
|
|
209
166
|
"""自走モードの判定。★人には一切聞かず、その場で allow を返す。
|
|
210
167
|
|
|
@@ -215,18 +172,14 @@ def decide_autorun(data, verdict, gate, cfg=None, datalink=hook_datalink, codex=
|
|
|
215
172
|
if verdict.get('decision') == policy.DENY:
|
|
216
173
|
return _deny(f'安全のため実行しません({verdict.get("reason", "危険な操作")})。')
|
|
217
174
|
|
|
218
|
-
#
|
|
219
|
-
|
|
175
|
+
# ★管制へ履歴は送らない(えふさん決定 2026-09-07・この記録は不要)。
|
|
176
|
+
# 2026-09-06 の自走化(便 7abe05e2)で「聞かずに通した」を管制へ1件ずつ送っていたが、
|
|
177
|
+
# 要らないと決まったので【送る仕組みごと消した】。設定で切り替える形にはしない
|
|
178
|
+
# (切り替えを残すと「いま送っているのか」を毎回確かめないと分からなくなる)。
|
|
179
|
+
# ★この機の記録には今までどおり1行だけ残す(何を通したかを後から数えられる状態は保つ)。
|
|
220
180
|
try:
|
|
221
|
-
|
|
222
|
-
if cfg is not None:
|
|
223
|
-
sent = bool(datalink.record_autorun(cfg, build_autorun_payload(data, gate)))
|
|
224
|
-
why = '' if sent else 'send-failed'
|
|
181
|
+
datalink.record_autorun_local(data.get('tool_name'), gate)
|
|
225
182
|
except Exception: # noqa: BLE001 記録のために職人を止めない
|
|
226
|
-
sent, why = False, 'error'
|
|
227
|
-
try:
|
|
228
|
-
datalink.record_autorun_local(data.get('tool_name'), gate, sent, why)
|
|
229
|
-
except Exception: # noqa: BLE001
|
|
230
183
|
pass
|
|
231
184
|
|
|
232
185
|
decision = {'behavior': 'allow'}
|
|
@@ -61,8 +61,9 @@ def behavior(out):
|
|
|
61
61
|
class FakeDatalink:
|
|
62
62
|
"""管制の代わり。呼ばれた道筋を全部覚える。"""
|
|
63
63
|
|
|
64
|
-
def __init__(self, cfg=CFG,
|
|
65
|
-
self.cfg, self.
|
|
64
|
+
def __init__(self, cfg=CFG, autorun=True):
|
|
65
|
+
self.cfg, self.autorun = cfg, autorun
|
|
66
|
+
# sent は「管制へ送ってしまった件数」。★0 のままであることを固定するために残す。
|
|
66
67
|
self.sent, self.local, self.asked, self.marks = [], [], 0, 0
|
|
67
68
|
|
|
68
69
|
# ── 自走で使うもの ──
|
|
@@ -72,12 +73,8 @@ class FakeDatalink:
|
|
|
72
73
|
def load_config(self):
|
|
73
74
|
return self.cfg
|
|
74
75
|
|
|
75
|
-
def
|
|
76
|
-
self.
|
|
77
|
-
return self.send_ok
|
|
78
|
-
|
|
79
|
-
def record_autorun_local(self, tool, gate=None, sent=True, reason=''):
|
|
80
|
-
self.local.append((tool, gate, sent, reason))
|
|
76
|
+
def record_autorun_local(self, tool, gate=None):
|
|
77
|
+
self.local.append((tool, gate))
|
|
81
78
|
|
|
82
79
|
# ── 従来(聞く)経路で使うもの。★自走では1回も呼ばれてはいけない ──
|
|
83
80
|
def register(self, cfg, payload):
|
|
@@ -119,119 +116,43 @@ for label, cmd in [
|
|
|
119
116
|
check(f'{label}: 人には聞かない(管制へ質問0回)', fake.asked, 0)
|
|
120
117
|
check(f'{label}: 端末の確認画面に落とさない(Noneでない)', out is not None, True)
|
|
121
118
|
|
|
122
|
-
print('\n── (b)
|
|
119
|
+
print('\n── (b) ★管制へは何も送らない(えふさん決定 2026-09-07・この記録は不要) ' + '─' * 4)
|
|
120
|
+
# 2026-09-06 の自走化では「聞かずに通した」を管制へ1件ずつ送っていたが、要らないと決まった。
|
|
121
|
+
# ★設定での切り替えにせず【送る仕組みごと消した】ので、ここでは「送る道が無いこと」を固定する。
|
|
123
122
|
fake = FakeDatalink()
|
|
124
123
|
prh.decide(event(), datalink=fake)
|
|
125
|
-
check('
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
check_true('ツール名が入っている', sent.get('toolName') == 'Bash')
|
|
130
|
-
check_true('実行される中身が入っている', 'vercel' in str(sent.get('toolInput')))
|
|
131
|
-
check_true('1行説明が「聞く文」ではなく「履歴の文」になっている',
|
|
132
|
-
sent['summary'].startswith('【自走で通しました'))
|
|
133
|
-
check_true('1行説明に「お客さまの確認」が残っていない(人に判断を求めない)',
|
|
134
|
-
'お客さまの確認' not in sent['summary'])
|
|
135
|
-
check_true('1行説明が問いかけで終わらない', not sent['summary'].endswith('よいですか'))
|
|
136
|
-
check('見出しの種別は残す(何を通したかが読める)',
|
|
137
|
-
prh.autorun_summary('【お客さまの確認:本番へ反映】部品を公開(配布)してよいですか'),
|
|
138
|
-
'【自走で通しました:本番へ反映】部品を公開(配布)しています')
|
|
139
|
-
check('見出しが無い文にも印を付ける',
|
|
140
|
-
prh.autorun_summary('ファイルを削除してよいですか'), '【自走で通しました】ファイルを削除しています')
|
|
141
|
-
check('想定外の形でも印だけは必ず付く', prh.autorun_summary('謎の文'), '【自走で通しました】謎の文')
|
|
142
|
-
check('★種別は auto(人に聞くカードではない・管制 PR#276)', sent.get('kind'), 'auto')
|
|
143
|
-
check('待つための持ち物は付けない(waitMs)', 'waitMs' in sent, False)
|
|
144
|
-
check('押させる選択肢は付けない(choices)', 'choices' in sent, False)
|
|
145
|
-
|
|
146
|
-
print('\n── (b2) ★送り方は1手だけ(登録して終わり・即閉じは消した) ' + '─' * 12)
|
|
147
|
-
# 管制が kind='auto' を status='recorded' で受けるので、カードにならない=閉じる必要が無い。
|
|
148
|
-
# 以前の「登録→すぐ閉じる」は、その一瞬だけカードが出得た逃げ方だった。丸ごと消してある。
|
|
149
|
-
calls = []
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
def fake_request(cfg, method, path, body=None, timeout=None):
|
|
153
|
-
calls.append((method, path.split('?')[0], timeout, dict(body or {})))
|
|
154
|
-
return {'id': 'req-9'} if method == 'POST' else {'ok': True}
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
def with_fake_request(handler, run):
|
|
158
|
-
"""_request を差し替えて run() を回す(元へ必ず戻す)。"""
|
|
159
|
-
orig = dl._request
|
|
160
|
-
dl._request = handler
|
|
161
|
-
try:
|
|
162
|
-
return run()
|
|
163
|
-
finally:
|
|
164
|
-
dl._request = orig
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
calls.clear()
|
|
168
|
-
check('送れた', with_fake_request(fake_request,
|
|
169
|
-
lambda: dl.record_autorun(CFG, {'site': 'demo', 'kind': 'auto', 'summary': 'x'})), True)
|
|
170
|
-
check('★1手だけ(登録して終わり)', len(calls), 1)
|
|
171
|
-
check('その1手は登録', calls[0][0], 'POST')
|
|
172
|
-
check('★閉じる手はもう無い', [c[0] for c in calls].count('DELETE'), 0)
|
|
173
|
-
check_true('職人を待たせない短い上限', all(c[2] == dl.AUTORUN_SEND_TIMEOUT_SEC for c in calls))
|
|
174
|
-
|
|
175
|
-
print('\n── (b3) ★古い管制(auto を知らない版)には昔の形で送り直す ' + '─' * 10)
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
def refuse_auto(cfg, method, path, body=None, timeout=None):
|
|
179
|
-
"""kind='auto' を 400 で弾く古い管制のふり。message なら受ける。"""
|
|
180
|
-
calls.append((method, path.split('?')[0], timeout, dict(body or {})))
|
|
181
|
-
if method == 'POST' and (body or {}).get('kind') == 'auto':
|
|
182
|
-
dl._last_failure = dl.FAIL_CLIENT # 400=こちらの送信内容の不備
|
|
183
|
-
return None
|
|
184
|
-
dl._last_failure = None
|
|
185
|
-
return {'id': 'old-1'} if method == 'POST' else {'ok': True}
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
calls.clear()
|
|
189
|
-
check('古い管制でも記録できた', with_fake_request(refuse_auto,
|
|
190
|
-
lambda: dl.record_autorun(CFG, {'site': 'demo', 'kind': 'auto', 'summary': '【自走で通しました】x'})),
|
|
191
|
-
True)
|
|
192
|
-
kinds = [c[3].get('kind') for c in calls if c[0] == 'POST']
|
|
193
|
-
check('1手目は新しい形(auto)', kinds[0], 'auto')
|
|
194
|
-
check('★弾かれたら昔の形(message)で送り直す', kinds[1], 'message')
|
|
195
|
-
check('昔の形には question を入れる(無いと古い管制も400)',
|
|
196
|
-
calls[1][3].get('question'), '【自走で通しました】x')
|
|
197
|
-
check('★昔の形のときだけ閉じる(カードにしない)', [c[0] for c in calls].count('DELETE'), 1)
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
def unreachable(cfg, method, path, body=None, timeout=None):
|
|
201
|
-
"""管制へ届かない(不通・401・5xx)。★送り直さない=同じものを2回投げない。"""
|
|
202
|
-
calls.append((method, path.split('?')[0], timeout, dict(body or {})))
|
|
203
|
-
dl._last_failure = dl.FAIL_UNREACHABLE
|
|
204
|
-
return None
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
calls.clear()
|
|
208
|
-
check('届かないときは False', with_fake_request(unreachable,
|
|
209
|
-
lambda: dl.record_autorun(CFG, {'site': 'demo', 'kind': 'auto', 'summary': 'x'})), False)
|
|
210
|
-
check('★届かないときは送り直さない(1回だけ)', len(calls), 1)
|
|
211
|
-
|
|
212
|
-
print('\n── (c) 管制へ送れなくても職人は止まらない ' + '─' * 28)
|
|
213
|
-
for label, fake in [
|
|
214
|
-
('送信に失敗', FakeDatalink(send_ok=False)),
|
|
215
|
-
('管制が未設定', FakeDatalink(cfg=None)),
|
|
216
|
-
]:
|
|
217
|
-
out = prh.decide(event(), datalink=fake)
|
|
218
|
-
check(f'{label}: それでも allow', behavior(out), 'allow')
|
|
219
|
-
check(f'{label}: この機の記録に1行残る', len(fake.local), 1)
|
|
220
|
-
check(f'{label}: 送れなかったと分かる', fake.local[0][2], False)
|
|
124
|
+
check('★管制へ1件も送らない', len(fake.sent), 0)
|
|
125
|
+
check('★聞きにも行かない(従来の3ボタンの道も通らない)', fake.asked, 0)
|
|
126
|
+
check('この機の記録には1行残る(黙って通したことにならない)', len(fake.local), 1)
|
|
127
|
+
check('記録はツール名と関門種別だけ', fake.local[0][:2], ('Bash', 'release'))
|
|
221
128
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
check('
|
|
225
|
-
|
|
129
|
+
# ★送るための部品が【1つも残っていない】こと。残っていると、いつか誰かが呼び戻せてしまう。
|
|
130
|
+
for gone in ('record_autorun', '_record_autorun_legacy', 'AUTORUN_SEND_TIMEOUT_SEC'):
|
|
131
|
+
check(f'連絡線に {gone} が残っていない', hasattr(dl, gone), False)
|
|
132
|
+
for gone in ('build_autorun_payload', 'autorun_summary', 'AUTORUN_KIND', 'AUTORUN_SUMMARY_PREFIX'):
|
|
133
|
+
check(f'番人に {gone} が残っていない', hasattr(prh, gone), False)
|
|
134
|
+
|
|
135
|
+
# ★自走の判定そのものに、管制へ送る呼び出しが1つも書かれていないこと(実物の行を見る)。
|
|
136
|
+
import inspect # noqa: E402
|
|
137
|
+
_src = inspect.getsource(prh.decide_autorun)
|
|
138
|
+
for word in ('record_autorun(', 'register(', 'hook-requests'):
|
|
139
|
+
check(f'自走の判定に {word} が無い', word in _src, False)
|
|
140
|
+
|
|
141
|
+
print('\n── (c) 記録できなくても職人は止まらない ' + '─' * 28)
|
|
226
142
|
|
|
227
143
|
|
|
228
144
|
class BoomDatalink(FakeDatalink):
|
|
229
|
-
def
|
|
230
|
-
raise RuntimeError('
|
|
145
|
+
def record_autorun_local(self, tool, gate=None):
|
|
146
|
+
raise RuntimeError('記録が爆発した')
|
|
231
147
|
|
|
232
148
|
|
|
233
149
|
out = prh.decide(event(), datalink=BoomDatalink())
|
|
234
|
-
check('記録が例外でも allow
|
|
150
|
+
check('記録が例外でも allow は返る(職人は止まらない)', behavior(out), 'allow')
|
|
151
|
+
|
|
152
|
+
fake = FakeDatalink(cfg=None)
|
|
153
|
+
out = prh.decide(event(), datalink=fake)
|
|
154
|
+
check('管制が未設定でも allow', behavior(out), 'allow')
|
|
155
|
+
check('管制が未設定でも管制を叩かない', len(fake.sent) + fake.asked, 0)
|
|
235
156
|
|
|
236
157
|
print('\n── (d) ★deny は1ミリも変わらない(自走でも通さない) ' + '─' * 18)
|
|
237
158
|
for label, ev in [
|
|
@@ -259,9 +180,8 @@ check('Codex: updatedInput を添えない',
|
|
|
259
180
|
'updatedInput' in out_codex['hookSpecificOutput']['decision'], False)
|
|
260
181
|
fake_cx = FakeDatalink()
|
|
261
182
|
prh.decide(event(), datalink=fake_cx, codex=True)
|
|
262
|
-
check('Codex
|
|
263
|
-
check('★Codex
|
|
264
|
-
check_true('★Codex でも人が読む1行が入る', fake_cx.sent[0]['summary'].startswith('【自走で通しました'))
|
|
183
|
+
check('★Codex でも管制へは何も送らない', len(fake_cx.sent), 0)
|
|
184
|
+
check('★Codex でもこの機の記録には1行残る', len(fake_cx.local), 1)
|
|
265
185
|
check('Codex でも deny は変わらない',
|
|
266
186
|
behavior(prh.decide(event('cat .env'), datalink=FakeDatalink(), codex=True)), 'deny')
|
|
267
187
|
out_claude = prh.decide(event(), datalink=FakeDatalink())
|