@cocorograph/hub-agent 0.8.38 → 0.8.40

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": "@cocorograph/hub-agent",
3
- "version": "0.8.38",
3
+ "version": "0.8.40",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの Claude / Codex チャットと tmux セッションを中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -1,16 +1,23 @@
1
1
  /**
2
- * Hub 正本 (Project.claude_md) と workspace ローカル CLAUDE.md の双方向同期。
2
+ * Hub 正本 (Project.claude_md) と workspace ローカル CLAUDE.md の同期。
3
3
  *
4
- * 方針: 双方向 last-write-wins + 履歴。
4
+ * 方針: **受け取り専用**(Hub → 端末の一方向。唯一の例外が下記「初回登録」)。
5
5
  * - Hub 側更新 → 端末 pull(ローカルを `.bak.<ts>` 退避してから全文上書き)
6
- * - 端末側更新 → Hub に PUT(Hub 側は履歴に直前版を積む。directly handled by backend)
7
- * - 両方変化(衝突)→ 端末側を勝者にして Hub に PUT、Hub の取り込み内容は
8
- * `.bak.conflict.<ts>` に退避(後で人手マージできるよう保存)
6
+ * - 端末側更新 → **何もしない**(PUT せず、ローカルも黙って上書きしない)。
7
+ * warn + `config-md-receive-only-drift` で乖離を見える形に残す
8
+ * - 両方変化 → 同上(端末を勝者にしない)
9
9
  * - どちらも変化なし → no-op
10
10
  *
11
+ * ⚠️ 2026-08-09 までは双方向 last-write-wins(端末側更新は PUT、両方変化は端末勝者で
12
+ * PUT + Hub 本文を `.bak.conflict.<ts>` へ退避)だった。Hub の正本がプレースホルダ入り
13
+ * になり、GET が CLI 別のレンダリング結果を返すようになったため、端末が持つ本文を
14
+ * 送り返すと必ず正本を平坦化する(backend が 400 で拒否し、`.bak.conflict` だけが
15
+ * 溜まる)。**正本を直すのは Hub 側**(AI が MCP / API 経由で更新する)。
16
+ *
11
17
  * 初回(メタファイル無し): **端末を勝たせない**(唯一の例外が下記「初回登録」)。
12
18
  * - ローカルが空でも非空でも、Hub 本文があれば pull。手元は `.bak` へ退避するので
13
- * 失われない。戻せば meta が入り、次回以降は通常どおり端末側が push される。
19
+ * 失われない(受け取り専用にした後は、戻しても push されないので **取り込みは
20
+ * Hub 側の正本を直して行う**)。
14
21
  * - 出どころ(自動生成物か手書きか)を判定材料にしない。かつては sidecar
15
22
  * (自動生成本文の hash)を置いて見分けていたが、**判定に使う箇所は 1 か所なのに
16
23
  * 書く / 消す箇所が 6 か所**あり、「消費できたか」がファイル削除の成否に依存する
@@ -23,6 +30,23 @@
23
30
  * 詳細な条件と、なぜ `localFilePreexisted` を永続化しないのかは
24
31
  * `syncClaudeMdWithHub` 内の `isInitialRegistration` のコメントを参照。
25
32
  *
33
+ * **ローカルの設定ファイルは受け取り専用**(2026-08-09)。Hub の正本は
34
+ * `Project.claude_md` 1 本(プレースホルダ入り)で、GET は `cli_kind` に応じて CLI 別へ
35
+ * レンダリングして返す。端末に置かれる **CLAUDE.md も AGENTS.md もその
36
+ * レンダリング結果**であって正本ではないので、
37
+ * - GET には必ず `cli_kind` を載せる(載せないと Codex が CLAUDE.md 向け本文を掴む)
38
+ * - **PUT は初回登録を除いて一切行わない**(push / conflict のどちらも)。乖離は
39
+ * warn + `config-md-receive-only-drift` で見える形にし、ローカルも黙って上書きしない
40
+ * - 正本を直すのは **Hub 側**(AI が MCP / API 経由で更新する)。端末の編集は次回
41
+ * pull で上書きされる前に `.bak` へ退避されるので失われない
42
+ * ⚠️ 初版(同日)は AGENTS.md だけを対象にしていたが、それは実装漏れだった。CLAUDE.md も
43
+ * 同じ経路を通り、送ると backend の平坦化ガードに 400 `config_md_flattened` で弾かれ、
44
+ * `.bak.conflict` だけが毎セッション積み上がる。詳細は `syncClaudeMdWithHub` 内の
45
+ * 受け取り専用ガードのコメント。
46
+ * `cli_kind` を載せていなかった頃に作られた壊れた同期状態(AGENTS.md の base に
47
+ * CLAUDE.md 本文が入っている)は、sync の冒頭で meta / base を捨てて初回 sync へ戻す
48
+ * (`migrateBrokenAgentsBase`)。
49
+ *
26
50
  * 失敗時のポリシー: ローカル CLAUDE.md は触らない(warn ログ + skip)。
27
51
  * session 起動を止めない(ensureClaudeMd と同じく best-effort)。
28
52
  *
@@ -58,6 +82,35 @@ function configPaths(configFilename = "CLAUDE.md") {
58
82
  }
59
83
  }
60
84
 
85
+ /**
86
+ * backend へ渡す `cli_kind`(レンダリング先の CLI 種別)を設定ファイル名から導出する。
87
+ *
88
+ * Hub は `Project.claude_md` を唯一の正本として持ち、**GET のたびに CLI 別へ
89
+ * レンダリングして返す**(`cli_kind=codex` なら AGENTS.md 向け)。省略すると
90
+ * `claude` 扱いになるため、Codex セッションで載せ忘れると **CLAUDE.md 向けの本文を
91
+ * AGENTS.md へ書き込む**ことになる(実際にそうなっていた)。
92
+ *
93
+ * ⚠️ 判定は `configPaths().filename` を通す(`configFilename` の生値で分岐すると、
94
+ * 未知の値の扱いがこの関数とファイル名解決とで食い違う)。
95
+ */
96
+ function cliKindForConfig(configFilename) {
97
+ return configPaths(configFilename).filename === "AGENTS.md" ? "codex" : "claude"
98
+ }
99
+
100
+ /**
101
+ * `claude-md-by-dir` の URL を組み立てる(GET / PUT 共通)。
102
+ *
103
+ * ⚠️ `cliKind` は **GET のときだけ** 渡す。PUT はレンダリング結果ではなく正本そのものを
104
+ * 送る経路で、backend 側の解釈が GET と同じとは限らないため、こちらの都合で
105
+ * 勝手にパラメータを増やさない。
106
+ */
107
+ function claudeMdByDirUrl({ hubUrl, dirName, cliKind }) {
108
+ const base =
109
+ `${hubUrl.replace(/\/+$/, "")}/api/admin/project/claude-md-by-dir/` +
110
+ `?dir_name=${encodeURIComponent(dirName)}`
111
+ return cliKind ? `${base}&cli_kind=${encodeURIComponent(cliKind)}` : base
112
+ }
113
+
61
114
  async function readJsonOrNull(p) {
62
115
  try {
63
116
  const text = await fs.readFile(p, "utf-8")
@@ -298,14 +351,19 @@ async function fetchHubClaudeMd({
298
351
  dirName,
299
352
  fetchImpl,
300
353
  logger,
354
+ configFilename = "CLAUDE.md",
301
355
  timeoutMs = HUB_REQUEST_TIMEOUT_MS,
302
356
  budget,
303
357
  }) {
304
358
  const f = fetchImpl || globalThis.fetch
305
359
  if (!f) return null
306
- const url =
307
- `${hubUrl.replace(/\/+$/, "")}/api/admin/project/claude-md-by-dir/` +
308
- `?dir_name=${encodeURIComponent(dirName)}`
360
+ // ⚠️ `cli_kind` を必ず載せる。省略すると backend は claude 向けにレンダリングする
361
+ // ので、Codex セッションが CLAUDE.md 向け本文を AGENTS.md の正本として掴む。
362
+ const url = claudeMdByDirUrl({
363
+ hubUrl,
364
+ dirName,
365
+ cliKind: cliKindForConfig(configFilename),
366
+ })
309
367
  const deadline = acquireDeadline(budget, timeoutMs)
310
368
  try {
311
369
  const fetched = await deadline.guard(
@@ -571,9 +629,7 @@ async function putHubClaudeMd({
571
629
  }) {
572
630
  const f = fetchImpl || globalThis.fetch
573
631
  if (!f) return { ok: false, status: null, data: null, error: null }
574
- const url =
575
- `${hubUrl.replace(/\/+$/, "")}/api/admin/project/claude-md-by-dir/` +
576
- `?dir_name=${encodeURIComponent(dirName)}`
632
+ const url = claudeMdByDirUrl({ hubUrl, dirName })
577
633
  // expected_hash は **string なら必ず載せる(空文字を含む)**。
578
634
  //
579
635
  // ⚠️ 空文字は「Hub が空であることを期待する」という **有効な CAS トークン**である。
@@ -668,6 +724,97 @@ function putResponseUpdatedAt(data) {
668
724
  return typeof v === "string" && v ? v : null
669
725
  }
670
726
 
727
+ /**
728
+ * **同じ本文を再送しても永久に通らない** 400 拒否の一覧(backend の `error` 値 → reason)。
729
+ *
730
+ * リトライ前提の通信失敗(`hub-put-failed`)と混ぜないために分ける。理由文字列は
731
+ * 拒否ごとに変える — 「CLI 種別を取り違えた」と「プレースホルダを失った平坦化本文を
732
+ * 送った」は原因も直し方も違うので、ログと戻り値で区別できないと調査が止まる。
733
+ *
734
+ * - `config_md_kind_mismatch` … CLAUDE.md の正本へ AGENTS.md の内容を送った等
735
+ * - `config_md_flattened` … レンダリング結果(プレースホルダが展開済み)を正本として
736
+ * 送った。正本にはあるプレースホルダが保存本文に無いので backend が拒否する
737
+ */
738
+ const PERMANENT_PUT_REJECTIONS = {
739
+ config_md_kind_mismatch: "hub-put-kind-mismatch",
740
+ config_md_flattened: "hub-put-flattened",
741
+ }
742
+
743
+ /**
744
+ * PUT の拒否理由が「再送しても通らない種類」なら、その reason を返す(違えば null)。
745
+ *
746
+ * ⚠️ 判定は 1 か所に束ねる(push 経路と conflict 経路で条件が分岐すると、片方だけ
747
+ * 新しい拒否理由に対応していない状態が生まれる)。呼び出し側は接尾辞を足すだけ。
748
+ */
749
+ function permanentPutRejectionReason(putError) {
750
+ const key = putError?.error
751
+ return (typeof key === "string" && PERMANENT_PUT_REJECTIONS[key]) || null
752
+ }
753
+
754
+ /**
755
+ * 「AGENTS.md の base に CLAUDE.md 本文が入っている」壊れた同期状態か。
756
+ *
757
+ * `cli_kind` を載せずに GET していた頃、Codex セッションは CLAUDE.md 向けの本文を
758
+ * 掴んで AGENTS.md 系の base / meta に書いていた。その base を残したまま新しい
759
+ * (AGENTS.md 向けにレンダリングされた)本文を受け取ると、3-way merge の base が
760
+ * 別 CLI の本文を指したままになる。
761
+ *
762
+ * ⚠️ 判定は **先頭の意味のある行**(空行を飛ばした最初の行)だけで行い、meta の
763
+ * `baseHash` の有無に依存させない。実端末の `agents-md.json` には `baseHash` キーが
764
+ * 無い(旧バージョンが書いた meta)ため、それを条件にすると移行が発火しない。
765
+ */
766
+ function looksLikeClaudeMdBody(text) {
767
+ if (typeof text !== "string") return false
768
+ for (const rawLine of text.split("\n")) {
769
+ const line = rawLine.trim()
770
+ if (line === "") continue
771
+ return /^#{1,6}\s*CLAUDE\.md\b/.test(line)
772
+ }
773
+ return false
774
+ }
775
+
776
+ /**
777
+ * 壊れた AGENTS.md の同期状態(base に CLAUDE.md 本文)を捨てて、次回を初回 sync に戻す。
778
+ *
779
+ * 1. ローカル本文を `.bak` へ退避(手元の内容を失わせない)
780
+ * 2. meta → base の順で削除する。**meta を先に消す**のは、meta だけ残ると
781
+ * `repairBaseIfInconsistent` が「ローカル本文で base を書き直す」経路に入り、
782
+ * 壊れた base を作り直しかねないため
783
+ *
784
+ * 呼び出し側はこの回をそこで終える(同一実行内で続けて判定しない = 中間状態を作らない)。
785
+ *
786
+ * @returns {Promise<boolean>} 移行を実行したら true
787
+ */
788
+ async function migrateBrokenAgentsBase({ targetDir, configFilename, logger, dirName }) {
789
+ const { filename, metaFile, baseFile } = configPaths(configFilename)
790
+ if (filename !== "AGENTS.md") return false
791
+ const base = await readBaseBody(targetDir, configFilename)
792
+ if (!looksLikeClaudeMdBody(base)) return false
793
+
794
+ const localBody = await readLocalConfigMd(targetDir, configFilename)
795
+ await backupLocalConfigMd(targetDir, localBody, "normal", configFilename)
796
+ const metaDir = path.join(targetDir, META_DIR)
797
+ const unlinkIfPresent = async (name) => {
798
+ try {
799
+ await fs.unlink(path.join(metaDir, name))
800
+ } catch (err) {
801
+ if (err?.code !== "ENOENT") throw err
802
+ }
803
+ }
804
+ await unlinkIfPresent(metaFile)
805
+ await unlinkIfPresent(baseFile)
806
+ logger?.warn?.(
807
+ {
808
+ dirName,
809
+ config_filename: filename,
810
+ base_bytes: Buffer.byteLength(base, "utf-8"),
811
+ local_backed_up: localBody != null,
812
+ },
813
+ "syncClaudeMdWithHub: AGENTS.md sync state held CLAUDE.md content; discarded meta/base so the next sync starts fresh",
814
+ )
815
+ return true
816
+ }
817
+
671
818
  async function readLocalConfigMd(targetDir, configFilename) {
672
819
  try {
673
820
  return await fs.readFile(path.join(targetDir, configPaths(configFilename).filename), "utf-8")
@@ -946,15 +1093,35 @@ export async function syncClaudeMdWithHub({
946
1093
  )
947
1094
  return { action: "skipped", reason: "budget-exhausted" }
948
1095
  }
1096
+
1097
+ // 0) 壊れた同期状態(AGENTS.md の base に CLAUDE.md 本文)の移行。
1098
+ //
1099
+ // ⚠️ **通信より前・判定より前**に置き、実行したらその回はそこで終える。同一実行内で
1100
+ // 続けて判定すると「meta を消した直後の初回 sync」が同じ回に走り、移行が中途半端に
1101
+ // 効いた中間状態を作る(次回セッションの初回 sync で pull させれば足りる)。
1102
+ try {
1103
+ if (await migrateBrokenAgentsBase({ targetDir, configFilename, logger, dirName })) {
1104
+ return { action: "skipped", reason: "agents-md-base-migrated" }
1105
+ }
1106
+ } catch (err) {
1107
+ // 退避 / 削除に失敗した回は同期を進めない(壊れた base を基準に使わせない)。
1108
+ logger?.warn?.(
1109
+ { dirName, err: err?.message || String(err) },
1110
+ "syncClaudeMdWithHub: failed to discard the broken AGENTS.md sync state; skip (retry next sync)",
1111
+ )
1112
+ return { action: "skipped", reason: "agents-md-base-migration-failed" }
1113
+ }
1114
+
949
1115
  const url = hubUrl || DEFAULT_HUB_API
950
1116
 
951
- // 1) Hub の正本を取得
1117
+ // 1) Hub の正本を取得(`cli_kind` を載せて CLI 別レンダリング結果を受け取る)
952
1118
  const hub = await fetchHubClaudeMd({
953
1119
  hubUrl: url,
954
1120
  accessToken: token,
955
1121
  dirName,
956
1122
  fetchImpl,
957
1123
  logger,
1124
+ configFilename,
958
1125
  timeoutMs: requestTimeoutMs,
959
1126
  budget,
960
1127
  })
@@ -1083,6 +1250,11 @@ export async function syncClaudeMdWithHub({
1083
1250
 
1084
1251
  // --- 初回登録: 「Hub に無くローカルにある」ものを一度だけ Hub へ取り込む -------
1085
1252
  //
1253
+ // ⚠️ 2026-08-09 以降、**これが PUT を発行する唯一の経路**(受け取り専用ガードの
1254
+ // 唯一の例外)。両立する理由は下の受け取り専用ガードのコメントに書いてある
1255
+ // (要約: 正本が空なら端末の本文は「正本のレンダリング結果」ではありえないので、
1256
+ // 送っても正本を平坦化しない = 上書きではなく種付け)。
1257
+ //
1086
1258
  // 条件は **すべて** 満たすときだけ:
1087
1259
  // - meta が無い(この端末はまだ一度も同期していない)
1088
1260
  // - Hub 本文が空(**等値比較のみ**。`updated_at` の有無は問わない)
@@ -1291,11 +1463,78 @@ export async function syncClaudeMdWithHub({
1291
1463
  return { action: "pull" }
1292
1464
  }
1293
1465
 
1466
+ // ---- ローカルの設定ファイルは受け取り専用(ここから下は必ず localChanged === true)
1467
+ //
1468
+ // Hub は `Project.claude_md`(プレースホルダ入りの正本)を CLI 別にレンダリングして
1469
+ // 返す。端末の **CLAUDE.md も AGENTS.md もそのレンダリング結果**であって正本では
1470
+ // ないので、端末側で編集されたからといって Hub へ送り返してはいけない(正本に
1471
+ // プレースホルダが残っていても、送るのは展開済みの平坦な本文になる = backend の
1472
+ // 平坦化ガードに 400 `config_md_flattened` で弾かれる)。
1473
+ //
1474
+ // ⚠️ **設定ファイルの種別で分岐しない**。初版(2026-08-09)は
1475
+ // `filename === "AGENTS.md"` で絞っていたが、合意した設計は
1476
+ // 「ローカルは常に受け取るだけ。AI が Hub の正本を更新する」であり、種別を問わない。
1477
+ // CLAUDE.md を放置すると、正本がプレースホルダ化された後に
1478
+ // 「GET はレンダリング結果 → 端末は localChanged と判定 → PUT → 400 で拒否」を
1479
+ // 毎セッション繰り返し、`.bak.conflict` だけが積み上がって収束しない
1480
+ // (AGENTS.md が 2026-08-01 から起こしていた症状の再演)。
1481
+ //
1482
+ // ⚠️ **この判定を前へ動かさないこと**。pull 分岐の **後ろ** に置いているので、
1483
+ // 「Hub だけが変化した純粋な pull」は従来どおり取り込む。前へ動かすと
1484
+ // 「受け取り専用」ではなく「受け取らない」になり、移行後の初回 pull も止まる。
1485
+ //
1486
+ // ⚠️ **push 経路と conflict 経路で別々に判定しない**。ここから下は両方とも
1487
+ // 「ローカルが(前回同期時点から)変化した」経路で、PUT する / しないの分岐は
1488
+ // 1 か所に置く。経路ごとに掛けると、片方だけ塞がった状態が生まれる。
1489
+ //
1490
+ // ⚠️ **ローカルを黙って上書きもしない**。conflict 側で「端末が勝てないなら Hub で
1491
+ // 上書き」に倒すと、退避 `.bak` は残っても人は気づかず、端末の編集が事実上消える。
1492
+ // 乖離は **warn + 専用の reason** で見える形にして残し、取り込みは人に委ねる。
1493
+ //
1494
+ // 【唯一の例外=初回登録(`isInitialRegistration`)がなぜ受け取り専用と両立するか】
1495
+ // 初回登録が成立するのは **Hub の正本が空**(`hubBody === ""` + meta 無し +
1496
+ // 呼び出し元が「このセッションより前からファイルがあった」と報告)のときだけ。
1497
+ // 正本が空である以上、端末の本文は「正本のレンダリング結果」ではありえない
1498
+ // (レンダリング元が無い)ので、送り返しても正本を平坦化しない。これは
1499
+ // **上書きではなく、未登録の正本を 1 度だけ種付けする**経路であり、
1500
+ // 「レンダリング結果を正本へ書き戻さない」という受け取り専用の禁止事項に触れない。
1501
+ // 実データ 16 件がこの経路を通っており、消すと未登録 workspace の正本が
1502
+ // 永久に空のままになる(=端末には毎回プレースホルダが配られ、Hub 側に
1503
+ // 案件固有の内容が載らない)。種付けが済めば meta と非空の正本が揃うので、
1504
+ // 以降の乖離はすべてこのガードに落ちる。
1505
+ //
1506
+ // ⚠️ ログに本文を載せないこと(接続情報が混ざりうる)。出してよいのは長さ・行数・
1507
+ // ハッシュのような、中身を復元できない指標だけ。
1508
+ if (!isInitialRegistration) {
1509
+ logger?.warn?.(
1510
+ {
1511
+ dirName,
1512
+ // ⚠️ 固定値を書かない。種別を広げた後に「常に AGENTS.md」と出ると、
1513
+ // どちらのファイルで乖離したのかがログから分からなくなる。
1514
+ config_filename: configPaths(configFilename).filename,
1515
+ hub_changed: hubChanged,
1516
+ local_bytes: localBody == null ? 0 : Buffer.byteLength(localBody, "utf-8"),
1517
+ local_lines: localBody == null ? 0 : localBody.split("\n").length,
1518
+ local_hash: localCurrentHash,
1519
+ hub_hash: hubHash,
1520
+ },
1521
+ "syncClaudeMdWithHub: local config md drifted from the rendered hub source of truth; it is receive-only so nothing was pushed and nothing was overwritten (edit the source of truth on Hub, then let the next sync pull)",
1522
+ )
1523
+ return { action: "skipped", reason: "config-md-receive-only-drift" }
1524
+ }
1525
+
1294
1526
  if (!hubChanged && localChanged) {
1295
1527
  // ⚠️ hash の観測と snapshot の自己整合性は **入口の門で保証済み**。ここに
1296
1528
  // 個別チェックを再掲しないこと(検査の本体が 2 つになると、片方だけ適用範囲が
1297
1529
  // 戻る回帰が起きる。実際に「PUT 経路にだけ掛ける」設計から blocker が出た)。
1298
1530
  // push: Hub に PUT。成功時にメタ更新。
1531
+ //
1532
+ // ⚠️ **ここへ来るのは初回登録だけ**(2026-08-09 以降)。上の受け取り専用ガードが
1533
+ // それ以外の `localChanged` を全部返しているため。`isInitialRegistration` は
1534
+ // `hubBody === ""` かつ meta 無しが前提なので、この分岐の `hubChanged` は
1535
+ // 常に false になる(= `!hubChanged` の条件はいま実質的に常に真)。
1536
+ // 条件を消して単純化しないこと: 受け取り専用ガードの例外を将来増やしたときに、
1537
+ // 「Hub が動いているのに端末本文で PUT する」経路が黙って復活する。
1299
1538
  const put = await putHubClaudeMd({
1300
1539
  hubUrl: url,
1301
1540
  accessToken: token,
@@ -1320,14 +1559,15 @@ export async function syncClaudeMdWithHub({
1320
1559
  )
1321
1560
  return { action: "skipped", reason: "hub-put-stale" }
1322
1561
  }
1323
- if (put.error?.error === "config_md_kind_mismatch") {
1324
- // CLI 種別の取り違え(AGENTS.md の内容を CLAUDE.md 正本へ送った等)。
1325
- // 同じ本文を再送しても永久に通らないので、リトライ前提の通信失敗と混ぜない。
1562
+ const permanent = permanentPutRejectionReason(put.error)
1563
+ if (permanent) {
1564
+ // CLI 種別の取り違え / 平坦化本文の送信。**同じ本文を再送しても永久に通らない**
1565
+ // ので、リトライ前提の通信失敗と混ぜない(meta も進めない)。
1326
1566
  logger?.warn?.(
1327
1567
  { dirName, ...put.error },
1328
- "syncClaudeMdWithHub: push rejected (config md kind mismatch); resending the same body will keep failing",
1568
+ "syncClaudeMdWithHub: push rejected by a permanent guard; resending the same body will keep failing",
1329
1569
  )
1330
- return { action: "skipped", reason: "hub-put-kind-mismatch" }
1570
+ return { action: "skipped", reason: permanent }
1331
1571
  }
1332
1572
  return { action: "skipped", reason: "hub-put-failed" }
1333
1573
  }
@@ -1363,6 +1603,19 @@ export async function syncClaudeMdWithHub({
1363
1603
 
1364
1604
  // hubChanged && localChanged → 衝突。端末側を勝者にして Hub に PUT。
1365
1605
  //
1606
+ // ⚠️⚠️ **この分岐はいま到達しない**(2026-08-09 以降)。受け取り専用ガードが
1607
+ // `localChanged` の経路を初回登録以外すべて返し、初回登録は `hubBody === ""` が
1608
+ // 前提なので `hubChanged` が false になるため。テストも
1609
+ // 「conflict 相当の入力 → `skipped(config-md-receive-only-drift)`・PUT 0 回・
1610
+ // `.bak.conflict` 0 件」を固定する側へ載せ替えてある。
1611
+ //
1612
+ // ⚠️ **消していないのは意図的**。「受け取り専用」は方針であって backend の制約では
1613
+ // なく(正本にプレースホルダを保ったまま端末から更新する API ができれば覆りうる)、
1614
+ // ガードと同じ commit で衝突解決の機構ごと落とすと、方針を見直すときに
1615
+ // 退避 (.bak.conflict) と楽観ロックの組み合わせを一から書き直すことになる。
1616
+ // ⚠️ 逆に言えば **いまは検査されていないコード**なので、ここを直したくなったら
1617
+ // 先に到達可能性(受け取り専用ガードの例外)を決めること。
1618
+ //
1366
1619
  // ⚠️ hash の観測と snapshot の自己整合性は **入口の門で保証済み**(push 経路と同じ
1367
1620
  // 理由でここに再掲しない)。壊れた snapshot は入口で返すので、**PUT しない回に
1368
1621
  // `.bak.conflict` だけが増える**こともない。
@@ -1389,13 +1642,14 @@ export async function syncClaudeMdWithHub({
1389
1642
  )
1390
1643
  return { action: "skipped", reason: "hub-put-stale-in-conflict" }
1391
1644
  }
1392
- if (put.error?.error === "config_md_kind_mismatch") {
1645
+ const permanent = permanentPutRejectionReason(put.error)
1646
+ if (permanent) {
1393
1647
  // push 経路と同じ理由(再送しても通らない拒否)。通信失敗と区別する。
1394
1648
  logger?.warn?.(
1395
1649
  { dirName, ...put.error },
1396
- "syncClaudeMdWithHub: conflict push rejected (config md kind mismatch); resending the same body will keep failing",
1650
+ "syncClaudeMdWithHub: conflict push rejected by a permanent guard; resending the same body will keep failing",
1397
1651
  )
1398
- return { action: "skipped", reason: "hub-put-kind-mismatch-in-conflict" }
1652
+ return { action: "skipped", reason: `${permanent}-in-conflict` }
1399
1653
  }
1400
1654
  return { action: "skipped", reason: "hub-put-failed-in-conflict" }
1401
1655
  }
@@ -1441,6 +1695,11 @@ export {
1441
1695
  readBaseBody,
1442
1696
  nowIso,
1443
1697
  configPaths,
1698
+ // `cli_kind` の導出と URL 組み立ては初期化経路(claude-md.mjs)でも使う。
1699
+ // ⚠️ 複製しないこと(片方だけ `cli_kind` を載せ忘れると、初回配信だけ別 CLI 向けの
1700
+ // 本文を掴むという非対称が生まれる)。
1701
+ cliKindForConfig,
1702
+ claudeMdByDirUrl,
1444
1703
  }
1445
1704
 
1446
1705
  // 内部テスト用エクスポート
@@ -1458,4 +1717,7 @@ export const _internals = {
1458
1717
  // 壊れるため、両者を突き合わせるテストから参照できるように公開する。
1459
1718
  makeBakName,
1460
1719
  bakNamePattern,
1720
+ cliKindForConfig,
1721
+ claudeMdByDirUrl,
1722
+ looksLikeClaudeMdBody,
1461
1723
  }
package/src/claude-md.mjs CHANGED
@@ -40,6 +40,8 @@ import {
40
40
  nowIso,
41
41
  isHubSnapshotTrustworthy,
42
42
  usableHubUpdatedAt,
43
+ cliKindForConfig,
44
+ claudeMdByDirUrl,
43
45
  } from "./claude-md-sync.mjs"
44
46
  import {
45
47
  HUB_REQUEST_TIMEOUT_MS,
@@ -92,12 +94,18 @@ async function fetchHubClaudeMdProject({
92
94
  fetchImpl,
93
95
  deadline,
94
96
  logger,
97
+ configFilename = "CLAUDE.md",
95
98
  }) {
96
99
  const f = fetchImpl || globalThis.fetch
97
100
  if (!f) return null
98
- const url =
99
- `${hubUrl.replace(/\/+$/, "")}/api/admin/project/claude-md-by-dir/` +
100
- `?dir_name=${encodeURIComponent(dirName)}`
101
+ // ⚠️ 同期側(`fetchHubClaudeMd`)と **同じ導出・同じ URL 組み立て**を使う。初回配信だけ
102
+ // `cli_kind` を落とすと、Codex の初回セッションが CLAUDE.md 向けの本文を AGENTS.md
103
+ // として書き込み、その本文が base / meta(同期の基準)にまで入る。
104
+ const url = claudeMdByDirUrl({
105
+ hubUrl,
106
+ dirName,
107
+ cliKind: cliKindForConfig(configFilename),
108
+ })
101
109
  try {
102
110
  const fetched = await deadline.guard(
103
111
  // 同期的に throw する fetch 実装でも guard に載るよう then で包む。
@@ -325,6 +333,7 @@ export async function ensureClaudeMd({
325
333
  fetchImpl,
326
334
  deadline,
327
335
  logger,
336
+ configFilename,
328
337
  })
329
338
  } finally {
330
339
  deadline.clear()
package/src/tmux.mjs CHANGED
@@ -828,14 +828,16 @@ export async function createSession(name, cwd, opts = {}) {
828
828
  }
829
829
  }
830
830
  // HUB_PROJECTS_BASE 配下の workspace の場合は CLAUDE.md を必要なら生成し、
831
- // Hub の正本(Project.claude_md)と双方向同期する。
831
+ // Hub の正本(Project.claude_md)から受け取る。
832
832
  // - ensureClaudeMd: 既存 CLAUDE.md は触らない。無ければ director frontmatter
833
833
  // または placeholder を生成する。
834
- // - syncClaudeMdWithHub: Hub 正本と双方向 last-write-wins 同期。Hub 側更新は
835
- // ローカルへ pull(.bak 退避してから上書き)、端末側更新は Hub へ push、
836
- // 両方変化は端末側を勝者にして Hub へ push(Hub の取り込み内容は
837
- // .bak.conflict に退避)。Hub が一度も保存されていないプロジェクトでは
838
- // 何もしない(テナント標準 CLAUDE.md の運用を尊重する)。
834
+ // - syncClaudeMdWithHub: **受け取り専用**。Hub 側更新はローカルへ pull(.bak 退避
835
+ // してから上書き)、端末側で編集されていても Hub へは送り返さない(正本は
836
+ // プレースホルダ入りで、端末にあるのはそのレンダリング結果のため)。乖離は
837
+ // warn + `config-md-receive-only-drift` で見える形に残す。唯一の例外が
838
+ // **初回登録**(Hub の正本が空 + そのファイルが元からあった場合に 1 度だけ push)。
839
+ // Hub が一度も保存されていないプロジェクトでは何もしない(テナント標準
840
+ // CLAUDE.md の運用を尊重する)。
839
841
  // どちらも失敗しても session 起動自体は止めない (warn のみ)。
840
842
  // ⚠️ 2 つの呼び出しで **1 本の budget を共有**する。1 往復ごとの期限だけでは
841
843
  // 往復の本数ぶん足し算されてセッション起動が長時間止まりうるため、Hub 処理に