dsh-plugin-git-commit-push 0.0.0-stage → 1.0.1

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/index.js ADDED
@@ -0,0 +1,908 @@
1
+ /**
2
+ * dsh-plugin-git-commit-push — a one-call Conventional-Commits workflow for DSH.
3
+ *
4
+ * WHY THIS EXISTS
5
+ * The skill it replaces asks a model to run `git status`, read `.gitignore`,
6
+ * inspect a diff, call `git log` for tone, write a message, then commit, tag and
7
+ * push — ten-odd tool calls whose OUTPUT is mostly raw git text. A plugin does
8
+ * the reading in-process, where bytes are free, and hands back one compact card.
9
+ * The model pays for a summary instead of a diff, and pays for a question only
10
+ * when a tag is genuinely warranted (the in-plugin question costs no tokens at
11
+ * all, because it never becomes a model message).
12
+ *
13
+ * WHEN IT MAY BE USED — the trigger policy
14
+ * ONLY when the user asks for it: they typed the `/git-commit-push` slash command,
15
+ * or they said in words that they want a commit and/or push. There is no
16
+ * automatic path. Finishing an edit, completing a task, or approaching the end
17
+ * of a session is NOT a trigger, and the tool description states this to the
18
+ * model in the strongest terms available. The plugin cannot enforce the rule
19
+ * from inside `execute` — by then the decision has already been made — so the
20
+ * enforcement lives in the tool description (which the model reads when
21
+ * choosing a tool) and in the accompanying SKILL.md.
22
+ *
23
+ * WHAT IT WILL NOT DO
24
+ * Never modifies `.gitignore`, git config or `user.name`/`user.email`; never
25
+ * `push --force`, `reset --hard`, `clean`, or `checkout --` (see lib/git.js for
26
+ * the fixed command set); never commits in a container directory — it reports
27
+ * the candidate repositories instead.
28
+ */
29
+ import {
30
+ loadSettingsReport, resolveSettings, uiOverrides,
31
+ CONFIG_PATH, userConfigPath, configCandidates,
32
+ } from './lib/config.js'
33
+ import { CONFIG } from './lib/schema.js'
34
+ import { skillDefinition } from './lib/skill.js'
35
+ import { survey } from './lib/survey.js'
36
+ import {
37
+ commit, createTag, headShort, numstat, push, pushAfterRebase,
38
+ stageAll, tagExists, tagNameError,
39
+ } from './lib/git.js'
40
+ import { renderCard, totalsOf } from './lib/analyze.js'
41
+
42
+ export const name = 'git-commit-push'
43
+ export const inject = ['tools']
44
+
45
+ /**
46
+ * Tool name the model sees. Underscored, not hyphenated: DSH tool names use
47
+ * snake_case (`read_image`, `web_fetch`), and the skill references this exact
48
+ * string.
49
+ */
50
+ export const TOOL_NAME = 'git_commit_push'
51
+
52
+ /** Slash command name, without the leading slash. */
53
+ export const COMMAND_NAME = 'git-commit-push'
54
+
55
+ /**
56
+ * Reduce any thrown value to one bounded, printable line.
57
+ *
58
+ * Git stderr can be long and multi-line; a commit failure should not push a
59
+ * screenful of remote narration into the transcript.
60
+ */
61
+ function brief(error) {
62
+ const raw = error instanceof Error ? error.message : String(error)
63
+ const text = raw.replace(/\s+/g, ' ').trim()
64
+ return text.length > 400 ? `${text.slice(0, 400)}…` : text
65
+ }
66
+
67
+ /** The session working directory, with the host process cwd as last resort. */
68
+ function cwdOf(exec) {
69
+ const header = exec?.agent?.session?.header?.cwd
70
+ if (typeof header === 'string' && header !== '') return header
71
+ return process.cwd()
72
+ }
73
+
74
+ /**
75
+ * Whether the settings ask for a tag question, and what name to suggest.
76
+ *
77
+ * @returns {{ shouldAsk: boolean, tag?: string, reasons: string[] }}
78
+ */
79
+ function tagDecision(surveyResult, settings, override) {
80
+ const reasons = []
81
+ if (settings.tagOnVersionChange && surveyResult.version !== undefined) {
82
+ reasons.push(`版本号 ${surveyResult.version.from ?? '?'} → ${surveyResult.version.to ?? '?'}`)
83
+ }
84
+ if (settings.tagOnBreaking && surveyResult.breaking) reasons.push('可能的破坏性变更')
85
+ if (settings.tagOnFileCount > 0 && surveyResult.entries.length >= settings.tagOnFileCount) {
86
+ reasons.push(`改动文件 ${surveyResult.entries.length} 个`)
87
+ }
88
+
89
+ let tag = override
90
+ if (tag === undefined && reasons.length > 0) {
91
+ const version = surveyResult.version?.to
92
+ if (typeof version === 'string' && version !== '') {
93
+ tag = version.startsWith(settings.tagPrefix) ? version : `${settings.tagPrefix}${version.replace(/^v/, '')}`
94
+ } else {
95
+ const now = new Date()
96
+ const stamp = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}`
97
+ tag = `${settings.tagPrefix}${stamp}`
98
+ }
99
+ }
100
+ return { shouldAsk: reasons.length > 0, ...(tag === undefined ? {} : { tag }), reasons }
101
+ }
102
+
103
+ /**
104
+ * Ask the user to confirm a tag, from inside the tool call.
105
+ *
106
+ * This is the token-saving move the skill cannot make: the question is answered
107
+ * in the plugin's own UI card and never becomes a model message.
108
+ *
109
+ * Failure policy: when the question cannot be asked at all (a delegated caller
110
+ * has no human answerer, an older host lacks the service, or the wait times
111
+ * out) the plugin does NOT tag. Creating a release tag is an outward-facing,
112
+ * hard-to-undo act, so "nobody answered" must mean "do not", and the note tells
113
+ * the caller exactly how to force it. Only `askBeforeTag: false` tags silently,
114
+ * because that is the user explicitly opting in.
115
+ *
116
+ * @returns {Promise<{ decided: string | undefined, via: 'user' | 'auto' | 'skipped', note?: string }>}
117
+ */
118
+ async function askTag(ctx, exec, tag, reasons, settings) {
119
+ if (tag === undefined) return { decided: undefined, via: 'skipped' }
120
+ if (settings.askBeforeTag !== true) return { decided: tag, via: 'auto' }
121
+ // A question needs a live caller to answer it. Without one (a direct `run`
122
+ // call with no tool context) there is nothing to ask, and the safe answer is
123
+ // the same as everywhere else in this function: do not tag.
124
+ if (exec?.agent === undefined) {
125
+ return { decided: undefined, via: 'skipped', note: `没有可询问的会话上下文,本次未打标签(可用 tag 参数指定 ${tag})` }
126
+ }
127
+
128
+ const service = ctx.get('userQuestions')
129
+ if (service === undefined) {
130
+ return { decided: undefined, via: 'skipped', note: `未安装提问服务,本次未打标签(可用 tag 参数指定 ${tag})` }
131
+ }
132
+
133
+ const questions = [{
134
+ id: 'git-commit-push-tag',
135
+ header: 'Git 标签',
136
+ question: `本次改动满足打标签条件(${reasons.join(';')})。是否创建标签 ${tag}?`,
137
+ options: [
138
+ { label: `添加 ${tag}`, description: `创建标签 ${tag} 并随分支一起推送` },
139
+ { label: '不添加', description: '只提交并推送提交,不创建标签' },
140
+ ],
141
+ }]
142
+ const request = { agent: exec.agent, signal: exec.signal, questions }
143
+
144
+ try {
145
+ const timed = typeof service.askTimed === 'function'
146
+ const answer = timed
147
+ ? await service.askTimed(request, exec.callId, settings.askTimeoutMs)
148
+ : await service.ask(request)
149
+
150
+ if (timed && answer !== null && typeof answer === 'object' && 'pending' in answer) {
151
+ return { decided: undefined, via: 'skipped', note: `等待标签确认超时,本次未打标签(可用 tag 参数指定 ${tag})` }
152
+ }
153
+ const selected = answer?.answers?.find(item => item.id === 'git-commit-push-tag')?.selected?.[0]
154
+ if (typeof selected === 'string' && selected.startsWith('添加')) return { decided: tag, via: 'user' }
155
+ return { decided: undefined, via: 'user' }
156
+ } catch (error) {
157
+ const message = brief(error)
158
+ // Match the documented error CODES, not just their names: the service
159
+ // reports `DELEGATED_CALLER`/`CALLER_NOT_LIVE` in `error.code` as well as in
160
+ // the message, and a host that reworded the message must not change whether
161
+ // we tag.
162
+ const code = typeof error?.code === 'string' ? error.code : ''
163
+ const delegated = /DELEGATED_CALLER|CALLER_NOT_LIVE/.test(code) || /DELEGATED_CALLER|CALLER_NOT_LIVE/.test(message)
164
+ return {
165
+ decided: undefined,
166
+ via: 'skipped',
167
+ note: delegated
168
+ ? `当前上下文无法询问用户,本次未打标签(可用 tag 参数指定 ${tag})`
169
+ : `标签确认失败(${message}),本次未打标签(可用 tag 参数指定 ${tag})`,
170
+ }
171
+ }
172
+ }
173
+
174
+ /**
175
+ * Stage, classify and commit.
176
+ *
177
+ * @returns {Promise<{ ok: boolean, hash?: string, subject?: string, error?: string, email?: string }>}
178
+ */
179
+ async function doCommit(ctx, exec, options) {
180
+ const settings = options.settings
181
+ const root = options.root
182
+ const message = options.message
183
+ const entries = options.entries
184
+
185
+ if (settings.autoAdd) {
186
+ // `exec` is present for a tool call and absent for a `/git-commit-push` invocation
187
+ // driven by tests or an embedding host, so the optional chain is required.
188
+ //
189
+ // AWAITED on purpose: this used to race the staged-numstat read below, so a
190
+ // real commit reported `+0 / -0` on the card — a wrong number right where a
191
+ // person looks for confirmation.
192
+ await stageAll(root, exec?.signal)
193
+ }
194
+
195
+ const stagedStats = await numstat(root, true)
196
+ // Guard the one setting that can make the commit itself impossible: with
197
+ // autoAdd off, an empty index fails with git's own "nothing added to commit",
198
+ // which reads like a plugin bug rather than a setting. When autoAdd is ON an
199
+ // empty index is legitimate — a repository whose first commit has no `HEAD`
200
+ // yet cannot produce a numstat at all — so the commit is attempted and git's
201
+ // own answer is reported if there is truly nothing to commit.
202
+ if (settings.autoAdd !== true && stagedStats.size === 0) {
203
+ return {
204
+ ok: false,
205
+ error: '暂存区为空:autoAdd 已关闭且没有任何文件被暂存。请先 git add,或把配置里的 autoAdd 改回 true。',
206
+ }
207
+ }
208
+ const { added, deleted } = totalsOf(stagedStats)
209
+
210
+ try {
211
+ await commit(root, message, settings.pinnedIdentity)
212
+ } catch (error) {
213
+ const text = brief(error)
214
+ // A hook rejection is reported, never bypassed: `--no-verify` is not used
215
+ // anywhere in this plugin.
216
+ return { ok: false, error: text }
217
+ }
218
+
219
+ const hash = await headShort(root)
220
+ return {
221
+ ok: true,
222
+ ...(hash === undefined ? {} : { hash }),
223
+ subject: message.split('\n')[0],
224
+ files: entries.length,
225
+ added,
226
+ deleted,
227
+ }
228
+ }
229
+
230
+ /**
231
+ * Create the tag and push branch + tag.
232
+ *
233
+ * The branch push and the tag push are separate commands so a tag rejection
234
+ * (the common "tag already exists on the remote" case) leaves a successful
235
+ * branch push fully verified instead of reported as an ambiguous single failure.
236
+ *
237
+ * @returns {Promise<{ pushed: boolean, tagCreated?: string, tagPushed?: boolean, note?: string, reason?: string }>}
238
+ */
239
+ async function doTagAndPush(ctx, exec, options) {
240
+ const { settings, root, branch, tag, upstream } = options
241
+ const result = { pushed: false }
242
+ // Notes accumulate; a later fact must never silently overwrite an earlier one.
243
+ const notes = []
244
+ const note = text => { notes.push(text) }
245
+
246
+ if (tag !== undefined) {
247
+ const invalid = await tagNameError(root, tag)
248
+ if (invalid !== undefined) {
249
+ note(`标签名无效(${invalid}),已跳过打标签`)
250
+ } else if (await tagExists(root, tag)) {
251
+ note(`标签 ${tag} 已存在,已跳过`)
252
+ } else {
253
+ try {
254
+ await createTag(root, tag)
255
+ result.tagCreated = tag
256
+ } catch (error) {
257
+ note(`创建标签失败:${brief(error)}`)
258
+ }
259
+ }
260
+ }
261
+
262
+ if (settings.autoPush !== true) {
263
+ note('autoPush 已关闭,未推送')
264
+ result.note = notes.join(';')
265
+ return result
266
+ }
267
+
268
+ const pushed = await pushAfterRebase(root, branch, { hasUpstream: upstream })
269
+ if (pushed.ok) {
270
+ result.pushed = true
271
+ if (pushed.rebased) note('远程有新提交,已 rebase 后重推')
272
+ if (result.tagCreated !== undefined) {
273
+ // The tag is a second command so its failure cannot be confused with the
274
+ // branch push. A rejected tag push does NOT undo a successful push and is
275
+ // reported as a note, not as a failed commit.
276
+ const tagPush = await push(root, branch, { hasUpstream: true, tag: result.tagCreated })
277
+ result.tagPushed = tagPush.ok
278
+ if (!tagPush.ok) note(`分支已推送,但标签 ${result.tagCreated} 推送失败(${tagPush.reason ?? '未知原因'})`)
279
+ }
280
+ result.note = notes.length > 0 ? notes.join(';') : undefined
281
+ return result
282
+ }
283
+
284
+ result.reason = pushed.reason
285
+ note(pushed.reason === 'rebase-conflict'
286
+ ? '推送被拒且 rebase 出现冲突,已中止 rebase 并保留你的改动,需手动处理'
287
+ : `推送失败(${pushed.reason ?? '未知原因'})`)
288
+ result.note = notes.join(';')
289
+ return result
290
+ }
291
+
292
+ /**
293
+ * Build the canonical value every action returns.
294
+ *
295
+ * `card` is part of the canonical value on purpose: `output.render` is a pure
296
+ * projection of `(args, value)`, so it cannot derive a card that needs the
297
+ * changeset and the push result unless the value carries it. Keeping it in the
298
+ * value also means a PTC caller gets the same text a human sees.
299
+ */
300
+ function valueOf(fields) {
301
+ return {
302
+ ok: fields.ok === true,
303
+ // `action` is required by the output schema, and `run` is exported, so a
304
+ // direct caller that omits it must still produce a schema-valid value.
305
+ action: fields.action ?? 'auto',
306
+ card: fields.card ?? '',
307
+ ...(fields.root === undefined ? {} : { root: fields.root }),
308
+ ...(fields.branch === undefined ? {} : { branch: fields.branch }),
309
+ ...(fields.files === undefined ? {} : { files: fields.files }),
310
+ ...(fields.changes === undefined ? {} : { changes: fields.changes }),
311
+ ...(fields.hash === undefined ? {} : { hash: fields.hash }),
312
+ ...(fields.draft === undefined ? {} : { draft: fields.draft }),
313
+ ...(fields.tag === undefined ? {} : { tag: fields.tag }),
314
+ ...(fields.tagCreated === undefined ? {} : { tagCreated: fields.tagCreated }),
315
+ pushed: fields.pushed === true,
316
+ ...(fields.note === undefined ? {} : { note: fields.note }),
317
+ ...(fields.error === undefined ? {} : { error: fields.error }),
318
+ }
319
+ }
320
+
321
+ /**
322
+ * The compact markdown card for a survey.
323
+ *
324
+ * @param {object} surveyResult
325
+ * @param {{ maxFilesShown: number }} settings
326
+ */
327
+ function prepareCard(surveyResult, settings) {
328
+ return renderCard({
329
+ branch: surveyResult.branch,
330
+ entries: surveyResult.entries,
331
+ stats: surveyResult.stats,
332
+ recentSubjects: surveyResult.recentSubjects,
333
+ version: surveyResult.version,
334
+ breaking: surveyResult.breaking,
335
+ draft: surveyResult.draft,
336
+ maxFiles: settings.maxFilesShown,
337
+ hasUpstream: surveyResult.hasUpstream,
338
+ })
339
+ }
340
+
341
+ /**
342
+ * The compact markdown card for a completed commit.
343
+ *
344
+ * The first line is the VERDICT, not a label. A person who never expands the
345
+ * card still learns whether the work landed and whether it was pushed — the
346
+ * earlier `**git commit** · …` header was easy to mistake for a log line and
347
+ * therefore for "nothing happened".
348
+ *
349
+ * Exported because the card contract (which verdict for which outcome) is worth
350
+ * testing directly: the push-failure branch cannot be produced cheaply against
351
+ * a real remote.
352
+ */
353
+ export function applyCard(options) {
354
+ const {
355
+ branch, hash, subject, tagCreated, pushed, pushedTag, note, fileCount, totals,
356
+ notes, autoPush, maxFiles = 12,
357
+ } = options
358
+ const lines = []
359
+ const verdict = pushed
360
+ ? '✅ **Git 提交并推送成功**'
361
+ : autoPush === true
362
+ ? '⚠️ **已提交,但推送失败**'
363
+ : '✅ **Git 提交成功(未推送)**'
364
+ lines.push(`${verdict} · \`${branch}\` · \`${hash ?? '?'}\``)
365
+ lines.push(`信息:${subject}`)
366
+ if (fileCount !== undefined) lines.push(`提交 ${fileCount} 个文件 · +${totals?.added ?? 0} / -${totals?.deleted ?? 0}`)
367
+
368
+ // The per-file notes the commit body carries, so the card shows exactly what
369
+ // went into the repository. One file needs no list: the subject is the note.
370
+ const listed = (notes ?? []).slice(0, maxFiles)
371
+ if (listed.length > 1) {
372
+ for (const item of listed) lines.push(` - ${item.note} · ${item.path}`)
373
+ const hidden = notes.length - listed.length
374
+ if (hidden > 0) lines.push(` - …另有 ${hidden} 个文件`)
375
+ }
376
+
377
+ lines.push(`标签:${tagCreated ?? '无'}`)
378
+ lines.push(pushed
379
+ ? `推送:已推送${pushedTag ? '(含标签)' : ''}`
380
+ : autoPush === true ? '推送:失败(见下方说明)' : '推送:未推送')
381
+ if (note !== undefined) lines.push(`说明:${note}`)
382
+ return lines.join('\n')
383
+ }
384
+
385
+ /**
386
+ * The single entry point behind both the tool and the `/git-commit-push` command.
387
+ *
388
+ * Settings are read here and passed down so that a settings file which exists
389
+ * but does not parse can be REPORTED. Silently committing with the defaults
390
+ * would leave a user staring at a config file that appears to do nothing.
391
+ *
392
+ * @param {object} ctx plugin context
393
+ * @param {object} request
394
+ * @param {string} request.action `prepare` | `apply` | `auto`
395
+ * @param {string} [request.message] a caller-supplied commit message
396
+ * @param {string} [request.tag] a caller-supplied tag name
397
+ * @param {boolean} [request.push] override the autoPush setting
398
+ * @param {'zh' | 'en'} [request.language]
399
+ * @param {string} [request.cwd] explicit repository path
400
+ * @param {object} [exec] tool run context (absent for the slash command)
401
+ * @returns {Promise<ReturnType<typeof valueOf>>}
402
+ */
403
+ export async function run(ctx, request, exec) {
404
+ const { settings: fileSettings, problem } = await loadSettingsReport()
405
+ // The row config — what DSH's own settings form wrote — outranks the JSON
406
+ // file; see lib/config.js for the precedence and lib/schema.js for the form.
407
+ const settings = resolveSettings({ ui: uiOverrides(ctx, PLUGIN_CONFIG), file: fileSettings })
408
+ const result = await runWithSettings(ctx, request, exec, settings)
409
+ if (problem === undefined) return result
410
+ return {
411
+ ...result,
412
+ card: `${result.card}\n配置未生效:${problem}`,
413
+ note: result.note === undefined ? problem : `${result.note};${problem}`,
414
+ }
415
+ }
416
+
417
+ /**
418
+ * Everything below `run` works on resolved settings.
419
+ *
420
+ * @param {object} ctx plugin context
421
+ * @param {object} request
422
+ * @param {object} [exec]
423
+ * @param {typeof import('./lib/config.js').DEFAULTS} settings
424
+ */
425
+ async function runWithSettings(ctx, request, exec, settings) {
426
+ const cwd = request.cwd !== undefined && request.cwd !== '' ? request.cwd : cwdOf(exec)
427
+ const language = request.language ?? settings.defaultLanguage
428
+ const signal = exec?.signal
429
+
430
+ // The plugin's own effective settings, after per-call overrides.
431
+ const effective = { ...settings, autoPush: request.push ?? settings.autoPush }
432
+
433
+ // Both `prepare` and `apply` start from the same survey: `apply` needs the
434
+ // repo root, the branch and the tag evidence, and the calls it makes are
435
+ // in-process reads that cost no tokens.
436
+ const current = await survey({ cwd, language, maxFilesShown: effective.maxFilesShown })
437
+
438
+ if (current.ok !== true) {
439
+ if (current.notRepo === true) {
440
+ const candidates = current.candidates ?? []
441
+ const card = candidates.length === 0
442
+ ? '❌ **未初始化 Git**(该项目不是 Git 仓库,未做任何提交)'
443
+ : [
444
+ '⚠️ **当前目录不是 Git 仓库**;其下有这些仓库,请用 cwd 参数指定:',
445
+ ...candidates.map(root => ` - ${root}`),
446
+ ].join('\n')
447
+ return valueOf({
448
+ ok: false,
449
+ action: request.action,
450
+ card,
451
+ error: candidates.length === 0 ? 'not-a-repository' : 'ambiguous-repository',
452
+ note: candidates.length === 0 ? undefined : '传入 cwd 指向具体仓库后重试',
453
+ })
454
+ }
455
+ return valueOf({
456
+ ok: false,
457
+ action: request.action,
458
+ card: current.reason === 'clean'
459
+ ? 'ℹ️ **没有需要提交的改动**(工作区是干净的)'
460
+ : `⚠️ **无法读取仓库状态**:${current.message ?? '未知原因'}`,
461
+ error: current.reason ?? 'survey-failed',
462
+ })
463
+ }
464
+
465
+ if (request.action === 'prepare') {
466
+ return valueOf({
467
+ ok: true,
468
+ action: 'prepare',
469
+ card: prepareCard(current, effective),
470
+ root: current.root,
471
+ branch: current.branch,
472
+ files: current.entries.length,
473
+ changes: current.entries.map(entry => ({ status: entry.status, path: entry.path })),
474
+ draft: current.draft.message,
475
+ })
476
+ }
477
+
478
+ // Who writes the body? A caller-supplied message is honored, but when it is a
479
+ // bare subject (no body of its own) the per-file notes are appended rather
480
+ // than replaced: "one message for every file" is exactly what the body is
481
+ // there to avoid, and the caller usually only has an opinion about the sum.
482
+ const supplied = request.message !== undefined && request.message !== ''
483
+ const generated = current.draft.message
484
+ let message = supplied ? request.message : generated
485
+ let commitNotes = supplied ? undefined : current.draft.notes
486
+ if (supplied && !request.message.includes('\n') && current.entries.length > 1) {
487
+ const body = generated.split('\n').slice(1).join('\n').replace(/^\n+/u, '')
488
+ if (body !== '') {
489
+ message = `${request.message}\n\n${body}`
490
+ commitNotes = current.draft.notes
491
+ }
492
+ }
493
+
494
+ let committed
495
+ try {
496
+ committed = await doCommit(ctx, exec, {
497
+ settings: effective,
498
+ root: current.root,
499
+ message,
500
+ entries: current.entries,
501
+ })
502
+ } catch (error) {
503
+ // `stageAll` and the numstat read can fail outside the commit itself
504
+ // (a locked index, a path the filesystem refuses). Report it as a card
505
+ // rather than letting the registry surface a raw throw.
506
+ committed = { ok: false, error: brief(error) }
507
+ }
508
+
509
+ if (committed.ok !== true) {
510
+ return valueOf({
511
+ ok: false,
512
+ action: request.action,
513
+ card: `❌ **提交失败**(改动仍留在工作区,未推送)\n原因:${committed.error ?? '未知原因'}`,
514
+ root: current.root,
515
+ branch: current.branch,
516
+ error: 'commit-failed',
517
+ note: committed.error,
518
+ })
519
+ }
520
+
521
+ const decision = tagDecision(current, effective, request.tag)
522
+ let tagResult
523
+ if (request.tag !== undefined) {
524
+ // An EXPLICIT tag is an instruction, not a suggestion, so it skips the
525
+ // question entirely. Routing it through askTag would let a missing asker —
526
+ // a subagent, a host without the question service, a timed-out wait, or a
527
+ // plain `run()` call — silently drop a tag the caller explicitly asked for.
528
+ tagResult = { decided: request.tag, via: 'explicit' }
529
+ } else if (decision.shouldAsk) {
530
+ tagResult = await askTag(ctx, exec, decision.tag, decision.reasons, effective)
531
+ } else {
532
+ tagResult = { decided: undefined, via: 'skipped' }
533
+ }
534
+
535
+ const pushResult = await doTagAndPush(ctx, exec, {
536
+ settings: effective,
537
+ root: current.root,
538
+ branch: current.branch,
539
+ tag: tagResult.decided,
540
+ upstream: current.hasUpstream,
541
+ })
542
+
543
+ const notes = [tagResult.note, pushResult.note].filter(note => typeof note === 'string' && note !== '')
544
+
545
+ // `ok` answers "did the thing the caller asked for happen", and the commit is
546
+ // the thing that was asked for: it landed, so this is a success even when the
547
+ // push did not. The push outcome is NOT hidden — `pushed` is false, the card
548
+ // says so, and `error` carries the machine-branchable reason — but reporting
549
+ // `ok: false` here would make `/git-commit-push` fail on a commit that is safely in the
550
+ // repository, which is worse than useless.
551
+ const pushFailed = effective.autoPush === true && !pushResult.pushed
552
+
553
+ return valueOf({
554
+ ok: true,
555
+ action: request.action,
556
+ card: applyCard({
557
+ branch: current.branch,
558
+ hash: committed.hash,
559
+ subject: committed.subject ?? message,
560
+ tagCreated: pushResult.tagCreated,
561
+ pushed: pushResult.pushed,
562
+ pushedTag: pushResult.tagPushed,
563
+ note: notes.length > 0 ? notes.join(';') : undefined,
564
+ fileCount: committed.files,
565
+ totals: { added: committed.added ?? 0, deleted: committed.deleted ?? 0 },
566
+ notes: commitNotes,
567
+ autoPush: effective.autoPush,
568
+ maxFiles: effective.maxFilesShown,
569
+ }),
570
+ root: current.root,
571
+ branch: current.branch,
572
+ files: committed.files,
573
+ hash: committed.hash,
574
+ tag: pushResult.tagCreated ?? tagResult.decided,
575
+ tagCreated: pushResult.tagCreated,
576
+ pushed: pushResult.pushed,
577
+ note: notes.length > 0 ? notes.join(';') : undefined,
578
+ error: pushFailed ? pushResult.reason ?? 'push-failed' : undefined,
579
+ })
580
+ }
581
+
582
+ /** The `render` projection: model-facing content is exactly the card. */
583
+ function renderCardContent(_args, value) {
584
+ const text = value?.card !== undefined && value.card !== '' ? value.card : (value?.error ?? 'no result')
585
+ return [{ type: 'text', text }]
586
+ }
587
+
588
+ /**
589
+ * A hand-written tool definition, in the RAW JSON Schema form.
590
+ *
591
+ * This is deliberate, and it is the one place where being explicit matters most:
592
+ *
593
+ * - `parameters` is the enforced JSON Schema subset (`JsonSchemaNode`), NOT the
594
+ * `ParameterSchemaSpec` authoring DSL. The DSL's per-property
595
+ * `required: true` is compiled by `defineTool` and by nothing else, so a
596
+ * hand-written definition must carry a real object root with a
597
+ * `required: string[]` array — otherwise the model receives a bare property
598
+ * map with no `type: 'object'`, which providers reject.
599
+ * - `output.schema` is validated by the registry at REGISTRATION time, and a
600
+ * `required` that is not an array of strings throws a JsonSchemaError there,
601
+ * taking the whole plugin down before either entry point registers.
602
+ * - `output.render` must be a pure projection of `(args, value)`, which is why
603
+ * the human-readable card travels INSIDE the canonical value.
604
+ */
605
+ const TOOL_DEFINITION = {
606
+ name: TOOL_NAME,
607
+ description:
608
+ 'Commit the current repository and (by default) push it, in one call. '
609
+ // The trigger rule comes FIRST and is deliberately blunt: this tool creates
610
+ // an outward-facing repository change, so it must never be reached for by
611
+ // inference from "the work looks finished".
612
+ + 'CALL THIS ONLY WHEN THE USER ASKS FOR IT — either they asked you in words to commit and/or push '
613
+ + '(「提交」「commit」「推送」「push」「推上去」), or they typed the `/git-commit-push` slash command. '
614
+ + 'NEVER call it on your own initiative: not because you or the user just finished editing files, not because a '
615
+ + 'task looks complete, not because the session is ending, and not as a tidy-up step. Editing files is not a '
616
+ + 'request to commit them. If it is unclear whether the user wants a commit, ask first. '
617
+ + 'action="prepare" returns a compact report of the pending changes — verdict line, file list with per-file line '
618
+ + 'counts, each file\'s own Conventional-Commits note, status counts, recent commit subjects for tone, and a '
619
+ + 'rule-generated draft message — WITHOUT spending tokens on the diff itself. Read it, write your own '
620
+ + 'Conventional-Commits subject, then call action="apply" with that subject as `message`. '
621
+ + 'MULTI-FILE COMMITS GET ONE NOTE PER FILE: when several files changed, the commit body lists each file with its '
622
+ + 'own typed note (`- fix(api): correct retry decision · src/api/retry.ts`), and the notes are generated per file '
623
+ + 'even when you supply only a subject — supplying a message with a body of your own replaces them entirely. '
624
+ + 'action="auto" commits immediately using the rule-generated message (no extra model turn; use it when the user '
625
+ + 'asked you to just commit). '
626
+ + 'The plugin stages the working tree, commits, asks the user about a tag when a version bump, a possible '
627
+ + 'breaking change or a large changeset warrants one (the question is answered in the UI and costs no tokens), '
628
+ + 'and pushes, retrying once through `pull --rebase` if the remote moved. '
629
+ + 'It never modifies .gitignore or git config, never force-pushes, and never bypasses a failing hook. '
630
+ + 'In a directory that is not a repository it reports the child repositories instead of guessing.',
631
+ parameters: {
632
+ type: 'object',
633
+ additionalProperties: false,
634
+ required: [],
635
+ properties: {
636
+ action: {
637
+ type: 'string',
638
+ enum: ['prepare', 'apply', 'auto'],
639
+ description: 'prepare: survey only. apply: commit with `message`. auto: survey and commit with the rule-generated message.',
640
+ },
641
+ message: {
642
+ type: 'string',
643
+ description: 'The commit message, in Conventional Commits form. Implies action="apply" when action is omitted. '
644
+ + 'The first line is the subject; later lines become the body and REPLACE the per-file notes the plugin would '
645
+ + 'generate. Supply only a subject and the per-file notes are still appended for you.',
646
+ },
647
+ tag: {
648
+ type: 'string',
649
+ description: 'Explicit tag name to create and push (e.g. "v1.2.3"). Skips the tag question. Omit to let the plugin decide.',
650
+ },
651
+ push: {
652
+ type: 'boolean',
653
+ description: 'Override the autoPush setting for this call only.',
654
+ },
655
+ language: {
656
+ type: 'string',
657
+ enum: ['zh', 'en'],
658
+ description: 'Language for the rule-generated message (default: the plugin setting, normally zh).',
659
+ },
660
+ cwd: {
661
+ type: 'string',
662
+ description: 'Path of the repository to operate on. Use it when the session working directory is a container '
663
+ + 'holding several repositories.',
664
+ },
665
+ },
666
+ },
667
+ output: {
668
+ schema: {
669
+ type: 'object',
670
+ additionalProperties: false,
671
+ required: ['ok', 'action', 'card', 'pushed'],
672
+ properties: {
673
+ ok: { type: 'boolean', description: 'Whether the work the caller asked for actually happened.' },
674
+ action: { type: 'string', description: 'The action that ran: prepare | apply | auto.' },
675
+ card: { type: 'string', description: 'The compact human-readable report for this call.' },
676
+ root: { type: 'string', description: 'Absolute path of the repository that was operated on.' },
677
+ branch: { type: 'string', description: 'Branch the commit landed on.' },
678
+ files: { type: 'number', description: 'Number of changed (prepare) or committed (apply/auto) files.' },
679
+ changes: {
680
+ type: 'array',
681
+ description: 'prepare only: one row per changed path.',
682
+ items: {
683
+ type: 'object',
684
+ additionalProperties: false,
685
+ required: ['status', 'path'],
686
+ properties: {
687
+ status: { type: 'string', description: 'A (added) | M (modified) | D (deleted) | R (renamed) | C (copied) | U (unmerged) | ? (untracked).' },
688
+ path: { type: 'string', description: 'Repository-relative path.' },
689
+ },
690
+ },
691
+ },
692
+ hash: { type: 'string', description: 'Short hash of the created commit.' },
693
+ draft: { type: 'string', description: 'prepare only: the rule-generated Conventional Commits message.' },
694
+ tag: { type: 'string', description: 'Tag that was created, or the one that was suggested.' },
695
+ tagCreated: { type: 'string', description: 'Tag actually created locally, when one was.' },
696
+ pushed: { type: 'boolean', description: 'Whether the branch reached the remote.' },
697
+ note: { type: 'string', description: 'Anything the caller must know: a rebase recovery, a skipped tag, a hook rejection.' },
698
+ error: { type: 'string', description: 'Machine-branchable failure code when something the caller asked for did not happen.' },
699
+ },
700
+ },
701
+ render: renderCardContent,
702
+ },
703
+ /**
704
+ * @param {Record<string, unknown>} args
705
+ * @param {{ signal?: AbortSignal, agent?: unknown, callId?: unknown }} exec
706
+ */
707
+ async execute(args, exec) {
708
+ const action = typeof args.action === 'string'
709
+ ? args.action
710
+ : (typeof args.message === 'string' && args.message !== '' ? 'apply' : 'prepare')
711
+ return run(PLUGIN_CONTEXT, {
712
+ action,
713
+ message: typeof args.message === 'string' ? args.message : undefined,
714
+ tag: typeof args.tag === 'string' ? args.tag : undefined,
715
+ push: typeof args.push === 'boolean' ? args.push : undefined,
716
+ language: args.language === 'zh' || args.language === 'en' ? args.language : undefined,
717
+ cwd: typeof args.cwd === 'string' ? args.cwd : undefined,
718
+ }, exec)
719
+ },
720
+ }
721
+
722
+ /**
723
+ * The plugin's own context, captured in `apply` so the hand-written tool
724
+ * definition can reach `ctx.get('userQuestions')` without importing
725
+ * `defineTool` (and therefore without depending on the host's module
726
+ * resolution reaching this package's own imports).
727
+ */
728
+ let PLUGIN_CONTEXT
729
+
730
+ /**
731
+ * The config DSH parsed from our `Config` schema, captured in `apply`.
732
+ *
733
+ * Held rather than copied because a volatile field is a stable reference: the
734
+ * settings form updates it in place, so reading it per call is what makes a
735
+ * change apply without a remount.
736
+ */
737
+ let PLUGIN_CONFIG
738
+
739
+ /** Parse the `/git-commit-push` command line into a request. */
740
+ export function parseCommitCommand(rawInput) {
741
+ const tokens = rawInput.trim().split(/\s+/).filter(token => token !== '')
742
+ const request = { action: 'auto' }
743
+ const messageParts = []
744
+ for (const token of tokens) {
745
+ if (token === '--auto') { request.action = 'auto'; continue }
746
+ if (token === '--prepare' || token === '--dry-run') { request.action = 'prepare'; continue }
747
+ if (token === '--no-push') { request.push = false; continue }
748
+ if (token === '--push') { request.push = true; continue }
749
+ if (token === '--en') { request.language = 'en'; continue }
750
+ if (token === '--zh') { request.language = 'zh'; continue }
751
+ if (token.startsWith('--tag=')) { request.tag = token.slice('--tag='.length); continue }
752
+ if (token.startsWith('--cwd=')) { request.cwd = token.slice('--cwd='.length); continue }
753
+ messageParts.push(token)
754
+ }
755
+ const message = messageParts.join(' ').trim()
756
+ if (message !== '') {
757
+ request.message = message
758
+ // An explicit message with no explicit verb means "commit this".
759
+ if (!tokens.includes('--prepare') && !tokens.includes('--dry-run')) request.action = 'apply'
760
+ }
761
+ return request
762
+ }
763
+
764
+ /**
765
+ * Register the slash command once the commands service is available.
766
+ *
767
+ * `commands` is a strictly optional capability, so it is deliberately NOT in
768
+ * the static `inject` list: putting it there would block the whole plugin (and
769
+ * therefore the `git_commit_push` tool) on a host that has no command surface. The
770
+ * scoped `ctx.inject` waits for it in the background instead, and the tool works
771
+ * either way. `commands` is the common case — this profile mounts it.
772
+ */
773
+ function registerCommand(ctx) {
774
+ ctx.inject(['commands'], (sctx) => {
775
+ const commands = sctx.get('commands')
776
+ if (commands === undefined) return
777
+ commands.register({
778
+ name: COMMAND_NAME,
779
+ description: '提交并推送当前项目(Conventional Commits),可用 --prepare 仅预览、--no-push 不推送',
780
+ input: { hint: '[提交信息 | --prepare | --en | --no-push | --tag=vX.Y.Z]' },
781
+ handler: async (invocation) => {
782
+ const request = parseCommitCommand(invocation.rawInput ?? '')
783
+ const exec = { signal: invocation.signal, agent: invocation.agent, callId: invocation.commandId }
784
+ try {
785
+ const result = await run(ctx, request, exec)
786
+ if (result.ok) return { kind: 'success', text: result.card }
787
+ return { kind: 'error', text: result.card !== '' ? result.card : '提交未完成' }
788
+ } catch (error) {
789
+ return { kind: 'error', text: brief(error) }
790
+ }
791
+ },
792
+ })
793
+ })
794
+ }
795
+
796
+ /**
797
+ * Register the skill this package ships (SKILL.md) as an embedded runtime skill.
798
+ *
799
+ * `skills` is an optional capability exactly like `commands`, so it is not in
800
+ * the static `inject` list: a host without a skill registry must still get the
801
+ * tool. The registry draws the ordering — a project-level skill with the same
802
+ * name outranks this runtime registration, so a user who keeps their own
803
+ * `git-commit-push` skill in the workspace keeps winning.
804
+ *
805
+ * A registration failure is logged, never thrown: losing the skill costs the
806
+ * model its procedure, losing the tool costs the user the feature.
807
+ */
808
+ function registerSkill(ctx) {
809
+ ctx.inject(['skills'], (sctx) => {
810
+ const skills = sctx.get('skills')
811
+ if (skills === undefined) return
812
+ const skill = skillDefinition()
813
+ if (skill === undefined) {
814
+ ctx.logger?.warn?.(`${name}: SKILL.md is missing or unusable; the git-commit-push skill was not registered`)
815
+ return
816
+ }
817
+ try {
818
+ skills.register(skill)
819
+ } catch (error) {
820
+ ctx.logger?.warn?.(`${name}: skill "${skill.name}" was not registered — ${brief(error)}`)
821
+ }
822
+ })
823
+ }
824
+
825
+ /**
826
+ * Re-implementation of the registry's schema assertions, so a test can prove
827
+ * the definition would survive `ctx.tools.register` WITHOUT needing a live host.
828
+ *
829
+ * The two rules that actually bite a hand-written definition:
830
+ * - `required` must be an ARRAY OF STRINGS. The `required: true` per-property
831
+ * form belongs to the `defineTool` authoring DSL and is rejected here, at
832
+ * registration time, with a JsonSchemaError that takes the plugin down.
833
+ * - every node's `type` must be one of the supported scalars/containers, and
834
+ * `enum`/`const` are only valid on scalars.
835
+ *
836
+ * @returns {string[]} violations; empty means registration would succeed
837
+ */
838
+ export function schemaProblems(schema, path = 'schema') {
839
+ const SUPPORTED_TYPES = ['object', 'array', 'string', 'number', 'integer', 'boolean', 'null']
840
+ const problems = []
841
+ if (typeof schema !== 'object' || schema === null || Array.isArray(schema)) {
842
+ return [`${path} must be a plain object`]
843
+ }
844
+ if (schema.type !== undefined && !SUPPORTED_TYPES.includes(schema.type)) {
845
+ problems.push(`${path}.type "${String(schema.type)}" is not supported`)
846
+ }
847
+ if (schema.required !== undefined) {
848
+ if (!Array.isArray(schema.required) || schema.required.some(entry => typeof entry !== 'string')) {
849
+ // The exact failure mode this guards: `required: true` from the DSL.
850
+ problems.push(`${path}.required must be an array of strings`)
851
+ } else if (schema.properties !== undefined) {
852
+ for (const name of schema.required) {
853
+ if (!(name in schema.properties)) problems.push(`${path}.required names "${name}", which is not declared`)
854
+ }
855
+ }
856
+ }
857
+ for (const [name, child] of Object.entries(schema.properties ?? {})) {
858
+ problems.push(...schemaProblems(child, `${path}.properties.${name}`))
859
+ }
860
+ if (schema.items !== undefined) problems.push(...schemaProblems(schema.items, `${path}.items`))
861
+ for (const [index, branch] of (schema.oneOf ?? []).entries()) {
862
+ problems.push(...schemaProblems(branch, `${path}.oneOf[${String(index)}]`))
863
+ }
864
+ return problems
865
+ }
866
+
867
+ /** Everything the registry would reject about this tool definition. */
868
+ export function toolDefinitionProblems() {
869
+ const problems = []
870
+ if (typeof TOOL_DEFINITION.name !== 'string' || TOOL_DEFINITION.name === '') problems.push('name is missing')
871
+ if (typeof TOOL_DEFINITION.description !== 'string' || TOOL_DEFINITION.description === '') problems.push('description is missing')
872
+ if (typeof TOOL_DEFINITION.output?.render !== 'function') problems.push('output.render is missing')
873
+ problems.push(...schemaProblems(TOOL_DEFINITION.parameters, 'parameters'))
874
+ if (TOOL_DEFINITION.output?.schema === undefined) problems.push('output.schema is missing')
875
+ else problems.push(...schemaProblems(TOOL_DEFINITION.output.schema, 'output.schema'))
876
+ return problems
877
+ }
878
+
879
+ /**
880
+ * Plugin entry point.
881
+ *
882
+ * @param {any} ctx host plugin context
883
+ * @param {any} [config] the row config DSH parsed from our `Config` schema
884
+ */
885
+ export function apply(ctx, config) {
886
+ PLUGIN_CONTEXT = ctx
887
+ // Kept rather than read once: the settings form writes volatile fields into
888
+ // these running references, so every call reads the current values.
889
+ PLUGIN_CONFIG = config
890
+ // Fail loudly and specifically here rather than with an opaque registry error:
891
+ // a malformed definition is an authoring bug the host reports once, at boot.
892
+ const problems = toolDefinitionProblems()
893
+ if (problems.length > 0) {
894
+ throw new Error(`${name}: invalid tool definition — ${problems.join('; ')}`)
895
+ }
896
+ ctx.tools.register(TOOL_DEFINITION)
897
+ registerCommand(ctx)
898
+ registerSkill(ctx)
899
+ }
900
+
901
+ /** Diagnostics: the settings files this plugin reads, and the self-check hooks. */
902
+ export { CONFIG_PATH, userConfigPath, configCandidates, TOOL_DEFINITION }
903
+ export { CONFIG as Config }
904
+ export { FIELDS as CONFIG_FIELDS, resolveSettings, uiOverrides } from './lib/config.js'
905
+ export { buildConfigSchema, loadSchemaLibrary } from './lib/schema.js'
906
+ export { skillDefinition, parseSkillFile, SKILL_NAME, SKILL_PATH, SKILL_SOURCE } from './lib/skill.js'
907
+ export { survey } from './lib/survey.js'
908
+ export { push, pushAfterRebase } from './lib/git.js'