@tyhld/conductor 0.12.5 → 0.12.7

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.5",
3
+ "version": "0.12.7",
4
4
  "description": "采配くん管制の見守りアプリ(conductor-agent)。各PCで常駐し、中央(devlog-tracker)へ定期的に生存報告(heartbeat)を送る常駐CLI。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,131 @@
1
+ #!/usr/bin/env python3
2
+ """skill_sync_notify.py — 職人の鉄則を配り損ねたことを管制へ知らせる(★便 29612ff1)。
3
+
4
+ 【なぜ要るか(えふさん確定 2026-09-16)】
5
+ 「職人ルールを1時間ごとに自動更新 承認。★更新失敗時に古いルールのまま黙って動かないこと。
6
+ 適用version/時刻を確認可能にする」
7
+
8
+ 1時間ごとの自動同期が鉄則を配れなかったとき、いちばん困るのは
9
+ 【古い鉄則のまま職人が動き続け、誰もそれに気づかない】こと。実際に便 4a5bbb9f で、
10
+ 鉄則7を直したのに現場の職人は古い文のまま動いていた(人が APPLY=1 を打つまで)。
11
+ だから配り損ねは、機体の中のログだけでなく【管制からも見える】形にする。
12
+
13
+ 【なぜ「確認カード」を選んだか(生存報告・開発履歴と比べて)】
14
+ ・生存報告(POST /api/conductor/report)… 送っているのは現場ごとの relay で、項目は
15
+ machine / site / branch / status / lastAction / artisanReady に決まっている。
16
+ 「鉄則が古い」を載せる場所が無く、足すと中央側の変更が要る。★中央は触らない方針。
17
+ ・開発履歴(devlog)… 書き込みはログインの JWT が要る(MCP 経路)。1時間ごとの
18
+ 無人の同期は JWT を持たない。★持たせると、常駐に新しい秘密を増やすことになる。
19
+ ・確認カード(POST /api/conductor/hook-requests)… ★これを選んだ。
20
+ - 「この機体で人が1つ操作しないと直らない」を伝えるために作られた口である(ADR-016)。
21
+ - 送るのに要るのは、番人がもう持っている接続情報だけ(hook_datalink が読む)。
22
+ 新しい鍵も新しい API も増えない。
23
+ - 答えの選択肢を付けなければ、管制では【お知らせ】として出て「閉じる」だけになる
24
+ (便 927da60e で確かめた描き方)。押せないボタンは出ない。
25
+ - 閉じられるまで出たままなので、★古い鉄則のままであることが外から見え続ける。
26
+
27
+ 【何があっても同期を止めない】
28
+ 接続情報が無い機体・管制へ届かない機体でも、ここは黙って終わる(exit 0)。
29
+ その場合でも、配り損ねた事実は $HOME/.tyhld/craftsman-skill.last に残っている。
30
+
31
+ 【使い方】
32
+ python3 skill_sync_notify.py <直近の試みの控えのパス>
33
+ ★控えの1行目が status=failed のときだけカードを出す。ok のときは何もしない。
34
+ """
35
+ import os
36
+ import sys
37
+
38
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
39
+
40
+ import hook_datalink # noqa: E402
41
+
42
+ #: カードを出したままにする時間(ミリ秒)=60分。管制側の上限(HOOK_MAX_WAIT_MS)と同じ。
43
+ #: ★このスクリプトは待たない。ここで宣言した時間だけ管制にカードが出たままになり、
44
+ #: 配り直しが成功すれば次の回にはもう出さないので、自然に消える。
45
+ #: ★1時間ごとの同期と同じ幅にしてあるので、直らない限りカードは途切れない。
46
+ NOTIFY_WAIT_MS = 60 * 60 * 1000
47
+
48
+ #: 同じカードを積み上げないための鍵の頭。★機体ごとに1枚だけにする。
49
+ #: 管制は同じ鍵の再登録を【寿命の延長】として扱う(upsertHookRequest)。だから
50
+ #: 毎時の失敗でカードが増えず、直ればその回で登録が止まって数分後に自然に閉じる。
51
+ REQUEST_KEY_PREFIX = 'craftsman-skill-stale'
52
+
53
+
54
+ def parse_last(path):
55
+ """直近の試みの控えを {鍵: 値} に読む。読めなければ空。
56
+
57
+ 形: `<ISO時刻> status=... reason=... version=... sha=... src=... dst=...`
58
+ """
59
+ try:
60
+ with open(path, encoding='utf-8') as f:
61
+ line = f.readline().strip()
62
+ except Exception: # noqa: BLE001
63
+ return {}
64
+ if not line:
65
+ return {}
66
+ parts = line.split(' ')
67
+ out = {'at': parts[0]}
68
+ for p in parts[1:]:
69
+ if '=' in p:
70
+ k, v = p.split('=', 1)
71
+ out[k] = v
72
+ return out
73
+
74
+
75
+ def build_payload(rec, env=None):
76
+ """カードの中身。★何が起きていて、何をすれば直るかを日本語で書く。"""
77
+ env = os.environ if env is None else env
78
+ machine = env.get('CONDUCTOR_MACHINE') or os.uname().nodename
79
+ reason = rec.get('reason', '不明')
80
+ body = '\n'.join([
81
+ f'いま止まっているもの: この PC({machine})の職人の決まり(鉄則)が、'
82
+ '新しい版に入れ替わっていません。職人は古い決まりのまま動きます。',
83
+ '再開のしかた: この PC で次の1行を打ってください。'
84
+ ' cd ~/projects/conductor そのあと APPLY=1 bash scripts/update-guard.sh',
85
+ '★このカードは管制からは押せません(この PC での操作が要ります)。',
86
+ f'PC: {machine}/うまくいかなかった理由: {reason}',
87
+ f'最後に試した時刻: {rec.get("at", "不明")}',
88
+ f'配ろうとした版: {rec.get("version", "不明")}',
89
+ ])
90
+ return {
91
+ 'site': env.get('CONDUCTOR_SITE') or machine,
92
+ 'machine': machine,
93
+ # ★sessionId は絶対に空で送らない(空だと管制が 400 で弾く)。
94
+ 'sessionId': hook_datalink.stable_session_id('', machine, 'guard-sync'),
95
+ 'kind': 'message',
96
+ 'question': body,
97
+ 'summary': f'職人の決まりが古いままです({machine})',
98
+ 'waitMs': NOTIFY_WAIT_MS,
99
+ # ★機体ごとに固定の鍵。毎時の失敗が同じ1枚に集約される(増殖しない)。
100
+ 'requestKey': f'{REQUEST_KEY_PREFIX}:{machine}',
101
+ }
102
+
103
+
104
+ def run(last_path, cfg=None, datalink=hook_datalink):
105
+ """返り値: 出したカードのID / None(出さなかった)。"""
106
+ rec = parse_last(last_path)
107
+ if rec.get('status') != 'failed':
108
+ return None
109
+ cfg = datalink.load_config() if cfg is None else cfg
110
+ if cfg is None:
111
+ # 接続情報が無い機体。★控えには残っているので、黙って消えるわけではない。
112
+ return None
113
+ reg = datalink.register(cfg, build_payload(rec))
114
+ if not reg or not reg.get('id'):
115
+ return None
116
+ # ★待たない。押されるまで管制に出たままにする=古い鉄則のままであることが見え続ける。
117
+ return reg['id']
118
+
119
+
120
+ def main() -> int:
121
+ if len(sys.argv) < 2:
122
+ return 0
123
+ try:
124
+ run(sys.argv[1])
125
+ except Exception: # noqa: BLE001 知らせに失敗しても同期は止めない
126
+ pass
127
+ return 0
128
+
129
+
130
+ if __name__ == '__main__':
131
+ sys.exit(main())
@@ -959,8 +959,15 @@ _conductor = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # …/
959
959
  _conductor = os.path.dirname(_conductor) # …/conductor
960
960
 
961
961
  check('同期モードがある', 'SYNC_ONLY' in _ug, True)
962
- check('同期モードは掃除・鉄則スキル・pnpm 点検を飛ばす',
963
- '①-b〜①-d2 は省略(同期モード)' in _ug, True)
962
+ check('同期モードは掃除・タイマー・pnpm 点検を飛ばす',
963
+ '①-b・①-c・①-d2 は省略(同期モード)' in _ug, True)
964
+ # ★職人の鉄則(①-d)は同期モードでも【配る】(えふさん確定 2026-09-16・便 29612ff1)。
965
+ # 番人のコードは1時間で届くのに鉄則だけ人待ちだと、直した鉄則が現場に届かないまま
966
+ # 毎時再生産される(便 4a5bbb9f で実測)。中身の検査は test/craftsman-skill-sync.test.ts。
967
+ check('職人の鉄則は同期モードでも配る',
968
+ _ug.index('①-d 職人の鉄則スキル') < _ug.index('①-b・①-c・①-d2 は省略(同期モード)'), True)
969
+ check('配り損ねたら成功と偽らず管制へ知らせる',
970
+ 'craftsman_notify_failure' in _ug and 'skill_sync_notify.py' in _ug, True)
964
971
  check('同期モードは権限ルールの整理より前で終わる',
965
972
  _ug.index('同期のみ完了しました') < _ug.index('④ 各現場の権限ルール'), True)
966
973
  # ★同期モードは「現場の二重登録を外す」(=番人の配線)までは行う(ADR-013)。
@@ -0,0 +1,178 @@
1
+ #!/usr/bin/env python3
2
+ """配り損ねの知らせ(skill_sync_notify.py)の単体テスト。
3
+
4
+ 実行: python3 scripts/hooks/test_skill_sync_notify.py
5
+ 1件でも期待と違えば exit 1。
6
+
7
+ 【何を守っているか(便 29612ff1・えふさん確定 2026-09-16)】
8
+ 「★更新失敗時に古いルールのまま黙って動かないこと。適用version/時刻を確認可能にする」
9
+ (a) 成功の控えでは【1件も知らせない】(毎時のカードで管制を埋めない)
10
+ (b) 失敗の控えでは【必ず知らせる】=黙って進まない
11
+ (c) カードの中身に「いま止まっているもの」「再開のしかた」が入っている
12
+ =管制が知らせとして描ける形(ai-kana の hook-notice-text.ts が探す見出し語)
13
+ (d) カードにボタンが1つも出ない形で送る(kind=message・choices なし)
14
+ =押せないボタンをお客さまの画面に置かない(ai-kana の hook-answerable.ts)
15
+ (e) 機体ごとに鍵が固定=毎時の失敗が同じ1枚に集約される(カードが増殖しない)
16
+ (f) 知らせに失敗しても例外を外へ出さない(★同期そのものは止めない)
17
+ (g) 控えが読めない・空・壊れていても落ちない
18
+ """
19
+ import os
20
+ import sys
21
+ import tempfile
22
+
23
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
24
+
25
+ import skill_sync_notify as sn # noqa: E402
26
+
27
+ failures = []
28
+
29
+
30
+ def check(label, got, want):
31
+ ok = got == want
32
+ if not ok:
33
+ failures.append(f'{label}: got={got!r} want={want!r}')
34
+ print(f'{"OK " if ok else "NG "} {label:64} {got}')
35
+
36
+
37
+ def check_true(label, got):
38
+ check(label, bool(got), True)
39
+
40
+
41
+ CFG = {'url': 'https://example.invalid', 'token': 'dummy-token-not-real'}
42
+ OK_LINE = ('2026-09-16T10:00:00+0900 status=ok reason=applied version=0.12.7 '
43
+ 'sha=abc123def456 src=/r/skills/x/SKILL.md dst=/h/.claude/skills/x/SKILL.md')
44
+ NG_LINE = ('2026-09-16T11:00:00+0900 status=failed reason=write-failed version=0.12.7 '
45
+ 'sha=abc123def456 src=/r/skills/x/SKILL.md dst=/h/.claude/skills/x/SKILL.md')
46
+
47
+
48
+ class FakeLink:
49
+ """連絡線の代役。★通信は1度も行わない。"""
50
+
51
+ #: 「既定のまま」と「わざと None を返させる」を区別するための印。
52
+ DEFAULT = object()
53
+
54
+ def __init__(self, cfg=CFG, reg=DEFAULT):
55
+ self.cfg = cfg
56
+ self.reg = {'id': 'req-1'} if reg is FakeLink.DEFAULT else reg
57
+ self.sent = []
58
+
59
+ def load_config(self, env=None):
60
+ return self.cfg
61
+
62
+ def register(self, cfg, payload):
63
+ self.sent.append(payload)
64
+ return self.reg
65
+
66
+ # ★本物と同じ実装を借りる(sessionId が空にならないことを一緒に確かめるため)。
67
+ stable_session_id = staticmethod(sn.hook_datalink.stable_session_id)
68
+
69
+
70
+ def write(tmp, name, text):
71
+ p = os.path.join(tmp, name)
72
+ with open(p, 'w', encoding='utf-8') as f:
73
+ f.write(text)
74
+ return p
75
+
76
+
77
+ # ── (a)(b) 出す・出さない ────────────────────────────────────────────────────
78
+ print('\n【(a)(b) 成功では出さない・失敗では必ず出す】')
79
+ with tempfile.TemporaryDirectory() as tmp:
80
+ link = FakeLink()
81
+ check('成功の控え: 知らせない', sn.run(write(tmp, 'ok.last', OK_LINE + '\n'), datalink=link), None)
82
+ check('成功の控え: 1件も送っていない', len(link.sent), 0)
83
+
84
+ link = FakeLink()
85
+ got = sn.run(write(tmp, 'ng.last', NG_LINE + '\n'), datalink=link)
86
+ check('失敗の控え: 知らせた(カードIDが返る)', got, 'req-1')
87
+ check('失敗の控え: 1件だけ送った', len(link.sent), 1)
88
+
89
+ # ★控えが無い・空・壊れている=出さない(落ちない)=(g)
90
+ link = FakeLink()
91
+ check('控えが無い: 落ちずに何もしない', sn.run(os.path.join(tmp, 'none.last'), datalink=link), None)
92
+ check('控えが空: 落ちずに何もしない', sn.run(write(tmp, 'e.last', ''), datalink=link), None)
93
+ check('控えが壊れている: 落ちずに何もしない', sn.run(write(tmp, 'b.last', 'ゴミ\n'), datalink=link), None)
94
+ check('壊れた控えでは1件も送らない', len(link.sent), 0)
95
+
96
+ # ── (c)(d)(e) カードの形 ────────────────────────────────────────────────────
97
+ print('\n【(c)(d)(e) カードの形】')
98
+ with tempfile.TemporaryDirectory() as tmp:
99
+ link = FakeLink()
100
+ sn.run(write(tmp, 'ng.last', NG_LINE + '\n'), datalink=link)
101
+ p = link.sent[0]
102
+ q = p['question']
103
+ # (c) 管制が本文として取り出す見出し語(ai-kana: NOTICE_BODY_LABELS)。
104
+ check_true('本文に「いま止まっているもの」がある', 'いま止まっているもの:' in q)
105
+ check_true('本文に「再開のしかた」がある', '再開のしかた:' in q)
106
+ check_true('再開の打ち方が書いてある', 'update-guard.sh' in q)
107
+ # ★「古い決まりのまま動く」ことが本文から分かる=黙って動いていない。
108
+ check_true('古いまま動くと書いてある', '古い' in q)
109
+ # 失敗の理由・時刻・版が読める=(2) 適用version/時刻を確認可能にする。
110
+ check_true('うまくいかなかった理由が載る', 'write-failed' in q)
111
+ check_true('最後に試した時刻が載る', '2026-09-16T11:00:00+0900' in q)
112
+ check_true('配ろうとした版が載る', '0.12.7' in q)
113
+ # (d) ボタンを1つも出さない形(kind=message かつ choices なし)。
114
+ check('種別は message', p['kind'], 'message')
115
+ check('選択肢を付けない(押せないボタンを置かない)', 'choices' in p, False)
116
+ check_true('sessionId が空でない(管制が 400 で弾かない)', bool(p['sessionId'].strip()))
117
+ check_true('見出しが1行ある', bool(p['summary'].strip()))
118
+ check('待つと申告する時間は管制の上限と同じ60分', p['waitMs'], 3_600_000)
119
+ # (e) 鍵が機体ごとに固定。
120
+ check_true('鍵が付いている', p['requestKey'].startswith(sn.REQUEST_KEY_PREFIX + ':'))
121
+ link2 = FakeLink()
122
+ sn.run(write(tmp, 'ng2.last', NG_LINE + '\n'), datalink=link2)
123
+ check('2回目も同じ鍵(カードが増えない)', link2.sent[0]['requestKey'], p['requestKey'])
124
+ # ★控えの中身(パス)以外の余計な物を載せない=秘密は1文字も入らない。
125
+ check('本文にトークンは入らない', CFG['token'] in q, False)
126
+
127
+ # ── (f) 知らせに失敗しても同期は止めない ────────────────────────────────────
128
+ print('\n【(f) 知らせに失敗しても同期を止めない】')
129
+ with tempfile.TemporaryDirectory() as tmp:
130
+ last = write(tmp, 'ng.last', NG_LINE + '\n')
131
+
132
+ # 接続情報が無い機体。
133
+ check('接続情報が無い: 何もせず終わる', sn.run(last, datalink=FakeLink(cfg=None)), None)
134
+ # 管制が受け取らなかった。
135
+ check('登録が空返事: 何もせず終わる', sn.run(last, datalink=FakeLink(reg=None)), None)
136
+ check('登録にIDが無い: 何もせず終わる', sn.run(last, datalink=FakeLink(reg={})), None)
137
+
138
+ # ★main() は何があっても 0 を返す(update-guard.sh が止まらない)。
139
+ argv = sys.argv
140
+ sys.argv = ['skill_sync_notify.py', last]
141
+ orig_load, orig_reg = sn.hook_datalink.load_config, sn.hook_datalink.register
142
+ try:
143
+ sn.hook_datalink.load_config = lambda env=None: CFG
144
+
145
+ def boom(cfg, payload):
146
+ raise RuntimeError('通信断')
147
+
148
+ sn.hook_datalink.register = boom
149
+ check('通信が落ちても exit 0', sn.main(), 0)
150
+ finally:
151
+ sn.hook_datalink.load_config, sn.hook_datalink.register = orig_load, orig_reg
152
+ sys.argv = argv
153
+ check('引数が無くても exit 0', (lambda: (sys.argv.__setitem__(slice(None), ['x']), sn.main())[1])(), 0)
154
+ sys.argv = argv
155
+
156
+ # ── ★空振り確認 ─────────────────────────────────────────────────────────────
157
+ print('\n【★空振り確認】')
158
+ with tempfile.TemporaryDirectory() as tmp:
159
+ # 「status を見ずに必ず出す」ようにしたら (a) が落ちる=(a) は本当に効いている。
160
+ orig_parse = sn.parse_last
161
+ sn.parse_last = lambda p: dict(orig_parse(p), status='failed')
162
+ try:
163
+ link = FakeLink()
164
+ sn.run(write(tmp, 'ok.last', OK_LINE + '\n'), datalink=link)
165
+ check('★空振り: 判定を壊すと成功の控えでも出てしまう', len(link.sent), 1)
166
+ finally:
167
+ sn.parse_last = orig_parse
168
+ link = FakeLink()
169
+ check('★戻したあと: 成功では出ない', sn.run(write(tmp, 'ok2.last', OK_LINE + '\n'), datalink=link), None)
170
+
171
+ print('\n' + '=' * 72)
172
+ if failures:
173
+ print(f'NG {len(failures)} 件:')
174
+ for f in failures:
175
+ print(' -', f)
176
+ sys.exit(1)
177
+ print('すべて期待どおり')
178
+ sys.exit(0)
@@ -13,6 +13,13 @@ description: 采配くんの便(プロンプト末尾に CONDUCTOR_JOB:<id>
13
13
  ## 鉄則13項
14
14
 
15
15
  1. **着手時の確認と枝分かれ** — `git status && git log --oneline -3 && git stash list` を確認してから main を最新化し、新ブランチを切る。
16
+ - ★**リポ全体の差分が「読めないファイル」で落ちたとき**(`unsupported file type` / `cannot hash <名前>` が出る/`.npmrc` のような設定が身に覚えなく `M` で出る)。**これは壊れていない。**
17
+ 秘密に分類されたファイルは職人から中身を隠してあるので、git がそのファイルを数えられないだけ。**中身を見に行く方へ回らない。**
18
+ - ① そのファイルを**触らない・開かない・`git diff` の対象にしない**。「読めるようにする」方向へ回避しない(鉄則4)。
19
+ - ② 差分は**対象を名指しして**見る。例 `git diff -- src docs`。全体で見たいときは除いて見る=`git diff -- . ':(exclude).npmrc'`。
20
+ - ③ `M` と出ていても、**自分が触っていなければ触っていない**。★職人には本当に変わったかを確かめられない。`git status --porcelain -- <そのファイル>` の**表示だけ**を報告に書き、**中身は1文字も書かない**。
21
+ - ④ **コミットに含めない**(`git add` は対象を名指しする=`git add -A` を使わない)。
22
+ - ⑤ それで進めないときだけ、回避せず**人の手番として報告して止まる**。
16
23
  2. **cd は1回だけ** — cd は作業開始時に1回だけ実行する。以後 cd を `&&` で他コマンドと連結しない・リダイレクトと混ぜない。
17
24
  3. **持ち場の外に出ない** — 指示された作業ディレクトリの外を変更しない。
18
25
  4. **壁に当たったら止まる** — サンドボックス制約・保護されたパスに当たったら回避せず中断して報告する(★通信で塞がれたときの読み方は鉄則12)。