@tyhld/conductor 0.12.71 → 0.12.73

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/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.71",
3
+ "version": "0.12.73",
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
- # Fshima の ~/.bashrc に入れて、ai-saku と migakia の2現場で実際に動かして
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
- # その現場の作業部屋(tmux)を用意し、職人(Claude Code)を開きます。
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
- 職人の画面を手で開きたいとき(tmux 方式・補助)
1230
+ いま職人が何をしているか見たいとき
1222
1231
  ${BAR}
1223
1232
 
1224
1233
  source ~/.bashrc && kc ${SITE}
1225
1234
 
1235
+ ★見るだけの画面です。職人を起動したり、口を出したりはしません。
1236
+ 生の記録まで見たいときは kc ${SITE} --detail
1237
+
1226
1238
  GUIDE
@@ -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)。
@@ -2066,8 +2066,13 @@ def _prod_db_change_result(reason):
2066
2066
  _SAFE_ABS_TOP = frozenset({'tmp'})
2067
2067
 
2068
2068
 
2069
- def rm_path_risk(path: str) -> str:
2070
- """rm の対象パスの危険度: 'dangerous' / 'safe' / 'unknown'。"""
2069
+ def rm_path_risk(path: str, work_dir: str = None) -> str:
2070
+ """rm の対象パスの危険度: 'dangerous' / 'safe' / 'unknown'。
2071
+
2072
+ work_dir が渡されたとき、絶対パスが work_dir の中を指すなら 'safe'(相対パスと同じ扱い)。
2073
+ ただし work_dir 自体・.git を含むパス・.. やシンボリックリンクで外に出るパスは 'dangerous'。
2074
+ work_dir が None のとき(非 conductor 環境)は従来どおり。
2075
+ """
2071
2076
  p = path.strip().strip('"').strip("'")
2072
2077
  if p == '':
2073
2078
  return 'unknown'
@@ -2086,14 +2091,34 @@ def rm_path_risk(path: str) -> str:
2086
2091
  comps = [c for c in p.split('/') if c]
2087
2092
  if not comps:
2088
2093
  return 'dangerous'
2094
+ # ★workDir 内の絶対パスは相対パスと同じ扱い(便 24c9b6b6)。
2095
+ # workDir 自体・.git・.. やシンボリックリンクで外に出るパスは引き続き dangerous。
2096
+ # workDir を知れないときは従来どおり止める(Fail Closed)。
2097
+ # ★秘密(.env 等)と設定(.claude 等)は別の検査(_secret_danger / _guard_write_scan)が
2098
+ # 引き続き止めるので、ここでは .git だけを追加で守る。
2099
+ if work_dir:
2100
+ try:
2101
+ resolved = os.path.realpath(p)
2102
+ work_resolved = os.path.realpath(work_dir)
2103
+ if resolved == work_resolved:
2104
+ pass # workDir 自体 → fall through to 'dangerous'
2105
+ elif resolved.startswith(work_resolved + os.sep):
2106
+ rel_comps = resolved[len(work_resolved) + 1:].split(os.sep)
2107
+ if '.git' not in rel_comps:
2108
+ return 'safe'
2109
+ # .git を含む → fall through to 'dangerous'
2110
+ except (OSError, ValueError):
2111
+ pass # 解決できない → fall through(Fail Closed)
2089
2112
  if comps[0] in _SAFE_ABS_TOP and len(comps) >= 2:
2090
2113
  return 'safe'
2091
2114
  return 'dangerous'
2092
2115
  return 'safe'
2093
2116
 
2094
2117
 
2095
- def _rm_danger(command: str):
2118
+ def _rm_danger(command: str, work_dir: str = None):
2096
2119
  """再帰的 rm の対象パスを全部見て、危険なものがあれば理由を返す。"""
2120
+ if work_dir is None:
2121
+ work_dir = os.environ.get('CONDUCTOR_WORK_DIR') or None
2097
2122
  for m in re.finditer(r'\brm\b', command):
2098
2123
  seg = _SPLIT_RE.split(command[m.end():], maxsplit=1)[0]
2099
2124
  toks = seg.split()
@@ -2101,7 +2126,7 @@ def _rm_danger(command: str):
2101
2126
  recursive = any(('r' in f.lstrip('-').lower()) or f in ('--recursive',) for f in flags)
2102
2127
  paths = [t for t in toks if not t.startswith('-')]
2103
2128
  for p in paths:
2104
- risk = rm_path_risk(p)
2129
+ risk = rm_path_risk(p, work_dir=work_dir)
2105
2130
  if risk == 'dangerous':
2106
2131
  return f'rm on dangerous path ({p})'
2107
2132
  if risk == 'unknown' and recursive: