@cocorograph/hub-agent 0.6.87 → 0.6.89

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.6.87",
3
+ "version": "0.6.89",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -0,0 +1,361 @@
1
+ /**
2
+ * Hub 正本 (Project.claude_md) と workspace ローカル CLAUDE.md の双方向同期。
3
+ *
4
+ * 方針: 双方向 last-write-wins + 履歴。
5
+ * - Hub 側更新 → 端末 pull(ローカルを `.bak.<ts>` 退避してから全文上書き)
6
+ * - 端末側更新 → Hub に PUT(Hub 側は履歴に直前版を積む。directly handled by backend)
7
+ * - 両方変化(衝突)→ 端末側を勝者にして Hub に PUT、Hub の取り込み内容は
8
+ * `.bak.conflict.<ts>` に退避(後で人手マージできるよう保存)
9
+ * - どちらも変化なし → no-op
10
+ *
11
+ * 初回(メタファイル無し):
12
+ * - ローカル CLAUDE.md が空 → pull(Hub の正本をそのまま受け取り)
13
+ * - ローカル非空 + Hub 非空 → 衝突扱い(端末側勝者)
14
+ * - ローカル非空 + Hub 空 → no-op(Hub に空を上書きしない、Stage 3 で初期化検討)
15
+ *
16
+ * 失敗時のポリシー: ローカル CLAUDE.md は触らない(warn ログ + skip)。
17
+ * session 起動を止めない(ensureClaudeMd と同じく best-effort)。
18
+ *
19
+ * 関連: backend Stage 1 / 1.5
20
+ * GET /api/admin/project/claude-md-by-dir/?dir_name=...
21
+ * PUT /api/admin/project/claude-md-by-dir/?dir_name=... body: { claude_md }
22
+ */
23
+ import fs from "node:fs/promises"
24
+ import os from "node:os"
25
+ import path from "node:path"
26
+ import crypto from "node:crypto"
27
+
28
+ const DEFAULT_HUB_API = "https://api.hub.cocorograph.com"
29
+ const TOKEN_PATH = path.join(os.homedir(), ".claude", ".hub_token.json")
30
+ const META_DIR = ".cockpit-sync"
31
+ const META_FILE = "claude-md.json"
32
+ const BAK_KEEP = 3 // 通常 .bak と衝突 .bak それぞれ直近 3 件まで保持
33
+
34
+ async function readJsonOrNull(p) {
35
+ try {
36
+ const text = await fs.readFile(p, "utf-8")
37
+ return JSON.parse(text)
38
+ } catch {
39
+ return null
40
+ }
41
+ }
42
+
43
+ async function loadAccessToken() {
44
+ const data = await readJsonOrNull(TOKEN_PATH)
45
+ return (data && typeof data.access === "string" && data.access) || null
46
+ }
47
+
48
+ function sha256(s) {
49
+ return crypto.createHash("sha256").update(s || "", "utf-8").digest("hex")
50
+ }
51
+
52
+ function nowIso() {
53
+ return new Date().toISOString()
54
+ }
55
+
56
+ function makeBakName(prefix) {
57
+ // ファイル名に使えるよう : を - に置換し、ミリ秒精度を保つ。
58
+ const ts = nowIso().replace(/[:.]/g, "-")
59
+ return `${prefix}.${ts}.bak`
60
+ }
61
+
62
+ /**
63
+ * Hub から claude-md-by-dir で正本を取得する。
64
+ *
65
+ * - 404 = 該当 Project 無し(Domain 型・local_dir 型 workspace)→ null
66
+ * - その他のエラー → null(warn ログ)
67
+ *
68
+ * @returns {Promise<null | {project_id, claude_md, claude_md_hash, claude_md_updated_at}>}
69
+ */
70
+ async function fetchHubClaudeMd({ hubUrl, accessToken, dirName, fetchImpl, logger }) {
71
+ const f = fetchImpl || globalThis.fetch
72
+ if (!f) return null
73
+ const url =
74
+ `${hubUrl.replace(/\/+$/, "")}/api/admin/project/claude-md-by-dir/` +
75
+ `?dir_name=${encodeURIComponent(dirName)}`
76
+ try {
77
+ const res = await f(url, {
78
+ headers: { Authorization: `Bearer ${accessToken}` },
79
+ })
80
+ if (res.status === 404) return null
81
+ if (!res.ok) {
82
+ logger?.warn?.(
83
+ { dirName, status: res.status },
84
+ "fetchHubClaudeMd: non-OK response",
85
+ )
86
+ return null
87
+ }
88
+ return await res.json()
89
+ } catch (err) {
90
+ logger?.warn?.(
91
+ { dirName, err: err?.message || String(err) },
92
+ "fetchHubClaudeMd: request failed",
93
+ )
94
+ return null
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Hub に PUT で本文を上書きする。Hub 側は履歴を積んでから保存する。
100
+ *
101
+ * @returns {Promise<boolean>} 成功なら true
102
+ */
103
+ async function putHubClaudeMd({ hubUrl, accessToken, dirName, body, fetchImpl, logger }) {
104
+ const f = fetchImpl || globalThis.fetch
105
+ if (!f) return false
106
+ const url =
107
+ `${hubUrl.replace(/\/+$/, "")}/api/admin/project/claude-md-by-dir/` +
108
+ `?dir_name=${encodeURIComponent(dirName)}`
109
+ try {
110
+ const res = await f(url, {
111
+ method: "PUT",
112
+ headers: {
113
+ Authorization: `Bearer ${accessToken}`,
114
+ "Content-Type": "application/json",
115
+ },
116
+ body: JSON.stringify({ claude_md: body }),
117
+ })
118
+ if (!res.ok) {
119
+ logger?.warn?.(
120
+ { dirName, status: res.status },
121
+ "putHubClaudeMd: non-OK response",
122
+ )
123
+ return false
124
+ }
125
+ return true
126
+ } catch (err) {
127
+ logger?.warn?.(
128
+ { dirName, err: err?.message || String(err) },
129
+ "putHubClaudeMd: request failed",
130
+ )
131
+ return false
132
+ }
133
+ }
134
+
135
+ async function readLocalClaudeMd(targetDir) {
136
+ try {
137
+ return await fs.readFile(path.join(targetDir, "CLAUDE.md"), "utf-8")
138
+ } catch (err) {
139
+ if (err?.code === "ENOENT") return null
140
+ throw err
141
+ }
142
+ }
143
+
144
+ async function writeLocalClaudeMd(targetDir, body) {
145
+ await fs.writeFile(path.join(targetDir, "CLAUDE.md"), body, "utf-8")
146
+ }
147
+
148
+ async function readMeta(targetDir) {
149
+ const metaPath = path.join(targetDir, META_DIR, META_FILE)
150
+ return await readJsonOrNull(metaPath)
151
+ }
152
+
153
+ async function writeMeta(targetDir, meta) {
154
+ const metaDir = path.join(targetDir, META_DIR)
155
+ await fs.mkdir(metaDir, { recursive: true })
156
+ await fs.writeFile(
157
+ path.join(metaDir, META_FILE),
158
+ JSON.stringify(meta, null, 2),
159
+ "utf-8",
160
+ )
161
+ }
162
+
163
+ /**
164
+ * 既存ローカル CLAUDE.md を .cockpit-sync/CLAUDE.md.<prefix>.<ts>.bak に退避する。
165
+ * 同 prefix の .bak は新しい順に BAK_KEEP 件保持し、古いものから FIFO 削除する。
166
+ *
167
+ * @param {'normal'|'conflict'} kind バックアップ種別(命名 prefix)
168
+ */
169
+ async function backupLocalClaudeMd(targetDir, body, kind) {
170
+ if (body == null) return
171
+ const metaDir = path.join(targetDir, META_DIR)
172
+ await fs.mkdir(metaDir, { recursive: true })
173
+ const prefix = kind === "conflict" ? "CLAUDE.md.conflict" : "CLAUDE.md"
174
+ const bakName = makeBakName(prefix)
175
+ await fs.writeFile(path.join(metaDir, bakName), body, "utf-8")
176
+ // 同 prefix の .bak を新しい順に BAK_KEEP 件まで残す。
177
+ const entries = await fs.readdir(metaDir).catch(() => [])
178
+ const sameKind = entries
179
+ .filter((n) => n.startsWith(`${prefix}.`) && n.endsWith(".bak"))
180
+ .sort() // 名前にタイムスタンプを含むので辞書順 ≒ 時系列順
181
+ .reverse() // 新しい順
182
+ const obsolete = sameKind.slice(BAK_KEEP)
183
+ await Promise.all(
184
+ obsolete.map((n) =>
185
+ fs.unlink(path.join(metaDir, n)).catch(() => {}),
186
+ ),
187
+ )
188
+ }
189
+
190
+ /**
191
+ * `<targetDir>` の CLAUDE.md を Hub 正本と同期する(双方向 last-write-wins)。
192
+ *
193
+ * @param {object} args
194
+ * @param {string} args.targetDir 絶対パス
195
+ * @param {string} args.dirName workspace ディレクトリ名(Dxxxxx_... / Pxxxxx)
196
+ * @param {string} [args.hubUrl] Hub API base
197
+ * @param {string} [args.accessToken] 省略時は ~/.claude/.hub_token.json から
198
+ * @param {object} [args.logger]
199
+ * @param {Function} [args.fetchImpl] テスト注入用
200
+ * @returns {Promise<{ action: 'pull'|'push'|'conflict'|'noop'|'skipped', reason?: string }>}
201
+ */
202
+ export async function syncClaudeMdWithHub({
203
+ targetDir,
204
+ dirName,
205
+ hubUrl,
206
+ accessToken,
207
+ logger,
208
+ fetchImpl,
209
+ } = {}) {
210
+ if (!targetDir) throw new Error("syncClaudeMdWithHub requires targetDir")
211
+ if (!dirName) throw new Error("syncClaudeMdWithHub requires dirName")
212
+
213
+ const token =
214
+ accessToken === undefined ? await loadAccessToken() : accessToken || null
215
+ if (!token) {
216
+ logger?.info?.({ dirName }, "syncClaudeMdWithHub: no access token, skip")
217
+ return { action: "skipped", reason: "no-token" }
218
+ }
219
+ const url = hubUrl || DEFAULT_HUB_API
220
+
221
+ // 1) Hub の正本を取得
222
+ const hub = await fetchHubClaudeMd({
223
+ hubUrl: url,
224
+ accessToken: token,
225
+ dirName,
226
+ fetchImpl,
227
+ logger,
228
+ })
229
+ if (!hub) {
230
+ return { action: "skipped", reason: "hub-not-resolvable" }
231
+ }
232
+ const hubBody = hub.claude_md || ""
233
+ const hubHash = hub.claude_md_hash || "" // 空文字なら Project.save 未実行 = 空本文
234
+ const hubUpdatedAt = hub.claude_md_updated_at || null
235
+
236
+ // 2) ローカル CLAUDE.md を読み、現在 hash を計算
237
+ const localBody = await readLocalClaudeMd(targetDir)
238
+ const localCurrentHash = localBody == null ? null : sha256(localBody)
239
+
240
+ // 3) メタファイルを読み、前回 sync の状態を取得
241
+ const meta = await readMeta(targetDir)
242
+
243
+ // Hub が一度も保存されていない(空本文 + updated_at が null)プロジェクトは
244
+ // 「Hub UI で意図的に未設定にしている」運用(テナント標準 CLAUDE.md に任せる等)の
245
+ // 可能性がある。端末側に何か書いてあっても push せず noop に倒す。
246
+ // ユーザー確定方針: 「Hub の claude_md が空のプロジェクトは何もしない」。
247
+ const hubInitialized = !!hubUpdatedAt
248
+ if (!hubInitialized) {
249
+ return { action: "noop", reason: "hub-uninitialized" }
250
+ }
251
+
252
+ // 判定: hubChanged / localChanged を計算する。
253
+ // - hubChanged: Hub.updated_at が前回 sync 時より進んだ
254
+ // updated_at が null(Project.save 未実行で初期空)の場合は変化なし扱い。
255
+ // - localChanged: 現在の localHash が前回 sync 時の hash と異なる(=端末で編集)
256
+ // meta が無くてローカル非空なら、初回参加時のローカル変更扱い(衝突候補)。
257
+ let hubChanged, localChanged
258
+ if (!meta) {
259
+ // 初回 sync。
260
+ hubChanged = !!hubBody
261
+ if (localBody == null) {
262
+ localChanged = false
263
+ } else {
264
+ localChanged = !!localBody // 非空なら端末側に何かある=衝突候補
265
+ }
266
+ } else {
267
+ hubChanged = !!hubUpdatedAt && hubUpdatedAt !== meta.lastSyncedHubUpdatedAt
268
+ localChanged =
269
+ localCurrentHash != null && localCurrentHash !== meta.lastSyncedHash
270
+ }
271
+
272
+ // 同じ内容で書き込まれている(hash 一致)なら衝突扱いせず noop に降格。
273
+ if (hubChanged && localChanged && hubHash && hubHash === localCurrentHash) {
274
+ hubChanged = false
275
+ localChanged = false
276
+ }
277
+
278
+ // 4) アクション決定
279
+ if (!hubChanged && !localChanged) {
280
+ return { action: "noop" }
281
+ }
282
+
283
+ if (hubChanged && !localChanged) {
284
+ // pull: ローカルを .bak 退避 → Hub の本文で上書き
285
+ await backupLocalClaudeMd(targetDir, localBody, "normal")
286
+ await writeLocalClaudeMd(targetDir, hubBody)
287
+ await writeMeta(targetDir, {
288
+ lastSyncedHubUpdatedAt: hubUpdatedAt,
289
+ lastSyncedHash: hubHash || sha256(hubBody),
290
+ lastSyncedAt: nowIso(),
291
+ })
292
+ logger?.info?.({ dirName }, "syncClaudeMdWithHub: pull")
293
+ return { action: "pull" }
294
+ }
295
+
296
+ if (!hubChanged && localChanged) {
297
+ // push: Hub に PUT。成功時にメタ更新。
298
+ const ok = await putHubClaudeMd({
299
+ hubUrl: url,
300
+ accessToken: token,
301
+ dirName,
302
+ body: localBody || "",
303
+ fetchImpl,
304
+ logger,
305
+ })
306
+ if (!ok) {
307
+ return { action: "skipped", reason: "hub-put-failed" }
308
+ }
309
+ await writeMeta(targetDir, {
310
+ // PUT 後の Hub.updated_at は再度 GET しないと厳密には取れないため、
311
+ // 「ローカル hash と Hub.updated_at は別途次回 sync で再同期」する設計。
312
+ // ここでは「今 push したぶんは Hub に反映済み」として、現在のローカル
313
+ // hash と「今の時刻」をメタに記録する(次回 sync 時に再判定可能)。
314
+ lastSyncedHubUpdatedAt: nowIso(),
315
+ lastSyncedHash: localCurrentHash,
316
+ lastSyncedAt: nowIso(),
317
+ })
318
+ logger?.info?.({ dirName }, "syncClaudeMdWithHub: push")
319
+ return { action: "push" }
320
+ }
321
+
322
+ // hubChanged && localChanged → 衝突。端末側を勝者にして Hub に PUT。
323
+ // Hub の取り込み内容は .bak.conflict に退避(後で人手マージできるよう)。
324
+ await backupLocalClaudeMd(targetDir, hubBody, "conflict")
325
+ const ok = await putHubClaudeMd({
326
+ hubUrl: url,
327
+ accessToken: token,
328
+ dirName,
329
+ body: localBody || "",
330
+ fetchImpl,
331
+ logger,
332
+ })
333
+ if (!ok) {
334
+ // PUT 失敗時はメタを更新しない(次回再試行)。ローカルは無傷。
335
+ return { action: "skipped", reason: "hub-put-failed-in-conflict" }
336
+ }
337
+ await writeMeta(targetDir, {
338
+ lastSyncedHubUpdatedAt: nowIso(),
339
+ lastSyncedHash: localCurrentHash,
340
+ lastSyncedAt: nowIso(),
341
+ })
342
+ logger?.warn?.(
343
+ { dirName },
344
+ "syncClaudeMdWithHub: conflict (terminal wins); hub body saved to .bak.conflict",
345
+ )
346
+ return { action: "conflict" }
347
+ }
348
+
349
+ // メタ操作・ハッシュ計算は ensureClaudeMd 側 (Hub pull 初回配信) でも使うため
350
+ // named export する。同じファイルパス・同じスキーマで管理することで、初回配信と
351
+ // 以降の sync で食い違いを起こさない。
352
+ export { sha256, readMeta, writeMeta, nowIso }
353
+
354
+ // 内部テスト用エクスポート
355
+ export const _internals = {
356
+ sha256,
357
+ readMeta,
358
+ writeMeta,
359
+ backupLocalClaudeMd,
360
+ BAK_KEEP,
361
+ }
package/src/claude-md.mjs CHANGED
@@ -2,17 +2,28 @@
2
2
  * 新規 workspace ディレクトリ初期化時の CLAUDE.md 生成。
3
3
  *
4
4
  * ~/hub/bin/generate-claude-md (Python) と同等の仕様を Node 側に持つ。
5
- * - `<dirName>/_director` slug で Hub director を引き当て、見つかれば
6
- * frontmatter (client / domain / industry / service / tech_stack /
7
- * google_drive) を反映した構造化 CLAUDE.md を書き出す
8
- * - 認証トークン (`~/.claude/.hub_token.json`) が無い / API 失敗 / director
9
- * 未登録ならプレースホルダ CLAUDE.md を書き出す
10
5
  *
11
- * CLAUDE.md が既存ならそのまま (上書きしない)
6
+ * 優先順 (新規生成時のみ。既存 CLAUDE.md は触らない):
7
+ * 1. **Hub の Project.claude_md 正本** (Stage 3 初回配信) — 非空ならそれを書く。
8
+ * 同期メタ (.cockpit-sync/claude-md.json) も初期化する。以降は
9
+ * syncClaudeMdWithHub が双方向同期する。
10
+ * 2. **Hub director frontmatter** — claude_md が空でも director があれば
11
+ * client/domain/industry/service/tech_stack/google_drive で構造化版を書く。
12
+ * 3. **placeholder** — token なし / API 失敗 / director 未登録なら最小プレースホルダ。
13
+ *
14
+ * 1 を最優先にしたのは、新規メンバーが workspace に入ったとき、組織で固まった
15
+ * CLAUDE.md(Stage 1/2 で正本化したもの)が即座に引かれて作業に取り掛かれる
16
+ * ようにするため。動的生成は「Hub に正本がまだ無いプロジェクト」の救済として
17
+ * のみ機能する。
12
18
  */
13
19
  import fs from "node:fs/promises"
14
20
  import os from "node:os"
15
21
  import path from "node:path"
22
+ import {
23
+ sha256,
24
+ writeMeta,
25
+ nowIso,
26
+ } from "./claude-md-sync.mjs"
16
27
 
17
28
  const DEFAULT_HUB_API = "https://api.hub.cocorograph.com"
18
29
  const TOKEN_PATH = path.join(os.homedir(), ".claude", ".hub_token.json")
@@ -31,6 +42,34 @@ async function loadAccessToken() {
31
42
  return (data && typeof data.access === "string" && data.access) || null
32
43
  }
33
44
 
45
+ /**
46
+ * Hub の Project.claude_md 正本を取得する (Stage 3 初回配信用)。
47
+ *
48
+ * - 404 (該当 Project 無し) → null
49
+ * - その他のエラー → null
50
+ * - 取得成功時は { claude_md, claude_md_hash, claude_md_updated_at } を返す
51
+ *
52
+ * 同じエンドポイントは syncClaudeMdWithHub も使うが、ensureClaudeMd は
53
+ * 初回生成 (メタ無し) 経路でしか呼ばれず、PUT もしないため import せず
54
+ * 個別実装する。
55
+ */
56
+ async function fetchHubClaudeMdProject({ hubUrl, accessToken, dirName, fetchImpl }) {
57
+ const f = fetchImpl || globalThis.fetch
58
+ if (!f) return null
59
+ const url =
60
+ `${hubUrl.replace(/\/+$/, "")}/api/admin/project/claude-md-by-dir/` +
61
+ `?dir_name=${encodeURIComponent(dirName)}`
62
+ try {
63
+ const res = await f(url, {
64
+ headers: { Authorization: `Bearer ${accessToken}` },
65
+ })
66
+ if (!res.ok) return null
67
+ return await res.json()
68
+ } catch {
69
+ return null
70
+ }
71
+ }
72
+
34
73
  async function fetchDirector({ hubUrl, accessToken, dirName, fetchImpl }) {
35
74
  const f = fetchImpl || globalThis.fetch
36
75
  if (!f) return null
@@ -320,7 +359,35 @@ export async function ensureClaudeMd({
320
359
  const token =
321
360
  accessToken === undefined ? await loadAccessToken() : accessToken || null
322
361
  const url = hubUrl || DEFAULT_HUB_API
323
- // director と repositories は独立して取れるので並列フェッチする。
362
+
363
+ // Stage 3: Hub に正本があればそれを最優先で書き込み、メタも初期化する
364
+ // (以降の syncClaudeMdWithHub が双方向同期の起点として使う)。
365
+ // Hub 側未保存 (claude_md_updated_at が null) なら従来パスへフォールバック。
366
+ if (token) {
367
+ const hub = await fetchHubClaudeMdProject({
368
+ hubUrl: url,
369
+ accessToken: token,
370
+ dirName,
371
+ fetchImpl,
372
+ })
373
+ const hubBody = hub?.claude_md || ""
374
+ const hubInitialized = !!hub?.claude_md_updated_at
375
+ if (hubInitialized && hubBody) {
376
+ await fs.writeFile(claudeMdPath, hubBody, "utf-8")
377
+ await writeMeta(targetDir, {
378
+ lastSyncedHubUpdatedAt: hub.claude_md_updated_at,
379
+ lastSyncedHash: hub.claude_md_hash || sha256(hubBody),
380
+ lastSyncedAt: nowIso(),
381
+ })
382
+ logger?.info?.(
383
+ { dirName, claudeMdPath },
384
+ "wrote CLAUDE.md from Hub project claude_md (initial sync)",
385
+ )
386
+ return { written: true, source: "hub" }
387
+ }
388
+ }
389
+
390
+ // 従来パス: director と repositories は独立して取れるので並列フェッチする。
324
391
  // どちらも失敗時は null / [] にフォールバックする (token なしの場合も同様)。
325
392
  const [director, repositories] = token
326
393
  ? await Promise.all([
package/src/tmux.mjs CHANGED
@@ -20,6 +20,7 @@ import path from "node:path"
20
20
  import { promisify } from "node:util"
21
21
 
22
22
  import { ensureClaudeMd } from "./claude-md.mjs"
23
+ import { syncClaudeMdWithHub } from "./claude-md-sync.mjs"
23
24
  import {
24
25
  capturePane,
25
26
  detectInputBoxText,
@@ -657,14 +658,22 @@ export async function createSession(name, cwd, opts = {}) {
657
658
  throw new Error(`cwd stat failed: ${err?.message || String(err)}`)
658
659
  }
659
660
  }
660
- // HUB_PROJECTS_BASE 配下の workspace の場合は CLAUDE.md を必要なら生成する。
661
- // Hub director があれば frontmatter 連動の構造化版、無ければ最小プレースホルダ。
662
- // 既存 CLAUDE.md は触らない。失敗しても session 起動自体は止めない (warn のみ)。
661
+ // HUB_PROJECTS_BASE 配下の workspace の場合は CLAUDE.md を必要なら生成し、
662
+ // Hub の正本(Project.claude_md)と双方向同期する。
663
+ // - ensureClaudeMd: 既存 CLAUDE.md は触らない。無ければ director frontmatter
664
+ // または placeholder を生成する。
665
+ // - syncClaudeMdWithHub: Hub 正本と双方向 last-write-wins 同期。Hub 側更新は
666
+ // ローカルへ pull(.bak 退避してから上書き)、端末側更新は Hub へ push、
667
+ // 両方変化は端末側を勝者にして Hub へ push(Hub の取り込み内容は
668
+ // .bak.conflict に退避)。Hub が一度も保存されていないプロジェクトでは
669
+ // 何もしない(テナント標準 CLAUDE.md の運用を尊重する)。
670
+ // どちらも失敗しても session 起動自体は止めない (warn のみ)。
663
671
  if (isUnderHubProjectsBase(resolvedCwd)) {
672
+ const dirName = path.basename(resolvedCwd)
664
673
  try {
665
674
  await ensureClaudeMd({
666
675
  targetDir: resolvedCwd,
667
- dirName: path.basename(resolvedCwd),
676
+ dirName,
668
677
  hubUrl: opts.hubUrl,
669
678
  accessToken: opts.hubAccessToken,
670
679
  logger: opts.logger,
@@ -676,6 +685,25 @@ export async function createSession(name, cwd, opts = {}) {
676
685
  "ensureClaudeMd failed (workspace dir is usable, but no CLAUDE.md)",
677
686
  )
678
687
  }
688
+ try {
689
+ const syncResult = await syncClaudeMdWithHub({
690
+ targetDir: resolvedCwd,
691
+ dirName,
692
+ hubUrl: opts.hubUrl,
693
+ accessToken: opts.hubAccessToken,
694
+ logger: opts.logger,
695
+ fetchImpl: opts.fetchImpl,
696
+ })
697
+ opts.logger?.debug?.(
698
+ { session: name, dirName, sync: syncResult },
699
+ "syncClaudeMdWithHub completed",
700
+ )
701
+ } catch (err) {
702
+ opts.logger?.warn?.(
703
+ { session: name, cwd: resolvedCwd, err: err?.message || String(err) },
704
+ "syncClaudeMdWithHub failed (session starts anyway)",
705
+ )
706
+ }
679
707
  }
680
708
  // 既存チェック
681
709
  try {