@tyhld/conductor 0.12.58 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tyhld/conductor",
3
- "version": "0.12.58",
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