@tyhld/conductor 0.12.57 → 0.12.59

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/headless.js CHANGED
@@ -16,7 +16,7 @@
16
16
  */
17
17
  import { spawn } from 'node:child_process';
18
18
  import { createInterface } from 'node:readline';
19
- import { existsSync } from 'node:fs';
19
+ import { createWriteStream, existsSync, mkdirSync } from 'node:fs';
20
20
  import path from 'node:path';
21
21
  import os from 'node:os';
22
22
  import { RELAY_DELIVER_MAX, RELAY_FETCH_TIMEOUT_MS, RELAY_HEARTBEAT_INTERVAL_MS, RELAY_PENDING_TIMEOUT_MS, RELAY_RESTORE_ORPHANS_ENABLED, buildAnchor, buildSendText, canDeliver, completeWithReport, createRelayState, deliveryReport, fetchCommand, fetchCommandStatus, fetchControl, fetchOrphans, isTerminalCommandStatus, kindOf, markDelivered, noteCentralDown, noteCentralUp, noteReadiness, patchStatus, reconnectDelayMs, selectOrphanToRestore, sendHeartbeat, } from './relay.js';
@@ -38,6 +38,10 @@ export const HEADLESS_STOP_CHECK_MS = 15_000;
38
38
  export const EVENT_TEXT_MAX = 500;
39
39
  /** resume 時に職人へ渡す続行指示。 */
40
40
  const RESUME_PROMPT = '前回の作業の続きを行ってください。中断された箇所から再開してください。';
41
+ /** 実行ログの保存先ルート。 */
42
+ export const RUNS_DIR = path.join(os.homedir(), '.local', 'state', 'conductor', 'runs');
43
+ /** セッションありと見なす最低実行時間(ms)。これより短いとセッションファイルが無い可能性が高い。 */
44
+ export const SESSION_MIN_DURATION_MS = 30_000;
41
45
  // ─────────────────────────────────────────────────────────────────────────────
42
46
  // claude の実行ファイルの探索
43
47
  //
@@ -171,6 +175,32 @@ export function isAuthError(job) {
171
175
  s.includes('invalid_api_key'));
172
176
  }
173
177
  // ─────────────────────────────────────────────────────────────────────────────
178
+ // 実行ログ
179
+ // ─────────────────────────────────────────────────────────────────────────────
180
+ /** 実行ログのディレクトリを作り、stdout / stderr の WriteStream を返す。 */
181
+ function openRunLogs(commandId, attempt) {
182
+ try {
183
+ const dir = path.join(RUNS_DIR, commandId);
184
+ mkdirSync(dir, { recursive: true });
185
+ const stdoutLog = createWriteStream(path.join(dir, `attempt-${attempt}.jsonl`), { flags: 'a' });
186
+ const stderrLog = createWriteStream(path.join(dir, `attempt-${attempt}.stderr.log`), { flags: 'a' });
187
+ return { stdoutLog, stderrLog };
188
+ }
189
+ catch (err) {
190
+ log(`WARN 実行ログのディレクトリを作れません: ${err instanceof Error ? err.message : String(err)}`);
191
+ return null;
192
+ }
193
+ }
194
+ /** WriteStream を安全に閉じる。 */
195
+ function closeLog(ws) {
196
+ if (ws) {
197
+ try {
198
+ ws.end();
199
+ }
200
+ catch { /* ignore */ }
201
+ }
202
+ }
203
+ // ─────────────────────────────────────────────────────────────────────────────
174
204
  // 子プロセスの起動と停止
175
205
  // ─────────────────────────────────────────────────────────────────────────────
