@cocorograph/hub-agent 0.8.21 → 0.8.23

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.
@@ -8,10 +8,20 @@
8
8
  * `.bak.conflict.<ts>` に退避(後で人手マージできるよう保存)
9
9
  * - どちらも変化なし → no-op
10
10
  *
11
- * 初回(メタファイル無し):
12
- * - ローカル CLAUDE.md が空 → pull(Hub の正本をそのまま受け取り)
13
- * - ローカル非空 + Hub 非空 → 衝突扱い(端末側勝者)
14
- * - ローカル非空 + Hub 空 → no-op(Hub に空を上書きしない、Stage 3 で初期化検討)
11
+ * 初回(メタファイル無し): **端末を勝たせない**(唯一の例外が下記「初回登録」)。
12
+ * - ローカルが空でも非空でも、Hub 本文があれば pull。手元は `.bak` へ退避するので
13
+ * 失われない。戻せば meta が入り、次回以降は通常どおり端末側が push される。
14
+ * - 出どころ(自動生成物か手書きか)を判定材料にしない。かつては sidecar
15
+ * (自動生成本文の hash)を置いて見分けていたが、**判定に使う箇所は 1 か所なのに
16
+ * 書く / 消す箇所が 6 か所**あり、「消費できたか」がファイル削除の成否に依存する
17
+ * せいで中間状態が繰り返し生まれたため廃止した(2026-08-02)。
18
+ *
19
+ * **初回登録**(`localFilePreexisted === true` のときだけ): 「Hub に無くローカルに
20
+ * ある」CLAUDE.md を 1 度だけ Hub へ取り込む。meta 無し + Hub 本文が空 + ローカル
21
+ * 非空 + 呼び出し元が「そのファイルは元からあった」と報告したときに限り push する。
22
+ * それ以外で **Hub 本文が空なら何もしない**(`hub-empty` / `hub-uninitialized`)。
23
+ * 詳細な条件と、なぜ `localFilePreexisted` を永続化しないのかは
24
+ * `syncClaudeMdWithHub` 内の `isInitialRegistration` のコメントを参照。
15
25
  *
16
26
  * 失敗時のポリシー: ローカル CLAUDE.md は触らない(warn ログ + skip)。
17
27
  * session 起動を止めない(ensureClaudeMd と同じく best-effort)。
@@ -20,6 +30,8 @@
20
30
  * GET /api/admin/project/claude-md-by-dir/?dir_name=...
21
31
  * PUT /api/admin/project/claude-md-by-dir/?dir_name=... body: { claude_md, expected_hash? }
22
32
  * expected_hash は楽観ロック。GET で読んだ hash を必ず添えて送る(不一致なら 409)。
33
+ * **空文字も有効なトークン**(「Hub が空である」ことを期待する)。falsy 判定で
34
+ * 落とすとキーごと消えて照合されず、盲目的な上書きになる。
23
35
  * CLI 種別を取り違えた本文(CLAUDE.md 正本へ AGENTS.md の内容 等)は 400
24
36
  * `config_md_kind_mismatch` で拒否される。再送しても通らないので理由を分ける。
25
37
  */
@@ -28,6 +40,11 @@ import os from "node:os"
28
40
  import path from "node:path"
29
41
  import crypto from "node:crypto"
30
42
 
43
+ import {
44
+ HUB_REQUEST_TIMEOUT_MS,
45
+ acquireDeadline,
46
+ } from "./hub-request-deadline.mjs"
47
+
31
48
  const DEFAULT_HUB_API = "https://api.hub.cocorograph.com"
32
49
  const META_DIR = ".cockpit-sync"
33
50
  const BAK_KEEP = 3 // 通常 .bak と衝突 .bak それぞれ直近 3 件まで保持
@@ -67,6 +84,135 @@ function nowIso() {
67
84
  return new Date().toISOString()
68
85
  }
69
86
 
