@modesto_ma/dsh-guanxiang 0.2.0-beta.2 → 0.2.0-beta.3

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": "@modesto_ma/dsh-guanxiang",
3
- "version": "0.2.0-beta.2",
3
+ "version": "0.2.0-beta.3",
4
4
  "description": "观象 GuanXiang:DeepSeek Harness (DSH) 日志分析插件。/guanxiang 识别日志所属应用,把分析派发给工作目录为该应用代码仓库的子 Agent;Web 左侧栏提供「观象」设置面板",
5
5
  "repository": {
6
6
  "type": "git",
@@ -90,6 +90,7 @@
90
90
  "@types/node": "^26.6.5",
91
91
  "@types/react": "~18.3.31",
92
92
  "conventional-changelog-conventionalcommits": "9.3.1",
93
+ "dsh-repo": "^0.1.0-beta.2",
93
94
  "react": "18.3.1",
94
95
  "semantic-release": "^25.0.9",
95
96
  "tsdown": "0.22.2",
@@ -595,7 +595,7 @@ function AdvancedTab({ draft, setDraft, overridden, busy, onReset }: TabProps &
595
595
  <div className="gx__row">
596
596
  <div className="gx__rowText">
597
597
  <div className="gx__rowLabel">允许提出 SKILL 修改 <Overridden keys={overridden} name="skillUpdates" /></div>
598
- <div className="gx__rowSummary">子 Agent 发现应用的日志分析 SKILL 有欠缺时可以写出修改提议;经用户授权后,观象用访问令牌把它推送到新分支并创建合并请求(GitLab,令牌需要 write_repository 权限)。</div>
598
+ <div className="gx__rowSummary">子 Agent 发现应用的日志分析 SKILL 有欠缺时可以写出修改提议;经用户授权后,观象经 dsh-repo 把它提交到新分支并创建合并请求(GitLab 或 GitHub,需要安装 dsh-repo 并配置该仓库主机的令牌)。</div>
599
599
  </div>
600
600
  <div className="gx__rowCtl"><input type="checkbox" className="gx__check" aria-label="允许提出 SKILL 修改" checked={draft.skillUpdatesEnabled} onChange={(event) => { setDraft({ ...draft, skillUpdatesEnabled: event.target.checked }) }} /></div>
601
601
  </div>
package/src/config.ts CHANGED
@@ -137,8 +137,8 @@ const configSchema = z.object({
137
137
  */
138
138
  analyzeTokenRef: z.string().trim().regex(TOKEN_ENV_PATTERN, '令牌名称只能包含字母、数字和下划线,且不能以数字开头').default('GUANXIANG_ANALYZE_TOKEN'),
139
139
  /**
140
- * Author and committer of SKILL improvement commits; omitted uses the git
141
- * identity configured on the host (`user.name` / `user.email`).
140
+ * Author of SKILL improvement commits; omitted uses the user of the token
141
+ * dsh-repo holds for the repository's host, who is always the committer.
142
142
  */
143
143
  skillUpdateAuthor: authorSchema.optional(),
144
144
  /** Registered `ctx.subagents` provider the children start on. */
package/src/index.ts CHANGED
@@ -5,8 +5,8 @@
5
5
  * 任务匹配已注册应用时由顶层 Agent 自行加载)和
6
6
  * `guanxiang_dispatch` 工具(按应用派发子 Agent,工作目录为该应用的代码
7
7
  * 仓库,首条消息用 `/<skill>` 加载仓库里的日志分析 SKILL),以及开启 SKILL
8
- * 改进时的 `guanxiang_propose_skill` 工具(经用户授权后把子 Agent 的 SKILL
9
- * 修改提议推送到新分支并创建合并请求)。应用仓库在设置
8
+ * 改进时的 `guanxiang_propose_skill` 工具(经用户授权后经 dsh-repo 把子 Agent
9
+ * 的 SKILL 修改提议提交到新分支并创建合并请求)。应用仓库在设置
10
10
  * 面板中只填 git 地址,由插件拉取;应用描述来自各仓库的
11
11
  * `.dsh/guanxiang.yaml`。另提供日志平台分析接口 `POST /open-api/logalyze`,
12
12
  * 经 dsh-channels 的 `feishuTopics` 服务在飞书群话题中开始分析。
@@ -55,14 +55,12 @@ export interface LeasedApp extends ReadyApp {
55
55
  readonly dir: string
56
56
  }
57
57
 
58
- /** A clone opened for writing objects and pushing; see {@link RepoRegistry.withClone}. */
58
+ /** A clone opened for writing objects; see {@link RepoRegistry.withClone}. */
59
59
  export interface CloneAccess {
60
60
  /** Fetch-only clone directory. */
61
61
  readonly dir: string
62
62
  /** The configured branch, or the remote's default branch when none is configured. */
63
63
  readonly branch: string
64
- /** Environment for network commands of this address, such as an access token. */
65
- readonly env: Readonly<Record<string, string>>
66
64
  }
67
65
 
68
66
  /** Held snapshots of one dispatch; release exactly once. */
@@ -374,7 +372,7 @@ export class RepoRegistry {
374
372
  if (entry.retired) throw new Error(`仓库 ${url} 已不在配置中`)
375
373
  const env = await this.deps.auth?.(url) ?? {}
376
374
  const branch = entry.spec.branch ?? (await this.remoteHead(entry.dir, env)).replace(/^refs\/remotes\/origin\//, '')
377
- return await task({ dir: entry.dir, branch, env })
375
+ return await task({ dir: entry.dir, branch })
378
376
  })
379
377
  }
380
378
 
package/src/service.ts CHANGED
@@ -22,6 +22,7 @@ import type { AnalyzeWire, CredentialsWire, EditableSettings, StateWire, TokenSt
22
22
  import { diffOverlay, overriddenKeys } from './settings/overlay.ts'
23
23
  import type { OverlayStore } from './settings/overlay.ts'
24
24
  import { createEntrySkillProvider } from './skill/entry.ts'
25
+ import { DSH_REPOS_SERVICE } from './skill-update/repos.ts'
25
26
  import { SkillUpdater } from './skill-update/tool.ts'
26
27
 
27
28
  /** A settings write the panel reports as a field error. */
@@ -83,6 +84,7 @@ export class GuanxiangService {
83
84
  gitTimeoutMs: config.gitTimeoutMs,
84
85
  ...config.skillUpdateAuthor === undefined ? {} : { author: config.skillUpdateAuthor },
85
86
  approval: () => ctx.get?.('approval') as HostApproval | undefined,
87
+ repos: () => ctx.get?.(DSH_REPOS_SERVICE),
86
88
  })
87
89
  this.dispatcher = new Dispatcher({
88
90
  registry: this.registry,
@@ -1,7 +1,7 @@
1
1
  /**
2
- * 在拉取仓库中用 git 底层命令生成 SKILL 修改的提交并推送:用临时索引文件
3
- * 在分析时的提交上替换文件、写出树对象,不改动任何检出、快照或本地分支;
4
- * 推送时用 GitLab push options 顺带创建合并请求。
2
+ * 在拉取仓库中用 git 底层命令检查 SKILL 修改:用临时索引文件在分析时的提交上
3
+ * 替换文件、写出树对象,算出逐文件行数和 diff,不改动任何检出、快照或本地
4
+ * 分支。树对象的前缀用作新分支名,提交本身经 dsh-repo 完成。
5
5
  *
6
6
  * @module dsh-guanxiang/skill-update/git
7
7
  */
@@ -37,19 +37,6 @@ export interface GitCommands {
37
37
  readonly timeoutMs: number
38
38
  }
39
39
 
40
- /** Author and committer of a commit. */
41
- export interface CommitAuthor {
42
- readonly name: string
43
- readonly email: string
44
- }
45
-
46
- /** Merge request fields sent as GitLab push options. */
47
- export interface MergeRequestOptions {
48
- readonly target: string
49
- readonly title: string
50
- readonly description: string
51
- }
52
-
53
40
  const DIFF_FLAGS = ['--no-color', '--no-ext-diff', '--no-renames']
54
41
 
55
42
  /**
@@ -95,63 +82,3 @@ export async function prepareChange(commands: GitCommands, dir: string, base: st
95
82
  await rm(scratch, { recursive: true, force: true })
96
83
  }
97
84
  }
98
-
99
- /**
100
- * Whether the remote already has a branch.
101
- * @param commands - git runner and timeout.
102
- * @param dir - clone directory.
103
- * @param branch - branch name.
104
- * @param env - network environment of the clone's address.
105
- * @returns whether `refs/heads/<branch>` exists on `origin`.
106
- */
107
- export async function remoteBranchExists(commands: GitCommands, dir: string, branch: string, env: Readonly<Record<string, string>>): Promise<boolean> {
108
- const output = await commands.git(['ls-remote', '--heads', 'origin', `refs/heads/${branch}`], { cwd: dir, timeoutMs: commands.timeoutMs, env })
109
- return output.trim() !== ''
110
- }
111
-
112
- /**
113
- * Commit a prepared tree on its base commit without touching any ref.
114
- * @param commands - git runner and timeout.
115
- * @param dir - clone directory.
116
- * @param change - prepared tree and its base.
117
- * @param message - full commit message.
118
- * @param author - explicit identity; omitted uses the host's git configuration.
119
- * @returns the new commit id.
120
- */
121
- export async function commitChange(commands: GitCommands, dir: string, change: PreparedChange, message: string, author: CommitAuthor | undefined): Promise<string> {
122
- const env = author === undefined
123
- ? undefined
124
- : { GIT_AUTHOR_NAME: author.name, GIT_AUTHOR_EMAIL: author.email, GIT_COMMITTER_NAME: author.name, GIT_COMMITTER_EMAIL: author.email }
125
- const output = await commands.git(['commit-tree', change.tree, '-p', change.base, '-F', '-'], { cwd: dir, timeoutMs: commands.timeoutMs, input: message, ...env === undefined ? {} : { env } })
126
- return output.trim()
127
- }
128
-
129
- /** Push options cannot carry line breaks; collapse every whitespace run. */
130
- function oneLine(text: string): string {
131
- return text.replace(/\s+/g, ' ').trim()
132
- }
133
-
134
- /**
135
- * Push a commit to a new remote branch and ask GitLab to open a merge request
136
- * for it (`merge_request.*` push options). The push carries an empty lease on
137
- * the branch, so it fails instead of updating a branch that appeared after
138
- * {@link remoteBranchExists} was checked. A server that does not support push
139
- * options rejects the push.
140
- * @param commands - git runner and timeout.
141
- * @param dir - clone directory.
142
- * @param commit - commit to push.
143
- * @param branch - new branch name.
144
- * @param request - merge request target and text.
145
- * @param env - network environment of the clone's address.
146
- * @returns the merge request URL the server printed, if any.
147
- */
148
- export async function pushForMergeRequest(commands: GitCommands, dir: string, commit: string, branch: string, request: MergeRequestOptions, env: Readonly<Record<string, string>>): Promise<string | undefined> {
149
- const options = [
150
- 'merge_request.create',
151
- `merge_request.target=${request.target}`,
152
- `merge_request.title=${oneLine(request.title)}`,
153
- `merge_request.description=${oneLine(request.description)}`,
154
- ].flatMap(option => ['-o', option])
155
- const output = await commands.git(['push', '--porcelain', `--force-with-lease=refs/heads/${branch}:`, ...options, 'origin', `${commit}:refs/heads/${branch}`], { cwd: dir, timeoutMs: commands.timeoutMs, env, output: 'all' })
156
- return /https?:\/\/\S+\/-\/merge_requests\/\d+/.exec(output)?.[0]
157
- }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * dsh-repo `dshRepos` 服务的查找:SKILL 修改提议经它在代码托管平台上建分支、
3
+ * 提交并创建合并请求(GitLab MR 或 GitHub PR)。
4
+ *
5
+ * 只从 `dsh-repo/contract` 引入类型;服务在运行时经 `ctx.get('dshRepos')`
6
+ * 取得,版本不符或缺少 `getBranch`(早于 0.1.0-beta.2 的 dsh-repo,提交时会忽略
7
+ * `baseSha` 与 `newBranch`)时不调用。
8
+ *
9
+ * @module dsh-guanxiang/skill-update/repos
10
+ */
11
+
12
+ import type { DshRepoService, DshReposVersion, RepoErrorCode } from 'dsh-repo/contract'
13
+
14
+ /** Name dsh-repo provides the service under. */
15
+ export const DSH_REPOS_SERVICE = 'dshRepos'
16
+
17
+ /** The `dshRepos` contract version this plugin speaks. */
18
+ export const DSH_REPOS_VERSION: DshReposVersion = 1
19
+
20
+ /** Result of looking the service up. */
21
+ export type ReposLookup =
22
+ | { readonly availability: 'ready'; readonly service: DshRepoService }
23
+ | { readonly availability: 'missing' | 'incompatible' }
24
+
25
+ /**
26
+ * Classify the value `ctx.get('dshRepos')` returned. The provider is another
27
+ * package that may be older or newer than this one, so its version is checked
28
+ * at run time; `getBranch` marks a provider that also honors `baseSha` and
29
+ * `newBranch` on commits.
30
+ * @param value - the looked-up service, or undefined.
31
+ * @returns the usable service, or why there is none.
32
+ */
33
+ export function lookupRepos(value: unknown): ReposLookup {
34
+ if (value === undefined || value === null) return { availability: 'missing' }
35
+ const service = value as Partial<Record<keyof DshRepoService, unknown>>
36
+ if (service.version !== DSH_REPOS_VERSION || typeof service.getBranch !== 'function' || typeof service.commitFiles !== 'function' || typeof service.createChange !== 'function') {
37
+ return { availability: 'incompatible' }
38
+ }
39
+ return { availability: 'ready', service: value as DshRepoService }
40
+ }
41
+
42
+ /**
43
+ * The failure class of a `dshRepos` rejection.
44
+ * @param error - the rejection.
45
+ * @returns its `code` when it is a `RepoError`.
46
+ */
47
+ export function repoErrorCode(error: unknown): RepoErrorCode | undefined {
48
+ if (!(error instanceof Error) || error.name !== 'RepoError') return undefined
49
+ const code = (error as Error & { code?: unknown }).code
50
+ return typeof code === 'string' ? code as RepoErrorCode : undefined
51
+ }
@@ -4,11 +4,13 @@
4
4
  * - 只接受本进程中由同一会话派发过的任务运行目录;应用、仓库和分析时的提交
5
5
  * 取自派发记录,不取自运行目录里可被 Agent 改写的内容。
6
6
  * - 只改应用 SKILL 目录 `.dsh/skills/<skill>/` 下的文件,提交以分析时的提交为
7
- * 父提交,推送到新分支 `guanxiang/skill-<应用 id>-<树对象前缀>`,从不推送已有
8
- * 分支或默认分支。
9
- * - 推送前经 DSH 审批服务(`ctx.approval`)请求一次性授权;dsh_channels 把它
10
- * 显示为飞书决策卡片,DSH Web 显示为审批提示。只有 `allowed-once` 才推送;
11
- * 没有审批服务时工具报错,不推送。
7
+ * 父提交,提交到新分支 `guanxiang/skill-<应用 id>-<树对象前缀>`,从不提交到
8
+ * 已有分支或默认分支。
9
+ * - 提交和创建合并请求经 dsh-repo 的 `dshRepos` 服务完成,支持 GitLab 与
10
+ * GitHub;令牌由 dsh-repo 按主机配置。没有该服务或版本不符时工具报错。
11
+ * - 提交前经 DSH 审批服务(`ctx.approval`)请求一次性授权;dsh_channels 把它
12
+ * 显示为飞书决策卡片,DSH Web 显示为审批提示。只有 `allowed-once` 才提交;
13
+ * 没有审批服务时工具报错,不提交。
12
14
  * - 子会话中不提供本工具。
13
15
  *
14
16
  * @module dsh-guanxiang/skill-update/tool
@@ -17,6 +19,7 @@
17
19
  import { randomBytes } from 'node:crypto'
18
20
  import { rename, rm, writeFile } from 'node:fs/promises'
19
21
  import { join, resolve } from 'node:path'
22
+ import type { CommitAuthor } from 'dsh-repo/contract'
20
23
  import { z } from 'zod'
21
24
  import { describeIssue } from '../config.ts'
22
25
  import { isDelegatedAgent } from '../dispatch/tool.ts'
@@ -24,9 +27,11 @@ import type { ContentBlock, HostApproval, ToolDefinition, ToolRunContext } from
24
27
  import type { GitRunner } from '../repos/git.ts'
25
28
  import type { RepoRegistry } from '../repos/registry.ts'
26
29
  import type { AppManifest, EditableSettings } from '../settings/contract.ts'
27
- import { commitChange, prepareChange, pushForMergeRequest, remoteBranchExists } from './git.ts'
28
- import type { CommitAuthor, FileStat, PreparedChange } from './git.ts'
30
+ import { prepareChange } from './git.ts'
31
+ import type { FileStat, PreparedChange } from './git.ts'
29
32
  import { DIFF_FILE, FILES_DIR, PROPOSAL_DIR, ProposalError, readProposal, REASON_FILE } from './proposal.ts'
33
+ import type { ProposedFile } from './proposal.ts'
34
+ import { DSH_REPOS_SERVICE, DSH_REPOS_VERSION, lookupRepos, repoErrorCode } from './repos.ts'
30
35
 
31
36
  /** Model-facing tool name. */
32
37
  export const PROPOSE_TOOL_NAME = 'guanxiang_propose_skill'
@@ -69,7 +74,7 @@ export interface ProposeValue {
69
74
  status: ProposeStatus
70
75
  app: string
71
76
  repo: string
72
- /** New branch the change was or would be pushed to. */
77
+ /** New branch the change was or would be committed to. */
73
78
  branch: string
74
79
  /** Branch the merge request targets. */
75
80
  target: string
@@ -77,6 +82,8 @@ export interface ProposeValue {
77
82
  /** Diff file in the task's run directory. */
78
83
  diffPath: string
79
84
  mergeRequestUrl?: string
85
+ /** Why the merge request was not created; only with `pushed`. */
86
+ mergeRequestError?: string
80
87
  }
81
88
 
82
89
  const argsSchema = z.object({
@@ -115,6 +122,7 @@ const OUTPUT_SCHEMA = {
115
122
  files: { type: 'array', items: FILE_STAT_SCHEMA },
116
123
  diffPath: { type: 'string' },
117
124
  mergeRequestUrl: { type: 'string' },
125
+ mergeRequestError: { type: 'string' },
118
126
  },
119
127
  } as const
120
128
 
@@ -135,12 +143,12 @@ export function describeFiles(files: readonly FileStat[]): string {
135
143
  export function renderProposeValue(value: ProposeValue): ContentBlock[] {
136
144
  const where = `${value.repo} 的分支 ${value.branch}(目标 ${value.target})`
137
145
  const text = {
138
- opened: `已推送到 ${where} 并创建合并请求:${value.mergeRequestUrl ?? ''}`,
139
- pushed: `已推送到 ${where},但服务器没有返回合并请求链接;请在 GitLab 上确认合并请求是否已创建。`,
140
- exists: `${where} 已存在,同一改动可能已经提交过,本次没有推送。`,
141
- rejected: '授权被拒绝,没有推送:用户拒绝了这次修改,或会话的审批策略为 never(例如「完全访问」权限模式)。',
142
- cancelled: '授权请求被撤回,没有推送。',
143
- unavailable: '当前没有可以回答授权请求的界面,没有推送。',
146
+ opened: `已提交到 ${where} 并创建合并请求:${value.mergeRequestUrl ?? ''}`,
147
+ pushed: `已提交到 ${where},但创建合并请求失败(${value.mergeRequestError ?? '未知原因'});请在代码托管平台上从该分支手动创建。`,
148
+ exists: `${where} 已存在,同一改动可能已经提交过,本次没有提交。`,
149
+ rejected: '授权被拒绝,没有提交:用户拒绝了这次修改,或会话的审批策略为 never(例如「完全访问」权限模式)。',
150
+ cancelled: '授权请求被撤回,没有提交。',
151
+ unavailable: '当前没有可以回答授权请求的界面,没有提交。',
144
152
  }[value.status]
145
153
  return [{ type: 'text', text: `${text}\n改动:${describeFiles(value.files)}\ndiff:${value.diffPath}` }]
146
154
  }
@@ -154,6 +162,8 @@ export interface SkillUpdaterDeps {
154
162
  readonly author?: CommitAuthor
155
163
  /** DSH's approval service, when the composition has one. */
156
164
  approval(): HostApproval | undefined
165
+ /** What `ctx.get('dshRepos')` returns now. */
166
+ repos(): unknown
157
167
  }
158
168
 
159
169
  function messageOf(error: unknown): string {
@@ -223,7 +233,7 @@ export class SkillUpdater {
223
233
  * @returns undefined when the task wrote no proposal.
224
234
  * @throws ProposalError or a git failure.
225
235
  */
226
- private async prepare(task: DispatchedTask): Promise<{ reason: string; change: PreparedChange; target: string; diffPath: string } | undefined> {
236
+ private async prepare(task: DispatchedTask): Promise<{ reason: string; files: readonly ProposedFile[]; change: PreparedChange; target: string; diffPath: string } | undefined> {
227
237
  const proposal = await readProposal(task.workDir, this.deps.settings().skillUpdates)
228
238
  if (proposal === undefined) return undefined
229
239
  const commands = { git: this.deps.git, timeoutMs: this.deps.gitTimeoutMs }
@@ -234,7 +244,7 @@ export class SkillUpdater {
234
244
  if (change.files.length === 0) throw new ProposalError(`提议的文件与提交 ${task.commit.slice(0, 12)} 中的 SKILL 相同,没有改动`)
235
245
  const diffPath = join(task.workDir, DIFF_FILE)
236
246
  await replaceFile(diffPath, change.diff)
237
- return { reason: proposal.reason, change, target, diffPath }
247
+ return { reason: proposal.reason, files: proposal.files, change, target, diffPath }
238
248
  }
239
249
 
240
250
  /**
@@ -244,7 +254,7 @@ export class SkillUpdater {
244
254
  definition(): ToolDefinition<ProposeValue> {
245
255
  return {
246
256
  name: PROPOSE_TOOL_NAME,
247
- description: '观象:把派发结果中某个应用的 SKILL 修改提议提交为合并请求。工具先请求用户授权,批准后推送新分支并创建合并请求,不会直接改动默认分支。'
257
+ description: '观象:把派发结果中某个应用的 SKILL 修改提议提交为合并请求。工具先请求用户授权,批准后提交到新分支并创建合并请求,不会直接改动默认分支。'
248
258
  + '仅在派发结果标出了 SKILL 修改提议时使用;不要自己用 git 推送。',
249
259
  parameters: PARAMETERS,
250
260
  output: {
@@ -261,7 +271,7 @@ export class SkillUpdater {
261
271
  * @param rawArgs - model arguments.
262
272
  * @param exec - execution context of the calling agent.
263
273
  * @returns how the submission ended.
264
- * @throws when the arguments, task, proposal, or approval service are unusable, or git fails; nothing is pushed then.
274
+ * @throws when the arguments, task, proposal, approval service, or `dshRepos` service are unusable, or the commit fails; nothing is committed then.
265
275
  */
266
276
  async execute(rawArgs: unknown, exec: ToolRunContext): Promise<ProposeValue> {
267
277
  const parsed = argsSchema.safeParse(rawArgs)
@@ -275,22 +285,26 @@ export class SkillUpdater {
275
285
  if (task === undefined) throw new Error(`${parsed.data.workDir} 不是本次 DSH 运行中观象派发任务的运行目录;DSH 重启后需要重新分析`)
276
286
  if (task.sessionId !== agent.session.header.id) throw new Error(`${parsed.data.workDir} 不是当前会话派发的任务`)
277
287
  const approval = this.deps.approval()
278
- if (approval === undefined) throw new Error('当前 DSH 组合没有审批服务(@deepseek-ai/dsh-user-approval),观象不会在未经授权时推送')
288
+ if (approval === undefined) throw new Error('当前 DSH 组合没有审批服务(@deepseek-ai/dsh-user-approval),观象不会在未经授权时提交')
289
+ const lookup = lookupRepos(this.deps.repos())
290
+ if (lookup.availability !== 'ready') {
291
+ throw new Error(lookup.availability === 'missing'
292
+ ? `提交 SKILL 修改需要 dsh-repo 插件提供的 ${DSH_REPOS_SERVICE} 服务;请安装 dsh-repo,并在它的 hosts 中配置该应用仓库所在的主机和令牌`
293
+ : `dsh-repo 的 ${DSH_REPOS_SERVICE} 服务与观象不兼容(观象需要服务版本 ${DSH_REPOS_VERSION} 且提供 getBranch,即 dsh-repo 0.1.0-beta.2 及以上);请升级 dsh-repo`)
294
+ }
295
+ const repos = lookup.service
279
296
 
280
297
  const prepared = await this.prepare(task)
281
298
  if (prepared === undefined) throw new Error(`${task.workDir} 中没有 SKILL 修改提议(${PROPOSAL_DIR}/${REASON_FILE} 与 ${PROPOSAL_DIR}/${FILES_DIR}/)`)
282
299
  const { change, target, diffPath, reason } = prepared
283
300
  const branch = `guanxiang/skill-${task.app.id}-${change.tree.slice(0, 10)}`
284
301
  const base = { app: task.app.id, repo: task.url, branch, target, files: [...change.files], diffPath }
285
- const commands = { git: this.deps.git, timeoutMs: this.deps.gitTimeoutMs }
286
302
 
287
- if (await this.deps.registry.withClone(task.url, clone => remoteBranchExists(commands, clone.dir, branch, clone.env))) {
288
- return { ...base, status: 'exists' }
289
- }
303
+ if (await repos.getBranch(task.url, branch, { signal: exec.signal }) !== undefined) return { ...base, status: 'exists' }
290
304
 
291
305
  const preview = reason.replace(/\s+/g, ' ').slice(0, REASON_PREVIEW_CHARS)
292
- const zh = `观象将向 ${task.url} 推送新分支 ${branch},并创建合并请求合入 ${target}。改动:${describeFiles(change.files)}。摘要:${summary}。理由:${preview}`
293
- const en = `GuanXiang will push branch ${branch} to ${task.url} and open a merge request into ${target}. Files: ${change.files.map(file => file.path).join(', ')}. Summary: ${summary}`
306
+ const zh = `观象将在 ${task.url} 提交新分支 ${branch},并创建合并请求合入 ${target}。改动:${describeFiles(change.files)}。摘要:${summary}。理由:${preview}`
307
+ const en = `GuanXiang will commit branch ${branch} to ${task.url} and open a merge request into ${target}. Files: ${change.files.map(file => file.path).join(', ')}. Summary: ${summary}`
294
308
  const outcome = await approval.request({
295
309
  agent,
296
310
  toolName: PROPOSE_TOOL_NAME,
@@ -304,11 +318,32 @@ export class SkillUpdater {
304
318
 
305
319
  const message = `${summary}\n\n${reason}\n\nProposed-by: 观象 GuanXiang\nAnalyzed-commit: ${task.commit}\nSession: ${task.sessionId}\n`
306
320
  const description = `由观象在日志分析中提出(应用 ${task.app.id},分析提交 ${task.commit.slice(0, 12)},会话 ${task.sessionId})。理由:${reason.slice(0, REASON_DESCRIPTION_CHARS)}`
307
- const url = await this.deps.registry.withClone(task.url, async (clone) => {
308
- exec.signal.throwIfAborted()
309
- const commit = await commitChange(commands, clone.dir, change, message, this.deps.author)
310
- return await pushForMergeRequest(commands, clone.dir, commit, branch, { target, title: summary, description }, clone.env)
311
- })
312
- return url === undefined ? { ...base, status: 'pushed' } : { ...base, status: 'opened', mergeRequestUrl: url }
321
+ // Only files that differ from the analyzed commit; an unchanged one would be an empty update.
322
+ const changed = new Set(change.files.map(file => file.path))
323
+ const files = prepared.files
324
+ .map(file => ({ action: 'write' as const, path: `${skillDirOf(task.app)}/${file.path}`, content: file.content }))
325
+ .filter(file => changed.has(file.path))
326
+ exec.signal.throwIfAborted()
327
+ try {
328
+ await repos.commitFiles(task.url, {
329
+ branch,
330
+ baseSha: task.commit,
331
+ newBranch: true,
332
+ message,
333
+ files,
334
+ ...this.deps.author === undefined ? {} : { author: this.deps.author },
335
+ signal: exec.signal,
336
+ })
337
+ } catch (error) {
338
+ if (repoErrorCode(error) === 'conflict') return { ...base, status: 'exists' }
339
+ throw error
340
+ }
341
+ try {
342
+ const opened = await repos.createChange(task.url, { title: summary, body: description, sourceBranch: branch, targetBranch: target, signal: exec.signal })
343
+ return { ...base, status: 'opened', mergeRequestUrl: opened.webUrl }
344
+ } catch (error) {
345
+ // The branch exists now; report it so the user can open the request by hand.
346
+ return { ...base, status: 'pushed', mergeRequestError: messageOf(error) }
347
+ }
313
348
  }
314
349
  }