176
206
  /**
@@ -223,6 +253,7 @@ export function spawnClaudeProcess(cfg, cmd, sessionId, attempt, isResume) {
223
253
  log(`ERROR claude の起動に失敗しました (${claudeBin}): ${err instanceof Error ? err.message : String(err)}`);
224
254
  return null;
225
255
  }
256
+ const logs = openRunLogs(cmd.id, attempt);
226
257
  const job = {
227
258
  commandId: cmd.id,
228
259
  process: proc,
@@ -237,6 +268,9 @@ export function spawnClaudeProcess(cfg, cmd, sessionId, attempt, isResume) {
237
268
  lastFlushAt: Date.now(),
238
269
  lastStopCheckAt: Date.now(),
239
270
  stderrBuf: '',
271
+ stdoutLog: logs?.stdoutLog ?? null,
272
+ stderrLog: logs?.stderrLog ?? null,
273
+ durationMs: 0,
240
274
  };
241
275
  // spawn の 'error' イベント(ENOENT 等は非同期で届く。同期例外と両方捕まえる)
242
276
  proc.on('error', (err) => {
@@ -261,10 +295,11 @@ export function spawnClaudeProcess(cfg, cmd, sessionId, attempt, isResume) {
261
295
  }
262
296
  proc.stdin.end();
263
297
  }
264
- // stdout: stream-json を行ごとに読み、CommandEvent に変換してバッファへ
298
+ // stdout: stream-json を行ごとに読み、CommandEvent に変換してバッファへ。ログファイルにも書く。
265
299
  if (proc.stdout) {
266
300
  const rl = createInterface({ input: proc.stdout });
267
301
  rl.on('line', (line) => {
302
+ job.stdoutLog?.write(line + '\n');
268
303
  try {
269
304
  const parsed = JSON.parse(line);
270
305
  const event = streamEventToCommandEvent(parsed, attempt);
@@ -276,10 +311,12 @@ export function spawnClaudeProcess(cfg, cmd, sessionId, attempt, isResume) {
276
311
  }
277
312
  });
278
313
  }
279
- // stderr: 蓄積(認証エラー検出用。上限5000字で先頭を切り落とす)
314
+ // stderr: 蓄積(認証エラー検出用。上限5000字で先頭を切り落とす)。ログファイルにも書く。
280
315
  if (proc.stderr) {
281
316
  proc.stderr.on('data', (chunk) => {
282
- job.stderrBuf += chunk.toString('utf8');
317
+ const text = chunk.toString('utf8');
318
+ job.stderrLog?.write(text);
319
+ job.stderrBuf += text;
283
320
  if (job.stderrBuf.length > 5000) {
284
321
  job.stderrBuf = job.stderrBuf.slice(-3000);
285
322
  }
@@ -290,6 +327,9 @@ export function spawnClaudeProcess(cfg, cmd, sessionId, attempt, isResume) {
290
327
  job.completed = true;
291
328
  job.exitCode = code;
292
329
  job.exitSignal = signal?.toString() ?? null;
330
+ job.durationMs = Date.now() - job.startedAt;
331
+ closeLog(job.stdoutLog);
332
+ closeLog(job.stderrLog);
293
333
  });
294
334
  return job;
295
335
  }
@@ -417,9 +457,10 @@ async function checkRunningJob(cfg, state) {
417
457
  return;
418
458
  }
419
459
  // 異常終了 → 次の tick で自動再開を試みる
460
+ const stderrTail = job.stderrBuf.slice(-300);
420
461
  await sendCommandEvents(cfg, job.commandId, [{
421
462
  type: 'error',
422
- text: maskSecrets(`異常終了 (exit=${job.exitCode}, signal=${job.exitSignal}): ${job.stderrBuf.slice(0, 300)}`).slice(0, EVENT_TEXT_MAX),
463
+ text: maskSecrets(`異常終了 (exit=${job.exitCode}, signal=${job.exitSignal}): ${stderrTail}`).slice(0, EVENT_TEXT_MAX),
423
464
  at: new Date().toISOString(),
424
465
  attempt: job.attempt,
425
466
  }]);
@@ -429,6 +470,8 @@ async function checkRunningJob(cfg, state) {
429
470
  rs.abandoned.add(job.commandId);
430
471
  }
431
472
  else {
473
+ // ★ pendingRetry に詰めて、次の tick で実際に spawn する(fetchCommand は sent を返さない)
474
+ state.pendingRetry = { cmd: { id: job.commandId, body: '' }, attempt: nextAttempt, durationMs: job.durationMs };
432
475
  log(`WARN site=${cfg.site} id=${job.commandId} 異常終了(exit=${job.exitCode}) → ` +
433
476
  `次の tick で再開を試みます (attempt ${nextAttempt}/${HEADLESS_MAX_ATTEMPTS})`);
434
477
  }
@@ -584,6 +627,39 @@ export async function headlessTick(cfg, state, hooks) {
584
627
  log('停止が解除されました。relayを再開します');
585
628
  }
586
629
  rs.lastHalted = false;
630
+ // ── 異常終了後の自動再開(pendingRetry)──
631
+ if (state.pendingRetry !== null) {
632
+ const retry = state.pendingRetry;
633
+ state.pendingRetry = null;
634
+ state.attempts.set(retry.cmd.id, retry.attempt);
635
+ const sessionId = sessionIdForCommand(retry.cmd.id);
636
+ // 前回がセッションを作れないほど短命(< 30秒)なら fresh start、そうでなければ resume
637
+ const isResume = retry.attempt > 1 && retry.durationMs >= SESSION_MIN_DURATION_MS;
638
+ const job = spawnClaudeProcess(cfg, retry.cmd, sessionId, retry.attempt, isResume);
639
+ if (job === null) {
640
+ await sendCommandEvents(cfg, retry.cmd.id, [{
641
+ type: 'error',
642
+ text: 'claude が見つかりません(自動再開を中止)',
643
+ at: new Date().toISOString(),
644
+ attempt: retry.attempt,
645
+ }]);
646
+ rs.abandoned.add(retry.cmd.id);
647
+ log(`ERROR site=${cfg.site} id=${retry.cmd.id} 自動再開: claude が見つからない → 放棄`);
648
+ return;
649
+ }
650
+ state.job = job;
651
+ rs.lastHeartbeatAt = null;
652
+ noteReadiness(rs, 'delivered', retry.cmd.id);
653
+ await sendCommandEvents(cfg, retry.cmd.id, [{
654
+ type: 'resumed',
655
+ text: `attempt ${retry.attempt}/${HEADLESS_MAX_ATTEMPTS} (自動再開)`,
656
+ at: new Date().toISOString(),
657
+ attempt: retry.attempt,
658
+ }]);
659
+ log(`site=${cfg.site} id=${retry.cmd.id} ` +
660
+ `自動再開しました (session=${sessionId}, attempt ${retry.attempt}/${HEADLESS_MAX_ATTEMPTS})`);
661
+ return;
662
+ }
587
663
  // ── 新規指示の取得 ──
588
664
  const cmd = await fetchCommand(cfg);
589
665
  if (!cmd) {
@@ -654,6 +730,7 @@ export function createHeadlessState() {
654
730
  job: null,
655
731
  attempts: new Map(),
656
732
  restored: false,
733
+ pendingRetry: null,
657
734
  };
658
735
  }
659
736
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tyhld/conductor",
3
- "version": "0.12.57",
3
+ "version": "0.12.59",
4
4
  "description": "采配くん管制の見守りアプリ(conductor-agent)。各PCで常駐し、中央(devlog-tracker)へ定期的に生存報告(heartbeat)を送る常駐CLI。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,120 @@
1
+ #!/usr/bin/env python3
2
+ """鉄則の中の「そのまま流す塊」が番人を通ることの検査(便 9bcd9579)。
3
+
4
+ 実行: python3 scripts/hooks/test_craftsman_block_passes_guard.py
5
+ 1件でも期待と違えば exit 1。
6
+
7
+ 【なぜ要るか(実測 2026-09-27・便 f6b9ddb6 の A/B 集計)】
8
+ 鉄則7 の「合流の4段確認をまとめた塊」が ★6便すべてで番人に止められていた。
9
+ 止めていたのは捨て先の指定そのもの(標準エラーを捨てる書き方)ではなく、その指定を
10
+ 【変数の値】の中に閉じ込めたうえで、その変数を文字列の途中へ差し込んでいた形である。
11
+
12
+ V="$(何か 2>/dev/null)" … 捨て先を変数の値に含めて置き
13
+ "repos/$その変数/branches/…" … 別の行で文字列の途中へ差し込む
14
+
15
+ 番人は変数を字面で展開してから測り直すので、展開後の捨て先が `/dev/null` ではなく
16
+ 後ろのパスが貼り付いた別の名前として取り出され、「/dev 直下への書き込み」に見えた。
17
+ =鉄則どおりに流すと毎便1往復が必ず捨てられていた。
18
+
19
+ 【ここで固定すること】
20
+ (a) 鉄則の中の bash の塊は、番人の判定(decide_bash)で allow になる
21
+ ・素で貼る形/`PR=<番号>` を付けた形/ファイルに置いてから流す形のどれでも
22
+ (b) 塊は4段(protect / ci / merge / verify)のままで、段の名前を出す行が残っている
23
+ (c) 塊の走る行に、保護を迂回する旗が無い
24
+ ★番人(policy.py)には手を入れない。この検査は鉄則の文章の側だけを見る。
25
+ """
26
+ import os
27
+ import re
28
+ import subprocess
29
+ import sys
30
+ import tempfile
31
+
32
+ HERE = os.path.dirname(os.path.abspath(__file__))
33
+ REPO = os.path.dirname(os.path.dirname(HERE))
34
+ SKILL = os.path.join(REPO, 'skills', 'conductor-craftsman', 'SKILL.md')
35
+
36
+ sys.path.insert(0, HERE)
37
+
38
+ import policy # noqa: E402
39
+
40
+ failures = []
41
+
42
+
43
+ def check(label, got, want):
44
+ ok = got == want
45
+ if not ok:
46
+ failures.append(f'{label}: got={got!r} want={want!r}')
47
+ print(f'{"OK " if ok else "NG "} {label:62} {str(got)[:44]}')
48
+
49
+
50
+ def bash_blocks(text):
51
+ """鉄則の中の ```bash … ``` を、行頭の字下げを外して取り出す。"""
52
+ out = []
53
+ lines = text.split('\n')
54
+ i = 0
55
+ while i < len(lines):
56
+ m = re.match(r'^(\s*)```bash\s*$', lines[i])
57
+ if not m:
58
+ i += 1
59
+ continue
60
+ pad = len(m.group(1))
61
+ body = []
62
+ i += 1
63
+ while i < len(lines) and not re.match(r'^\s*```\s*$', lines[i]):
64
+ ln = lines[i]
65
+ body.append(ln[pad:] if ln[:pad].strip() == '' else ln)
66
+ i += 1
67
+ out.append('\n'.join(body) + '\n')
68
+ i += 1
69
+ return out
70
+
71
+
72
+ with open(SKILL, encoding='utf-8') as fh:
73
+ blocks = bash_blocks(fh.read())
74
+
75
+ check('鉄則の中に流す塊が在る', len(blocks) >= 1, True)
76
+
77
+ TMP = tempfile.mkdtemp(prefix='craftsman-block-')
78
+
79
+ for n, block in enumerate(blocks, 1):
80
+ print(f'── 塊 {n} ' + '─' * 56)
81
+
82
+ path = os.path.join(TMP, f'block{n}.sh')
83
+ with open(path, 'w', encoding='utf-8') as fh:
84
+ fh.write(block)
85
+
86
+ # (a) どの流し方でも番人が通す。
87
+ forms = [
88
+ ('素で貼る', block),
89
+ ('番号を付けて貼る', 'PR=123\n' + block),
90
+ ('ファイルに置いてから流す',
91
+ 'cat > ' + path + " <<'EOF'\n" + block + 'EOF\nPR=123 bash ' + path),
92
+ ]
93
+ for label, cmd in forms:
94
+ r = policy.decide_bash(cmd, REPO)
95
+ check(f'(a{n}) {label}', r['decision'], 'allow')
96
+ if r['decision'] != 'allow':
97
+ print(' 理由:', r['reason'])
98
+
99
+ # 塊が壊れていたら、通っても意味が無い。
100
+ p = subprocess.run(['bash', '-n', path], capture_output=True, text=True)
101
+ check(f'(a{n}) bash -n が通る', p.returncode, 0)
102
+ if p.returncode != 0:
103
+ print(' ', p.stderr.strip()[:200])
104
+
105
+ # (b) 4段のまま・段の名前が出る。
106
+ for stage in ('protect', 'ci', 'merge', 'verify'):
107
+ check(f'(b{n}) 段 {stage} の結果を出す行が在る',
108
+ bool(re.search(r'\b(ok|ng)\s+' + stage + r'\b', block)), True)
109
+
110
+ # (c) 保護を迂回する旗が走る行に無い(説明のコメントは数えない)。
111
+ runnable = '\n'.join(ln for ln in block.split('\n') if not ln.lstrip().startswith('#'))
112
+ check(f'(c{n}) 保護を迂回する旗が走る行に無い', '--' + 'admin' in runnable, False)
113
+
114
+ print()
115
+ if failures:
116
+ print(f'NG {len(failures)} 件')
117
+ for f in failures:
118
+ print(' ', f)
119
+ sys.exit(1)
120
+ print('すべて期待どおり')
@@ -54,13 +54,14 @@ description: 采配くんの便(プロンプト末尾に CONDUCTOR_JOB:<id>
54
54
  PR="${PR:?PR=<PR番号> が要る}"
55
55
  ng() { printf 'NG[%s] %s\n' "$1" "$2" >&2; exit 1; }
56
56
  ok() { printf 'OK[%s] %s\n' "$1" "$2"; }
57
- V() { gh pr view "$PR" --json "$1" --jq "$2" 2>/dev/null; }
57
+ q() { "$@" 2>/dev/null; } # 失敗の文句だけを捨てる(★塊の中で 2>/dev/null はここ1か所だけ)
58
+ V() { q gh pr view "$PR" --json "$1" --jq "$2"; }
58
59
 
59
60
  # ① 保護 … 必須の検査が1件以上のときだけ「保護あり」。0・404・403・失敗は「保護なし」へ倒す。
60
61
  BASE="$(V baseRefName .baseRefName)"; [ -n "$BASE" ] || ng protect '合流先を読めない'
61
- SLUG="$(gh repo view --json nameWithOwner --jq .nameWithOwner 2>/dev/null)"
62
+ SLUG="$(q gh repo view --json nameWithOwner --jq .nameWithOwner)"
62
63
  [ -n "$SLUG" ] || ng protect 'リポジトリを読めない'
63
- N="$(gh api "repos/$SLUG/branches/$BASE/protection/required_status_checks" --jq '.contexts | length' 2>/dev/null)"
64
+ N="$(q gh api "repos/$SLUG/branches/$BASE/protection/required_status_checks" --jq '.contexts | length')"
64
65
  case "$N" in ''|*[!0-9]*) N=0 ;; esac
65
66
  if [ "$N" -ge 1 ]; then PROT=yes; else PROT=no; fi
66
67
  ok protect "base=$BASE 保護=$PROT(必須の検査 $N 件)"
@@ -134,7 +135,12 @@ description: 采配くんの便(プロンプト末尾に CONDUCTOR_JOB:<id>
134
135
  止まる書き方を最初から使わなければ、打ち直しの往復が要らない。★番人の判定は変えない(弱めない)。
135
136
 
136
137
  - **cd を `&&` でつなぐ**(`cd 先 && 何か`・cd とリダイレクトを同じ行に混ぜる)→ 止まる。**cd は作業のはじめに1回だけ単独で**打ち、以後は絶対パスか `git -C <実際のパス>`(鉄則2)。
137
- - **`.npmrc` を読む**(`cat ~/.npmrc` など)→ 止まる。**中身は見に行かない。** 差分は対象を名指しで `git diff -- src docs`、全体で見たいときは除いて `git diff -- . ':(exclude).npmrc'`(鉄則1)。
138
+ - **リポの根やホームを再帰で検索する**(`grep -rn 語 .`・`rg 語 .`・`grep -rn 語 ~`・`grep -rn 語 /home/…/リポ`)→ 止まる。★**実際に止まっているのはこの形**で、`cat .npmrc` のような直接読みではない(便 f6b9ddb6 の9回はすべて普通の再帰検索が秘密ファイルに当たった形)。**探す場所をフォルダ名で名指しする。**
139
+ - 通る例: `grep -rn 'relay' src docs` / `rg 'relay' src docs` / 追跡済みだけ見るなら `git grep -n 'relay' -- src docs`
140
+ - ★**除外指定では通らない**(実測 2026-09-27)。`grep -rn 語 . --exclude=.npmrc` は、`.` の再帰でまず止まり、さらに**秘密のファイル名を引数に書いた事自体**でも止まる。「除いて探す」ではなく「場所を名指しする」が正解。
141
+ - **秘密のファイルを読む・引数に名前を書く**(`cat ~/.npmrc`・`--exclude=.npmrc`)→ 止まる。**中身は見に行かない。** 差分は対象を名指しで `git diff -- src docs`、全体で見たいときは除いて `git diff -- . ':(exclude).npmrc'`(`git diff` は中身を読み出す動詞ではないので、この形は通る・鉄則1)。
142
+ - **標準エラーの捨て先を、あとで文字列の途中に差し込む変数の中に入れる**(`S="$(何か 2>/dev/null)"` と置き、別の行で `"repos/<その変数>/branches/…"` のように使う)→ 止まる。番人は変数を字面で展開してから測り直すので、捨て先が `/dev/null` ではなく**その後ろのパスが貼り付いた別の名前**として取り出され、`/dev` 直下への書き込みに見える(実測 2026-09-27。鉄則7の塊がこれで6便すべて止まっていた)。**文句を捨てる所を、小さな関数1か所に寄せる。**
143
+ - 通る例: 先に `q() { "$@" 2>/dev/null; }` を置き、以後は `S="$(q 何か)"` と書く(★変数の値に捨て先が入らないので、どこに差し込んでも止まらない)。
138
144
  - **中身が変わる場所を指す再帰の `rm`**(`rm -rf "$DIR/x"`・`rm -rf build/*` のように変数や `*` を含む)→ 止まる。**消す先を実在のパスで名指しする**(例 `rm -rf /tmp/claude-1000/作業名`・鉄則13)。
139
145
  - **作業場所が変数のままの `git push`**(`git -C "$DIR" push`・行き先のブランチを確かめられない形)→ 止まる。**リポの中で、ブランチを名指しして** `git push -u origin <作業ブランチ名>`。
140
146