@tyhld/conductor 0.12.72 → 0.12.74
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/cli.js +11 -0
- package/dist/headless.js +253 -26
- package/dist/relay.js +17 -2
- package/dist/view.js +455 -0
- package/package.json +2 -1
- package/sales-template/install.sh +19 -7
- package/sales-template/setup.sh +18 -0
- package/scripts/kc.sh +85 -0
- package/skills/conductor-craftsman/SKILL.md +13 -2
package/dist/cli.js
CHANGED
|
@@ -16,6 +16,7 @@ import os from 'node:os';
|
|
|
16
16
|
import path from 'node:path';
|
|
17
17
|
import { RELAY_FETCH_TIMEOUT_MS, createRelayState, deliveryReport, parseAgentKind, reconnectDelayMs, relayTick, tmuxHasSession, pendingReportIds, sendPendingReportFiles, sweepProcessedReports, } from './relay.js';
|
|
18
18
|
import { createHeadlessState, headlessNextDelayMs, headlessTick, parseRunnerMode, } from './headless.js';
|
|
19
|
+
import { runViewCommand } from './view.js';
|
|
19
20
|
import { createRelayRunner } from './relay-runner.js';
|
|
20
21
|
import { installProcessGuards } from './process-guard.js';
|
|
21
22
|
import { registerNudgeTarget, unregisterNudgeTarget, watchNudges, } from './nudge.js';
|
|
@@ -468,6 +469,12 @@ function printUsage() {
|
|
|
468
469
|
' conductor ear この機に1本の「耳」(合図の受け取り役)',
|
|
469
470
|
' conductor build-runner この機に1本の「司令塔」(試作の段取り役)',
|
|
470
471
|
' conductor setup <現場名> [フォルダ] [--profile <default|proto>] 采配くんをこのPCに設置する(初回の1回だけ)',
|
|
472
|
+
' conductor view <現場名> [--detail] その現場の今を見るだけの画面(kc の実体)',
|
|
473
|
+
'',
|
|
474
|
+
'見るだけの画面(conductor view = kc 現場名):',
|
|
475
|
+
' いま職人が何をしているかを、やさしい日本語で見るだけの画面です。',
|
|
476
|
+
' ★職人(Claude Code)・tmux・relay を起動しません。入力も受け付けません(抜けるだけ)。',
|
|
477
|
+
' --detail を付けると、職人の生の記録(秘密は隠したもの)も続けて出します。',
|
|
471
478
|
'',
|
|
472
479
|
'設置の型(--profile。既定は default=今までどおり):',
|
|
473
480
|
' default ふつうの現場。従来と同じ設定を置きます。',
|
|
@@ -545,6 +552,10 @@ async function main() {
|
|
|
545
552
|
// ★引数はそのまま setup.sh へ渡す(judgement は全部インストーラ側)。
|
|
546
553
|
runSetupCommand(process.argv.slice(3));
|
|
547
554
|
break;
|
|
555
|
+
case 'view':
|
|
556
|
+
// ★見るだけの画面。職人も tmux も relay も起動せず、入力も受け付けない。
|
|
557
|
+
process.exit(runViewCommand(process.argv.slice(3)));
|
|
558
|
+
break;
|
|
548
559
|
case 'version':
|
|
549
560
|
case '-v':
|
|
550
561
|
case '--version':
|
package/dist/headless.js
CHANGED
|
@@ -36,6 +36,31 @@ export const HEADLESS_EVENT_FLUSH_MS = 2_000;
|
|
|
36
36
|
export const HEADLESS_STOP_CHECK_MS = 15_000;
|
|
37
37
|
/** イベントの text フィールドの上限(文字)。 */
|
|
38
38
|
export const EVENT_TEXT_MAX = 500;
|
|
39
|
+
/** 記録1行から取り出すイベントの上限(件)。1行に content が大量に入っていても溢れさせない。 */
|
|
40
|
+
export const EVENT_MAX_PER_LINE = 8;
|
|
41
|
+
/** 1つの便で管制へ送るイベントの上限(件)。これを超えた分は捨てる(生の記録の全量は外へ出さない)。 */
|
|
42
|
+
export const EVENT_MAX_PER_JOB = 2_000;
|
|
43
|
+
/**
|
|
44
|
+
* 工程の区切りの1行を拾う印(職人の鉄則が出す形)。
|
|
45
|
+
* 例: `進捗: 既存の画面構成を確認しました` → 「既存の画面構成を確認しました」
|
|
46
|
+
* ★全角コロンも受ける(日本語入力のままでも通る形にする)。
|
|
47
|
+
*/
|
|
48
|
+
export const PROGRESS_MARKER_RE = /^\s*進捗\s*[::]\s*(.+)$/;
|
|
49
|
+
/**
|
|
50
|
+
* 職人の発言から「工程の区切りの1行」を取り出す。印が無ければ null。
|
|
51
|
+
* ★複数行の発言の中に印の行があれば、最初の1本だけを拾う。
|
|
52
|
+
*/
|
|
53
|
+
export function extractProgressLine(text) {
|
|
54
|
+
for (const line of text.split('\n')) {
|
|
55
|
+
const m = PROGRESS_MARKER_RE.exec(line);
|
|
56
|
+
if (m) {
|
|
57
|
+
const body = m[1].trim();
|
|
58
|
+
if (body.length > 0)
|
|
59
|
+
return body;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
39
64
|
/** resume 時に職人へ渡す続行指示。 */
|
|
40
65
|
const RESUME_PROMPT = '前回の作業の続きを行ってください。中断された箇所から再開してください。';
|
|
41
66
|
/** 実行ログの保存先ルート。 */
|
|
@@ -70,6 +95,25 @@ export function resolveGitOrigin(workDir) {
|
|
|
70
95
|
return null;
|
|
71
96
|
}
|
|
72
97
|
}
|
|
98
|
+
/**
|
|
99
|
+
* その番号のプロセスが今も動いているかを OS に聞く。
|
|
100
|
+
*
|
|
101
|
+
* ★signal 0 は「送らずに存在と権限だけ確かめる」。動いていれば例外なし、
|
|
102
|
+
* 居なければ ESRCH で例外。番号が無い(起動に失敗した)ときは動いていない扱い。
|
|
103
|
+
* ★「作業中」と出してよいかの判定にこれを使う(えふさん確定 2026-10-02)。
|
|
104
|
+
*/
|
|
105
|
+
export function isProcessAlive(pid) {
|
|
106
|
+
if (typeof pid !== 'number' || !Number.isInteger(pid) || pid <= 0)
|
|
107
|
+
return false;
|
|
108
|
+
try {
|
|
109
|
+
process.kill(pid, 0);
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
catch (err) {
|
|
113
|
+
// EPERM = 居るが自分のものではない。ここでは「居る」として扱う。
|
|
114
|
+
return err?.code === 'EPERM';
|
|
115
|
+
}
|
|
116
|
+
}
|
|
73
117
|
/** 実行中のジョブをファイルに書く(中継が死んでも拾い直せるように)。 */
|
|
74
118
|
export function saveActiveJob(site, record) {
|
|
75
119
|
try {
|
|
@@ -89,6 +133,38 @@ export function clearActiveJob(site) {
|
|
|
89
133
|
}
|
|
90
134
|
catch { /* ignore */ }
|
|
91
135
|
}
|
|
136
|
+
/** 現場ごとの「直前に終わった仕事」の記録ファイルのパスを返す。 */
|
|
137
|
+
export function lastJobPath(site) {
|
|
138
|
+
return path.join(ACTIVE_JOB_DIR, `last-job-${site}.json`);
|
|
139
|
+
}
|
|
140
|
+
/** 直前に終わった仕事を書く。失敗しても作業は止めない。 */
|
|
141
|
+
export function saveLastJob(site, record) {
|
|
142
|
+
try {
|
|
143
|
+
mkdirSync(ACTIVE_JOB_DIR, { recursive: true });
|
|
144
|
+
writeFileSync(lastJobPath(site), JSON.stringify(record) + '\n');
|
|
145
|
+
}
|
|
146
|
+
catch (err) {
|
|
147
|
+
log(`WARN last-job の保存に失敗: ${err instanceof Error ? err.message : String(err)}`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
/** 直前に終わった仕事を読む。無ければ null。 */
|
|
151
|
+
export function loadLastJob(site) {
|
|
152
|
+
try {
|
|
153
|
+
const p = lastJobPath(site);
|
|
154
|
+
if (!existsSync(p))
|
|
155
|
+
return null;
|
|
156
|
+
const raw = readFileSync(p, 'utf8').trim();
|
|
157
|
+
if (!raw)
|
|
158
|
+
return null;
|
|
159
|
+
const rec = JSON.parse(raw);
|
|
160
|
+
if (!rec.commandId)
|
|
161
|
+
return null;
|
|
162
|
+
return rec;
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
92
168
|
/** 起動時に前回の実行中ジョブを読む。無ければ null。 */
|
|
93
169
|
export function loadActiveJob(site) {
|
|
94
170
|
try {
|
|
@@ -288,30 +364,124 @@ export function sessionIdForCommand(commandId, attempt = 1) {
|
|
|
288
364
|
const hex = attempt.toString(16).padStart(4, '0');
|
|
289
365
|
return commandId.slice(0, -4) + hex;
|
|
290
366
|
}
|
|
291
|
-
/**
|
|
292
|
-
|
|
367
|
+
/**
|
|
368
|
+
* 操作1件を人が読める1行にする。
|
|
369
|
+
*
|
|
370
|
+
* ★出どころは職人が書く説明文(description)を第一とする(便 fbaba4b1 のえふさん確定)。
|
|
371
|
+
* description が無い道具(Read / Grep / Skill など)は、道具の名前と、
|
|
372
|
+
* 秘密を含まないと決めた項目(対象のファイル・探す語)だけを添える。
|
|
373
|
+
* ★生のコマンド文字列(input.command)は添えない。全量を PC の外へ出さないため。
|
|
374
|
+
*/
|
|
375
|
+
export function toolUseLabel(name, input) {
|
|
376
|
+
const desc = typeof input?.description === 'string' ? input.description.trim() : '';
|
|
377
|
+
if (desc.length > 0)
|
|
378
|
+
return desc;
|
|
379
|
+
for (const key of ['file_path', 'path', 'notebook_path', 'pattern', 'skill', 'subagent_type']) {
|
|
380
|
+
const v = input?.[key];
|
|
381
|
+
if (typeof v === 'string' && v.trim().length > 0)
|
|
382
|
+
return `${name}: ${v.trim()}`;
|
|
383
|
+
}
|
|
384
|
+
return name;
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* 記録の中の「文字か、文字の配列か、text ブロックの配列」を1本の文字列にする。
|
|
388
|
+
* tool_result の content と、result 行の errors(★実物は素の文字列の配列)の両方に使う。
|
|
389
|
+
*/
|
|
390
|
+
function flattenText(content) {
|
|
391
|
+
if (typeof content === 'string')
|
|
392
|
+
return content;
|
|
393
|
+
if (Array.isArray(content)) {
|
|
394
|
+
return content
|
|
395
|
+
.map((b) => {
|
|
396
|
+
if (typeof b === 'string')
|
|
397
|
+
return b;
|
|
398
|
+
if (typeof b === 'object' && b !== null)
|
|
399
|
+
return String(b.text ?? '');
|
|
400
|
+
return '';
|
|
401
|
+
})
|
|
402
|
+
.filter((s) => s.length > 0)
|
|
403
|
+
.join('\n');
|
|
404
|
+
}
|
|
405
|
+
return '';
|
|
406
|
+
}
|
|
407
|
+
/** 秘密を隠して上限で切った CommandEvent を作る。中身が空なら null。 */
|
|
408
|
+
function makeEvent(type, raw, at, attempt) {
|
|
409
|
+
const text = maskSecrets(raw.trim()).slice(0, EVENT_TEXT_MAX);
|
|
410
|
+
if (text.length === 0)
|
|
411
|
+
return null;
|
|
412
|
+
return { type, text, at, attempt };
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* stream-json の1行から CommandEvent を取り出す。無関係な行は空配列。
|
|
416
|
+
*
|
|
417
|
+
* 【実物の形(2026-10-02 この現場の便 fbaba4b1 の attempt-1.jsonl で実測)】
|
|
418
|
+
* ★`type:'assistant'` に subtype は無く、中身は `message.content` の配列。
|
|
419
|
+
* 配列の要素は `{type:'text',text}` / `{type:'thinking'}` / `{type:'tool_use',name,input}`。
|
|
420
|
+
* ★`type:'user'` も `message.content` の配列で、`{type:'tool_result',is_error,content}` が入る。
|
|
421
|
+
* ★`type:'result'` は `subtype:'success'`(result 項目あり)か
|
|
422
|
+
* `subtype:'error_during_execution'`(★result 項目が無く errors がある)。
|
|
423
|
+
*
|
|
424
|
+
* 【なぜ直したか】以前はここが `type:'assistant' && subtype:'text'` を見ていた。
|
|
425
|
+
* 実物にその形は1件も無いため、1便のあいだ送れていたのは最後の result 1件だけで、
|
|
426
|
+
* 管制から「いま何をしているか」が消えていた(=画面なし方式にした時の退行)。
|
|
427
|
+
*
|
|
428
|
+
* 【1行から複数件出る】1つの assistant 行に発言と操作が同時に入るので、配列を返す。
|
|
429
|
+
*/
|
|
430
|
+
export function streamEventsFromLine(parsed, attempt) {
|
|
293
431
|
const now = new Date().toISOString();
|
|
294
432
|
const type = parsed.type;
|
|
295
433
|
const subtype = parsed.subtype;
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
if (
|
|
434
|
+
const out = [];
|
|
435
|
+
const push = (e) => {
|
|
436
|
+
if (e !== null && out.length < EVENT_MAX_PER_LINE)
|
|
437
|
+
out.push(e);
|
|
438
|
+
};
|
|
439
|
+
const content = (() => {
|
|
440
|
+
const msg = parsed.message;
|
|
441
|
+
if (typeof msg !== 'object' || msg === null)
|
|
299
442
|
return null;
|
|
300
|
-
|
|
443
|
+
const c = msg.content;
|
|
444
|
+
return Array.isArray(c) ? c : null;
|
|
445
|
+
})();
|
|
446
|
+
if (type === 'assistant' && content !== null) {
|
|
447
|
+
for (const block of content) {
|
|
448
|
+
if (block?.type === 'text') {
|
|
449
|
+
const text = String(block.text ?? '');
|
|
450
|
+
// ★工程の区切りの印が在ればその1行だけを送る(人向けの表示はこれを使う)。
|
|
451
|
+
const marked = extractProgressLine(text);
|
|
452
|
+
push(makeEvent('progress', marked ?? text, now, attempt));
|
|
453
|
+
}
|
|
454
|
+
else if (block?.type === 'tool_use') {
|
|
455
|
+
const input = typeof block.input === 'object' && block.input !== null
|
|
456
|
+
? block.input
|
|
457
|
+
: undefined;
|
|
458
|
+
push(makeEvent('tool', toolUseLabel(String(block.name ?? 'unknown'), input), now, attempt));
|
|
459
|
+
}
|
|
460
|
+
// thinking は送らない(人向けの表示に使わないうえ、量が多い)。
|
|
461
|
+
}
|
|
462
|
+
return out;
|
|
301
463
|
}
|
|
302
|
-
if (type === '
|
|
303
|
-
const
|
|
304
|
-
|
|
464
|
+
if (type === 'user' && content !== null) {
|
|
465
|
+
for (const block of content) {
|
|
466
|
+
// ★失敗した操作だけを送る。成功した操作の生出力は量が多く、外へ出す必要もない。
|
|
467
|
+
if (block?.type === 'tool_result' && block.is_error === true) {
|
|
468
|
+
push(makeEvent('error', flattenText(block.content), now, attempt));
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
return out;
|
|
305
472
|
}
|
|
306
473
|
if (type === 'result') {
|
|
307
|
-
if (subtype === '
|
|
308
|
-
|
|
309
|
-
|
|
474
|
+
if (parsed.is_error === true || subtype === 'error_during_execution') {
|
|
475
|
+
// ★error_during_execution には result 項目が無い。errors を使う。
|
|
476
|
+
const text = String(parsed.result ?? '') || flattenText(parsed.errors) || `失敗しました (${subtype ?? 'result'})`;
|
|
477
|
+
push(makeEvent('error', text, now, attempt));
|
|
310
478
|
}
|
|
311
|
-
|
|
312
|
-
|
|
479
|
+
else {
|
|
480
|
+
push(makeEvent('result', String(parsed.result ?? ''), now, attempt));
|
|
481
|
+
}
|
|
482
|
+
return out;
|
|
313
483
|
}
|
|
314
|
-
return
|
|
484
|
+
return out;
|
|
315
485
|
}
|
|
316
486
|
/**
|
|
317
487
|
* stream-json の1行から認証エラーを検出する。
|
|
@@ -372,14 +542,22 @@ export function isAuthError(job) {
|
|
|
372
542
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
373
543
|
// 実行ログ
|
|
374
544
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
375
|
-
/**
|
|
545
|
+
/** 人向けの進捗の記録ファイルのパスを返す(`kc 現場名` の通常表示が読む)。 */
|
|
546
|
+
export function eventsLogPath(commandId, attempt) {
|
|
547
|
+
return path.join(RUNS_DIR, commandId, `attempt-${attempt}.events.jsonl`);
|
|
548
|
+
}
|
|
549
|
+
/** 実行ログのディレクトリを作り、stdout / stderr / 進捗 の WriteStream を返す。 */
|
|
376
550
|
function openRunLogs(commandId, attempt) {
|
|
377
551
|
try {
|
|
378
552
|
const dir = path.join(RUNS_DIR, commandId);
|
|
379
553
|
mkdirSync(dir, { recursive: true });
|
|
380
554
|
const stdoutLog = createWriteStream(path.join(dir, `attempt-${attempt}.jsonl`), { flags: 'a' });
|
|
381
555
|
const stderrLog = createWriteStream(path.join(dir, `attempt-${attempt}.stderr.log`), { flags: 'a' });
|
|
382
|
-
|
|
556
|
+
// ★生の記録には時刻が入っていない(実測 2026-10-02: assistant 行の項目は
|
|
557
|
+
// message/parent_tool_use_id/session_id/type/uuid だけ)。時刻つきの流れを出すには
|
|
558
|
+
// 拾った側で時刻を刻んで残すしかない。ここには秘密を隠して上限で切った物だけを書く。
|
|
559
|
+
const eventsLog = createWriteStream(eventsLogPath(commandId, attempt), { flags: 'a' });
|
|
560
|
+
return { stdoutLog, stderrLog, eventsLog };
|
|
383
561
|
}
|
|
384
562
|
catch (err) {
|
|
385
563
|
log(`WARN 実行ログのディレクトリを作れません: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -428,6 +606,15 @@ export function spawnClaudeProcess(cfg, cmd, sessionId, attempt, isResume) {
|
|
|
428
606
|
env.CONDUCTOR_SITE = cfg.site;
|
|
429
607
|
env.CONDUCTOR_MACHINE = cfg.machine;
|
|
430
608
|
env.CONDUCTOR_WORK_DIR = cfg.workDir;
|
|
609
|
+
// ★claude が見つかった bin ディレクトリを PATH の先頭に足す(便 24c9b6b6)。
|
|
610
|
+
// systemd 経由の起動では nvm の bin が PATH に無いため、子プロセスの bash から
|
|
611
|
+
// claude・node・npm・pnpm が command not found になる。resolveClaudeBin() が
|
|
612
|
+
// 信頼したディレクトリだけを足す。重複させない・PATH が空でも動く。
|
|
613
|
+
const claudeBinDir = path.dirname(claudeBin);
|
|
614
|
+
const currentPath = env.PATH ?? '';
|
|
615
|
+
if (!currentPath.split(':').includes(claudeBinDir)) {
|
|
616
|
+
env.PATH = currentPath ? `${claudeBinDir}:${currentPath}` : claudeBinDir;
|
|
617
|
+
}
|
|
431
618
|
// ★テスト用: CONDUCTOR_CLAUDE_CONFIG_DIR を設定すると、claude の設定フォルダを差し替える。
|
|
432
619
|
// 本物のログインを壊さずに認証エラーを再現できる(空のフォルダを指す → not logged in)。
|
|
433
620
|
// 本番では設定しない(claude は既定の ~/.claude を使う)。
|
|
@@ -474,12 +661,15 @@ export function spawnClaudeProcess(cfg, cmd, sessionId, attempt, isResume) {
|
|
|
474
661
|
stopped: false,
|
|
475
662
|
timedOut: false,
|
|
476
663
|
eventBuffer: [],
|
|
664
|
+
eventsSent: 0,
|
|
665
|
+
lastActivityAt: Date.now(),
|
|
477
666
|
lastFlushAt: Date.now(),
|
|
478
667
|
lastStopCheckAt: Date.now(),
|
|
479
668
|
stderrBuf: '',
|
|
480
669
|
authErrorDetected: false,
|
|
481
670
|
stdoutLog: logs?.stdoutLog ?? null,
|
|
482
671
|
stderrLog: logs?.stderrLog ?? null,
|
|
672
|
+
eventsLog: logs?.eventsLog ?? null,
|
|
483
673
|
durationMs: 0,
|
|
484
674
|
};
|
|
485
675
|
// spawn の 'error' イベント(ENOENT 等は非同期で届く。同期例外と両方捕まえる)
|
|
@@ -510,15 +700,23 @@ export function spawnClaudeProcess(cfg, cmd, sessionId, attempt, isResume) {
|
|
|
510
700
|
const rl = createInterface({ input: proc.stdout });
|
|
511
701
|
rl.on('line', (line) => {
|
|
512
702
|
job.stdoutLog?.write(line + '\n');
|
|
703
|
+
// ★記録が1行出たこと自体が「手が動いている」印。管制の「作業中」判定に使う。
|
|
704
|
+
job.lastActivityAt = Date.now();
|
|
513
705
|
try {
|
|
514
706
|
const parsed = JSON.parse(line);
|
|
515
707
|
// ★stream-json から認証エラーを検出(stderr に出ない実物に対応)
|
|
516
708
|
if (!job.authErrorDetected && detectStreamAuthError(parsed)) {
|
|
517
709
|
job.authErrorDetected = true;
|
|
518
710
|
}
|
|
519
|
-
const event
|
|
520
|
-
|
|
711
|
+
for (const event of streamEventsFromLine(parsed, attempt)) {
|
|
712
|
+
// ★1便あたりの件数に上限を持つ(生の記録の全量は PC の外へ送らない)。
|
|
713
|
+
if (job.eventsSent >= EVENT_MAX_PER_JOB)
|
|
714
|
+
break;
|
|
715
|
+
job.eventsSent += 1;
|
|
521
716
|
job.eventBuffer.push(event);
|
|
717
|
+
// 時刻つきで手元にも残す(`kc 現場名` の通常表示がこれを読む)。
|
|
718
|
+
job.eventsLog?.write(JSON.stringify(event) + '\n');
|
|
719
|
+
}
|
|
522
720
|
}
|
|
523
721
|
catch {
|
|
524
722
|
// JSON でないか壊れている行 — 無視
|
|
@@ -544,6 +742,7 @@ export function spawnClaudeProcess(cfg, cmd, sessionId, attempt, isResume) {
|
|
|
544
742
|
job.durationMs = Date.now() - job.startedAt;
|
|
545
743
|
closeLog(job.stdoutLog);
|
|
546
744
|
closeLog(job.stderrLog);
|
|
745
|
+
closeLog(job.eventsLog);
|
|
547
746
|
});
|
|
548
747
|
return job;
|
|
549
748
|
}
|
|
@@ -627,7 +826,20 @@ async function checkRunningJob(cfg, state) {
|
|
|
627
826
|
// ── 完了したプロセスの後始末 ──
|
|
628
827
|
if (job.completed) {
|
|
629
828
|
await flushEvents(cfg, job);
|
|
829
|
+
// ★消す前に本文を取る(`kc 現場名` が「直前の仕事」を出すために残す)。
|
|
830
|
+
const savedBody = loadActiveJob(cfg.site)?.body ?? '';
|
|
630
831
|
clearActiveJob(cfg.site);
|
|
832
|
+
const noteLastJob = (outcome, detail) => {
|
|
833
|
+
saveLastJob(cfg.site, {
|
|
834
|
+
commandId: job.commandId,
|
|
835
|
+
body: savedBody,
|
|
836
|
+
outcome,
|
|
837
|
+
startedAt: job.startedAt,
|
|
838
|
+
finishedAt: Date.now(),
|
|
839
|
+
attempt: job.attempt,
|
|
840
|
+
...(detail !== undefined ? { detail } : {}),
|
|
841
|
+
});
|
|
842
|
+
};
|
|
631
843
|
if (job.timedOut) {
|
|
632
844
|
// 上限時間で強制停止した → 管制へ理由つきで failed を記録する
|
|
633
845
|
const elapsed = Math.round((Date.now() - job.startedAt) / 1000);
|
|
@@ -640,6 +852,7 @@ async function checkRunningJob(cfg, state) {
|
|
|
640
852
|
}]);
|
|
641
853
|
await patchStatus(cfg, job.commandId, 'failed', reason.slice(0, EVENT_TEXT_MAX));
|
|
642
854
|
rs.abandoned.add(job.commandId);
|
|
855
|
+
noteLastJob('timeout', reason);
|
|
643
856
|
state.job = null;
|
|
644
857
|
rs.lastHeartbeatAt = null;
|
|
645
858
|
log(`WARN site=${cfg.site} id=${job.commandId} ${reason}`);
|
|
@@ -653,6 +866,7 @@ async function checkRunningJob(cfg, state) {
|
|
|
653
866
|
at: new Date().toISOString(),
|
|
654
867
|
attempt: job.attempt,
|
|
655
868
|
}]);
|
|
869
|
+
noteLastJob('stopped', '管制から止めました');
|
|
656
870
|
state.job = null;
|
|
657
871
|
rs.lastHeartbeatAt = null;
|
|
658
872
|
log(`site=${cfg.site} id=${job.commandId} stop_requested で停止`);
|
|
@@ -673,6 +887,7 @@ async function checkRunningJob(cfg, state) {
|
|
|
673
887
|
// ★中央へ failed を返す(次の便が止まらないように)
|
|
674
888
|
await patchStatus(cfg, job.commandId, 'failed', authMsg);
|
|
675
889
|
rs.abandoned.add(job.commandId);
|
|
890
|
+
noteLastJob('auth_error', authMsg);
|
|
676
891
|
state.job = null;
|
|
677
892
|
rs.lastHeartbeatAt = null;
|
|
678
893
|
log(`WARN site=${cfg.site} id=${job.commandId} 認証エラー → 再試行しない`);
|
|
@@ -689,6 +904,7 @@ async function checkRunningJob(cfg, state) {
|
|
|
689
904
|
await patchStatus(cfg, job.commandId, 'done');
|
|
690
905
|
log(`site=${cfg.site} id=${job.commandId} 完了(exit=0, 報告なし)`);
|
|
691
906
|
}
|
|
907
|
+
noteLastJob('done', ok ? '報告まで出しました' : '報告は出せませんでした');
|
|
692
908
|
state.job = null;
|
|
693
909
|
rs.lastHeartbeatAt = null;
|
|
694
910
|
return;
|
|
@@ -714,6 +930,7 @@ async function checkRunningJob(cfg, state) {
|
|
|
714
930
|
attempt: job.attempt,
|
|
715
931
|
}]);
|
|
716
932
|
rs.abandoned.add(job.commandId);
|
|
933
|
+
noteLastJob('failed', failReason);
|
|
717
934
|
}
|
|
718
935
|
else {
|
|
719
936
|
// ★ pendingRetry に詰めて、次の tick で実際に spawn する(fetchCommand は sent を返さない)
|
|
@@ -747,11 +964,20 @@ async function checkRunningJob(cfg, state) {
|
|
|
747
964
|
await stopJob(job);
|
|
748
965
|
return;
|
|
749
966
|
}
|
|
750
|
-
// 生存ハートビート
|
|
967
|
+
// ── 生存ハートビート ──
|
|
968
|
+
// ★「実プロセスが動いている間だけ」送る(えふさん確定 2026-10-02)。
|
|
969
|
+
// job.completed は exit イベントで立つが、exit が届く前に OS 上のプロセスが
|
|
970
|
+
// 消えていることがある。だから OS に直接聞いて、動いていなければ送らない。
|
|
971
|
+
// 送らなければ中央の lastSeenAt が止まり、管制は「作業中」と出せなくなる。
|
|
972
|
+
const alive = isProcessAlive(job.process.pid);
|
|
751
973
|
const throttleOk = rs.lastHeartbeatAt === null || now - rs.lastHeartbeatAt >= RELAY_HEARTBEAT_INTERVAL_MS;
|
|
752
|
-
if (throttleOk) {
|
|
974
|
+
if (alive && throttleOk) {
|
|
753
975
|
rs.lastHeartbeatAt = now;
|
|
754
|
-
await sendHeartbeat(cfg, job.commandId, true
|
|
976
|
+
await sendHeartbeat(cfg, job.commandId, true, {
|
|
977
|
+
startedAt: new Date(job.startedAt).toISOString(),
|
|
978
|
+
lastActivityAt: new Date(job.lastActivityAt).toISOString(),
|
|
979
|
+
...(job.process.pid !== undefined ? { pid: job.process.pid } : {}),
|
|
980
|
+
});
|
|
755
981
|
}
|
|
756
982
|
// イベントバッファのフラッシュ
|
|
757
983
|
if (now - job.lastFlushAt >= HEADLESS_EVENT_FLUSH_MS && job.eventBuffer.length > 0) {
|
|
@@ -832,6 +1058,7 @@ export async function restoreHeadlessOrphans(cfg, state) {
|
|
|
832
1058
|
return;
|
|
833
1059
|
}
|
|
834
1060
|
log(`site=${cfg.site} id=${saved.commandId} 手元の記録から孤児を検出(中継再起動後の拾い直し)`);
|
|
1061
|
+
const postedAt = saved.postedAt;
|
|
835
1062
|
const pick = { id: saved.commandId, body: saved.body, site: cfg.site, sentAt: saved.startedAt };
|
|
836
1063
|
const attempt = saved.attempt + 1;
|
|
837
1064
|
state.attempts.set(pick.id, attempt);
|
|
@@ -852,7 +1079,7 @@ export async function restoreHeadlessOrphans(cfg, state) {
|
|
|
852
1079
|
return;
|
|
853
1080
|
}
|
|
854
1081
|
state.job = job;
|
|
855
|
-
saveActiveJob(cfg.site, { commandId: pick.id, body: pick.body, sessionId, attempt, startedAt: Date.now(), site: cfg.site, machine: cfg.machine, workDir: cfg.workDir, gitOrigin: resolveGitOrigin(cfg.workDir) ?? undefined });
|
|
1082
|
+
saveActiveJob(cfg.site, { commandId: pick.id, body: pick.body, sessionId, attempt, startedAt: Date.now(), site: cfg.site, machine: cfg.machine, workDir: cfg.workDir, gitOrigin: resolveGitOrigin(cfg.workDir) ?? undefined, pid: job.process.pid, postedAt });
|
|
856
1083
|
markDelivered(state.relay, pick.id);
|
|
857
1084
|
state.relay.lastHeartbeatAt = null;
|
|
858
1085
|
noteReadiness(state.relay, 'delivered', pick.id);
|
|
@@ -916,7 +1143,7 @@ export async function restoreHeadlessOrphans(cfg, state) {
|
|
|
916
1143
|
return;
|
|
917
1144
|
}
|
|
918
1145
|
state.job = job;
|
|
919
|
-
saveActiveJob(cfg.site, { commandId: pick.id, body: pick.body, sessionId, attempt, startedAt: Date.now(), site: cfg.site, machine: cfg.machine, workDir: cfg.workDir, gitOrigin: resolveGitOrigin(cfg.workDir) ?? undefined });
|
|
1146
|
+
saveActiveJob(cfg.site, { commandId: pick.id, body: pick.body, sessionId, attempt, startedAt: Date.now(), site: cfg.site, machine: cfg.machine, workDir: cfg.workDir, gitOrigin: resolveGitOrigin(cfg.workDir) ?? undefined, pid: job.process.pid, postedAt: pick.sentAt });
|
|
920
1147
|
markDelivered(state.relay, pick.id);
|
|
921
1148
|
state.relay.lastHeartbeatAt = null;
|
|
922
1149
|
noteReadiness(state.relay, 'delivered', pick.id);
|
|
@@ -1010,7 +1237,7 @@ export async function headlessTick(cfg, state, hooks) {
|
|
|
1010
1237
|
return;
|
|
1011
1238
|
}
|
|
1012
1239
|
state.job = job;
|
|
1013
|
-
saveActiveJob(cfg.site, { commandId: retry.cmd.id, body: retry.cmd.body, sessionId, attempt: retry.attempt, startedAt: Date.now(), site: cfg.site, machine: cfg.machine, workDir: cfg.workDir, gitOrigin: resolveGitOrigin(cfg.workDir) ?? undefined });
|
|
1240
|
+
saveActiveJob(cfg.site, { commandId: retry.cmd.id, body: retry.cmd.body, sessionId, attempt: retry.attempt, startedAt: Date.now(), site: cfg.site, machine: cfg.machine, workDir: cfg.workDir, gitOrigin: resolveGitOrigin(cfg.workDir) ?? undefined, pid: job.process.pid });
|
|
1014
1241
|
rs.lastHeartbeatAt = null;
|
|
1015
1242
|
noteReadiness(rs, 'delivered', retry.cmd.id);
|
|
1016
1243
|
await sendCommandEvents(cfg, retry.cmd.id, [{
|
|
@@ -1067,7 +1294,7 @@ export async function headlessTick(cfg, state, hooks) {
|
|
|
1067
1294
|
return;
|
|
1068
1295
|
}
|
|
1069
1296
|
state.job = job;
|
|
1070
|
-
saveActiveJob(cfg.site, { commandId: cmd.id, body: cmd.body, sessionId, attempt, startedAt: Date.now(), site: cfg.site, machine: cfg.machine, workDir: cfg.workDir, gitOrigin: resolveGitOrigin(cfg.workDir) ?? undefined });
|
|
1297
|
+
saveActiveJob(cfg.site, { commandId: cmd.id, body: cmd.body, sessionId, attempt, startedAt: Date.now(), site: cfg.site, machine: cfg.machine, workDir: cfg.workDir, gitOrigin: resolveGitOrigin(cfg.workDir) ?? undefined, pid: job.process.pid, postedAt: cmd.sentAt });
|
|
1071
1298
|
markDelivered(rs, cmd.id);
|
|
1072
1299
|
rs.lastHeartbeatAt = null;
|
|
1073
1300
|
noteReadiness(rs, 'delivered', cmd.id);
|
package/dist/relay.js
CHANGED
|
@@ -204,6 +204,17 @@ export const BUSY_GLYPHS = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '
|
|
|
204
204
|
export function kindOf(cfg) {
|
|
205
205
|
return cfg.agentKind ?? 'claude';
|
|
206
206
|
}
|
|
207
|
+
/** 中央が返した日時らしい値を ms に直す。読めないときは undefined。 */
|
|
208
|
+
export function parseSentAt(raw) {
|
|
209
|
+
if (typeof raw === 'number' && Number.isFinite(raw) && raw > 0)
|
|
210
|
+
return raw;
|
|
211
|
+
if (typeof raw === 'string' && raw.trim().length > 0) {
|
|
212
|
+
const t = Date.parse(raw);
|
|
213
|
+
if (Number.isFinite(t) && t > 0)
|
|
214
|
+
return t;
|
|
215
|
+
}
|
|
216
|
+
return undefined;
|
|
217
|
+
}
|
|
207
218
|
/**
|
|
208
219
|
* 確定判断の件数と字数の上限(★中央 ai-kana の DISPATCH_CONTEXT_LIMITS と同じ値にすること)。
|
|
209
220
|
* 中央が守っているが、配り役でも同じ上限で守る(受け取った物が多すぎても本文を太らせない)。
|
|
@@ -2375,6 +2386,10 @@ export async function fetchCommand(cfg) {
|
|
|
2375
2386
|
return null;
|
|
2376
2387
|
}
|
|
2377
2388
|
const cmd = { id: String(first.id), body: first.body };
|
|
2389
|
+
// ★投函の時刻は中央が返したときだけ受け取る(無ければ付けない=画面で断定しない)。
|
|
2390
|
+
const sentAt = parseSentAt(first.sentAt) ?? parseSentAt(first.createdAt);
|
|
2391
|
+
if (sentAt !== undefined)
|
|
2392
|
+
cmd.sentAt = sentAt;
|
|
2378
2393
|
// ★親の便・確定判断は、中央が返した物だけを形を確かめて受け取る(違う形は捨てる)。
|
|
2379
2394
|
if (typeof first.parentCommandId === 'string' && first.parentCommandId.trim()) {
|
|
2380
2395
|
cmd.parentCommandId = first.parentCommandId.trim();
|
|
@@ -2669,7 +2684,7 @@ export function decideHeartbeat(alive, throttleOk) {
|
|
|
2669
2684
|
* (=/seen の perm 系)。画面から質問を読む処理ごと撤去したため、送るのは body 無しの生存通知だけ。
|
|
2670
2685
|
* 確認そのものは PermissionRequest フックが別の口(hook-requests)でデータとして届ける。
|
|
2671
2686
|
*/
|
|
2672
|
-
export async function sendHeartbeat(cfg, id, busy = false) {
|
|
2687
|
+
export async function sendHeartbeat(cfg, id, busy = false, liveness) {
|
|
2673
2688
|
try {
|
|
2674
2689
|
// ★busy が【真偽値の true】のときだけ body を載せる。それ以外は今までと1バイトも変えない
|
|
2675
2690
|
// (body 無しの PATCH)。中央も "true" や 1 のような真偽値でない値は無視する仕様。
|
|
@@ -2677,7 +2692,7 @@ export async function sendHeartbeat(cfg, id, busy = false) {
|
|
|
2677
2692
|
? {
|
|
2678
2693
|
method: 'PATCH',
|
|
2679
2694
|
headers: { ...authHeaders(cfg), 'Content-Type': 'application/json' },
|
|
2680
|
-
body: JSON.stringify({ busy: true }),
|
|
2695
|
+
body: JSON.stringify({ busy: true, ...(liveness ?? {}) }),
|
|
2681
2696
|
}
|
|
2682
2697
|
: { method: 'PATCH', headers: authHeaders(cfg) };
|
|
2683
2698
|
const res = await fetch(`${cfg.url}/api/conductor/commands/${encodeURIComponent(id)}/seen`, {
|
package/dist/view.js
ADDED
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @tyhld/conductor — `kc 現場名` の「見るだけの画面」(便 fbaba4b1)。
|
|
3
|
+
*
|
|
4
|
+
* 【なぜ要るか】画面なし方式(headless)にしたことで、職人が「いま何をしているか」が
|
|
5
|
+
* お客さまから見えなくなった。以前の `kc` は tmux と Claude Code を新しく起動するので、
|
|
6
|
+
* 画面なし方式の現場で打つと【仕事を受け取っていない空の職人】が開き、その裏では
|
|
7
|
+
* 別の職人が同じフォルダで作業している(実測 2026-10-02 メインPC)。
|
|
8
|
+
* =「見えない」だけでなく「触ると危ない」状態だった。
|
|
9
|
+
*
|
|
10
|
+
* 【この画面の約束(えふさん確定 2026-10-02)】
|
|
11
|
+
* ① 通常=人間向けの日本語/詳細=Claude Code の生ログ の2段構成。
|
|
12
|
+
* 以前の画面をそのまま流すのではなく、非エンジニアが読んで分かる形にする。
|
|
13
|
+
* ② 「🟠 作業中」と出してよいのは、実プロセスが動いている + 最近の作業記録がある
|
|
14
|
+
* 場合だけ。それ以外は 6 つの状態に明確に分ける(=「便を出した=作業開始」にしない)。
|
|
15
|
+
* ③ 投函・開始・実作業は別々に持つ。
|
|
16
|
+
*
|
|
17
|
+
* 【何をしないか】Claude Code・tmux・中継を起動しない。管制へ通信しない。
|
|
18
|
+
* 入力も受け付けない(抜ける操作だけ)。★この画面は手元の記録を読むだけ。
|
|
19
|
+
*/
|
|
20
|
+
import { execFileSync } from 'node:child_process';
|
|
21
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
import os from 'node:os';
|
|
24
|
+
import { RUNS_DIR, eventsLogPath, isProcessAlive, loadActiveJob, loadLastJob, maskSecrets, } from './headless.js';
|
|
25
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
26
|
+
// 定数
|
|
27
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
28
|
+
/**
|
|
29
|
+
* 「最近の作業記録がある」と見なす上限(ms)。これを超えて記録が出ていなければ
|
|
30
|
+
* 実プロセスが動いていても「作業中」とは出さない(えふさん確定②)。
|
|
31
|
+
* ★長い1操作(試験の実行など)で記録が止まることがあるので、短くし過ぎない。
|
|
32
|
+
*/
|
|
33
|
+
export const STALE_ACTIVITY_MS = 5 * 60_000;
|
|
34
|
+
/** 通常表示に出す「流れ」の行数の上限。 */
|
|
35
|
+
export const FLOW_LINES_MAX = 20;
|
|
36
|
+
/** 詳細表示に出す生ログの行数の上限。 */
|
|
37
|
+
export const DETAIL_LINES_MAX = 200;
|
|
38
|
+
/** 詳細表示の1行の長さの上限(文字)。生ログの1行は数千文字になるので切る。 */
|
|
39
|
+
export const DETAIL_LINE_CHARS_MAX = 600;
|
|
40
|
+
/** 画面に出す見出し(★文言はえふさん確定のまま)。 */
|
|
41
|
+
export const STATUS_LABEL = {
|
|
42
|
+
working: '🟠 作業中',
|
|
43
|
+
queued: '⏳ 順番待ち',
|
|
44
|
+
pc_down: '💤 PC停止中',
|
|
45
|
+
unconfirmed: '🟡 未確認',
|
|
46
|
+
awaiting_user: '🔵 操作・確認待ち',
|
|
47
|
+
done: '✅ 完了',
|
|
48
|
+
};
|
|
49
|
+
/** その状態が何を意味するかの一言(非エンジニアが読む)。 */
|
|
50
|
+
export const STATUS_NOTE = {
|
|
51
|
+
working: '職人が動いています',
|
|
52
|
+
queued: 'まだ職人を起動していません',
|
|
53
|
+
pc_down: 'このPCの見守り役が動いていません',
|
|
54
|
+
unconfirmed: '職人は止まった/終了しました(確認前です)',
|
|
55
|
+
awaiting_user: 'えふさんの手番です',
|
|
56
|
+
done: '終わりました',
|
|
57
|
+
};
|
|
58
|
+
/**
|
|
59
|
+
* 6つの状態のどれかを決める純粋関数(★「作業中」の条件を厳密にする所)。
|
|
60
|
+
*
|
|
61
|
+
* ★順番に意味がある。
|
|
62
|
+
* 1. 見守り役が止まっていれば、何も管制へ届かない=💤。
|
|
63
|
+
* 2. えふさんの手番は、動いているかどうかより先に出す(人を待たせないため)。
|
|
64
|
+
* 3. 実行中の仕事があるときは、実プロセスと最近の記録の【両方】がそろって初めて 🟠。
|
|
65
|
+
* そろわなければ 🟡(勝手に「作業中」と出さない)。
|
|
66
|
+
* 4. 実行中の仕事が無ければ、直前の結果が在れば ✅、無ければ ⏳。
|
|
67
|
+
*/
|
|
68
|
+
export function decideSiteStatus(input) {
|
|
69
|
+
if (input.relayRunning === false)
|
|
70
|
+
return 'pc_down';
|
|
71
|
+
if (input.awaitingUser)
|
|
72
|
+
return 'awaiting_user';
|
|
73
|
+
if (input.hasActiveJob) {
|
|
74
|
+
if (!input.processAlive)
|
|
75
|
+
return 'unconfirmed';
|
|
76
|
+
if (input.sinceActivityMs === null)
|
|
77
|
+
return 'unconfirmed';
|
|
78
|
+
if (input.sinceActivityMs > STALE_ACTIVITY_MS)
|
|
79
|
+
return 'unconfirmed';
|
|
80
|
+
return 'working';
|
|
81
|
+
}
|
|
82
|
+
if (input.hasLastJob)
|
|
83
|
+
return 'done';
|
|
84
|
+
return 'queued';
|
|
85
|
+
}
|
|
86
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
87
|
+
// 書式
|
|
88
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
89
|
+
/** 時刻を「09:08」の形で出す。 */
|
|
90
|
+
export function hhmm(ms) {
|
|
91
|
+
const d = new Date(ms);
|
|
92
|
+
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* 日時を「今日09:08」「昨日20:47」「9/28 11:02」の形で出す(非エンジニア向け)。
|
|
96
|
+
* ★基準の時刻を渡せるようにして、試験が時計に依存しないようにする。
|
|
97
|
+
*/
|
|
98
|
+
export function friendlyTime(ms, nowMs = Date.now()) {
|
|
99
|
+
const d = new Date(ms);
|
|
100
|
+
const startOfDay = (t) => {
|
|
101
|
+
const x = new Date(t);
|
|
102
|
+
x.setHours(0, 0, 0, 0);
|
|
103
|
+
return x.getTime();
|
|
104
|
+
};
|
|
105
|
+
const days = Math.round((startOfDay(nowMs) - startOfDay(ms)) / 86_400_000);
|
|
106
|
+
if (days === 0)
|
|
107
|
+
return `今日${hhmm(ms)}`;
|
|
108
|
+
if (days === 1)
|
|
109
|
+
return `昨日${hhmm(ms)}`;
|
|
110
|
+
return `${d.getMonth() + 1}/${d.getDate()} ${hhmm(ms)}`;
|
|
111
|
+
}
|
|
112
|
+
/** 経過時間を「26分」「2時間14分」の形で出す。 */
|
|
113
|
+
export function friendlyDuration(durationMs) {
|
|
114
|
+
const total = Math.max(0, Math.floor(durationMs / 60_000));
|
|
115
|
+
if (total < 60)
|
|
116
|
+
return `${total}分`;
|
|
117
|
+
const h = Math.floor(total / 60);
|
|
118
|
+
const m = total % 60;
|
|
119
|
+
return m === 0 ? `${h}時間` : `${h}時間${m}分`;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* 便の本文から「仕事」の1行を作る。
|
|
123
|
+
*
|
|
124
|
+
* ★中央は題名を返していない(実測 2026-10-02: /commands が返すのは id と body)。
|
|
125
|
+
* だから本文の冒頭を使う。囲みの印([CC1返事案/…] など)と投函の前置きは落とす。
|
|
126
|
+
* ★題名が取れないときは空文字を返す(呼び出し側が「—」と出す=断定しない)。
|
|
127
|
+
*/
|
|
128
|
+
export function jobTitleFromBody(body, max = 60) {
|
|
129
|
+
let head = (body ?? '').trim();
|
|
130
|
+
if (head.length === 0)
|
|
131
|
+
return '';
|
|
132
|
+
head = head.replace(/^\[[^\]]*\]\s*/, ''); // [CC1返事案/white] の囲みを落とす
|
|
133
|
+
head = head.replace(/^投函:.*?\/\s*現場:\s*\S+\s*/, ''); // 投函・現場の前置きを落とす
|
|
134
|
+
head = head.split('\n').map((l) => l.trim()).find((l) => l.length > 0) ?? '';
|
|
135
|
+
if (head.length === 0)
|
|
136
|
+
return '';
|
|
137
|
+
return [...head].length > max ? `${[...head].slice(0, max).join('')}…` : head;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* 進捗の記録を読み、時刻つきの流れの行にする。
|
|
141
|
+
*
|
|
142
|
+
* ★出どころは「職人が書く操作の説明文」と「工程の区切りの1行」だけ(えふさん確定)。
|
|
143
|
+
* 追加の AI 呼び出しによる要約はしない。
|
|
144
|
+
* ★同じ文が続いたときは1行にまとめる(同じ説明文の道具を連続で使うと読みにくいため)。
|
|
145
|
+
*/
|
|
146
|
+
export function flowLinesFromEvents(events, max = FLOW_LINES_MAX) {
|
|
147
|
+
const lines = [];
|
|
148
|
+
for (const e of events) {
|
|
149
|
+
if (e.type !== 'progress' && e.type !== 'tool' && e.type !== 'error' && e.type !== 'result')
|
|
150
|
+
continue;
|
|
151
|
+
const text = (e.text ?? '').trim();
|
|
152
|
+
if (text.length === 0)
|
|
153
|
+
continue;
|
|
154
|
+
const at = Date.parse(e.at);
|
|
155
|
+
if (!Number.isFinite(at))
|
|
156
|
+
continue;
|
|
157
|
+
const prefix = e.type === 'error' ? '⚠ ' : '';
|
|
158
|
+
const line = `${prefix}${text}`;
|
|
159
|
+
if (lines.length > 0 && lines[lines.length - 1].text === line)
|
|
160
|
+
continue;
|
|
161
|
+
lines.push({ at, text: line });
|
|
162
|
+
}
|
|
163
|
+
return lines.slice(-max);
|
|
164
|
+
}
|
|
165
|
+
/** 進捗の記録のファイルを読む(無ければ空)。 */
|
|
166
|
+
export function readFlowEvents(commandId, attempt) {
|
|
167
|
+
const p = eventsLogPath(commandId, attempt);
|
|
168
|
+
if (!existsSync(p))
|
|
169
|
+
return [];
|
|
170
|
+
const out = [];
|
|
171
|
+
for (const line of readFileSync(p, 'utf8').split('\n')) {
|
|
172
|
+
if (line.trim().length === 0)
|
|
173
|
+
continue;
|
|
174
|
+
try {
|
|
175
|
+
const e = JSON.parse(line);
|
|
176
|
+
if (typeof e.text === 'string' && typeof e.at === 'string')
|
|
177
|
+
out.push(e);
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
// 壊れている行は飛ばす
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return out;
|
|
184
|
+
}
|
|
185
|
+
/** 生の記録ファイルのパス(詳細表示が読む)。 */
|
|
186
|
+
export function rawLogPath(commandId, attempt) {
|
|
187
|
+
return path.join(RUNS_DIR, commandId, `attempt-${attempt}.jsonl`);
|
|
188
|
+
}
|
|
189
|
+
/** 最後に記録が出た時刻(ms)。記録が無ければ null。 */
|
|
190
|
+
export function lastRecordAt(commandId, attempt) {
|
|
191
|
+
for (const p of [eventsLogPath(commandId, attempt), rawLogPath(commandId, attempt)]) {
|
|
192
|
+
try {
|
|
193
|
+
if (existsSync(p))
|
|
194
|
+
return statSync(p).mtimeMs;
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
// 読めないときは次を見る
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* えふさんの手番(確認待ち)かを生の記録から見る。
|
|
204
|
+
*
|
|
205
|
+
* ★フックの行は `system/hook_started` と `system/hook_response` が `hook_id` で対になる
|
|
206
|
+
* (実測 2026-10-02)。答えの行が来ていない PermissionRequest / Notification が
|
|
207
|
+
* 残っていれば、職人はその答えを待って止まっている=えふさんの手番。
|
|
208
|
+
*/
|
|
209
|
+
export function awaitingUserFromRawLog(rawLines) {
|
|
210
|
+
const open = new Set();
|
|
211
|
+
for (const line of rawLines) {
|
|
212
|
+
if (line.trim().length === 0)
|
|
213
|
+
continue;
|
|
214
|
+
let row;
|
|
215
|
+
try {
|
|
216
|
+
row = JSON.parse(line);
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
if (row.type !== 'system')
|
|
222
|
+
continue;
|
|
223
|
+
const id = typeof row.hook_id === 'string' ? row.hook_id : null;
|
|
224
|
+
if (id === null)
|
|
225
|
+
continue;
|
|
226
|
+
const ev = String(row.hook_event ?? '');
|
|
227
|
+
if (row.subtype === 'hook_started' && (ev === 'PermissionRequest' || ev === 'Notification')) {
|
|
228
|
+
open.add(id);
|
|
229
|
+
}
|
|
230
|
+
else if (row.subtype === 'hook_response') {
|
|
231
|
+
open.delete(id);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return open.size > 0;
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* 生の記録から PR の番号を拾う。見つからなければ null。
|
|
238
|
+
* ★「確かめられたときだけ出す」ための関数。見つからないことを「PR はありません」とは読まない。
|
|
239
|
+
*/
|
|
240
|
+
export function prNumberFromRawLog(rawLines) {
|
|
241
|
+
let found = null;
|
|
242
|
+
for (const line of rawLines) {
|
|
243
|
+
const m = /https:\/\/github\.com\/[^/\s"]+\/[^/\s"]+\/pull\/(\d+)/.exec(line);
|
|
244
|
+
if (m)
|
|
245
|
+
found = Number(m[1]);
|
|
246
|
+
}
|
|
247
|
+
return found;
|
|
248
|
+
}
|
|
249
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
250
|
+
// 見守り役(中継)が動いているか
|
|
251
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
252
|
+
/**
|
|
253
|
+
* この現場の中継(conductor@<現場名>)が動いているかを OS に聞く。
|
|
254
|
+
* 確かめられないときは null を返す(★「止まっています」と断定しない)。
|
|
255
|
+
*
|
|
256
|
+
* ★起動も再起動もしない。状態を聞くだけ。
|
|
257
|
+
*/
|
|
258
|
+
export function relayRunningForSite(site) {
|
|
259
|
+
const probes = [
|
|
260
|
+
['systemctl', ['--user', 'is-active', `conductor@${site}.service`]],
|
|
261
|
+
['launchctl', ['print', `gui/${process.getuid?.() ?? 0}/com.tyhld.conductor.${site}`]],
|
|
262
|
+
];
|
|
263
|
+
for (const [bin, args] of probes) {
|
|
264
|
+
try {
|
|
265
|
+
const out = execFileSync(bin, args, {
|
|
266
|
+
encoding: 'utf8',
|
|
267
|
+
timeout: 5_000,
|
|
268
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
269
|
+
});
|
|
270
|
+
return /\bactive\b|state = running/.test(out);
|
|
271
|
+
}
|
|
272
|
+
catch (err) {
|
|
273
|
+
const e = err;
|
|
274
|
+
// コマンドが無い(ENOENT)→ 次の方法を試す。
|
|
275
|
+
if (e?.code === 'ENOENT')
|
|
276
|
+
continue;
|
|
277
|
+
// コマンドは在って非0で返った=「動いていない」と答えた。
|
|
278
|
+
if (typeof e?.status === 'number')
|
|
279
|
+
return false;
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return null;
|
|
284
|
+
}
|
|
285
|
+
/** 手元の記録だけを読んで SiteView を作る(★通信しない・何も起動しない)。 */
|
|
286
|
+
export function collectSiteView(site, nowMs = Date.now()) {
|
|
287
|
+
const active = loadActiveJob(site);
|
|
288
|
+
const last = loadLastJob(site);
|
|
289
|
+
const rawLines = (() => {
|
|
290
|
+
if (active === null)
|
|
291
|
+
return [];
|
|
292
|
+
const p = rawLogPath(active.commandId, active.attempt);
|
|
293
|
+
try {
|
|
294
|
+
if (!existsSync(p))
|
|
295
|
+
return [];
|
|
296
|
+
return readFileSync(p, 'utf8').split('\n');
|
|
297
|
+
}
|
|
298
|
+
catch {
|
|
299
|
+
return [];
|
|
300
|
+
}
|
|
301
|
+
})();
|
|
302
|
+
const lastActivityAt = active === null ? null : lastRecordAt(active.commandId, active.attempt);
|
|
303
|
+
const processAlive = active === null ? false : isProcessAlive(active.pid);
|
|
304
|
+
const awaitingUser = awaitingUserFromRawLog(rawLines);
|
|
305
|
+
const relayRunning = relayRunningForSite(site);
|
|
306
|
+
const status = decideSiteStatus({
|
|
307
|
+
relayRunning,
|
|
308
|
+
hasActiveJob: active !== null,
|
|
309
|
+
processAlive,
|
|
310
|
+
sinceActivityMs: lastActivityAt === null ? null : nowMs - lastActivityAt,
|
|
311
|
+
awaitingUser,
|
|
312
|
+
hasLastJob: last !== null,
|
|
313
|
+
});
|
|
314
|
+
const body = active?.body ?? last?.body ?? '';
|
|
315
|
+
return {
|
|
316
|
+
site,
|
|
317
|
+
status,
|
|
318
|
+
active,
|
|
319
|
+
last,
|
|
320
|
+
title: jobTitleFromBody(body),
|
|
321
|
+
postedAt: active?.postedAt ?? null,
|
|
322
|
+
startedAt: active?.startedAt ?? last?.startedAt ?? null,
|
|
323
|
+
lastActivityAt,
|
|
324
|
+
flow: active === null ? [] : flowLinesFromEvents(readFlowEvents(active.commandId, active.attempt)),
|
|
325
|
+
prNumber: prNumberFromRawLog(rawLines),
|
|
326
|
+
relayRunning,
|
|
327
|
+
nowMs,
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
const RULE = '────────────────────';
|
|
331
|
+
/**
|
|
332
|
+
* 通常表示(人間向けの日本語)を組み立てる。
|
|
333
|
+
* ★確かめられないことは「—」と出す。「ありません」と断定しない。
|
|
334
|
+
*/
|
|
335
|
+
export function renderNormalView(v) {
|
|
336
|
+
const out = [];
|
|
337
|
+
const unknown = '—(確かめられません)';
|
|
338
|
+
out.push(`${v.site} ${STATUS_LABEL[v.status]} ${STATUS_NOTE[v.status]}`);
|
|
339
|
+
out.push(`仕事:${v.title.length > 0 ? v.title : unknown}`);
|
|
340
|
+
// ★投函・開始・実作業を別々に出す(えふさん確定③)。
|
|
341
|
+
out.push(`投函:${v.postedAt !== null ? friendlyTime(v.postedAt, v.nowMs) : '—(管制が持っています)'}`);
|
|
342
|
+
out.push(`開始:${v.startedAt !== null ? friendlyTime(v.startedAt, v.nowMs) : '—(まだ起動していません)'}`);
|
|
343
|
+
if (v.startedAt !== null) {
|
|
344
|
+
const until = v.status === 'working' ? v.nowMs : (v.lastActivityAt ?? v.last?.finishedAt ?? v.nowMs);
|
|
345
|
+
out.push(`実作業:${friendlyDuration(until - v.startedAt)}`);
|
|
346
|
+
}
|
|
347
|
+
else {
|
|
348
|
+
out.push('実作業:—');
|
|
349
|
+
}
|
|
350
|
+
out.push(`最後に動いた:${v.lastActivityAt !== null ? friendlyTime(v.lastActivityAt, v.nowMs) : unknown}`);
|
|
351
|
+
const current = v.flow.length > 0 ? v.flow[v.flow.length - 1].text : '';
|
|
352
|
+
out.push(`現在:${current.length > 0 ? current : unknown}`);
|
|
353
|
+
out.push(RULE);
|
|
354
|
+
if (v.flow.length > 0) {
|
|
355
|
+
for (const l of v.flow)
|
|
356
|
+
out.push(`${hhmm(l.at)} ${l.text}`);
|
|
357
|
+
}
|
|
358
|
+
else if (v.active !== null) {
|
|
359
|
+
out.push('(まだ進捗が出ていません)');
|
|
360
|
+
}
|
|
361
|
+
else if (v.last !== null) {
|
|
362
|
+
// 仕事が動いていないときは、直前の仕事の結果を出す。
|
|
363
|
+
out.push(`直前の仕事:${jobTitleFromBody(v.last.body) || unknown}`);
|
|
364
|
+
out.push(`結果:${LAST_OUTCOME_LABEL[v.last.outcome] ?? v.last.outcome}`);
|
|
365
|
+
if (v.last.detail !== undefined && v.last.detail.length > 0) {
|
|
366
|
+
out.push(`くわしく:${maskSecrets(v.last.detail)}`);
|
|
367
|
+
}
|
|
368
|
+
out.push(`終わった時刻:${friendlyTime(v.last.finishedAt, v.nowMs)}`);
|
|
369
|
+
}
|
|
370
|
+
else {
|
|
371
|
+
out.push('(この現場の記録はまだありません)');
|
|
372
|
+
}
|
|
373
|
+
out.push(RULE);
|
|
374
|
+
// ★PR・CI は確かめられたときだけ出す。
|
|
375
|
+
out.push(`PR:${v.prNumber !== null ? `#${v.prNumber}` : '—(この画面では確かめていません)'}`);
|
|
376
|
+
out.push('CI:—(この画面では確かめていません)');
|
|
377
|
+
if (v.relayRunning === null) {
|
|
378
|
+
out.push('※このPCの見守り役の状態は確かめられませんでした');
|
|
379
|
+
}
|
|
380
|
+
out.push('');
|
|
381
|
+
out.push('※見るだけの画面です(職人も作業部屋も見守り役も起動しません/入力は送れません)');
|
|
382
|
+
out.push('※Claude Code の生ログを見たいときは --detail を付けてください');
|
|
383
|
+
return out.join('\n');
|
|
384
|
+
}
|
|
385
|
+
/** 直前の仕事の終わり方の日本語。 */
|
|
386
|
+
export const LAST_OUTCOME_LABEL = {
|
|
387
|
+
done: '✅ 終わりました',
|
|
388
|
+
failed: '⚠ 失敗しました',
|
|
389
|
+
stopped: '⏹ 管制から止めました',
|
|
390
|
+
timeout: '⚠ 上限時間で止めました',
|
|
391
|
+
auth_error: '⚠ ログインが切れていました',
|
|
392
|
+
};
|
|
393
|
+
/**
|
|
394
|
+
* 詳細表示(Claude Code の生ログ)を組み立てる。
|
|
395
|
+
* ★秘密を隠してから出す。行数に上限を持つ。
|
|
396
|
+
*/
|
|
397
|
+
export function renderDetailView(v, lines = []) {
|
|
398
|
+
const out = [];
|
|
399
|
+
out.push(`${v.site} 詳細(Claude Code の生ログ・末尾 ${DETAIL_LINES_MAX} 行まで)`);
|
|
400
|
+
out.push(RULE);
|
|
401
|
+
const tail = lines.filter((l) => l.trim().length > 0).slice(-DETAIL_LINES_MAX);
|
|
402
|
+
if (tail.length === 0) {
|
|
403
|
+
out.push('(生ログはまだありません)');
|
|
404
|
+
}
|
|
405
|
+
else {
|
|
406
|
+
for (const l of tail)
|
|
407
|
+
out.push(maskSecrets(l).slice(0, DETAIL_LINE_CHARS_MAX));
|
|
408
|
+
}
|
|
409
|
+
out.push(RULE);
|
|
410
|
+
out.push('※見るだけの画面です');
|
|
411
|
+
return out.join('\n');
|
|
412
|
+
}
|
|
413
|
+
/** 詳細表示に出す生ログの行を読む。 */
|
|
414
|
+
export function readRawLogLines(v) {
|
|
415
|
+
const rec = v.active ?? (v.last !== null ? { commandId: v.last.commandId, attempt: v.last.attempt } : null);
|
|
416
|
+
if (rec === null)
|
|
417
|
+
return [];
|
|
418
|
+
const p = rawLogPath(rec.commandId, rec.attempt);
|
|
419
|
+
try {
|
|
420
|
+
if (!existsSync(p))
|
|
421
|
+
return [];
|
|
422
|
+
return readFileSync(p, 'utf8').split('\n');
|
|
423
|
+
}
|
|
424
|
+
catch {
|
|
425
|
+
return [];
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
429
|
+
// 入口(cli.ts が呼ぶ)
|
|
430
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
431
|
+
/**
|
|
432
|
+
* `conductor view <現場名> [--detail]` の本体。
|
|
433
|
+
* ★何も起動しない・通信しない・入力を受け付けない。画面に出して終わる。
|
|
434
|
+
*/
|
|
435
|
+
export function runViewCommand(argv) {
|
|
436
|
+
const detail = argv.includes('--detail');
|
|
437
|
+
const site = argv.find((a) => !a.startsWith('-'));
|
|
438
|
+
if (site === undefined || site.length === 0) {
|
|
439
|
+
console.error('使い方: conductor view <現場名> [--detail]');
|
|
440
|
+
return 2;
|
|
441
|
+
}
|
|
442
|
+
// ★現場のフォルダが無い=この PC の現場ではない。何も作らずに知らせる。
|
|
443
|
+
const projectDir = path.join(os.homedir(), 'projects', site);
|
|
444
|
+
if (!existsSync(projectDir)) {
|
|
445
|
+
console.error(`そんな現場はありません: ${site}`);
|
|
446
|
+
return 1;
|
|
447
|
+
}
|
|
448
|
+
const v = collectSiteView(site);
|
|
449
|
+
console.log(renderNormalView(v));
|
|
450
|
+
if (detail) {
|
|
451
|
+
console.log('');
|
|
452
|
+
console.log(renderDetailView(v, readRawLogLines(v)));
|
|
453
|
+
}
|
|
454
|
+
return 0;
|
|
455
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tyhld/conductor",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.74",
|
|
4
4
|
"description": "采配くん管制の見守りアプリ(conductor-agent)。各PCで常駐し、中央(devlog-tracker)へ定期的に生存報告(heartbeat)を送る常駐CLI。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
"scripts/update-guard.sh",
|
|
23
23
|
"scripts/dist-source.sh",
|
|
24
24
|
"scripts/conductor-run.sh",
|
|
25
|
+
"scripts/kc.sh",
|
|
25
26
|
"scripts/codex",
|
|
26
27
|
"!scripts/codex/__pycache__",
|
|
27
28
|
"skills",
|
|
@@ -1041,14 +1041,16 @@ fi
|
|
|
1041
1041
|
# =お客さまに tmux の3行を手で打ってもらう限り、同じ事故がいつでも起きる。
|
|
1042
1042
|
# だから「kc <現場名>」の1語にまとめ、名前を間違えようがない形にする。
|
|
1043
1043
|
#
|
|
1044
|
-
#
|
|
1045
|
-
#
|
|
1046
|
-
#
|
|
1044
|
+
# 【中身は ~/.bashrc に書かない(便 fbaba4b1)】
|
|
1045
|
+
# kc の実体は配布物の scripts/kc.sh で、設置係が ~/.tyhld/kc.sh へ置く。
|
|
1046
|
+
# ★ここで ~/.bashrc に足すのは「それを呼ぶだけ」の1枚。こうしておくと、次から
|
|
1047
|
+
# kc を直しても ~/.bashrc を書き換えに行かなくて済む(お客さまの手を借りない)。
|
|
1048
|
+
# 振る舞い(tmux 方式の3行・画面なし方式では見るだけの画面)は kc.sh が持つ。
|
|
1047
1049
|
#
|
|
1048
1050
|
# 【壊さないための決め】
|
|
1049
1051
|
# ・~/.bashrc は【追記だけ】。既存の行は1つも書き換えない。
|
|
1050
1052
|
# ・既に kc が定義されていれば何もしない(何度打っても二重にならない=冪等)。
|
|
1051
|
-
#
|
|
1053
|
+
# ・実体が無い機体では、今までどおり tmux 方式で開く控えを持たせる。
|
|
1052
1054
|
# ═════════════════════════════════════════════════════════════════════════════
|
|
1053
1055
|
|
|
1054
1056
|
step '⑫ 職人を呼ぶ合言葉(kc)を用意する'
|
|
@@ -1072,9 +1074,16 @@ else
|
|
|
1072
1074
|
|
|
1073
1075
|
# ── 采配くん: 職人を呼ぶ合言葉 ────────────────────────────────────────────────
|
|
1074
1076
|
# 使い方: kc <現場名> 例) kc my-app
|
|
1075
|
-
#
|
|
1076
|
-
#
|
|
1077
|
+
# 画面なし方式の現場 … いま職人が何をしているかを「見るだけの画面」で出します。
|
|
1078
|
+
# tmux 方式の現場 … その現場の作業部屋(tmux)を用意し、職人を開きます。
|
|
1079
|
+
# ★中身は ~/.tyhld/kc.sh(采配くんの配布物)が持っています。ここは呼ぶだけなので、
|
|
1080
|
+
# 采配くんを更新すれば kc も新しくなります。このファイルは書き換えに来ません。
|
|
1077
1081
|
kc() {
|
|
1082
|
+
if [ -r "$HOME/.tyhld/kc.sh" ]; then
|
|
1083
|
+
bash "$HOME/.tyhld/kc.sh" "$@"
|
|
1084
|
+
return $?
|
|
1085
|
+
fi
|
|
1086
|
+
# 実体がまだ無い機体のための控え(従来どおり tmux 方式で開きます)。
|
|
1078
1087
|
local s="$1"
|
|
1079
1088
|
if [ -n "$TMUX" ]; then echo "すでにtmuxの中です。Ctrl+b → d で一度抜けてください"; return 1; fi
|
|
1080
1089
|
if [ -z "$s" ]; then echo "使い方: kc 現場名(例 kc TYHLD-platform)"; return 1; fi
|
|
@@ -1218,9 +1227,12 @@ ${BAR}
|
|
|
1218
1227
|
それでも出ないときは、この画面をそのままサポートへお送りください。
|
|
1219
1228
|
|
|
1220
1229
|
${BAR}
|
|
1221
|
-
|
|
1230
|
+
いま職人が何をしているか見たいとき
|
|
1222
1231
|
${BAR}
|
|
1223
1232
|
|
|
1224
1233
|
source ~/.bashrc && kc ${SITE}
|
|
1225
1234
|
|
|
1235
|
+
★見るだけの画面です。職人を起動したり、口を出したりはしません。
|
|
1236
|
+
生の記録まで見たいときは kc ${SITE} --detail
|
|
1237
|
+
|
|
1226
1238
|
GUIDE
|
package/sales-template/setup.sh
CHANGED
|
@@ -746,6 +746,24 @@ for guard_conf in "${GUARD_CONFIGS[@]}"; do
|
|
|
746
746
|
fi
|
|
747
747
|
done
|
|
748
748
|
|
|
749
|
+
# --- 合言葉 kc の実体を固定パスへ配置(便 fbaba4b1) -------------------------
|
|
750
|
+
# ★これまで kc の中身は ~/.bashrc の末尾に書き込まれていた。その形だと kc を直すたびに
|
|
751
|
+
# 全PCの ~/.bashrc を書き換えに行くことになる(お客さまの手で編集する=事故の種)。
|
|
752
|
+
# 番人と同じ「1台に1箇所」へ置き、~/.bashrc の kc は呼ぶだけにする。
|
|
753
|
+
KC_SRC="$SETUP_DIST_ROOT/scripts/kc.sh"
|
|
754
|
+
KC_DEST="$HOME/.tyhld/kc.sh"
|
|
755
|
+
if [[ -f "$KC_SRC" ]]; then
|
|
756
|
+
mkdir -p "$HOME/.tyhld"
|
|
757
|
+
if ! bash -n "$KC_SRC"; then
|
|
758
|
+
fail "合言葉 kc の実体の構文確認に失敗しました($KC_SRC)。固定パスは変更していません。"
|
|
759
|
+
fi
|
|
760
|
+
cp -f "$KC_SRC" "$KC_DEST"
|
|
761
|
+
chmod +x "$KC_DEST"
|
|
762
|
+
echo "[setup] 合言葉 kc の実体を配置: $KC_DEST(この1台の全現場が共有します)"
|
|
763
|
+
else
|
|
764
|
+
echo "[setup] 合言葉 kc の実体は配布物にありません(古い配布物。~/.bashrc の kc がそのまま使われます)"
|
|
765
|
+
fi
|
|
766
|
+
|
|
749
767
|
# --- 番人フックを【ホームの ~/.claude/settings.json】へ1本だけ登録 ---
|
|
750
768
|
# ★1台1箇所。ここに書けば、この機の全現場(全 tmux セッション)へ同時に効く。
|
|
751
769
|
# 現場ごとの settings.json には番人フックを書かない=二重番人・取り残しを構造的に防ぐ(ADR-013)。
|
package/scripts/kc.sh
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# 采配くん: 合言葉 kc の実体(便 fbaba4b1)
|
|
3
|
+
#
|
|
4
|
+
# 【なぜ配布物の中に置くか】
|
|
5
|
+
# これまで kc の中身は install.sh が ~/.bashrc の末尾へ書き込んでいた。
|
|
6
|
+
# ★その形だと、kc を直すたびに全PCの ~/.bashrc を書き換えに行くことになる
|
|
7
|
+
# (お客さまの手で編集する=事故の種)。だから中身はここ1か所に置き、
|
|
8
|
+
# ~/.bashrc の kc は「このファイルを呼ぶだけ」にする。
|
|
9
|
+
#
|
|
10
|
+
# 【2つの方式で振る舞いが違う】
|
|
11
|
+
# ・画面なし方式(headless)… 職人は便ごとに relay が起動する。
|
|
12
|
+
# ここで新しい Claude Code を開くと「便を持っていない空の職人」が開き、
|
|
13
|
+
# 裏で働いている本物の職人とは別物になる(実機 2026-10-02 に発生)。
|
|
14
|
+
# =★何も起動せず「見るだけの画面」(conductor view)を出す。
|
|
15
|
+
# ・tmux 方式(従来)… これまでどおり作業部屋を用意して職人を開く。
|
|
16
|
+
# ★振る舞いは1文字も変えない。
|
|
17
|
+
#
|
|
18
|
+
# 使い方: kc <現場名> [--detail]
|
|
19
|
+
set -uo pipefail
|
|
20
|
+
|
|
21
|
+
s="${1:-}"
|
|
22
|
+
shift || true
|
|
23
|
+
|
|
24
|
+
if [ -z "$s" ]; then
|
|
25
|
+
echo "使い方: kc 現場名(例 kc TYHLD-platform)"
|
|
26
|
+
exit 1
|
|
27
|
+
fi
|
|
28
|
+
if [ ! -d "$HOME/projects/$s" ]; then
|
|
29
|
+
echo "そんな現場はありません: $s"
|
|
30
|
+
ls "$HOME/projects" 2>/dev/null || true
|
|
31
|
+
exit 1
|
|
32
|
+
fi
|
|
33
|
+
|
|
34
|
+
# ── その現場が画面なし方式(headless)かを見る ────────────────────────────────
|
|
35
|
+
# ★ここで relay を起動しない・設定を書き換えない。在るものを読むだけ。
|
|
36
|
+
is_headless() {
|
|
37
|
+
# ① 人が明示した指定がいちばん強い。
|
|
38
|
+
case "${CONDUCTOR_RUNNER_MODE:-}" in
|
|
39
|
+
headless) return 0 ;;
|
|
40
|
+
tmux) return 1 ;;
|
|
41
|
+
esac
|
|
42
|
+
# ② Linux(systemd)… 常駐に実際に渡っている環境変数を読む。
|
|
43
|
+
if command -v systemctl >/dev/null 2>&1; then
|
|
44
|
+
if systemctl --user show -p Environment "conductor@$s.service" 2>/dev/null \
|
|
45
|
+
| grep -qF 'CONDUCTOR_RUNNER_MODE=headless'; then
|
|
46
|
+
return 0
|
|
47
|
+
fi
|
|
48
|
+
fi
|
|
49
|
+
# ③ 常駐が止まっていて②が読めないときの控え… drop-in のファイルを見る。
|
|
50
|
+
local d="$HOME/.config/systemd/user/conductor@$s.service.d"
|
|
51
|
+
if [ -d "$d" ] && grep -rqlF 'CONDUCTOR_RUNNER_MODE=headless' "$d" 2>/dev/null; then
|
|
52
|
+
return 0
|
|
53
|
+
fi
|
|
54
|
+
# ④ Mac(launchd)… plist に書かれた指定を見る。
|
|
55
|
+
local p="$HOME/Library/LaunchAgents/com.tyhld.conductor.$s.plist"
|
|
56
|
+
if [ -f "$p" ] && grep -qF 'headless' "$p" 2>/dev/null; then
|
|
57
|
+
return 0
|
|
58
|
+
fi
|
|
59
|
+
return 1
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if is_headless; then
|
|
63
|
+
# ★見るだけの画面。職人・tmux・relay を起動せず、入力も受け付けない。
|
|
64
|
+
if command -v conductor >/dev/null 2>&1; then
|
|
65
|
+
exec conductor view "$s" "$@"
|
|
66
|
+
fi
|
|
67
|
+
# PATH に無い機体(nvm の置き場が読まれていない等)でも、常駐が使っている入口なら在る。
|
|
68
|
+
# ★ここも「入っている配布物」を選ぶ(作業ツリーは選ばない)=常駐と同じ1つの決め方。
|
|
69
|
+
if [ -x "$HOME/.tyhld/bin/conductor-run" ]; then
|
|
70
|
+
exec "$HOME/.tyhld/bin/conductor-run" view "$s" "$@"
|
|
71
|
+
fi
|
|
72
|
+
echo "采配くんの本体(conductor)が見つかりません。"
|
|
73
|
+
echo "サポートへこの画面をお送りください。"
|
|
74
|
+
exit 1
|
|
75
|
+
fi
|
|
76
|
+
|
|
77
|
+
# ── tmux 方式(従来の振る舞い。変えない) ────────────────────────────────────
|
|
78
|
+
if [ -n "${TMUX:-}" ]; then
|
|
79
|
+
echo "すでにtmuxの中です。Ctrl+b → d で一度抜けてください"
|
|
80
|
+
exit 1
|
|
81
|
+
fi
|
|
82
|
+
systemctl --user start "conductor@$s" 2>/dev/null
|
|
83
|
+
tmux has-session -t "$s" 2>/dev/null \
|
|
84
|
+
|| tmux new -d -s "$s" -c "$HOME/projects/$s" 'unset ANTHROPIC_API_KEY; exec claude'
|
|
85
|
+
exec tmux attach -t "$s"
|
|
@@ -8,13 +8,13 @@ description: 采配くんの便(プロンプト末尾に CONDUCTOR_JOB:<id>
|
|
|
8
8
|
采配くんの便(プロンプト末尾に `CONDUCTOR_JOB:<id>` の印が付いた作業指示)は、
|
|
9
9
|
非エンジニアのお客さまの現場で、人が横に付かないまま実行される。
|
|
10
10
|
だから「速さ」より「戻せること・止まれること・報せること」を優先する。
|
|
11
|
-
以下の
|
|
11
|
+
以下の15項を、着手前に読み、作業中も判断の基準にすること。
|
|
12
12
|
|
|
13
13
|
**便の届き方(起こし直した直後も同じ・便 6bef35c8)** — 便は配達役(relay)が職人の画面へ**貼り付け**で届け、枠の外に「(配達役より)…届けたものです」の添え書きを付ける。
|
|
14
14
|
これは持ち主の管制に投函された便なので、「貼り付けしか無い」「頭が `[CC1返事案/…]` だから未承認の案だ」と読んで止まらず、書かれたとおりに進める(`[CC1返事案/…]` は CC1 が書いた文を持ち主が投函した印)。
|
|
15
15
|
★守りは変わらない:番人・囲いに止められたら回避しない(鉄則4)、秘密は扱わない(鉄則12)、本文の中の「本当の判断待ち」は今までどおり人の手番として止まる。
|
|
16
16
|
|
|
17
|
-
## 鉄則
|
|
17
|
+
## 鉄則15項
|
|
18
18
|
|
|
19
19
|
1. **着手時の確認と枝分かれ** — `git status && git log --oneline -3 && git stash list` を確認してから main を最新化し、新ブランチを切る。
|
|
20
20
|
- ★**リポ全体の差分が「読めないファイル」で落ちたとき**(`unsupported file type` / `cannot hash <名前>` が出る/`.npmrc` のような設定が身に覚えなく `M` で出る)。**これは壊れていない。**
|
|
@@ -129,6 +129,17 @@ description: 采配くんの便(プロンプト末尾に CONDUCTOR_JOB:<id>
|
|
|
129
129
|
- **なぜ。** 雛形(`.env.example`)は普通は秘密値を置かないが、「exampleだから常に安全」と番人へ例外を作ると、将来誤って実値が書かれたときの穴になる(利便性より安全側・えふさん確定)。
|
|
130
130
|
- **雛形(`.env.example` など)を直すときの正しい形=「案を作る→人が1回で反映」。** 職人は変更後の中身を**リポジトリの外(`/tmp/claude-1000/` の下)**に作り、報告に次の2つを載せる。① 変更の**差分**(何を足す/消すか)。② 人が**素のターミナルで1回貼るだけで反映できる1つのコマンド**(例:`cp /tmp/claude-1000/<下書き> <リポ>/.env.example`)。★このコマンドは**人が自分のターミナルで**打つ(職人の番人は人の端末には掛からない)。職人が `.env.example` を直接書き換えようとしても番人が止めるので、**回避せず、この形で案を出す**。
|
|
131
131
|
- **本物の `.env`・`.env.local` は、これまでどおり中身も書き込みも扱わない**(秘密の値は1文字も読まない・書かない=鉄則12の金庫)。
|
|
132
|
+
15. **工程の区切りで「いま何をしているか」を日本語1行で出す(えふさん確定 2026-10-02・便 fbaba4b1)**
|
|
133
|
+
- **形は1つだけ。** 行の先頭に `進捗:` を置き、そのうしろにやさしい日本語を1行書く。★この印は配達役が機械で拾うので、形を変えない(全角コロン `進捗:` も可)。
|
|
134
|
+
```
|
|
135
|
+
進捗: 既存の画面構成を確認しました
|
|
136
|
+
進捗: 変更するファイルを特定しました
|
|
137
|
+
進捗: 試験を実行しています
|
|
138
|
+
```
|
|
139
|
+
- **いつ出すか。** 工程の区切り(調べ終わった/直す場所が決まった/実装を始めた/試験を流した/PR を出した など)で**1行ずつ**。1つの工程で何度も出さない。細かい手順の実況はしない(うるさくなる)。
|
|
140
|
+
- **なぜ。** お客さま(非エンジニア)は `kc 現場名` の画面でこの1行を見る。これが無いと、画面に出るのは道具の名前だけで「いま何をしているか」が分からない。実機 2026-10-02 に「職人が動いているのに何も見えない」が起きたのが発端。
|
|
141
|
+
- **書き方。** 専門用語(rebase / squash / lint / CI 等)を避け、**何が進んだか**を書く。秘密の値・鍵・`.env` の中身は1文字も書かない(鉄則12)。
|
|
142
|
+
- **道具の一言説明(description)も同じ画面に出る。** だから下の「作業中の確認の一言説明」は、確認カードだけでなく**この画面の材料**でもある。英語で書かない・「何をするか」が一目で分かる日本語にする。
|
|
132
143
|
|
|
133
144
|
## 番人に止められる常連(こう書くと止まる → 代わりにこう書く)
|
|
134
145
|
|