87
+ /**
88
+ * GET が返した `claude_md_updated_at` を「同期判定に使える値」へ正規化する。
89
+ *
90
+ * 非空 string だけを採用し、それ以外(キー欠落 / null / 空文字 / 数値など)は
91
+ * **`null`=観測できていない**に倒す。空文字を残すと文字列比較で常に不一致になり
92
+ * 「Hub が動いた」と誤認するため、null と同じ扱いにする。
93
+ *
94
+ * ⚠️ **信用判定(`isHubSnapshotTrustworthy` / `isNeverSavedLegacy`)へ渡す前に
95
+ * これを掛けないこと。** 掛けると「欠落」と「型不正」の区別が消える。掛けてよいのは
96
+ * 門を通った後だけ。
97
+ *
98
+ * @param {unknown} raw GET 応答の生値
99
+ * @returns {string|null}
100
+ */
101
+ export function usableHubUpdatedAt(raw) {
102
+ return typeof raw === "string" && raw !== "" ? raw : null
103
+ }
104
+
105
+ /**
106
+ * 「Project.save が一度も走っていない legacy」の判定(信用契約の正例 (a))。
107
+ *
108
+ * ⚠️ **判定は 3 値**。`body === "" && hash === ""` の 2 値だけにすると、
109
+ * `"" / "" / 1750000000` のように「時刻が観測できない(型不正)」snapshot まで
110
+ * legacy として通ってしまう。backend は一度でも save すれば必ず `updated_at` を
111
+ * 打つ(本文が空でも打つ = 経路 C)ので、**`null` / キー欠落(`undefined`)だけ**が
112
+ * legacy の正常形。
113
+ *
114
+ * ⚠️ 引数の `hubUpdatedAtRaw` は **GET 応答の生値**。正規化済みの値を渡すと
115
+ * 「欠落」と「型不正」の区別が消える(それがこの述語の存在理由)。
116
+ *
117
+ * この 1 か所を `isHubSnapshotTrustworthy` と `syncClaudeMdWithHub` の両方が使う
118
+ * (2 か所に書き分けると、片方だけ 2 値へ戻る回帰が起きる)。
119
+ *
120
+ * @param {unknown} hubBody GET が返した `claude_md`
121
+ * @param {unknown} hubHash GET が返した `claude_md_hash`
122
+ * @param {unknown} hubUpdatedAtRaw GET が返した `claude_md_updated_at`(生値)
123
+ */
124
+ function isNeverSavedLegacy(hubBody, hubHash, hubUpdatedAtRaw) {
125
+ return (
126
+ hubBody === "" &&
127
+ hubHash === "" &&
128
+ (hubUpdatedAtRaw === null || hubUpdatedAtRaw === undefined)
129
+ )
130
+ }
131
+
132
+ /**
133
+ * この GET snapshot を **「同期済み状態の基準」として信用してよいか**。
134
+ *
135
+ * ⚠️ **PUT 経路に限定して使わないこと。** 契約は「PUT してよいか」ではなく
136
+ * 「基準にしてよいか」で、**pull も基準(meta / base)を書く**。かつて「pull は Hub に
137
+ * 書かないから安全」と考えて PUT 経路だけに掛けたが、これは誤りだった:
138
+ *
139
+ * 1. 1 回目の GET が壊れた snapshot `{body: C, hash: sha256(H), updated_at: T}` を返す
140
+ * 2. pull してローカルを C にし、meta に `lastSyncedHash = sha256(H)` / `T` を保存する
141
+ * → meta は本来「ローカル本文 == 同期済み Hub 本文」を意味する commit marker
142
+ * なのに、**本文 C と hash sha256(H) を同じ世代へ書いて不変条件が壊れる**
143
+ * 3. 2 回目の GET が正常化して `{body: H, hash: sha256(H), updated_at: T}` を返すと、
144
+ * `hubChanged=false`(updated_at 同値)/ `localChanged=true`(C ≠ sha256(H))と
145
+ * 判定され、CAS も `expected_hash=sha256(H)` で **正しく通る**
146
+ * 4. 結果 **C が PUT されて Hub 正本 H が消える**(実測: 1 回目 pull → 2 回目 push)
147
+ *
148
+ * つまり「壊れた回に書いた基準」が、**正常化した次の回に上書きを成立させる**。
149
+ * だから経路ごとに掛ける / 掛けないを判断せず、**入口で 1 回だけ**掛ける。
150
+ *
151
+ * ⚠️ **CAS(`expected_hash`)は「GET の後に Hub の版が動いていないこと」しか守らない。
152
+ * 「クライアントがその版の本文を実際に観測したこと」は守らない。** hash が正しいほど
153
+ * CAS は成功するので、CAS はこの型の事故に対する防御にならない。
154
+ *
155
+ * 正例は 2 つあり、**どちらも実データに存在する**ので片方を落とさないこと:
156
+ * (a) `"" / "" / (null | undefined)` … Project.save が一度も走っていない legacy
157
+ * (hash が空文字)。この 1 件だけが `updated_at` の欠落を
158
+ * 許される正例。
159
+ * (b) `hash === sha256(body)` かつ `updated_at` が非空 string … 保存済み。
160
+ * `"" / sha256("")`(経路 C の 4 件)も含む
161
+ *
162
+ * ⚠️ **(a) が許すのは `updated_at` が `null` / `undefined`(=キー欠落)のときだけ。**
163
+ * `""` / 数値 / 非空文字列は legacy を名乗れない(backend が一度も save していない
164
+ * なら `null` かキー欠落しか返さない)ので (b) に落ち、`hash !== sha256("")` で false に
165
+ * なる。`undefined` を残しているのは、キーを省略して返す**古い backend との互換**を
166
+ * 壊さないため。
167
+ *
168
+ * ⚠️ **呼び出し側で `updated_at` を正規化してから渡さないこと。** 空文字や数値を
169
+ * 事前に `null` へ潰すと、この関数から見て「欠落(legacy の正例)」と「型不正
170
+ * (観測漏れ)」が区別できなくなり、`"" / "" / 1750000000` のような壊れた snapshot が
171
+ * legacy として門を通る。**GET 応答の生値をそのまま渡し、正規化は判定を通った後に
172
+ * 行うこと**(`syncClaudeMdWithHub` / `ensureClaudeMd` の両方がその順で呼んでいる)。
173
+ * 2026-08-02 の実データ走査では `"" / "" / 非空 updated_at` は 0 件だったが、
174
+ * **件数を契約にはしない**(0 件だったのは「今そうだった」に過ぎない)。
175
+ *
176
+ * ⚠️ **判定に使う値は 3 つ(`body` / `hash` / `updated_at`)で、3 つともここで見る。**
177
+ * `updated_at` を外に出していた頃、`{body: H1, hash: sha256(H1), updated_at: T0}`
178
+ * (本文と hash は進んでいるのに時刻だけ前回値)という「自己整合だが時刻が古い」
179
+ * snapshot が基準として採用され、Hub の H0 → H1 を `hubChanged = false` と誤認した。
180
+ * その回は noop でも、次にローカルを編集した回に「Hub 不変・ローカルだけ変化」=
181
+ * push と判定され、**CAS は現在の sha256(H1) を送るので正しく成功し、Hub の H1 が
182
+ * 消える**。だから 3 値は 1 か所で束ねる。
183
+ *
184
+ * ⚠️ 旧 backend が空本文を `null` で返す互換があれば、その環境では同期が止まる。
185
+ * ただし現 backend の契約は文字列であり、**暗黙に空へ補正して共有正本を上書きする
186
+ * より安全**なのでこの方針を採る。
187
+ *
188
+ * ⚠️ **この関数が snapshot を信用してよいかの唯一の契約**。呼び出し側で 3 値のうち
189
+ * 一部だけを見る形に戻さないこと。呼び出し側が利用者向けに細かい reason を出すために
190
+ * 個別チェックを先に置くのは構わないが(二重でよい)、**最終判定は必ずここを通す**。
191
+ *
192
+ * ⚠️ 初期化経路(`claude-md.mjs` の `ensureClaudeMd`)も **同じ関数を同じ 3 引数で**
193
+ * 使うこと。あちらも Hub 本文と meta を書く = 基準を作るので、条件が同じでなければ
194
+ * ならない(複製すると片方だけ適用範囲が戻る回帰が起きる)。
195
+ *
196
+ * @param {unknown} hubBody GET が返した `claude_md`
197
+ * @param {unknown} hubHash GET が返した `claude_md_hash`
198
+ * @param {unknown} hubUpdatedAt GET が返した `claude_md_updated_at`(**生値**。
199
+ * 呼び出し側で `null` へ正規化してから渡さないこと)
200
+ * @returns {boolean} この snapshot を同期の基準として使ってよいか
201
+ */
202
+ export function isHubSnapshotTrustworthy(hubBody, hubHash, hubUpdatedAt) {
203
+ if (typeof hubBody !== "string" || typeof hubHash !== "string") return false
204
+ // (a) 一度も保存されていない legacy。sha256("") とは違う値なので (b) では拾えない。
205
+ // backend が一度も save していない = `updated_at` は null / キー欠落が正常。
206
+ // ⚠️ **3 値すべてを見る**(`body` / `hash` だけで true を返さない)。
207
+ if (isNeverSavedLegacy(hubBody, hubHash, hubUpdatedAt)) return true
208
+ // (b) 申告された hash が本文と実際に一致する。
209
+ if (hubHash !== sha256(hubBody)) return false
210
+ // (b-2) 一度でも保存されていれば backend は必ず `updated_at` を打つ(本文が空でも
211
+ // 打つ。実データの経路 C = `"" / sha256("") / timestamp` がその実例)。
212
+ // 空文字は「観測できていない」と同じ扱い(比較しても常に不一致で基準にならない)。
213
+ return typeof hubUpdatedAt === "string" && hubUpdatedAt !== ""
214
+ }
215
+
70
216
  function makeBakName(prefix) {
71
217
  // ファイル名に使えるよう : を - に置換し、ミリ秒精度を保つ。
72
218
  const ts = nowIso().replace(/[:.]/g, "-")
@@ -104,72 +250,33 @@ function bakNamePattern(prefix) {
104
250
  }
105
251
 
106
252
  /**
107
- * Hub API **1 往復あたり**(headers 受信 〜 body 消費)に掛ける既定の期限 (ms)。
108
- *
109
- * ⚠️ **このモジュールが保証するのは「各 HTTP 往復が 15 秒で打ち切られる」ことだけ**。
110
- * 1 回の同期は GET と PUT を行うし、ファイル I/O と呼び出し元の処理も乗る。さらに
111
- * 初期化経路(`src/claude-md.mjs` の `ensureClaudeMd`)にはまだ期限が無い。
112
- * つまり **同期全体・セッション起動全体の budget はここでは決まらない**(別タスク)。
113
- * ここに「全体で N 秒以内」と書くと、その瞬間から嘘になる。
114
- *
115
- * 15 秒とした根拠: 短くしたい誘惑があるが、低速回線・スリープ復帰直後の**正常な**
116
- * 往復を恒常的に失敗させる方が害が大きい(同期が毎回諦めると正本がドリフトし続ける)。
117
- * 実際の往復は本文 1 KB 未満・1 秒以内で完了するため 15 秒は十分な余裕がある。
118
- * ⚠️ 変更前は **無制限**(body を閉じないレスポンスで往復が永久に返らない)だったので、
119
- * 15 秒でも厳密な改善である。
253
+ * 期限・全体予算は `./hub-request-deadline.mjs` へ物理移動した(初期化経路
254
+ * `src/claude-md.mjs` と共有するため。複製すると片方だけ直ってドリフトする)。
255
+ * 保証する範囲もそちらの JSDoc が正本。
120
256
  */
121
- const HUB_REQUEST_TIMEOUT_MS = 15_000
122
257
 
123
258
  /**
124
- * 1 往復ぶんの期限とキャンセル手段をまとめたもの。
259
+ * 使わないレスポンス body を **その応答単位で** 解放する。
260
+ *
261
+ * ⚠️ 非 2xx を「status だけ見て return」すると、body を読みも閉じもしないまま
262
+ * 関数を抜けることになり、**サーバーが body を閉じない応答では接続が残り続ける**
263
+ * (実測: 503 のヘッダーだけ返す 3 本の GET で、`ensureClaudeMd` が 17ms で
264
+ * 返った 350ms 後もソケットが 3 本接続中だった)。`deadline.clear()` は
265
+ * タイマーとリスナーを外すだけで signal を abort しないので、ここが唯一の解放手段。
125
266
  *
126
- * ⚠️ `Promise.race` だけでは**負けた側(`fetch` / `res.json()` / body 読み)が裏で
127
- * 走り続ける**ため、期限超過時は必ず `AbortController.abort()`(+ 呼び出し側での
128
- * `reader.cancel()`)を行い、正常終了時は `clear()` でタイマーを解除すること。
129
- * タイマーは `unref()` して、これだけでプロセスが生き残らないようにする。
267
+ * ⚠️ **共有 deadline の signal を abort してはいけない**。`fetchDirector` と
268
+ * `fetchRepositories` は 1 本の deadline を共有しており、片方の通常 404 で共有
269
+ * signal を切ると、成功しうるもう片方まで巻き添えで中断される。共有 abort は
270
+ * 「期限切れ」か「両方の処理が終わった後」に限る。
271
+ *
272
+ * ⚠️ `await` しない(cancel が返ってこない実装で、ここが新たな詰まりになる)。
130
273
  */
131
- function createDeadline(ms) {
132
- const controller = new AbortController()
133
- let fire = null
134
- const expired = new Promise((resolve) => {
135
- fire = resolve
136
- })
137
- const timer = setTimeout(() => {
138
- try {
139
- controller.abort()
140
- } catch {
141
- /* noop */
142
- }
143
- fire()
144
- }, ms)
145
- if (typeof timer.unref === "function") timer.unref()
146
- return {
147
- signal: controller.signal,
148
- abort() {
149
- try {
150
- controller.abort()
151
- } catch {
152
- /* noop */
153
- }
154
- },
155
- clear() {
156
- clearTimeout(timer)
157
- },
158
- /**
159
- * `promise` を期限付きで待つ。
160
- * @returns {Promise<{timedOut: boolean, value?: any, error?: any}>}
161
- * 期限超過なら `{timedOut:true}`。負けた promise には必ずハンドラを付けて
162
- * unhandled rejection にしない。
163
- */
164
- async guard(promise) {
165
- return await Promise.race([
166
- promise.then(
167
- (value) => ({ timedOut: false, value }),
168
- (error) => ({ timedOut: false, error }),
169
- ),
170
- expired.then(() => ({ timedOut: true })),
171
- ])
172
- },
274
+ export function cancelResponseBody(res) {
275
+ try {
276
+ const p = res?.body?.cancel?.()
277
+ if (p && typeof p.catch === "function") p.catch(() => {})
278
+ } catch {
279
+ /* noop: 解放できなくても呼び出し側の判断は変わらない */
173
280
  }
174
281
  }
175
282
 
@@ -181,7 +288,7 @@ function createDeadline(ms) {
181
288
  *
182
289
  * ⚠️ **GET は PUT より先に走る**ので、ここに期限が無いと PUT 側だけ直しても
183
290
  * 「セッション作成が止まらない」という目的を達成できない(同じ失敗の型)。
184
- * 非 OK 時の本文は読まない(status だけで十分)。掛けているのは期限だけ。
291
+ * 非 OK 時の本文は読まないが、**閉じずに捨てない**(`cancelResponseBody`)。
185
292
  *
186
293
  * @returns {Promise<null | {project_id, claude_md, claude_md_hash, claude_md_updated_at}>}
187
294
  */
@@ -192,13 +299,14 @@ async function fetchHubClaudeMd({
192
299
  fetchImpl,
193
300
  logger,
194
301
  timeoutMs = HUB_REQUEST_TIMEOUT_MS,
302
+ budget,
195
303
  }) {
196
304
  const f = fetchImpl || globalThis.fetch
197
305
  if (!f) return null
198
306
  const url =
199
307
  `${hubUrl.replace(/\/+$/, "")}/api/admin/project/claude-md-by-dir/` +
200
308
  `?dir_name=${encodeURIComponent(dirName)}`
201
- const deadline = createDeadline(timeoutMs)
309
+ const deadline = acquireDeadline(budget, timeoutMs)
202
310
  try {
203
311
  const fetched = await deadline.guard(
204
312
  // 同期的に throw する fetch 実装でも guard に載るよう then で包む。
@@ -218,8 +326,12 @@ async function fetchHubClaudeMd({
218
326
  }
219
327
  if (fetched.error) throw fetched.error
220
328
  const res = fetched.value
221
- if (res.status === 404) return null
329
+ if (res.status === 404) {
330
+ cancelResponseBody(res)
331
+ return null
332
+ }
222
333
  if (!res.ok) {
334
+ cancelResponseBody(res)
223
335
  logger?.warn?.(
224
336
  { dirName, status: res.status },
225
337
  "fetchHubClaudeMd: non-OK response",
@@ -275,9 +387,10 @@ const PUT_ERROR_FIELD_MAX_CHARS = 200
275
387
  * では 100 MB を抱えた後にしか気づけない。そこで `res.body` を**逐次読み**し、
276
388
  * バイト数がこの上限を超えた時点で読み取りを打ち切って reader を cancel する。
277
389
  *
278
- * 64 KiB とした根拠: 実際の拒否応答(`config_md_kind_mismatch` の JSON)は 1 KB 未満、
279
- * nginx / Cloudflare の HTML エラーページでも数 KB で、64 KiB あれば正当な診断情報は
280
- * 必ず収まる。
390
+ * 64 KiB とした根拠: 正当な診断応答は「allowlist した数フィールドの JSON」か
391
+ * 「nginx / Cloudflare の定型 HTML エラーページ」のどちらかで、どちらも 64 KiB に
392
+ * 収まらない構造になっていない。これを超える本文は診断情報ではなく異常とみなす。
393
+ * ⚠️ 実測サイズを根拠として書かないこと(応答の文面が変われば陳腐化する)。
281
394
  */
282
395
  const PUT_ERROR_BODY_MAX_BYTES = 64 * 1024
283
396
 
@@ -431,6 +544,8 @@ async function readNonOkDiagnostics(res, deadline) {
431
544
  * `expectedHash` を渡すと backend が行ロック下で現在 hash と照合し、不一致なら
432
545
  * **409** を返す(楽観ロック)。「GET してから PUT するまでに他端末 / Hub UI が
433
546
  * 動いた」ケースを検出するために必ず渡すこと。渡さないと素通りで盲目的に上書きする。
547
+ * **空文字 `""` も有効なトークン**(「Hub が空であること」を期待する)で、省略とは
548
+ * 意味が違う。呼び出し側は falsy 判定で潰さず、観測した値をそのまま渡すこと。
434
549
  *
435
550
  * 戻り値は「通信できたか」「HTTP ステータス」「レスポンス本文」「拒否理由」を分けて返す。
436
551
  * 呼び出し側は 409(=読んだ後に Hub が動いた。次回 sync で再判定すべき)・
@@ -452,21 +567,29 @@ async function putHubClaudeMd({
452
567
  fetchImpl,
453
568
  logger,
454
569
  timeoutMs = HUB_REQUEST_TIMEOUT_MS,
570
+ budget,
455
571
  }) {
456
572
  const f = fetchImpl || globalThis.fetch
457
573
  if (!f) return { ok: false, status: null, data: null, error: null }
458
574
  const url =
459
575
  `${hubUrl.replace(/\/+$/, "")}/api/admin/project/claude-md-by-dir/` +
460
576
  `?dir_name=${encodeURIComponent(dirName)}`
461
- // expected_hash は「値があるときだけ」載せる。undefined を JSON 化すると
462
- // キーごと消えるとはいえ、空文字を送ると backend 側で「空 hash と一致するか」の
463
- // 照合に落ちるため、truthy のときのみ明示的に付与する。
464
- const payload = expectedHash
465
- ? { claude_md: body, expected_hash: expectedHash }
466
- : { claude_md: body }
577
+ // expected_hash は **string なら必ず載せる(空文字を含む)**。
578
+ //
579
+ // ⚠️ 空文字は「Hub が空であることを期待する」という **有効な CAS トークン**である。
580
+ // backend は `expected_hash is not None` で照合に入り、行ロック下で
581
+ // `locked.claude_md_hash or ''` と比較する。つまり GET と PUT の間に人が本文を
582
+ // 保存していれば `"" != sha256(本文)` で 409 になり、正しく守られる。
583
+ // truthy 判定で落とすと **キーごと消えて照合されず、盲目的な上書きになる**
584
+ // (初回登録経路でこの穴が実際に開いていた)。
585
+ // undefined / null(=呼び出し側が hash を観測できていない)のときだけ省く。
586
+ const payload =
587
+ typeof expectedHash === "string"
588
+ ? { claude_md: body, expected_hash: expectedHash }
589
+ : { claude_md: body }
467
590
  // headers 受信から body 消費までを 1 本の期限に収める(別々に張ると、
468
591
  // headers だけ返して body を閉じない応答で結局止まる)。
469
- const deadline = createDeadline(timeoutMs)
592
+ const deadline = acquireDeadline(budget, timeoutMs)
470
593
  try {
471
594
  const fetched = await deadline.guard(
472
595
  Promise.resolve().then(() =>
@@ -696,10 +819,52 @@ async function repairBaseIfInconsistent({
696
819
  return true
697
820
  }
698
821
 
822
+ /** 同名衝突で名前を作り直す上限回数(超えたら例外を投げて退避を諦める)。 */
823
+ const BAK_NAME_MAX_ATTEMPTS = 5
824
+
825
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
826
+
827
+ /**
828
+ * `.bak` を **排他作成**(`flag: 'wx'`)で書き、実際に使った名前を返す。
829
+ *
830
+ * ⚠️ 素の `writeFile` だと、同一ミリ秒に 2 回退避したとき(`makeBakName` の精度は
831
+ * ミリ秒)に **既存の退避を黙って上書きする**。退避は「手元の本文が失われない」
832
+ * ことの唯一の根拠なので、上書きは許さない: `EEXIST` なら少し待って名前を作り直し、
833
+ * 上限まで衝突したら **例外を投げる**(呼び出し側の pull はそこで中止され、
834
+ * ローカルは無傷のまま次回やり直せる)。
835
+ */
836
+ async function writeBakExclusive(metaDir, prefix, body) {
837
+ let lastErr = null
838
+ for (let attempt = 0; attempt < BAK_NAME_MAX_ATTEMPTS; attempt += 1) {
839
+ const name = makeBakName(prefix)
840
+ try {
841
+ await fs.writeFile(path.join(metaDir, name), body, { encoding: "utf-8", flag: "wx" })
842
+ return name
843
+ } catch (err) {
844
+ if (err?.code !== "EEXIST") throw err
845
+ lastErr = err
846
+ // 名前はミリ秒精度なので、1ms 以上待てば別名になる。
847
+ await sleep(2)
848
+ }
849
+ }
850
+ throw (
851
+ lastErr ||
852
+ new Error(`backupLocalConfigMd: could not create a unique .bak for ${prefix}`)
853
+ )
854
+ }
855
+
699
856
  /**
700
857
  * 既存ローカル CLAUDE.md を .cockpit-sync/CLAUDE.md.<prefix>.<ts>.bak に退避する。
701
858
  * 同 prefix の .bak は新しい順に BAK_KEEP 件保持し、古いものから FIFO 削除する。
702
859
  *
860
+ * ⚠️ **いま作成した .bak は FIFO の削除対象から必ず除外する**。名前の辞書順を
861
+ * 時系列順として扱っているため、既存 .bak が未来時刻の名前を持っていると
862
+ * (時計の巻き戻り・手作業で置かれた未来日付・別端末の時刻ずれ)新規 .bak が
863
+ * 「最も古い側」に並び、**同じ関数の中で作った直後に消される**。pull はその後
864
+ * ローカルを Hub 本文で上書きするので、手元の本文が復元不能になる。
865
+ * 「初回は Hub が勝つが、手元は .bak に退避されるので失われない」という安全性の
866
+ * 根拠は、この除外が無いと成立しない。
867
+ *
703
868
  * @param {'normal'|'conflict'} kind バックアップ種別(命名 prefix)
704
869
  */
705
870
  async function backupLocalConfigMd(targetDir, body, kind, configFilename) {
@@ -708,8 +873,7 @@ async function backupLocalConfigMd(targetDir, body, kind, configFilename) {
708
873
  await fs.mkdir(metaDir, { recursive: true })
709
874
  const filename = configPaths(configFilename).filename
710
875
  const prefix = kind === "conflict" ? `${filename}.conflict` : filename
711
- const bakName = makeBakName(prefix)
712
- await fs.writeFile(path.join(metaDir, bakName), body, "utf-8")
876
+ const bakName = await writeBakExclusive(metaDir, prefix, body)
713
877
  // 同 prefix の .bak を新しい順に BAK_KEEP 件まで残す。
714
878
  // 対象は「hub-agent が makeBakName で作った、この kind の .bak」だけに限定する
715
879
  // (conflict 退避や手作業で置かれた異物を巻き添えにしないため。bakNamePattern 参照)。
@@ -719,7 +883,9 @@ async function backupLocalConfigMd(targetDir, body, kind, configFilename) {
719
883
  .filter((n) => pattern.test(n))
720
884
  .sort() // 名前にタイムスタンプを含むので辞書順 ≒ 時系列順
721
885
  .reverse() // 新しい順
722
- const obsolete = sameKind.slice(BAK_KEEP)
886
+ // ⚠️ `bakName` を除外する。保持枠を 1 件超えることはあるが、次回以降の退避で
887
+ // 収束する(対して「いま退避した本文を消す」は取り返しがつかない)。
888
+ const obsolete = sameKind.slice(BAK_KEEP).filter((n) => n !== bakName)
723
889
  await Promise.all(
724
890
  obsolete.map((n) =>
725
891
  fs.unlink(path.join(metaDir, n)).catch(() => {}),
@@ -739,6 +905,15 @@ async function backupLocalConfigMd(targetDir, body, kind, configFilename) {
739
905
  * @param {Function} [args.fetchImpl] テスト注入用
740
906
  * @param {number} [args.requestTimeoutMs] Hub API 1 往復の期限(既定
741
907
  * `HUB_REQUEST_TIMEOUT_MS`)。テスト注入・低速環境の調整用。
908
+ * @param {object} [args.budget] `createSessionBudget()` の戻り値。セッション起動 1 回で
909
+ * **Hub 処理に割り当てた wall-clock 予算**(生成時からの経過時間。通信の待ち時間の
910
+ * 累積ではない)。渡すと各往復が「1 往復の期限」と「予算の残り」の小さい方で
911
+ * 打ち切られる。予算切れなら **何も書かずに** `skipped(budget-exhausted)` を返す。
912
+ * @param {boolean} [args.localFilePreexisted] 呼び出し元(`ensureClaudeMd` の
913
+ * `source === 'existing'`)が「このセッションより前からローカルにファイルがあった」
914
+ * と確認できたときだけ `true` を渡す。**初回登録の唯一の根拠**で、`undefined` /
915
+ * `false` なら初回登録しない(安全側)。ディスクには永続化しない(下記
916
+ * `isInitialRegistration` のコメント参照)。
742
917
  * @returns {Promise<{ action: 'pull'|'push'|'conflict'|'noop'|'skipped', reason?: string }>}
743
918
  */
744
919
  export async function syncClaudeMdWithHub({
@@ -750,6 +925,8 @@ export async function syncClaudeMdWithHub({
750
925
  fetchImpl,
751
926
  configFilename = "CLAUDE.md",
752
927
  requestTimeoutMs = HUB_REQUEST_TIMEOUT_MS,
928
+ budget,
929
+ localFilePreexisted,
753
930
  } = {}) {
754
931
  if (!targetDir) throw new Error("syncClaudeMdWithHub requires targetDir")
755
932
  if (!dirName) throw new Error("syncClaudeMdWithHub requires dirName")
@@ -760,6 +937,15 @@ export async function syncClaudeMdWithHub({
760
937
  logger?.info?.({ dirName }, "syncClaudeMdWithHub: no access token, skip")
761
938
  return { action: "skipped", reason: "no-token" }
762
939
  }
940
+ if (budget?.expired()) {
941
+ // セッション起動 1 回ぶんの wall-clock 予算を使い切った(通信していなくても、
942
+ // 生成時から経てば尽きる)。ローカルは触らず次回へ。
943
+ logger?.warn?.(
944
+ { dirName },
945
+ "syncClaudeMdWithHub: session wall-clock budget for hub work exhausted; skip (retry next session)",
946
+ )
947
+ return { action: "skipped", reason: "budget-exhausted" }
948
+ }
763
949
  const url = hubUrl || DEFAULT_HUB_API
764
950
 
765
951
  // 1) Hub の正本を取得
@@ -770,13 +956,123 @@ export async function syncClaudeMdWithHub({
770
956
  fetchImpl,
771
957
  logger,
772
958
  timeoutMs: requestTimeoutMs,
959
+ budget,
773
960
  })
774
961
  if (!hub) {
775
962
  return { action: "skipped", reason: "hub-not-resolvable" }
776
963
  }
777
- const hubBody = hub.claude_md || ""
778
- const hubHash = hub.claude_md_hash || "" // 空文字なら Project.save 未実行 = 空本文
779
- const hubUpdatedAt = hub.claude_md_updated_at || null
964
+ // ⚠️ 「観測できた空文字」と「そもそも観測できていない」を、**本文も hash も**
965
+ // 区別する。
966
+ // - string(**`""` を含む**)→ その値をそのまま採用。`""` は「Hub が空である」を
967
+ // 意味する **有効な CAS トークン**(Project.save 未実行 = 空本文)。
968
+ // - string でない(キー欠落 / null)→ `null`。「照合できない」を意味する。
969
+ // 両者を `|| ""` で潰すと、照合できない状態を「空を期待する」と偽って PUT して
970
+ // しまう(=盲目的な上書き)。`null` のときは PUT せず次回へ回す(fail-closed)。
971
+ const hubBody = typeof hub.claude_md === "string" ? hub.claude_md : null
972
+ const hubHash = typeof hub.claude_md_hash === "string" ? hub.claude_md_hash : null
973
+ // ⚠️ `updated_at` は **信用判定を通すまで正規化しない**(生値のまま持つ)。
974
+ // 空文字・数値を先に `null` へ潰すと、legacy の正例 (a) から見て「キー欠落」と
975
+ // 「型不正」が同じ値になり、`"" / "" / 1750000000` のような壊れた snapshot が
976
+ // legacy として門を通ってしまう(= `expected_hash: ""` の PUT が成立する)。
977
+ // 利用向けの正規化は門を通った後(下)で行う。
978
+ const hubUpdatedAtRaw = hub.claude_md_updated_at
979
+
980
+ // ---- 入口の門: snapshot を使う前に **1 回だけ** 信用できるかを検査する ----------
981
+ //
982
+ // ⚠️ **経路ごとに掛ける / 掛けないを判断しない。** かつては「PUT する経路だけ」に
983
+ // 絞っていたが、それがこの穴の作り方そのものだった。pull は Hub に書かないが、
984
+ // **meta / base という同期の基準を書く**。壊れた基準を残すと、次回に正常な応答が
985
+ // 返ったときに上書きが成立する(詳細な再現手順は `isHubSnapshotTrustworthy` の
986
+ // JSDoc)。よって **ローカル読み込みより前・あらゆる判定と書込より前**に置く。
987
+ //
988
+ // ここを通った後は、以降のすべての経路で次を前提にしてよい:
989
+ // - `hubBody` は string(`""` を含む)
990
+ // - `hubHash` は string(`""` を含む)
991
+ // - snapshot は自己整合(`"" / ""` か `hash === sha256(body)`)
992
+ // - `hubUpdatedAt` は、未保存 legacy(`"" / ""`)を除いて非空 string
993
+ // したがって **push / conflict の直前に同じ検査を再掲しないこと**(本体が 2 つに
994
+ // なり、「どちらが本物か」が分からなくなる)。
995
+ //
996
+ // ⚠️ **同期判定が依存する値は 3 つ(`body` / `hash` / `updated_at`)で、3 つとも
997
+ // ここで検査する。** 前回は body と hash だけを検査していたため、
998
+ // `{body: H1, hash: sha256(H1), updated_at: T0}`(本文と hash は新しいのに時刻だけ
999
+ // 前回値)という自己整合な snapshot が門を通り、Hub の H0 → H1 を
1000
+ // `hubChanged = false` と誤認していた。その回は noop でも、次にローカルを編集した
1001
+ // 回に「Hub 不変・ローカルだけ変化」= push と判定され、**CAS は現在の
1002
+ // sha256(H1) を送るので正しく成功し、Hub の H1 がローカル本文で消える**
1003
+ // (`.bak.conflict` も残らない)。「2 つは見たが 3 つ目を見ていない」を閉じるため、
1004
+ // **検査を入口以外に分散させないこと**。
1005
+ //
1006
+ // ⚠️ 旧実装の `hub.claude_md || ""` は、本文欠落を「Hub は空」へ黙って変換していた。
1007
+ // 初回登録(Hub 空 + ローカル非空 → push)を足したことで、この変換は
1008
+ // **「本文を観測していないのに PUT の許可条件を満たす」** 経路になった。しかも
1009
+ // hash と updated_at が正しく返る壊れた payload では **CAS が正しく一致して通る**。
1010
+ //
1011
+ // ⚠️ warn に本文・入力値を出さない(固定の理由と dirName だけ)。
1012
+ if (hubBody === null) {
1013
+ logger?.warn?.(
1014
+ { dirName },
1015
+ "syncClaudeMdWithHub: hub payload had no claude_md body; skip (cannot compare or overwrite safely)",
1016
+ )
1017
+ return { action: "skipped", reason: "hub-body-missing" }
1018
+ }
1019
+ if (hubHash === null) {
1020
+ // 照合トークンを観測できていない。**本文から hash を推測して送らない**
1021
+ // (捏造したトークンで照合が通ると上書きが成立する)。基準としても使えない。
1022
+ logger?.warn?.(
1023
+ { dirName },
1024
+ "syncClaudeMdWithHub: hub payload had no claude_md_hash; skip (cannot verify optimistic lock or trust the snapshot)",
1025
+ )
1026
+ return { action: "skipped", reason: "hub-hash-missing" }
1027
+ }
1028
+ // 一度でも保存されていれば backend は必ず `updated_at` を打つ(本文が空でも打つ。
1029
+ // 実データの経路 C = `"" / sha256("") / timestamp` がその実例)。したがって
1030
+ // **「未保存 legacy だけ」が `updated_at` 欠落の正例**で、それ以外は観測漏れ。
1031
+ // ⚠️ 経路 C を誤って弾かないよう、例外条件は `isHubSnapshotTrustworthy` の (a) と
1032
+ // **同じ述語**(`isNeverSavedLegacy` の 3 値)を使う。`hubBody === ""` だけ /
1033
+ // `"" / ""` の 2 値だけで判定しないこと。
1034
+ const hubNeverSaved = isNeverSavedLegacy(hubBody, hubHash, hubUpdatedAtRaw)
1035
+ // ⚠️ 以下 2 つは **利用者に返す reason を細かく保つための先読み**であって、信用判定の
1036
+ // 本体ではない(本体は下の `isHubSnapshotTrustworthy` 1 か所)。条件を足すときは
1037
+ // 必ず関数側に足し、ここは reason の説明を合わせるだけにすること。
1038
+ //
1039
+ // ⚠️ **時刻 → hash の順**にしている。legacy を名乗れない理由が「時刻が観測できない」
1040
+ // ときは、本文と hash が `"" / ""` でも「保存済みなのに時刻が無い」=
1041
+ // `hub-updated-at-missing` の方が実態に近い(`"" / ""` は hash 不整合としても
1042
+ // 説明できてしまうが、それでは「何を直せばよいか」が伝わらない)。
1043
+ if (!hubNeverSaved && usableHubUpdatedAt(hubUpdatedAtRaw) === null) {
1044
+ logger?.warn?.(
1045
+ { dirName },
1046
+ "syncClaudeMdWithHub: hub payload had no usable claude_md_updated_at (and is not an unsaved legacy); skip (cannot tell whether hub moved)",
1047
+ )
1048
+ return { action: "skipped", reason: "hub-updated-at-missing" }
1049
+ }
1050
+ if (!hubNeverSaved && hubHash !== sha256(hubBody)) {
1051
+ logger?.warn?.(
1052
+ { dirName },
1053
+ "syncClaudeMdWithHub: hub payload is not self-consistent (hash does not match body); skip (cannot use it as a sync baseline)",
1054
+ )
1055
+ return { action: "skipped", reason: "hub-payload-inconsistent" }
1056
+ }
1057
+ // ---- 信用判定の本体(正本は 1 か所)------------------------------------------
1058
+ // 上の先読みを全部通っていればここは常に true になる。冗長に見えるが、**先読み側を
1059
+ // 触ったときに契約から外れたら気づける**ようにするための最終ゲート。
1060
+ // ⚠️ 渡すのは **生値**(`hubUpdatedAtRaw`)。正規化済みの値を渡すと legacy の正例が
1061
+ // 「欠落」と「型不正」を区別できなくなる(関数側の JSDoc 参照)。
1062
+ if (!isHubSnapshotTrustworthy(hubBody, hubHash, hubUpdatedAtRaw)) {
1063
+ logger?.warn?.(
1064
+ { dirName },
1065
+ "syncClaudeMdWithHub: hub snapshot failed the trust contract; skip (cannot use it as a sync baseline)",
1066
+ )
1067
+ return { action: "skipped", reason: "hub-payload-inconsistent" }
1068
+ }
1069
+
1070
+ // ---- 門を通った後で、利用用の timestamp へ正規化する --------------------------
1071
+ // ここから下は「Hub が動いたか」の文字列比較・meta への保存に使う値。
1072
+ // ⚠️ **正規化はここより上へ動かさないこと**(門に正規化済みの値が渡ると、legacy の
1073
+ // 正例 (a) が「欠落」と「型不正」を区別できなくなる)。門を通っているので、
1074
+ // ここでの値は「未保存 legacy なら null / それ以外は非空 string」が保証される。
1075
+ const hubUpdatedAt = usableHubUpdatedAt(hubUpdatedAtRaw)
780
1076
 
781
1077
  // 2) ローカル CLAUDE.md を読み、現在 hash を計算
782
1078
  const localBody = await readLocalConfigMd(targetDir, configFilename)
@@ -785,36 +1081,123 @@ export async function syncClaudeMdWithHub({
785
1081
  // 3) メタファイルを読み、前回 sync の状態を取得
786
1082
  const meta = await readMeta(targetDir, configFilename)
787
1083
 
788
- // Hub が一度も保存されていない(空本文 + updated_at が null)プロジェクトは
1084
+ // --- 初回登録: 「Hub に無くローカルにある」ものを一度だけ Hub へ取り込む -------
1085
+ //
1086
+ // 条件は **すべて** 満たすときだけ:
1087
+ // - meta が無い(この端末はまだ一度も同期していない)
1088
+ // - Hub 本文が空(**等値比較のみ**。`updated_at` の有無は問わない)
1089
+ // - ローカル本文が非空
1090
+ // - `localFilePreexisted === true`(厳密比較。`undefined` / `false` は登録しない)
1091
+ // - CLAUDE.md(AGENTS.md は対象外)
1092
+ //
1093
+ // ⚠️ **なぜ `localFilePreexisted` をディスクに永続化しないのか**
1094
+ // `ensureClaudeMd` は戻り値 `{written, source}` で「ファイルが元からあったか」を
1095
+ // 既に知っているのに、呼び出し元がそれを捨てていた。ディスクに証拠を残すと
1096
+ // 「消費できたかが削除の成否に依存する」問題(3 巡連続で blocker を出した
1097
+ // 自動生成本文の sidecar)が再発するため、**同一呼び出し内のメモリでだけ受け渡す**。
1098
+ // 渡されなければ登録しない = 迷ったら Hub を触らない側に倒れる。
1099
+ //
1100
+ // ⚠️ **なぜ AGENTS.md を対象外にするのか**
1101
+ // Hub 側では CLAUDE.md と同じ `Project.claude_md` フィールドを共有しており、
1102
+ // AGENTS.md の内容を送ると混入事故になる(backend に混入ガードが入っていて 400
1103
+ // `config_md_kind_mismatch` で拒否される)。フィールド分離が入るまで対象外。
1104
+ const isInitialRegistration =
1105
+ !meta &&
1106
+ hubBody === "" &&
1107
+ !!localBody &&
1108
+ localFilePreexisted === true &&
1109
+ configPaths(configFilename).filename === "CLAUDE.md"
1110
+
1111
+ // Hub が一度も保存されていない(空本文 + 空 hash + updated_at 欠落)プロジェクトは
789
1112
  // 「Hub UI で意図的に未設定にしている」運用(テナント標準 CLAUDE.md に任せる等)の
790
1113
  // 可能性がある。端末側に何か書いてあっても push せず noop に倒す。
791
- // ユーザー確定方針: 「Hub の claude_md が空のプロジェクトは何もしない」。
792
- const hubInitialized = !!hubUpdatedAt
793
- if (!hubInitialized) {
1114
+ //
1115
+ // ⚠️ 判定は入口と **同じ述語**(`isNeverSavedLegacy` の 3 値 = 上で計算済みの
1116
+ // `hubNeverSaved`)を使う。門を通った後は `hubNeverSaved === (hubUpdatedAt === null)`
1117
+ // が成り立つので値としては `!hubUpdatedAt` と等価だが、**述語を 1 つに束ねておかないと
1118
+ // 片方だけ 2 値へ戻る**(= 3 値契約が部分的に破れる)回帰が起きる。
1119
+ const hubInitialized = !hubNeverSaved
1120
+ if (!hubInitialized && !isInitialRegistration) {
794
1121
  return { action: "noop", reason: "hub-uninitialized" }
795
1122
  }
796
1123
 
1124
+ // 本文が空 + `updated_at` は非 null(=経路 C。backend は空本文でも保存時に
1125
+ // updated_at を打つため、実データで 4 件実在した)。上の早期 return では拾えないので
1126
+ // **アクション判定に入る前に**ここで止める。
1127
+ //
1128
+ // ⚠️ 判定は空文字との **等値比較のみ**(`!hubBody` のような truthy 判定にしない。
1129
+ // 「本文が空である」以外の意味を混ぜないため)。
1130
+ //
1131
+ // 初回登録(上)だけがこの門を通れる。2 回目以降(meta あり)は、Hub UI で本文が
1132
+ // 消された状態を端末側本文で勝手に埋め戻さない。
1133
+ if (hubBody === "" && !isInitialRegistration) {
1134
+ return { action: "noop", reason: "hub-empty" }
1135
+ }
1136
+
797
1137
  // 判定: hubChanged / localChanged を計算する。
798
- // - hubChanged: Hub.updated_at が前回 sync 時より進んだ
799
- // updated_at が null(Project.save 未実行で初期空)の場合は変化なし扱い。
1138
+ // - hubChanged: Hub.updated_at が前回 sync 時より進んだ **か**、Hub 本文の hash が
1139
+ // 前回 sync 時の基準と異なる(どちらか一方でも動いていれば Hub は動いている)。
1140
+ // updated_at が null(Project.save 未実行で初期空)の場合は時刻側を変化なし扱い。
800
1141
  // - localChanged: 現在の localHash が前回 sync 時の hash と異なる(=端末で編集)
801
- // meta が無くてローカル非空なら、初回参加時のローカル変更扱い(衝突候補)。
1142
+ // meta が無い初回は常に false(= Hub-wins)。唯一の例外が初回登録(下記)。
802
1143
  let hubChanged, localChanged
803
1144
  if (!meta) {
804
- // 初回 sync。
1145
+ // 初回 sync。**CLAUDE.md / AGENTS.md で扱いを分けない**。
1146
+ //
1147
+ // 【なぜ初回は Hub を勝たせるのか】
1148
+ // 「ローカルに何か書いてある」だけで端末の変更と見なすと、ensureClaudeMd が
1149
+ // Hub 到達不能時に書いた placeholder / director 版が conflict の勝者になり、
1150
+ // **Hub の共有正本(手書き・数千文字)が自動生成物(数百文字)で潰れる**。
1151
+ // 実データ 30 workspace のうち 8 件がこの経路で PUT を発行し、4 件は正本を
1152
+ // 破壊する内容だった。Codex の AGENTS.md も同じで、既存リポジトリ同梱の
1153
+ // ローカル規約が Project.claude_md を壊さないよう Hub を優先する。
1154
+ //
1155
+ // 【なぜ「出どころ」を見分けようとしないのか】
1156
+ // 自動生成物かどうかを sidecar(生成時の hash)で見分ける実装を廃止した。
1157
+ //
1158
+ // ⚠️ sidecar に識別力が無かったわけではない。同一セッション内では
1159
+ // ensureClaudeMd → syncClaudeMdWithHub が連続 await されるので識別の必要が
1160
+ // そもそも無いが、**次セッション以降では同じファイルが `source === 'existing'`
1161
+ // になるため、sidecar は「前回自分が生成した本文だ」を跨いで識別できていた**。
1162
+ // それでも廃止したのは、判定に使うのはここ 1 か所だけなのに書く / 消す箇所が
1163
+ // 6 か所あり、「消費できたか」がファイル削除の成否に依存して **データ喪失を
1164
+ // 伴う中間状態が繰り返し生まれた**ため(3 巡のレビューで毎回この境界から
1165
+ // blocker が出た)。
1166
+ // **その結果、旧セッションが生成した雛形が空の Hub へ初回登録されることは、
1167
+ // トレードオフとして明示的に受け入れている**(実データでは初回登録される
1168
+ // 16 件のうち 10 件が hub-agent の自動生成雛形にあたる)。
1169
+ //
1170
+ // ⚠️ 将来「雛形を正本にしたくない」が要件化しても、**この sidecar を復活させない
1171
+ // こと**。サーバー側に origin / 登録状態を持つなど、削除の成否に依存しない
1172
+ // durable な設計を先に決める。
1173
+ // 「昔のレンダラで再現して等値なら自動生成物」という救済も実データ 0 件で廃止
1174
+ // 済み(本文は 2026-07-22 の aa58223 で変わっており原理的に一致しない)。
1175
+ //
1176
+ // 【引き換えに何が起きるか】
1177
+ // 手書き CLAUDE.md の **初回** 同期では 1 回だけ Hub 側が勝ち、手元は
1178
+ // `.bak` へ退避される(pull 経路が退避してから上書きする)。`.bak` から
1179
+ // 戻せば meta が入るので、次回以降は通常どおり端末側が push される。失われない。
805
1180
  hubChanged = !!hubBody
806
- if (configPaths(configFilename).filename === "AGENTS.md") {
807
- // Codex の AGENTS.md は既存リポジトリにも通常存在し、案件正本とは別の
808
- // ローカル規約を含む。初回に terminal-wins で Project.claude_md を壊さないよう、
809
- // Hub 正本を優先して pull(既存 AGENTS.md は .bak に退避)する。
810
- localChanged = false
811
- } else if (localBody == null) {
812
- localChanged = false
813
- } else {
814
- localChanged = !!localBody // 非空なら端末側に何かある=衝突候補
815
- }
1181
+ // 唯一の例外が初回登録(Hub に無くローカルにある)。push 経路へ載せる。
1182
+ localChanged = isInitialRegistration
816
1183
  } else {
817
- hubChanged = !!hubUpdatedAt && hubUpdatedAt !== meta.lastSyncedHubUpdatedAt
1184
+ // 「Hub が動いたか」は **時刻と本文 hash の両方**で見る(片方だけにしない)。
1185
+ //
1186
+ // ⚠️ **OR を外して hash 基準だけにしないこと。** 同一本文を Hub で保存し直した
1187
+ // 「時刻だけの更新」は hash が変わらないので、hash 基準だけだと見落とす。
1188
+ // ⚠️ **時刻基準だけにも戻さないこと。** `updated_at` が前回値のまま本文だけ
1189
+ // 進んだ snapshot(入口の門を通る自己整合な payload)で Hub の変更を見落とし、
1190
+ // 次にローカルを編集した回に push が成立して Hub 側が消える(入口の門の
1191
+ // コメント参照)。
1192
+ //
1193
+ // `meta.lastSyncedHash` は全書込経路で「前回同期時点の **Hub 本文** の hash」に
1194
+ // なっている(pull / 収束修復は `hubHash`、push / conflict は PUT 後に Hub 正本と
1195
+ // 一致するローカル本文の hash、初期 pull は `hubHash`)。だから現在の `hubHash`
1196
+ // と直接比較してよい。
1197
+ const hubTimestampMoved =
1198
+ !!hubUpdatedAt && hubUpdatedAt !== meta.lastSyncedHubUpdatedAt
1199
+ const hubBodyMoved = hubHash !== meta.lastSyncedHash
1200
+ hubChanged = hubTimestampMoved || hubBodyMoved
818
1201
  localChanged =
819
1202
  localCurrentHash != null && localCurrentHash !== meta.lastSyncedHash
820
1203
  }
@@ -828,32 +1211,35 @@ export async function syncClaudeMdWithHub({
828
1211
  // meta 未更新 → 次回この分岐 → さらに次で Hub 編集を上書き)。
829
1212
  // よってこの分岐は「収束を確定させる自己修復」にする。
830
1213
  if (hubChanged && localChanged && hubHash && hubHash === localCurrentHash) {
831
- // ⚠️ GET の payload を無条件に信用しない。updated_at があり、かつ申告された
832
- // hash が本文と実際に一致するときだけ同期基準を進める(壊れた payload で
833
- // 基準を進めると、その後の pull / push の判定が丸ごと狂う)。
834
- if (hubUpdatedAt && hubHash === sha256(hubBody)) {
835
- // ⚠️ base → meta の順(writeSyncState)。meta を先に進めると、base の書込が
836
- // 失敗しても「同期済み」と見なされ、次回 sync が通常 noop に入って
837
- // base が壊れたまま固定される。
838
- await writeSyncState(targetDir, {
839
- body: hubBody,
840
- meta: {
841
- lastSyncedHubUpdatedAt: hubUpdatedAt,
842
- lastSyncedHash: hubHash,
843
- lastSyncedAt: nowIso(),
844
- },
845
- }, configFilename)
846
- logger?.info?.(
847
- { dirName },
848
- "syncClaudeMdWithHub: converged (meta/base repaired to hub state)",
849
- )
850
- return { action: "noop", reason: "converged" }
851
- }
852
- logger?.warn?.(
853
- { dirName, has_updated_at: !!hubUpdatedAt },
854
- "syncClaudeMdWithHub: converged but hub payload is inconsistent; meta not advanced",
1214
+ // snapshot の自己整合性(本文 / hash / updated_at)は **入口の門で保証済み**
1215
+ // なのでここでは見ない(見ると検査の本体が 2 つになる)。
1216
+ //
1217
+ // ⚠️ かつてここに `if (hubUpdatedAt)` と、その偽側で
1218
+ // `converged-untrusted-payload` を返す分岐があったが **到達不能**だったので消した:
1219
+ // - meta あり … `hub-uninitialized` の早期 return が手前で効くので
1220
+ // `hubUpdatedAt` は必ず truthy(入口の門で「保存済みなら非空 string」も要求)
1221
+ // - meta なし … `hubChanged = !!hubBody` と `localChanged = isInitialRegistration`
1222
+ // (後者は `hubBody === ""` が前提)が同時に true にならない
1223
+ // 到達しないのにログが「payload is inconsistent」と説明していて、入口へ検査を
1224
+ // 集約した現契約とも逆になっていた。⚠️ `hubChanged` の定義を変えるときは
1225
+ // この到達可能性を取り直すこと。
1226
+ //
1227
+ // ⚠️ base → meta の順(writeSyncState)。meta を先に進めると、base の書込が
1228
+ // 失敗しても「同期済み」と見なされ、次回 sync が通常 noop に入って
1229
+ // base が壊れたまま固定される。
1230
+ await writeSyncState(targetDir, {
1231
+ body: hubBody,
1232
+ meta: {
1233
+ lastSyncedHubUpdatedAt: hubUpdatedAt,
1234
+ lastSyncedHash: hubHash,
1235
+ lastSyncedAt: nowIso(),
1236
+ },
1237
+ }, configFilename)
1238
+ logger?.info?.(
1239
+ { dirName },
1240
+ "syncClaudeMdWithHub: converged (meta/base repaired to hub state)",
855
1241
  )
856
- return { action: "noop", reason: "converged-untrusted-payload" }
1242
+ return { action: "noop", reason: "converged" }
857
1243
  }
858
1244
 
859
1245
  // 4) アクション決定
@@ -875,16 +1261,29 @@ export async function syncClaudeMdWithHub({
875
1261
  }
876
1262
 
877
1263
  if (hubChanged && !localChanged) {
878
- // pull: ローカルを .bak 退避 → Hub の本文で上書き
879
- await backupLocalConfigMd(targetDir, localBody, "normal", configFilename)
880
- await writeLocalConfigMd(targetDir, hubBody, configFilename)
1264
+ // pull: ローカルを .bak 退避 → Hub の本文で上書き。
1265
+ //
1266
+ // ⚠️ **バイト等値なら本文も `.bak` も触らない**。内容が同じなのに毎回 `.bak` を
1267
+ // 作ると、`BAK_KEEP=3` の FIFO で **利用者の元の本文が押し出される**。meta の
1268
+ // 書込が繰り返し失敗する状況(=毎回この pull 経路に入る)で実際に起こりうる。
1269
+ // 退避が「初回は Hub が勝つが手元は失われない」の唯一の根拠なので、
1270
+ // 「同じ内容を退避し直して古い退避を捨てる」動きは許さない。
1271
+ if (localBody !== hubBody) {
1272
+ await backupLocalConfigMd(targetDir, localBody, "normal", configFilename)
1273
+ await writeLocalConfigMd(targetDir, hubBody, configFilename)
1274
+ }
881
1275
  // 3-way merge 用 base: pull 直後はローカル == Hub なので Hub 本文を base に。
882
1276
  // base → meta の順で書く(writeSyncState の JSDoc 参照)。
1277
+ //
1278
+ // ⚠️ `lastSyncedHash` に **snapshot が申告した hash をそのまま**入れてよいのは、
1279
+ // 入口の門で `hubHash === sha256(hubBody)` を保証しているから。ここで
1280
+ // `hubHash || sha256(hubBody)` のようなフォールバックを足すと「本文とは別物の
1281
+ // hash を基準として保存する」余地が戻り、次回の正常応答で push が成立する。
883
1282
  await writeSyncState(targetDir, {
884
1283
  body: hubBody,
885
1284
  meta: {
886
1285
  lastSyncedHubUpdatedAt: hubUpdatedAt,
887
- lastSyncedHash: hubHash || sha256(hubBody),
1286
+ lastSyncedHash: hubHash,
888
1287
  lastSyncedAt: nowIso(),
889
1288
  },
890
1289
  }, configFilename)
@@ -893,18 +1292,23 @@ export async function syncClaudeMdWithHub({
893
1292
  }
894
1293
 
895
1294
  if (!hubChanged && localChanged) {
1295
+ // ⚠️ hash の観測と snapshot の自己整合性は **入口の門で保証済み**。ここに
1296
+ // 個別チェックを再掲しないこと(検査の本体が 2 つになると、片方だけ適用範囲が
1297
+ // 戻る回帰が起きる。実際に「PUT 経路にだけ掛ける」設計から blocker が出た)。
896
1298
  // push: Hub に PUT。成功時にメタ更新。
897
1299
  const put = await putHubClaudeMd({
898
1300
  hubUrl: url,
899
1301
  accessToken: token,
900
1302
  dirName,
901
1303
  body: localBody || "",
902
- // 楽観ロック: この sync が GET で読んだ時点の hash を渡す。読んだ後に Hub が
1304
+ // 楽観ロック: この sync が GET で読んだ時点の hash を **そのまま** 渡す
1305
+ // (`""` も有効なトークンなので falsy 判定で潰さない)。読んだ後に Hub が
903
1306
  // 動いていれば backend が 409 を返すので、盲目的な上書きにならない。
904
- expectedHash: hubHash || undefined,
1307
+ expectedHash: hubHash,
905
1308
  fetchImpl,
906
1309
  logger,
907
1310
  timeoutMs: requestTimeoutMs,
1311
+ budget,
908
1312
  })
909
1313
  if (!put.ok) {
910
1314
  if (put.status === 409) {
@@ -958,6 +1362,10 @@ export async function syncClaudeMdWithHub({
958
1362
  }
959
1363
 
960
1364
  // hubChanged && localChanged → 衝突。端末側を勝者にして Hub に PUT。
1365
+ //
1366
+ // ⚠️ hash の観測と snapshot の自己整合性は **入口の門で保証済み**(push 経路と同じ
1367
+ // 理由でここに再掲しない)。壊れた snapshot は入口で返すので、**PUT しない回に
1368
+ // `.bak.conflict` だけが増える**こともない。
961
1369
  // Hub の取り込み内容は .bak.conflict に退避(後で人手マージできるよう)。
962
1370
  await backupLocalConfigMd(targetDir, hubBody, "conflict", configFilename)
963
1371
  const put = await putHubClaudeMd({
@@ -965,11 +1373,12 @@ export async function syncClaudeMdWithHub({
965
1373
  accessToken: token,
966
1374
  dirName,
967
1375
  body: localBody || "",
968
- // push 経路と同じく、GET 時点の hash で楽観ロックを効かせる。
969
- expectedHash: hubHash || undefined,
1376
+ // push 経路と同じく、GET 時点の hash を **そのまま** 渡して楽観ロックを効かせる。
1377
+ expectedHash: hubHash,
970
1378
  fetchImpl,
971
1379
  logger,
972
1380
  timeoutMs: requestTimeoutMs,
1381
+ budget,
973
1382
  })
974
1383
  if (!put.ok) {
975
1384
  // PUT 失敗時はメタを更新しない(次回再試行)。ローカルは無傷。