@cocorograph/hub-agent 0.8.21 → 0.8.22

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/src/claude-md.mjs CHANGED
@@ -15,15 +15,41 @@
15
15
  * CLAUDE.md(Stage 1/2 で正本化したもの)が即座に引かれて作業に取り掛かれる
16
16
  * ようにするため。動的生成は「Hub に正本がまだ無いプロジェクト」の救済として
17
17
  * のみ機能する。
18
+ *
19
+ * ⚠️ 2 / 3 の自動生成本文は **sidecar を一切残さない**。かつては「この本文は
20
+ * 自動生成物だ」という証拠(hash)を `.cockpit-sync/*.fallback.json` に書き、
21
+ * 次回同期の初回判定で参照していたが、書く / 消すたびに中間状態が生まれる割に
22
+ * 識別力がほぼ無かったため廃止した(2026-08-02。`claude-md-sync.mjs` の
23
+ * meta 無し初回判定のコメントが正本)。旧版が残した sidecar は誰も読まないので
24
+ * 掃除もしない(掃除を入れると「消せなかったとき」の分岐がまた増える)。
25
+ *
26
+ * ⚠️ 戻り値の `source` は捨てないこと。`'existing'`(=このセッションより前から
27
+ * ローカルにファイルがあった)は、同期側の **初回登録**(Hub に無くローカルに
28
+ * あるものを 1 度だけ取り込む)の唯一の根拠で、`syncClaudeMdWithHub` の
29
+ * `localFilePreexisted` へそのまま渡す。
30
+ *
31
+ * ⚠️ 本文レンダラは `./claude-md-render.mjs` に分割してある(純粋な文字列生成)。
32
+ * director / repositories の取得は **このファイルが持つ**(唯一の呼び出し元)。
18
33
  */
19
34
  import fs from "node:fs/promises"
20
35
  import os from "node:os"
21
36
  import path from "node:path"
22
37
  import {
23
- sha256,
24
38
  writeSyncState,
39
+ cancelResponseBody,
25
40
  nowIso,
41
+ isHubSnapshotTrustworthy,
42
+ usableHubUpdatedAt,
26
43
  } from "./claude-md-sync.mjs"
44
+ import {
45
+ HUB_REQUEST_TIMEOUT_MS,
46
+ acquireDeadline,
47
+ } from "./hub-request-deadline.mjs"
48
+ import {
49
+ renderPlaceholder,
50
+ renderRepositorySection,
51
+ renderWithDirector,
52
+ } from "./claude-md-render.mjs"
27
53
 
28
54
  const DEFAULT_HUB_API = "https://api.hub.cocorograph.com"
29
55
 
@@ -54,42 +80,138 @@ async function loadAccessToken(configFilename = "CLAUDE.md") {
54
80
  * 同じエンドポイントは syncClaudeMdWithHub も使うが、ensureClaudeMd は
55
81
  * 初回生成 (メタ無し) 経路でしか呼ばれず、PUT もしないため import せず
56
82
  * 個別実装する。
83
+ *
84
+ * ⚠️ `fetch` にも `res.json()` にも **同じ deadline** を掛ける(headers だけ返して
85
+ * body を閉じない応答では往復が永久に返らない)。ここが止まると `createSession`
86
+ * ごと止まる。
57
87
  */
58
- async function fetchHubClaudeMdProject({ hubUrl, accessToken, dirName, fetchImpl }) {
88
+ async function fetchHubClaudeMdProject({
89
+ hubUrl,
90
+ accessToken,
91
+ dirName,
92
+ fetchImpl,
93
+ deadline,
94
+ logger,
95
+ }) {
59
96
  const f = fetchImpl || globalThis.fetch
60
97
  if (!f) return null
61
98
  const url =
62
99
  `${hubUrl.replace(/\/+$/, "")}/api/admin/project/claude-md-by-dir/` +
63
100
  `?dir_name=${encodeURIComponent(dirName)}`
64
101
  try {
65
- const res = await f(url, {
66
- headers: { Authorization: `Bearer ${accessToken}` },
67
- })
68
- if (!res.ok) return null
69
- return await res.json()
102
+ const fetched = await deadline.guard(
103
+ // 同期的に throw する fetch 実装でも guard に載るよう then で包む。
104
+ Promise.resolve().then(() =>
105
+ f(url, {
106
+ headers: { Authorization: `Bearer ${accessToken}` },
107
+ signal: deadline.signal,
108
+ }),
109
+ ),
110
+ )
111
+ if (fetched.timedOut) {
112
+ logger?.warn?.({ dirName }, "fetchHubClaudeMdProject: round-trip timed out; giving up")
113
+ return null
114
+ }
115
+ if (fetched.error) throw fetched.error
116
+ const res = fetched.value
117
+ if (!res?.ok) {
118
+ // 本文は使わないが、閉じずに捨てない(body を閉じない応答で接続が残る)。
119
+ // ⚠️ ここで共有 deadline を abort しないこと(cancelResponseBody の JSDoc 参照)。
120
+ cancelResponseBody(res)
121
+ return null
122
+ }
123
+ const parsed = await deadline.guard(Promise.resolve().then(() => res.json()))
124
+ if (parsed.timedOut) {
125
+ deadline.abort()
126
+ logger?.warn?.({ dirName }, "fetchHubClaudeMdProject: response body read timed out")
127
+ return null
128
+ }
129
+ if (parsed.error) throw parsed.error
130
+ return parsed.value
70
131
  } catch {
71
132
  return null
72
133
  }
73
134
  }
74
135
 
75
- async function fetchDirector({ hubUrl, accessToken, dirName, fetchImpl }) {
136
+ /**
137
+ * GET して JSON を読むだけの共通処理(期限付き)。失敗・非 OK・期限切れは null。
138
+ *
139
+ * ⚠️ `fetch` だけでなく `res.json()` にも同じ期限を掛ける。headers だけ返して
140
+ * body を閉じない応答で往復が永久に返らないのが元の欠陥だったため。
141
+ */
142
+ async function fetchJsonWithDeadline({ f, url, accessToken, deadline, logger, dirName, label }) {
143
+ try {
144
+ const fetched = await deadline.guard(
145
+ // 同期的に throw する fetch 実装でも guard に載るよう then で包む。
146
+ Promise.resolve().then(() =>
147
+ f(url, {
148
+ headers: { Authorization: `Bearer ${accessToken}` },
149
+ signal: deadline.signal,
150
+ }),
151
+ ),
152
+ )
153
+ if (fetched.timedOut) {
154
+ logger?.warn?.({ dirName }, `${label}: round-trip timed out; giving up`)
155
+ return null
156
+ }
157
+ if (fetched.error) throw fetched.error
158
+ const res = fetched.value
159
+ if (!res?.ok) {
160
+ // ⚠️ **共有 deadline を abort しない**。director と repositories は 1 本の
161
+ // deadline を共有しており、片方の通常 404(旧 backend では repositories が
162
+ // 404)で共有 signal を切ると、成功しうるもう片方まで巻き添えになる。
163
+ // 解放するのは **この応答の body だけ**。
164
+ cancelResponseBody(res)
165
+ return null
166
+ }
167
+ const parsed = await deadline.guard(Promise.resolve().then(() => res.json()))
168
+ if (parsed.timedOut) {
169
+ deadline.abort()
170
+ logger?.warn?.({ dirName }, `${label}: response body read timed out`)
171
+ return null
172
+ }
173
+ if (parsed.error) throw parsed.error
174
+ return parsed.value
175
+ } catch (err) {
176
+ logger?.warn?.(
177
+ { dirName, err: err?.message || String(err) },
178
+ `${label}: request failed`,
179
+ )
180
+ return null
181
+ }
182
+ }
183
+
184
+ /**
185
+ * dir 名から Hub director(KnowledgePage)を取得する。
186
+ *
187
+ * ⚠️ 一度 `claude-md-sync.mjs` へ物理移動していたが、移動の理由だった「救済判定」
188
+ * (旧レンダラで再現して等値比較する移行措置)を廃止したため**ここへ戻した**。
189
+ * 呼び出し元は `ensureClaudeMd` だけで、sync 側は director を引かない。
190
+ *
191
+ * @param {object} args
192
+ * @param {object} args.deadline `hub-request-deadline.mjs` の deadline。
193
+ * ⚠️ director と repositories は**同じ deadline を共有**して呼ぶこと
194
+ * (片方が期限切れになったらもう片方も abort される、を成立させるため)。
195
+ */
196
+ async function fetchDirector({ hubUrl, accessToken, dirName, fetchImpl, deadline, logger }) {
76
197
  const f = fetchImpl || globalThis.fetch
77
198
  if (!f) return null
78
199
  const slug = `${dirName}/_director`
79
200
  const url =
80
201
  `${hubUrl.replace(/\/+$/, "")}/api/knowledge-pages/` +
81
202
  `?slug=${encodeURIComponent(slug)}&doc_type=director`
82
- try {
83
- const res = await f(url, {
84
- headers: { Authorization: `Bearer ${accessToken}` },
85
- })
86
- if (!res.ok) return null
87
- const payload = await res.json()
88
- const items = payload?.results || []
89
- return items[0] || null
90
- } catch {
91
- return null
92
- }
203
+ const payload = await fetchJsonWithDeadline({
204
+ f,
205
+ url,
206
+ accessToken,
207
+ deadline,
208
+ logger,
209
+ dirName,
210
+ label: "fetchDirector",
211
+ })
212
+ if (!payload) return null
213
+ const items = payload?.results || []
214
+ return items[0] || null
93
215
  }
94
216
 
95
217
  /**
@@ -97,237 +219,40 @@ async function fetchDirector({ hubUrl, accessToken, dirName, fetchImpl }) {
97
219
  *
98
220
  * 旧 Hub backend(GitRepository モデル未導入)では 404 を返すので、
99
221
  * その場合は空配列扱いで後方互換を維持する。
222
+ * ⚠️ 配置の経緯は `fetchDirector` の JSDoc 参照。
100
223
  *
101
224
  * @returns {Promise<Array<object>>}
102
225
  */
103
- async function fetchRepositories({ hubUrl, accessToken, dirName, fetchImpl }) {
226
+ async function fetchRepositories({ hubUrl, accessToken, dirName, fetchImpl, deadline, logger }) {
104
227
  const f = fetchImpl || globalThis.fetch
105
228
  if (!f) return []
106
229
  const url =
107
230
  `${hubUrl.replace(/\/+$/, "")}/api/git-repositories/by-dir/` +
108
231
  `${encodeURIComponent(dirName)}/`
109
- try {
110
- const res = await f(url, {
111
- headers: { Authorization: `Bearer ${accessToken}` },
112
- })
113
- if (!res.ok) return []
114
- const payload = await res.json()
115
- return Array.isArray(payload?.repositories) ? payload.repositories : []
116
- } catch {
117
- return []
118
- }
119
- }
120
-
121
- /**
122
- * GitRepository 配列を CLAUDE.md のクローンレス作業手順セクションに描画する。
123
- *
124
- * リポジトリが 1 件もない場合は空文字列を返し、CLAUDE.md には何も追記しない。
125
- */
126
- /**
127
- * repo_url が GitHub の `owner/repo` slug 形式かを判定する。
128
- *
129
- * `://` や `@` を含むフル URL(GitLab の `git@gitlab.com:...` や
130
- * `https://gitlab.com/...` 等)は false を返す。これらは `gh` ではなく
131
- * 素の `git clone` でチェックアウトする。
132
- */
133
- function isGitHubSlug(repoUrl) {
134
- if (/:\/\/|@/.test(repoUrl)) return false
135
- return /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repoUrl)
136
- }
137
-
138
- function renderRepositorySection(repositories, dirName = "", cliKind = "claude") {
139
- if (!Array.isArray(repositories) || repositories.length === 0) return ""
140
-
141
- const lines = ["## Cockpit クローンレス開発フロー", ""]
142
- lines.push(
143
- "この workspace ディレクトリには Git リポジトリを常設しません。",
144
- "コードを修正するときは、毎回一時ディレクトリにチェックアウトして作業します。",
145
- "",
146
- )
147
-
148
- for (const repo of repositories) {
149
- const repoUrl = (repo?.repo_url || "").trim()
150
- if (!repoUrl) continue
151
- const branch = (repo?.default_branch || "main").trim()
152
- const deployDisplay = (repo?.deploy_method_display || "").trim()
153
- const deployNotes = (repo?.deploy_notes || "").trim()
154
- const stagingName = (repo?.staging_server_name || "").trim()
155
- const productionName = (repo?.production_server_name || "").trim()
156
- const repoLabel = (repo?.name || "").trim()
157
- const heading = repoLabel
158
- ? `### ${repoUrl} — ${repoLabel}`
159
- : `### ${repoUrl}`
160
-
161
- lines.push(heading)
162
- lines.push("")
163
- lines.push(`- **デフォルトブランチ**: \`${branch}\``)
164
- if (deployDisplay) lines.push(`- **デプロイ方式**: ${deployDisplay}`)
165
- if (stagingName) lines.push(`- **ステージング**: ${stagingName}`)
166
- if (productionName) lines.push(`- **本番**: ${productionName}`)
167
- lines.push("")
168
-
169
- const githubSlug = isGitHubSlug(repoUrl)
170
- lines.push("**作業手順(クローンレス)**:")
171
- lines.push("")
172
- lines.push("```bash")
173
- lines.push("TMPDIR=$(mktemp -d)")
174
- if (githubSlug) {
175
- // GitHub: gh CLI で clone・PR 作成
176
- lines.push(`gh repo clone ${repoUrl} "$TMPDIR" -- --depth 1 --filter=blob:none --no-checkout`)
177
- lines.push(`cd "$TMPDIR" && git fetch origin ${branch} && git checkout -b feat/<your-change> origin/${branch}`)
178
- lines.push("# ファイル編集 → 動作確認")
179
- lines.push('git commit -am "<message>"')
180
- lines.push("git push -u origin HEAD")
181
- lines.push(`gh pr create --base ${branch} --fill`)
182
- } else {
183
- // GitLab 等: 素の git clone。MR は push 後に Web/glab で作成
184
- lines.push(`git clone --depth 1 --filter=blob:none --no-checkout ${repoUrl} "$TMPDIR"`)
185
- lines.push(`cd "$TMPDIR" && git fetch origin ${branch} && git checkout -b feat/<your-change> origin/${branch}`)
186
- lines.push("# ファイル編集 → 動作確認")
187
- lines.push('git commit -am "<message>"')
188
- lines.push("git push -u origin HEAD")
189
- lines.push("# MR は GitLab の Web UI(push 時に表示される URL)か `glab mr create` で作成")
190
- }
191
- lines.push("# 作業終了後")
192
- lines.push('cd / && rm -rf "$TMPDIR"')
193
- lines.push("```")
194
- lines.push("")
195
-
196
- if (deployNotes) {
197
- lines.push("**デプロイ手順メモ**:")
198
- lines.push("")
199
- lines.push("```")
200
- lines.push(deployNotes)
201
- lines.push("```")
202
- lines.push("")
203
- }
204
-
205
- // デプロイ接続情報の安全な取得手順(秘匿値を会話に出さない)。
206
- if (stagingName || productionName) {
207
- const envExample = productionName ? "production" : "staging"
208
- lines.push("**デプロイ接続情報の取得(秘匿値を会話に出さない)**:")
209
- lines.push("")
210
- lines.push("```bash")
211
- lines.push(
212
- "# 秘密鍵を ~/.ssh/hub_deploy/ に書き出し、ssh コマンドだけ受け取る",
213
- )
214
- lines.push(
215
- `python3 ~/.${cliKind === "codex" ? "codex" : "claude"}/scripts/hub_helper.py deploy_creds ${dirName || "<dir_name>"} --env ${envExample}`,
216
- )
217
- lines.push("```")
218
- lines.push("")
219
- lines.push(
220
- "返り値の `ssh_command` をそのまま使って接続します。`key_file` は",
221
- "chmod 600 で書き出されます。パスワード認証のサーバーは `password_set: true`",
222
- "だけ返るので、その場合は Hub の「サーバー情報」タブから人手で取得します。",
223
- "",
224
- )
225
- }
226
- }
227
-
228
- lines.push("### デプロイ前の必須ガード", "")
229
- lines.push(
230
- "- **本番反映の前に必ず現状バックアップを取る**(DB ダンプ / 対象ディレクトリの退避)。",
231
- "- ステージングで動作確認してから本番へ。",
232
- "- 破壊的操作(`reset --hard` / `rm -rf` / DB 操作)の前に対象を確認する。",
233
- "- Git 認証はローカルの `gh` CLI(GitHub)/ SSH 鍵(GitLab 等)を使用します。",
234
- "",
235
- )
236
- return lines.join("\n")
237
- }
238
-
239
- function renderWithDirector(dirName, director, repositorySection = "", configFilename = "CLAUDE.md") {
240
- const fm = director?.frontmatter || {}
241
- const client = (fm.client || "").trim()
242
- const domain = (fm.domain || "").trim()
243
- const industry = (fm.industry || "").trim()
244
- const service = (fm.service || "").trim()
245
- const techStack = Array.isArray(fm.tech_stack) ? fm.tech_stack : []
246
- const googleDrive = (fm.google_drive || "").trim()
247
-
248
- const lines = [
249
- `# ${configFilename}`,
250
- "",
251
- `This file provides guidance to ${configFilename === "AGENTS.md" ? "Codex" : "Claude Code"} when working with code in this repository.`,
252
- "",
253
- "## プロジェクト概要",
254
- "",
255
- ]
256
- if (client) lines.push(`- **クライアント**: ${client}`)
257
- if (domain) lines.push(`- **ドメイン**: ${domain}`)
258
- if (industry) lines.push(`- **業種**: ${industry}`)
259
- if (service) lines.push(`- **サービス**: ${service}`)
260
- if (!(client || domain || industry || service)) {
261
- lines.push("- (director frontmatter に該当情報なし)")
262
- }
263
- lines.push("")
264
-
265
- if (techStack.length > 0) {
266
- lines.push("## 技術スタック")
267
- lines.push("")
268
- for (const t of techStack) lines.push(`- ${t}`)
269
- lines.push("")
270
- }
271
-
272
- lines.push("## 関連")
273
- lines.push("")
274
- lines.push(
275
- `- **Hub Director**: \`[[${dirName}/_director]]\` — 案件の進行・意思決定・直近ログ`,
276
- )
277
- if (googleDrive) {
278
- lines.push(`- **Google Drive**: \`$GDRIVE/${googleDrive}/\` — 成果物・素材`)
279
- }
280
- lines.push("")
281
-
282
- if (repositorySection) {
283
- lines.push(repositorySection)
284
- }
285
-
286
- lines.push("## 初期化")
287
- lines.push("")
288
- lines.push(
289
- "これは hub-agent が director frontmatter から自動生成した雛形です。",
290
- )
291
- lines.push(
292
- "開発手順・SSH 接続情報・横断ナレッジへのポインタなど、より詳細な内容は",
293
- )
294
- lines.push(
295
- `このリポジトリで ${configFilename === "AGENTS.md" ? "`codex` を起動して `/init-Codex-md`" : "`claude` を起動して `/init-claude-md`"} スキルを実行してください。`,
296
- )
297
- lines.push("")
298
- return lines.join("\n")
299
- }
300
-
301
- function renderPlaceholder(dirName, repositorySection = "", configFilename = "CLAUDE.md") {
302
- const sections = [
303
- `# ${configFilename}`,
304
- "",
305
- `This file provides guidance to ${configFilename === "AGENTS.md" ? "Codex" : "Claude Code"} when working with code in this repository.`,
306
- "",
307
- "## 関連",
308
- "",
309
- `- **Hub Director**: \`[[${dirName}/_director]]\` — 案件の進行・意思決定・直近ログ (未作成)`,
310
- "",
311
- ]
312
- if (repositorySection) {
313
- sections.push(repositorySection)
314
- }
315
- sections.push(
316
- "## 初期化",
317
- "",
318
- "これは hub-agent の自動生成プレースホルダです。Hub director も未作成です。",
319
- `詳細を生成するには、このリポジトリで ${configFilename === "AGENTS.md" ? "`codex` を起動して `/init-Codex-md`" : "`claude` を起動して `/init-claude-md`"} スキルを実行してください。`,
320
- "",
321
- )
322
- return sections.join("\n")
232
+ const payload = await fetchJsonWithDeadline({
233
+ f,
234
+ url,
235
+ accessToken,
236
+ deadline,
237
+ logger,
238
+ dirName,
239
+ label: "fetchRepositories",
240
+ })
241
+ return Array.isArray(payload?.repositories) ? payload.repositories : []
323
242
  }
324
243
 
325
244
  /**
326
245
  * `<targetDir>/CLAUDE.md` を必要に応じて生成する。
327
246
  *
328
247
  * - CLAUDE.md が既存なら何もしない (上書きしない)
248
+ * - Hub に正本があればそれを書き、同期メタを初期化する (source: 'hub')
329
249
  * - Hub director が見つかれば frontmatter 連動の構造化 CLAUDE.md を生成
330
250
  * - 見つからない / 認証なし / API 失敗ならプレースホルダを生成
251
+ * - **予算切れのときは何も書かない** (source: 'deferred')。不確定な状態で
252
+ * 自動生成物を置かず次回へ持ち越す。
253
+ * - **200 が返ったのに snapshot が自己整合でないときも何も書かない**
254
+ * (source: 'deferred')。不整合な本文 / hash を「同期済みの基準」として保存すると、
255
+ * 直後の `syncClaudeMdWithHub` が正常な応答を得た瞬間に Hub 正本を上書きする。
331
256
  *
332
257
  * @param {object} args
333
258
  * @param {string} args.targetDir 絶対パス
@@ -336,7 +261,15 @@ function renderPlaceholder(dirName, repositorySection = "", configFilename = "CL
336
261
  * @param {string} [args.accessToken] 省略時は `~/.claude/.hub_token.json` から読む
337
262
  * @param {object} [args.logger]
338
263
  * @param {Function} [args.fetchImpl] テスト注入用
339
- * @returns {Promise<{ written: boolean, source: 'existing' | 'director' | 'placeholder' }>}
264
+ * @param {number} [args.requestTimeoutMs] Hub API 1 往復の期限(既定
265
+ * `HUB_REQUEST_TIMEOUT_MS`)
266
+ * @param {object} [args.budget] `createSessionBudget()` の戻り値。セッション
267
+ * 起動 1 回で **Hub 処理に割り当てた wall-clock 予算**(生成時からの経過時間。
268
+ * 通信の待ち時間の累積ではない)を超えたら打ち切る。⚠️ 予算切れのときは
269
+ * **fallback 本文を書かずに** `deferred` を返す(不確定な状態で自動生成物を
270
+ * 置くと、次回 sync が Hub 正本をそれで上書きしうるため)。
271
+ * @returns {Promise<{ written: boolean,
272
+ * source: 'existing' | 'hub' | 'director' | 'placeholder' | 'deferred' }>}
340
273
  */
341
274
  export async function ensureClaudeMd({
342
275
  targetDir,
@@ -346,6 +279,8 @@ export async function ensureClaudeMd({
346
279
  logger,
347
280
  fetchImpl,
348
281
  configFilename = "CLAUDE.md",
282
+ requestTimeoutMs = HUB_REQUEST_TIMEOUT_MS,
283
+ budget,
349
284
  } = {}) {
350
285
  if (!targetDir) throw new Error("ensureClaudeMd requires targetDir")
351
286
  if (!dirName) throw new Error("ensureClaudeMd requires dirName")
@@ -360,6 +295,16 @@ export async function ensureClaudeMd({
360
295
  /* not found → continue */
361
296
  }
362
297
 
298
+ /** 予算切れ: 何も書かずに諦める(セッション起動は止めない)。 */
299
+ const deferred = () => {
300
+ logger?.warn?.(
301
+ { dirName, configMdPath },
302
+ "ensureClaudeMd: session wall-clock budget for hub work exhausted; wrote nothing (retry next session)",
303
+ )
304
+ return { written: false, source: "deferred" }
305
+ }
306
+ if (budget?.expired()) return deferred()
307
+
363
308
  // accessToken は明示 null (テスト用) で「token を読まない」を選べるようにする。
364
309
  // undefined のときだけ ~/.claude/.hub_token.json にフォールバックする。
365
310
  const token =
@@ -370,14 +315,72 @@ export async function ensureClaudeMd({
370
315
  // (以降の syncClaudeMdWithHub が双方向同期の起点として使う)。
371
316
  // Hub 側未保存 (claude_md_updated_at が null) なら従来パスへフォールバック。
372
317
  if (token) {
373
- const hub = await fetchHubClaudeMdProject({
374
- hubUrl: url,
375
- accessToken: token,
376
- dirName,
377
- fetchImpl,
378
- })
379
- const hubBody = hub?.claude_md || ""
380
- const hubInitialized = !!hub?.claude_md_updated_at
318
+ const deadline = acquireDeadline(budget, requestTimeoutMs)
319
+ let hub = null
320
+ try {
321
+ hub = await fetchHubClaudeMdProject({
322
+ hubUrl: url,
323
+ accessToken: token,
324
+ dirName,
325
+ fetchImpl,
326
+ deadline,
327
+ logger,
328
+ })
329
+ } finally {
330
+ deadline.clear()
331
+ }
332
+ // ⚠️ 本文も hash も「観測できた空文字」と「キー欠落 / 型不正」を区別する
333
+ // (`|| ""` で潰さない)。同期側(claude-md-sync.mjs)では、この潰しが
334
+ // 「Hub は空」の誤判定を生み、正しい hash の CAS が通って共有正本を上書きする
335
+ // 経路になっていた。
336
+ const hubBody = typeof hub?.claude_md === "string" ? hub.claude_md : null
337
+ const hubHash = typeof hub?.claude_md_hash === "string" ? hub.claude_md_hash : null
338
+ // ⚠️ `updated_at` は **門を通すまで正規化しない**(生値のまま持つ)。空文字や数値を
339
+ // 先に `null` へ潰すと、門から見て「キー欠落(未保存 legacy の正例)」と「型不正
340
+ // (観測漏れ)」が同じ値になり、`"" / "" / 1750000000` のような壊れた snapshot が
341
+ // legacy として通ってしまう。正規化は門を通った後(下)で行う。
342
+ const hubUpdatedAtRaw = hub?.claude_md_updated_at
343
+
344
+ // ---- 初期化経路にも **同じ門**を掛ける(`isHubSnapshotTrustworthy` を共有)----
345
+ //
346
+ // ここは PUT を発行しないが、**ローカル本文と meta / base(=同期基準)を書く**。
347
+ // 不整合な snapshot(本文 C なのに hash が sha256(H))をそのまま基準にすると、
348
+ // 直後に連続 await される `syncClaudeMdWithHub` の GET が正常な H を返した瞬間に
349
+ // 「Hub 不変・ローカルだけ変化」と判定され、**C が CAS 付きで PUT されて H が
350
+ // 消える**(実測済み)。前回は本文の型判定だけ揃えて **対になる hash の検査を
351
+ // 揃えなかった**ため、`hub.claude_md_hash || sha256(hubBody)` が
352
+ // 「truthy な不一致 hash」をそのまま基準として採用していた。
353
+ //
354
+ // ⚠️ **検査する値は同期側と同じ 3 つ(`body` / `hash` / `updated_at`)。**
355
+ // 「同期側は 3 値を見るが初期化側は 2 値だけ」という非対称を残さないこと
356
+ // (=「片方は塞いだが対になる側を見ていない」型の穴)。ここで書く meta の
357
+ // `lastSyncedHubUpdatedAt` は、以降の `syncClaudeMdWithHub` が「Hub が動いたか」を
358
+ // 判定する基準そのものなので、時刻を観測できていない snapshot を基準にしてはいけない。
359
+ //
360
+ // ⚠️ **「壊れた 200」と「正常な未初期化 / 404」を同じ fallback に丸めないこと。**
361
+ // - GET 自体が失敗 / 404(`hub === null`)→ 従来どおり fallback 生成へ進む
362
+ // - 未初期化(`"" / ""` + `updated_at` なし)→ 整合しているので従来どおり
363
+ // fallback 生成へ進む(経路 C の `"" / sha256("") / T` も同じく整合)
364
+ // - **200 なのに契約を満たさない**(hash 不一致 / 保存済みなのに `updated_at` が
365
+ // 欠落・非文字列)→ `deferred`(何も書かずに次回へ回す)
366
+ // ⚠️ ログに body / hash / updated_at の実値を出さない(固定の理由と dirName だけ)。
367
+ //
368
+ // ⚠️ 渡すのは **生値**(`hubUpdatedAtRaw`)。正規化してから渡すと、門が
369
+ // 「未保存 legacy(`null` / キー欠落)」と「型不正(空文字・数値)」を区別できなくなる
370
+ // (`isHubSnapshotTrustworthy` の JSDoc 参照)。
371
+ if (hub && !isHubSnapshotTrustworthy(hubBody, hubHash, hubUpdatedAtRaw)) {
372
+ logger?.warn?.(
373
+ { dirName },
374
+ "ensureClaudeMd: hub snapshot failed the trust contract (hash/updated_at do not match a saved body); wrote nothing (retry next session)",
375
+ )
376
+ return { written: false, source: "deferred" }
377
+ }
378
+
379
+ // 門を通った後で、利用用(meta の同期基準になる文字列)へ正規化する。
380
+ // ⚠️ この正規化を門より上へ動かさないこと。同期側と **同じ関数**を使い、
381
+ // 「初期化側だけ判定が違う」非対称を作らない。
382
+ const hubUpdatedAt = usableHubUpdatedAt(hubUpdatedAtRaw)
383
+ const hubInitialized = hubUpdatedAt !== null
381
384
  if (hubInitialized && hubBody) {
382
385
  await fs.writeFile(configMdPath, hubBody, "utf-8")
383
386
  // 初回 pull 直後は base = Hub 本文。以降の session-end 3-way merge の入力。
@@ -387,8 +390,12 @@ export async function ensureClaudeMd({
387
390
  await writeSyncState(targetDir, {
388
391
  body: hubBody,
389
392
  meta: {
390
- lastSyncedHubUpdatedAt: hub.claude_md_updated_at,
391
- lastSyncedHash: hub.claude_md_hash || sha256(hubBody),
393
+ // 門を通っているので非空 string であることが保証されている。
394
+ lastSyncedHubUpdatedAt: hubUpdatedAt,
395
+ // ⚠️ 門を通っているので `hubHash === sha256(hubBody)` が保証されている。
396
+ // `|| sha256(hubBody)` のフォールバックを戻さないこと(「本文と別物の
397
+ // hash を基準に採用する」余地が復活し、次の正常応答で push が成立する)。
398
+ lastSyncedHash: hubHash,
392
399
  lastSyncedAt: nowIso(),
393
400
  },
394
401
  }, configFilename)
@@ -402,12 +409,25 @@ export async function ensureClaudeMd({
402
409
 
403
410
  // 従来パス: director と repositories は独立して取れるので並列フェッチする。
404
411
  // どちらも失敗時は null / [] にフォールバックする (token なしの場合も同様)。
405
- const [director, repositories] = token
406
- ? await Promise.all([
407
- fetchDirector({ hubUrl: url, accessToken: token, dirName, fetchImpl }),
408
- fetchRepositories({ hubUrl: url, accessToken: token, dirName, fetchImpl }),
412
+ // ⚠️ **同じ deadline を共有**する(片方が期限切れになったらもう片方も abort)。
413
+ let director = null
414
+ let repositories = []
415
+ if (token) {
416
+ const deadline = acquireDeadline(budget, requestTimeoutMs)
417
+ try {
418
+ ;[director, repositories] = await Promise.all([
419
+ fetchDirector({ hubUrl: url, accessToken: token, dirName, fetchImpl, deadline, logger }),
420
+ fetchRepositories({ hubUrl: url, accessToken: token, dirName, fetchImpl, deadline, logger }),
409
421
  ])
410
- : [null, []]
422
+ } finally {
423
+ deadline.clear()
424
+ }
425
+ }
426
+
427
+ // ⚠️ 書く直前にもう一度予算を見る。取得の途中で予算が尽きた場合、director が
428
+ // 取れていない = 入力が不確定な状態なので、その状態の placeholder を置いてはいけない
429
+ // (次回 sync でそれが Hub 正本を上書きする経路が生まれる)。
430
+ if (budget?.expired()) return deferred()
411
431
 
412
432
  const cliKind = configFilename === "AGENTS.md" ? "codex" : "claude"
413
433
  const repositorySection = renderRepositorySection(repositories, dirName, cliKind)
@@ -416,6 +436,8 @@ export async function ensureClaudeMd({
416
436
  : renderPlaceholder(dirName, repositorySection, configFilename)
417
437
  const source = director ? "director" : "placeholder"
418
438
 
439
+ // 自動生成本文はそのまま書くだけ(sidecar は書かない)。書込に失敗したら例外が
440
+ // 呼び出し元へ抜けるが、hub-agent 側で warn に落としてセッション起動は続行する。
419
441
  await fs.writeFile(configMdPath, body, "utf-8")
420
442
  logger?.info?.(
421
443
  { dirName, configMdPath, source },