@cocorograph/hub-agent 0.7.11 → 0.7.13
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 +1 -1
- package/src/claude-md-sync.mjs +28 -1
- package/src/claude-md.mjs +3 -0
- package/src/main.mjs +12 -5
- package/src/tmux.mjs +71 -2
- package/src/tool-result-mask.mjs +38 -0
package/package.json
CHANGED
package/src/claude-md-sync.mjs
CHANGED
|
@@ -29,6 +29,7 @@ const DEFAULT_HUB_API = "https://api.hub.cocorograph.com"
|
|
|
29
29
|
const TOKEN_PATH = path.join(os.homedir(), ".claude", ".hub_token.json")
|
|
30
30
|
const META_DIR = ".cockpit-sync"
|
|
31
31
|
const META_FILE = "claude-md.json"
|
|
32
|
+
const BASE_FILE = "CLAUDE.md.base"
|
|
32
33
|
const BAK_KEEP = 3 // 通常 .bak と衝突 .bak それぞれ直近 3 件まで保持
|
|
33
34
|
|
|
34
35
|
async function readJsonOrNull(p) {
|
|
@@ -160,6 +161,23 @@ async function writeMeta(targetDir, meta) {
|
|
|
160
161
|
)
|
|
161
162
|
}
|
|
162
163
|
|
|
164
|
+
/**
|
|
165
|
+
* 同期成功時の本文を `.cockpit-sync/CLAUDE.md.base` に保存する。
|
|
166
|
+
*
|
|
167
|
+
* session-end スキル (対話中の Claude) が CLAUDE.md を Hub と 3-way merge する際の
|
|
168
|
+
* "base" 入力として使う。前回 sync 直後のローカル == Hub の本文を保持しておくことで、
|
|
169
|
+
* 「ローカル変更」と「Hub 変更」の差分を別々に抽出してから意味マージできる。
|
|
170
|
+
*
|
|
171
|
+
* meta(claude-md.json) は hash と updated_at しか持たないため、本文そのものが要る
|
|
172
|
+
* 3-way merge の用途ではこのファイルが必要。
|
|
173
|
+
*/
|
|
174
|
+
async function writeBaseBody(targetDir, body) {
|
|
175
|
+
if (body == null) return
|
|
176
|
+
const metaDir = path.join(targetDir, META_DIR)
|
|
177
|
+
await fs.mkdir(metaDir, { recursive: true })
|
|
178
|
+
await fs.writeFile(path.join(metaDir, BASE_FILE), body, "utf-8")
|
|
179
|
+
}
|
|
180
|
+
|
|
163
181
|
/**
|
|
164
182
|
* 既存ローカル CLAUDE.md を .cockpit-sync/CLAUDE.md.<prefix>.<ts>.bak に退避する。
|
|
165
183
|
* 同 prefix の .bak は新しい順に BAK_KEEP 件保持し、古いものから FIFO 削除する。
|
|
@@ -289,6 +307,8 @@ export async function syncClaudeMdWithHub({
|
|
|
289
307
|
lastSyncedHash: hubHash || sha256(hubBody),
|
|
290
308
|
lastSyncedAt: nowIso(),
|
|
291
309
|
})
|
|
310
|
+
// 3-way merge 用 base: pull 直後はローカル == Hub なので Hub 本文を base に。
|
|
311
|
+
await writeBaseBody(targetDir, hubBody)
|
|
292
312
|
logger?.info?.({ dirName }, "syncClaudeMdWithHub: pull")
|
|
293
313
|
return { action: "pull" }
|
|
294
314
|
}
|
|
@@ -315,6 +335,9 @@ export async function syncClaudeMdWithHub({
|
|
|
315
335
|
lastSyncedHash: localCurrentHash,
|
|
316
336
|
lastSyncedAt: nowIso(),
|
|
317
337
|
})
|
|
338
|
+
// 3-way merge 用 base: push 直後は Hub の正本がローカルと一致するので、
|
|
339
|
+
// 次回の base は今 push した本文 (= localBody)。
|
|
340
|
+
await writeBaseBody(targetDir, localBody || "")
|
|
318
341
|
logger?.info?.({ dirName }, "syncClaudeMdWithHub: push")
|
|
319
342
|
return { action: "push" }
|
|
320
343
|
}
|
|
@@ -339,6 +362,8 @@ export async function syncClaudeMdWithHub({
|
|
|
339
362
|
lastSyncedHash: localCurrentHash,
|
|
340
363
|
lastSyncedAt: nowIso(),
|
|
341
364
|
})
|
|
365
|
+
// 3-way merge 用 base: conflict push 後の Hub 正本は端末側本文 (= localBody)。
|
|
366
|
+
await writeBaseBody(targetDir, localBody || "")
|
|
342
367
|
logger?.warn?.(
|
|
343
368
|
{ dirName },
|
|
344
369
|
"syncClaudeMdWithHub: conflict (terminal wins); hub body saved to .bak.conflict",
|
|
@@ -349,13 +374,15 @@ export async function syncClaudeMdWithHub({
|
|
|
349
374
|
// メタ操作・ハッシュ計算は ensureClaudeMd 側 (Hub pull 初回配信) でも使うため
|
|
350
375
|
// named export する。同じファイルパス・同じスキーマで管理することで、初回配信と
|
|
351
376
|
// 以降の sync で食い違いを起こさない。
|
|
352
|
-
export { sha256, readMeta, writeMeta, nowIso }
|
|
377
|
+
export { sha256, readMeta, writeMeta, writeBaseBody, nowIso }
|
|
353
378
|
|
|
354
379
|
// 内部テスト用エクスポート
|
|
355
380
|
export const _internals = {
|
|
356
381
|
sha256,
|
|
357
382
|
readMeta,
|
|
358
383
|
writeMeta,
|
|
384
|
+
writeBaseBody,
|
|
359
385
|
backupLocalClaudeMd,
|
|
360
386
|
BAK_KEEP,
|
|
387
|
+
BASE_FILE,
|
|
361
388
|
}
|
package/src/claude-md.mjs
CHANGED
|
@@ -22,6 +22,7 @@ import path from "node:path"
|
|
|
22
22
|
import {
|
|
23
23
|
sha256,
|
|
24
24
|
writeMeta,
|
|
25
|
+
writeBaseBody,
|
|
25
26
|
nowIso,
|
|
26
27
|
} from "./claude-md-sync.mjs"
|
|
27
28
|
|
|
@@ -379,6 +380,8 @@ export async function ensureClaudeMd({
|
|
|
379
380
|
lastSyncedHash: hub.claude_md_hash || sha256(hubBody),
|
|
380
381
|
lastSyncedAt: nowIso(),
|
|
381
382
|
})
|
|
383
|
+
// 初回 pull 直後は base = Hub 本文。以降の session-end 3-way merge の入力。
|
|
384
|
+
await writeBaseBody(targetDir, hubBody)
|
|
382
385
|
logger?.info?.(
|
|
383
386
|
{ dirName, claudeMdPath },
|
|
384
387
|
"wrote CLAUDE.md from Hub project claude_md (initial sync)",
|
package/src/main.mjs
CHANGED
|
@@ -3022,9 +3022,12 @@ async function dispatch(msg, ctx) {
|
|
|
3022
3022
|
return
|
|
3023
3023
|
}
|
|
3024
3024
|
case "worktree.remove": {
|
|
3025
|
-
// body: { request_id, name }
|
|
3025
|
+
// body: { request_id, name, force? }
|
|
3026
3026
|
// cockpit (PR 1719) のサイドバー削除ボタンから呼ばれる。
|
|
3027
|
-
//
|
|
3027
|
+
// 2 段階削除:
|
|
3028
|
+
// - force=false (デフォルト): 未コミット/未 push を検出して dirty なら削除せず
|
|
3029
|
+
// `dirty: true, changes, unpushed_commits` を返す。フロントが「破棄して削除」確認を出す。
|
|
3030
|
+
// - force=true: 検査をスキップして `git worktree remove --force` で物理削除。
|
|
3028
3031
|
const name = (msg.name || "").trim()
|
|
3029
3032
|
if (!name) {
|
|
3030
3033
|
ctx.client.send({
|
|
@@ -3035,14 +3038,18 @@ async function dispatch(msg, ctx) {
|
|
|
3035
3038
|
})
|
|
3036
3039
|
return
|
|
3037
3040
|
}
|
|
3041
|
+
const force = msg.force === true
|
|
3038
3042
|
try {
|
|
3039
|
-
const
|
|
3043
|
+
const result = await removeWorktreeDir(name, { force })
|
|
3040
3044
|
ctx.client.send({
|
|
3041
3045
|
type: "worktree.remove.result",
|
|
3042
3046
|
request_id: msg.request_id,
|
|
3043
3047
|
ok: true,
|
|
3044
|
-
name:
|
|
3045
|
-
wt_path:
|
|
3048
|
+
name: result.name,
|
|
3049
|
+
wt_path: result.wt_path,
|
|
3050
|
+
dirty: result.dirty === true,
|
|
3051
|
+
changes: result.changes || [],
|
|
3052
|
+
unpushed_commits: result.unpushed_commits || 0,
|
|
3046
3053
|
})
|
|
3047
3054
|
} catch (err) {
|
|
3048
3055
|
ctx.client.send({
|
package/src/tmux.mjs
CHANGED
|
@@ -232,13 +232,66 @@ export async function listWorktreeStubs(liveSessionNames) {
|
|
|
232
232
|
return stubs
|
|
233
233
|
}
|
|
234
234
|
|
|
235
|
+
/**
|
|
236
|
+
* worktree 内の未コミット変更と未 push コミットを検出する(破棄前確認用)。
|
|
237
|
+
*
|
|
238
|
+
* - `git status --porcelain` で未コミット変更(追跡/未追跡/変更/削除)を列挙
|
|
239
|
+
* - `git rev-list --count @{u}..HEAD` で upstream に未 push のコミット数を取得
|
|
240
|
+
*
|
|
241
|
+
* upstream 未設定の worktree(新規ブランチで未 push)も「unpushed あり扱い」にして
|
|
242
|
+
* 誤って削除されないようにする(rev-list が失敗しても unpushed_commits=1 で警告)。
|
|
243
|
+
*
|
|
244
|
+
* @param {string} wtPath worktree の絶対パス
|
|
245
|
+
* @returns {Promise<{dirty: boolean, changes: string[], unpushed_commits: number}>}
|
|
246
|
+
*/
|
|
247
|
+
async function inspectWorktreeDirty(wtPath) {
|
|
248
|
+
/** @type {string[]} */
|
|
249
|
+
let changes = []
|
|
250
|
+
let unpushed = 0
|
|
251
|
+
try {
|
|
252
|
+
const { stdout } = await execFileP("git", ["-C", wtPath, "status", "--porcelain"])
|
|
253
|
+
changes = String(stdout || "")
|
|
254
|
+
.split("\n")
|
|
255
|
+
.map((line) => line.replace(/\r$/, ""))
|
|
256
|
+
.filter((line) => line.length > 0)
|
|
257
|
+
} catch {
|
|
258
|
+
// git status 自体が失敗するケースは滅多に無いが、保守的に「変更あり扱い」にせず空配列で続行
|
|
259
|
+
changes = []
|
|
260
|
+
}
|
|
261
|
+
try {
|
|
262
|
+
const { stdout } = await execFileP("git", [
|
|
263
|
+
"-C",
|
|
264
|
+
wtPath,
|
|
265
|
+
"rev-list",
|
|
266
|
+
"--count",
|
|
267
|
+
"@{u}..HEAD",
|
|
268
|
+
])
|
|
269
|
+
unpushed = parseInt(String(stdout || "0").trim(), 10) || 0
|
|
270
|
+
} catch {
|
|
271
|
+
// upstream 未設定 (新規ブランチで未 push) は rev-list が失敗する → 警告のため 1 扱い
|
|
272
|
+
unpushed = 1
|
|
273
|
+
}
|
|
274
|
+
return {
|
|
275
|
+
dirty: changes.length > 0 || unpushed > 0,
|
|
276
|
+
changes,
|
|
277
|
+
unpushed_commits: unpushed,
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
235
281
|
/**
|
|
236
282
|
* `git worktree remove --force <wtPath>` を実行する。事前に live tmux session が
|
|
237
283
|
* あれば kill する。cockpit `worktree.remove` ハンドラから呼ばれる。
|
|
238
284
|
*
|
|
239
285
|
* - name から path を解決するには buildWorktreeIndex で fs を走査する (path 検証も兼ねる)
|
|
240
286
|
* - HUB_PROJECTS_BASE 配下に居ない path は拒否 (path traversal 防止)
|
|
241
|
-
* -
|
|
287
|
+
* - **未コミット変更 / 未 push コミットがあれば、`opts.force=true` でない限り
|
|
288
|
+
* 削除を中止して構造化応答 `{ ok: true, dirty: true, ... }` を返す**(throw しない、
|
|
289
|
+
* 呼び出し元が「破棄して削除」確認ダイアログを出して再呼び出ししやすい設計)。
|
|
290
|
+
* - 未コミットの変更等で git remove 自体が失敗した場合はそのエラーを上位に投げる
|
|
291
|
+
*
|
|
292
|
+
* @param {string} name
|
|
293
|
+
* @param {{ force?: boolean, tmuxBin?: string }} [opts]
|
|
294
|
+
* @returns {Promise<{ name: string, wt_path: string, dirty?: boolean, changes?: string[], unpushed_commits?: number }>}
|
|
242
295
|
*/
|
|
243
296
|
export async function removeWorktree(name, opts = {}) {
|
|
244
297
|
const sanitized = sanitizeTmuxName(String(name || "").trim())
|
|
@@ -251,6 +304,22 @@ export async function removeWorktree(name, opts = {}) {
|
|
|
251
304
|
if (!resolved.startsWith(projectsBase + path.sep)) {
|
|
252
305
|
throw new Error("worktree path outside projects base")
|
|
253
306
|
}
|
|
307
|
+
|
|
308
|
+
// force=false (デフォルト): 未コミット / 未 push を検出して dirty なら削除中止
|
|
309
|
+
if (!opts.force) {
|
|
310
|
+
const inspect = await inspectWorktreeDirty(resolved)
|
|
311
|
+
if (inspect.dirty) {
|
|
312
|
+
// セッションは kill せず(誤操作からの復帰を妨げない)、構造化応答で UI に判断を返す
|
|
313
|
+
return {
|
|
314
|
+
name: sanitized,
|
|
315
|
+
wt_path: resolved,
|
|
316
|
+
dirty: true,
|
|
317
|
+
changes: inspect.changes,
|
|
318
|
+
unpushed_commits: inspect.unpushed_commits,
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
254
323
|
// 走行中の session があれば kill
|
|
255
324
|
try {
|
|
256
325
|
await execFileP(tmuxBin(opts), ["kill-session", "-t", sanitized])
|
|
@@ -271,7 +340,7 @@ export async function removeWorktree(name, opts = {}) {
|
|
|
271
340
|
? resolved.slice(0, -wtSegment.length - 1)
|
|
272
341
|
: path.resolve(resolved, "..", "..", "..")
|
|
273
342
|
await execFileP("git", ["-C", parentRepo, "worktree", "remove", "--force", resolved])
|
|
274
|
-
return { name: sanitized, wt_path: resolved }
|
|
343
|
+
return { name: sanitized, wt_path: resolved, dirty: false }
|
|
275
344
|
}
|
|
276
345
|
|
|
277
346
|
/**
|
package/src/tool-result-mask.mjs
CHANGED
|
@@ -38,6 +38,18 @@ export const SECRET_PLACEHOLDER = "[REDACTED]"
|
|
|
38
38
|
export const SKILL_PROMPT_PLACEHOLDER =
|
|
39
39
|
"[get_skill_prompt の本文はマスクされています(必要なら再取得できます)]"
|
|
40
40
|
|
|
41
|
+
/**
|
|
42
|
+
* デプロイ接続情報の置換マーカー。
|
|
43
|
+
*
|
|
44
|
+
* hub_helper.deploy_creds や mcp__hub_staff__get_deploy_target が返す情報は、
|
|
45
|
+
* 「秘匿値そのものは返さない」設計(参照パス・has_* フラグのみ)になっているが、
|
|
46
|
+
* host/port/user/deploy_method 等のメタが画面・jsonl に出ると別案件サーバーへの
|
|
47
|
+
* 誤デプロイの原因になる(人間オペレーターの目視ミスを誘発)。
|
|
48
|
+
* 一律にプレースホルダで潰し、AI には「Hub から再取得できる」ことだけ知らせる方針。
|
|
49
|
+
*/
|
|
50
|
+
export const DEPLOY_CRED_PLACEHOLDER =
|
|
51
|
+
"[デプロイ接続情報はマスクされています(必要なら hub_helper.py deploy_creds で再取得できます)]"
|
|
52
|
+
|
|
41
53
|
/**
|
|
42
54
|
* 既知の秘匿情報パターン (誤検知を抑えるため、形が明確なトークンに限定する)。
|
|
43
55
|
* 各要素は global フラグ付き RegExp。replace で SECRET_PLACEHOLDER に置換する。
|
|
@@ -103,11 +115,33 @@ export function isSkillPromptTool(name) {
|
|
|
103
115
|
)
|
|
104
116
|
}
|
|
105
117
|
|
|
118
|
+
/**
|
|
119
|
+
* tool 名がデプロイ接続情報を返す MCP ツールか判定する。素名と MCP 名前空間
|
|
120
|
+
* 付き (`mcp__hub_staff__get_deploy_target` / `mcp__hub_staff__deploy_creds` 等)
|
|
121
|
+
* の両方を拾う。
|
|
122
|
+
* @param {unknown} name
|
|
123
|
+
* @returns {boolean}
|
|
124
|
+
*/
|
|
125
|
+
export function isDeployCredentialTool(name) {
|
|
126
|
+
if (typeof name !== "string") return false
|
|
127
|
+
return (
|
|
128
|
+
name === "get_deploy_target" ||
|
|
129
|
+
name === "deploy_creds" ||
|
|
130
|
+
name.endsWith("__get_deploy_target") ||
|
|
131
|
+
name.endsWith("__deploy_creds")
|
|
132
|
+
)
|
|
133
|
+
}
|
|
134
|
+
|
|
106
135
|
/** tool_result の content (string | block[]) をプレースホルダ 1 本へ潰す。 */
|
|
107
136
|
function skillPlaceholderContent() {
|
|
108
137
|
return [{ type: "text", text: SKILL_PROMPT_PLACEHOLDER }]
|
|
109
138
|
}
|
|
110
139
|
|
|
140
|
+
/** tool_result の content をデプロイ接続情報プレースホルダ 1 本へ潰す。 */
|
|
141
|
+
function deployCredPlaceholderContent() {
|
|
142
|
+
return [{ type: "text", text: DEPLOY_CRED_PLACEHOLDER }]
|
|
143
|
+
}
|
|
144
|
+
|
|
111
145
|
/** content blocks 配列の text を redactSecrets で処理した新配列を返す (非破壊)。 */
|
|
112
146
|
function redactBlocks(blocks) {
|
|
113
147
|
if (!Array.isArray(blocks)) return blocks
|
|
@@ -166,6 +200,10 @@ export function maskMessageObject(obj, toolNames) {
|
|
|
166
200
|
changed = true
|
|
167
201
|
return { ...b, content: skillPlaceholderContent() }
|
|
168
202
|
}
|
|
203
|
+
if (isDeployCredentialTool(name)) {
|
|
204
|
+
changed = true
|
|
205
|
+
return { ...b, content: deployCredPlaceholderContent() }
|
|
206
|
+
}
|
|
169
207
|
// 非スキル tool_result: 中身 (string | block[]) の秘匿情報を伏せる。
|
|
170
208
|
if (typeof b.content === "string") {
|
|
171
209
|
const masked = redactSecrets(b.content)
|