dsh-github-flow 0.5.0 → 0.7.0

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.
Files changed (103) hide show
  1. package/README.en.md +112 -17
  2. package/README.md +115 -17
  3. package/docs/UPGRADE-NOTES.md +12 -11
  4. package/docs/architecture.md +81 -22
  5. package/docs/images/1.png +0 -0
  6. package/lib/api/route-helpers.d.ts +19 -0
  7. package/lib/api/route-helpers.js +54 -0
  8. package/lib/api/routes-actions.js +214 -9
  9. package/lib/api/routes-lifecycle.js +8 -1
  10. package/lib/api/routes-overview.d.ts +26 -0
  11. package/lib/api/routes-overview.js +396 -36
  12. package/lib/api/routes-proxy.js +8 -1
  13. package/lib/api/routes.d.ts +11 -1
  14. package/lib/api/routes.js +15 -2
  15. package/lib/cache.d.ts +56 -4
  16. package/lib/cache.js +77 -7
  17. package/lib/client.d.ts +8 -3
  18. package/lib/client.js +5542 -1391
  19. package/lib/destructive.d.ts +59 -0
  20. package/lib/destructive.js +285 -0
  21. package/lib/executor/args-guard.d.ts +17 -0
  22. package/lib/executor/args-guard.js +23 -0
  23. package/lib/executor/error-classifier.d.ts +55 -0
  24. package/lib/executor/error-classifier.js +199 -0
  25. package/lib/executor/failure-anchors.d.ts +52 -0
  26. package/lib/executor/failure-anchors.js +116 -0
  27. package/lib/executor/git-parser.d.ts +124 -0
  28. package/lib/executor/git-parser.js +397 -0
  29. package/lib/executor/pr-review.d.ts +95 -0
  30. package/lib/executor/pr-review.js +175 -0
  31. package/lib/executor/run-log.d.ts +36 -0
  32. package/lib/executor/run-log.js +78 -0
  33. package/lib/executor.d.ts +277 -6
  34. package/lib/executor.js +1265 -94
  35. package/lib/github-service.d.ts +1 -1
  36. package/lib/main.d.ts +92 -18
  37. package/lib/main.js +186 -95
  38. package/lib/renderers/common.d.ts +0 -3
  39. package/lib/renderers/common.js +8 -44
  40. package/lib/renderers/markdown.d.ts +1 -0
  41. package/lib/renderers/markdown.js +1 -0
  42. package/lib/renderers/pr.js +26 -1
  43. package/lib/renderers/task.d.ts +9 -0
  44. package/lib/renderers/task.js +120 -0
  45. package/lib/review-rules.d.ts +60 -0
  46. package/lib/review-rules.js +204 -0
  47. package/lib/shared/state-badges.d.ts +42 -0
  48. package/lib/shared/state-badges.js +102 -0
  49. package/lib/task-primitives.d.ts +109 -0
  50. package/lib/task-primitives.js +194 -0
  51. package/lib/tool-tiers.d.ts +57 -0
  52. package/lib/tool-tiers.js +168 -0
  53. package/lib/tools/agent-task.js +4 -4
  54. package/lib/tools/api.js +18 -6
  55. package/lib/tools/cache.js +42 -40
  56. package/lib/tools/codespace.js +4 -12
  57. package/lib/tools/discussion.js +4 -4
  58. package/lib/tools/gist.d.ts +1 -39
  59. package/lib/tools/gist.js +4 -14
  60. package/lib/tools/help.js +72 -18
  61. package/lib/tools/image-utils.d.ts +27 -0
  62. package/lib/tools/image-utils.js +73 -3
  63. package/lib/tools/issue.js +16 -28
  64. package/lib/tools/label.js +4 -15
  65. package/lib/tools/pr.d.ts +47 -0
  66. package/lib/tools/pr.js +170 -33
  67. package/lib/tools/project.js +4 -13
  68. package/lib/tools/release.js +11 -22
  69. package/lib/tools/repo.js +4 -4
  70. package/lib/tools/ruleset.js +9 -6
  71. package/lib/tools/run.js +76 -24
  72. package/lib/tools/secret.js +11 -17
  73. package/lib/tools/skill.js +18 -22
  74. package/lib/tools/status.js +4 -4
  75. package/lib/tools/task.d.ts +219 -0
  76. package/lib/tools/task.js +457 -0
  77. package/lib/types.d.ts +514 -1
  78. package/lib/ui/help-modal.d.ts +6 -0
  79. package/lib/ui/help-modal.js +124 -16
  80. package/lib/ui/issue-modal.d.ts +14 -0
  81. package/lib/ui/issue-modal.js +370 -0
  82. package/lib/ui/markdown-lite.d.ts +66 -0
  83. package/lib/ui/markdown-lite.js +312 -0
  84. package/lib/ui/network-banner.d.ts +12 -0
  85. package/lib/ui/network-banner.js +35 -0
  86. package/lib/ui/pr-modal.d.ts +17 -0
  87. package/lib/ui/pr-modal.js +555 -0
  88. package/lib/ui/repo-badges.d.ts +43 -0
  89. package/lib/ui/repo-badges.js +82 -0
  90. package/lib/ui/repo-selector.d.ts +14 -0
  91. package/lib/ui/repo-selector.js +112 -0
  92. package/lib/ui/run-modal.d.ts +26 -0
  93. package/lib/ui/run-modal.js +486 -0
  94. package/lib/ui/storage.d.ts +47 -0
  95. package/lib/ui/storage.js +139 -0
  96. package/lib/ui/styles.d.ts +1 -1
  97. package/lib/ui/styles.js +1330 -44
  98. package/lib/ui/sync-settings-modal.js +1 -1
  99. package/lib/ui/utils.js +13 -1
  100. package/lib/ui/workspace-panel.js +226 -29
  101. package/lib/version.d.ts +1 -1
  102. package/lib/version.js +1 -1
  103. package/package.json +9 -2
@@ -0,0 +1,59 @@
1
+ /**
2
+ * 破坏性操作声明表 + 统一闸门。
3
+ *
4
+ * 为什么要有这张表:同一个「拦截 → 返回影响评估 → 要求 confirm: true」的模式在
5
+ * 16 个 (工具, action) 上各写了一遍。任何一处忘写就是漏洞,而漏洞是**看不见的**
6
+ * (工具照常返回成功,没人知道本该拦一次)。表化之后「没有条目 = 没有闸门」可以
7
+ * 一眼审完,并且能被测试穷举。
8
+ *
9
+ * 拦截点收在一处实现(`wrapWithGate`),由每个工具的工厂函数返回时套上——见文件末尾该函数的注释。`src/tools/` 里不再保留任何一行闸门代码。
10
+ *
11
+ * ⚠️ 与旧实现的一处已知差异:`cache delete` / `issue transfer` / `label delete` 的
12
+ * 旧文案里内插了**运行时解析出的仓库名**(`repoTarget`)。注册期拿不到它(要额外打
13
+ * 一次 `gh repo view`),因此文案改为不内插仓库名。渲染层只消费
14
+ * warning/impact/requiredAction 三个字段,不消费 repo,故输出形态无变化。
15
+ */
16
+ /** 闸门状态字面量的唯一来源:工具层与渲染层都引用它,不再各写一遍字符串。 */
17
+ export declare const GATE_BLOCKED: 'AWAITING_CONFIRMATION';
18
+ export interface GatePayload {
19
+ status: typeof GATE_BLOCKED;
20
+ warning: string;
21
+ impact: string;
22
+ requiredAction: string;
23
+ /** 部分工具额外回显的定位字段(action / name / gist_id / cache_id / key / all) */
24
+ [key: string]: unknown;
25
+ }
26
+ interface GateSpec {
27
+ /**
28
+ * 命中条件。缺省 = 该 action 恒拦。
29
+ *
30
+ * 为什么需要这个字段:`release upload` 只在 clobber 时危险、`skill publish` 只在
31
+ * 正式发布时危险。条件写进表里,过滤逻辑就不再散落在各工具的分支里。
32
+ * 同时用它表达「原实现里位于闸门之前的必填校验」——否则一个缺参数的调用会先拿到
33
+ * 「请确认」而不是本该拿到的「必须提供 xxx」,那是行为回退。
34
+ */
35
+ when?: (args: any) => boolean;
36
+ describe(args: any): GatePayload;
37
+ }
38
+ export declare const DESTRUCTIVE_ACTIONS: Record<string, Record<string, GateSpec>>;
39
+ /**
40
+ * 统一闸门:命中则返回拦截载荷,否则返回 undefined。
41
+ *
42
+ * `confirm === true` 的判定放在这里而不是每个 describe 里——15 份重复的
43
+ * `if (args.confirm !== true)` 正是这次要消掉的东西。
44
+ */
45
+ export declare function guardDestructive(toolName: string, args: any): GatePayload | undefined;
46
+ /**
47
+ * 用闸门包一层 execute(注册期唯一入口)。
48
+ *
49
+ * 为什么包在注册期而不是 `executor.run()`:`run()` 只看得见 argv,看不见
50
+ * `action` / `confirm` 语义。把语义解析塞进 argv 层就是把简单问题复杂化。
51
+ *
52
+ * 保持 `name` / `description` / `parameters` / `output` 原样,只替换 `execute`,
53
+ * 宿主侧的 schema 校验与渲染链路完全不受影响。
54
+ */
55
+ export declare function wrapWithGate<T extends {
56
+ name: string;
57
+ execute: (...a: any[]) => any;
58
+ }>(tool: T): T;
59
+ export {};
@@ -0,0 +1,285 @@
1
+ /**
2
+ * 破坏性操作声明表 + 统一闸门。
3
+ *
4
+ * 为什么要有这张表:同一个「拦截 → 返回影响评估 → 要求 confirm: true」的模式在
5
+ * 16 个 (工具, action) 上各写了一遍。任何一处忘写就是漏洞,而漏洞是**看不见的**
6
+ * (工具照常返回成功,没人知道本该拦一次)。表化之后「没有条目 = 没有闸门」可以
7
+ * 一眼审完,并且能被测试穷举。
8
+ *
9
+ * 拦截点收在一处实现(`wrapWithGate`),由每个工具的工厂函数返回时套上——见文件末尾该函数的注释。`src/tools/` 里不再保留任何一行闸门代码。
10
+ *
11
+ * ⚠️ 与旧实现的一处已知差异:`cache delete` / `issue transfer` / `label delete` 的
12
+ * 旧文案里内插了**运行时解析出的仓库名**(`repoTarget`)。注册期拿不到它(要额外打
13
+ * 一次 `gh repo view`),因此文案改为不内插仓库名。渲染层只消费
14
+ * warning/impact/requiredAction 三个字段,不消费 repo,故输出形态无变化。
15
+ */
16
+ /** 闸门状态字面量的唯一来源:工具层与渲染层都引用它,不再各写一遍字符串。 */
17
+ export const GATE_BLOCKED = 'AWAITING_CONFIRMATION';
18
+ const str = (v) => (v === undefined || v === null ? '' : String(v).trim());
19
+ /** 有值即认为「调用方已经给到了定位参数」,可以进入拦截而不是先报缺参。 */
20
+ const has = (v) => str(v) !== '';
21
+ export const DESTRUCTIVE_ACTIONS = {
22
+ github_pr: {
23
+ // 默认检出不带 -f;只有显式 force 才需要二次确认(会丢弃本地未推送提交)
24
+ checkout: {
25
+ when: (a) => a.force === true,
26
+ describe: (a) => ({
27
+ status: GATE_BLOCKED,
28
+ warning: `⚠️ force 会把本地分支重置到 PR #${str(a.pr_number)} 的最新状态,丢弃本地未推送的提交。`,
29
+ impact: '重置后本地分支与 PR 头一致,本地独有提交只能通过 git reflog 找回。',
30
+ requiredAction: '如确认重置,请附加 `force: true, confirm: true` 重新调用。',
31
+ }),
32
+ },
33
+ merge: {
34
+ // 缺 pr_number 时旧实现先抛「merge 操作必须提供 pr_number」;闸门前移后必须自己把
35
+ // 这条前置校验编码进 when,否则会返回一个「PR #」空号的确认载荷(实测)。
36
+ when: (a) => has(a.pr_number),
37
+ describe: (a) => ({
38
+ status: GATE_BLOCKED,
39
+ warning: `⚠️ 高危操作拦截:你即将合并 PR #${str(a.pr_number)}(合并策略: ${a.merge_method || 'squash'}${a.delete_branch ? ',且删除源分支' : ''})。`,
40
+ impact: '合并操作将直接向基准分支写入代码并更新远端 PR 状态。',
41
+ requiredAction: '如确认执行合并,请向用户明确确认并附加 `confirm: true` 重新调用。',
42
+ }),
43
+ },
44
+ revert: {
45
+ when: (a) => has(a.pr_number),
46
+ describe: (a) => ({
47
+ status: GATE_BLOCKED,
48
+ warning: `⚠️ 高危操作拦截:你即将对 PR #${str(a.pr_number)} 创建反向提交(revert)。`,
49
+ impact: '将在远端创建反向提交并生成新的 revert PR——这是新增远端实体,不是本地操作。',
50
+ requiredAction: '如确认执行,请向用户明确确认并附加 `confirm: true` 重新调用。',
51
+ }),
52
+ },
53
+ },
54
+ github_issue: {
55
+ close: {
56
+ // 缺 issue_number 时旧实现先抛「close 操作必须提供 issue_number」
57
+ when: (a) => has(a.issue_number),
58
+ describe: (a) => ({
59
+ status: GATE_BLOCKED,
60
+ warning: `⚠️ 高危操作拦截:你即将关闭 Issue #${str(a.issue_number)}。`,
61
+ impact: '关闭后该议题状态将变更为 CLOSED,请与用户确认是否确实已解决或需要关闭。',
62
+ requiredAction: '如确认关闭,请在参数中附加 `confirm: true` 重新调用。',
63
+ }),
64
+ },
65
+ delete: {
66
+ // 缺 issue_number 时旧实现先抛「delete 操作必须提供 issue_number」
67
+ when: (a) => has(a.issue_number),
68
+ describe: (a) => ({
69
+ status: GATE_BLOCKED,
70
+ warning: `⚠️ 高危操作拦截:你即将**物理删除** Issue #${str(a.issue_number)}(非 close,不可恢复)。`,
71
+ impact: 'Issue 编号将永久消失,全部评论与附件随之湮灭,GraphQL 层无法恢复——这与 close(可 reopen 逆转)有本质区别。',
72
+ requiredAction: '如确认删除,请与用户核实后附加 `confirm: true` 重新调用。',
73
+ }),
74
+ },
75
+ transfer: {
76
+ // 旧实现按 issue_number → target_repo → 同仓 的顺序抛错;前两条要在这里保住,
77
+ // 同仓校验依赖运行时解析出的 repoTarget,只能在工具内(confirm 之后)暴露。
78
+ when: (a) => has(a.issue_number) && has(a.target_repo),
79
+ describe: (a) => ({
80
+ status: GATE_BLOCKED,
81
+ warning: `⚠️ 高危操作拦截:你即将把 Issue #${str(a.issue_number)} 迁移到 ${str(a.target_repo)}。`,
82
+ impact: `迁移后 Issue 归属 ${str(a.target_repo)}(原 URL 301 重定向),标签/里程碑不随迁,操作不可静默撤回。需要目标仓库的写权限。`,
83
+ requiredAction: '如确认迁移,请与用户核实后附加 `confirm: true` 重新调用。',
84
+ }),
85
+ },
86
+ },
87
+ github_release: {
88
+ // 原实现先校验 tag / assets 再判 clobber,条件里一并带上,避免缺参时先拿到确认提示
89
+ upload: {
90
+ when: (a) => a.clobber === true && has(a.tag) && Array.isArray(a.assets) && a.assets.length > 0,
91
+ describe: (a) => ({
92
+ status: GATE_BLOCKED,
93
+ warning: `⚠️ 高危操作拦截:你即将覆盖上传 Release ${str(a.tag)} 的同名旧资产(clobber 模式)。`,
94
+ impact: 'clobber 会先删除既有同名资产再上传;若上传中途失败,旧资产已丢失且无法恢复。',
95
+ requiredAction: '如确认覆盖,请向用户核实并附加 `confirm: true` 重新调用。',
96
+ }),
97
+ },
98
+ delete: {
99
+ when: (a) => has(a.tag),
100
+ describe: (a) => ({
101
+ status: GATE_BLOCKED,
102
+ warning: `⚠️ 高危操作拦截:你即将删除 Release ${str(a.tag)}${a.cleanup_tag ? ' 及其 Git Tag' : ''}。`,
103
+ impact: '删除 Release 不可逆;若同时清理 Tag,引用该 Tag 的历史版本入口将全部失效。',
104
+ requiredAction: '如确认删除,请与用户核实并附加 `confirm: true` 重新调用。',
105
+ }),
106
+ },
107
+ },
108
+ github_secret: {
109
+ delete: {
110
+ when: (a) => has(a.name),
111
+ describe: (a) => {
112
+ // 与旧实现同口径:kind === 'variable' 时按 Variable 措辞,否则按 Secret
113
+ const isVariable = a.kind === 'variable';
114
+ const kindLabel = isVariable ? 'Variable' : 'Secret';
115
+ return {
116
+ status: GATE_BLOCKED,
117
+ warning: `⚠️ 高危操作拦截:你即将删除 ${kindLabel}「${str(a.name)}」${a.org ? `(组织 ${str(a.org)})` : ''}。`,
118
+ impact: isVariable
119
+ ? '删除后所有引用该变量的工作流将取不到值,运行中的构建立即失败。'
120
+ : '删除后所有引用该密钥的 CI/CD 工作流将立即无法取到值,运行中的构建会失败。',
121
+ requiredAction: '如确认删除,请向用户核实并附加 `confirm: true` 重新调用。',
122
+ };
123
+ },
124
+ },
125
+ },
126
+ github_label: {
127
+ delete: {
128
+ when: (a) => has(a.name),
129
+ describe: (a) => ({
130
+ status: GATE_BLOCKED,
131
+ action: 'delete',
132
+ name: a.name,
133
+ warning: `⚠️ 高危破坏性操作:即将删除仓库标签 "${str(a.name)}"`,
134
+ impact: '删除标签不可逆,且会导致目标仓库内所有已打上该标签的 Issue 与 PR 自动解绑该标记。',
135
+ requiredAction: `如确认彻底删除,请在参数中附加 \`confirm: true\` 重新调用。示例:\`github_label(action: 'delete', name: '${str(a.name)}', confirm: true)\``,
136
+ }),
137
+ },
138
+ },
139
+ github_cache: {
140
+ // 三条删除分支共用一套 confirm 语义。选择器缺失时原实现会抛「必须指定…」,
141
+ // 这里 predicate 为 false,把精确报错留给工具本体,不用泛化文案顶替。
142
+ delete: {
143
+ when: (a) => a.all === true || has(a.cache_id) || has(a.key),
144
+ describe: (a) => {
145
+ const cacheIdStr = str(a.cache_id);
146
+ const keyStr = str(a.key);
147
+ const hasAll = a.all === true;
148
+ // 分支判定与工具本体的归一化同口径:cache_id 非空即按 ID 删除,否则按 key
149
+ const byId = cacheIdStr !== '';
150
+ const targetDesc = hasAll
151
+ ? '清空该仓库的所有 GitHub Actions 构建缓存'
152
+ : byId
153
+ ? `删除仓库中 ID 为 "${cacheIdStr}" 的构建缓存`
154
+ : `删除仓库中 Key 为 "${keyStr}" 的构建缓存`;
155
+ return {
156
+ status: GATE_BLOCKED,
157
+ action: 'delete',
158
+ ...(hasAll ? { all: true } : byId ? { cache_id: cacheIdStr } : { key: keyStr }),
159
+ warning: `⚠️ 高危破坏性操作:即将${targetDesc}`,
160
+ impact: '删除构建缓存不可逆,会导致后续流水线构建无法复用依赖与构建产物,显著增加 CI 运行耗时。',
161
+ requiredAction: '如确认删除,请在参数中附加 `confirm: true` 重新调用。',
162
+ };
163
+ },
164
+ },
165
+ },
166
+ github_codespace: {
167
+ delete: {
168
+ when: (a) => has(a.codespace),
169
+ describe: (a) => ({
170
+ status: GATE_BLOCKED,
171
+ warning: `⚠️ 销毁确认拦截:你即将永久删除云端开发环境「${str(a.codespace)}」。`,
172
+ impact: '物理销毁 Codespace 将永久释放云端计算资源,环境中任何未提交并推送到远端 Git 的临时修改都将无法找回。',
173
+ requiredAction: '如确认销毁该实例,请核对环境名称并在参数中附加 `confirm: true` 重新调用。',
174
+ }),
175
+ },
176
+ },
177
+ github_gist: {
178
+ delete: {
179
+ when: (a) => has(a.gist_id),
180
+ describe: (a) => ({
181
+ status: GATE_BLOCKED,
182
+ action: 'delete',
183
+ gist_id: str(a.gist_id),
184
+ warning: `⚠️ 高危破坏性操作:即将永久删除 Gist 代码片段 "${str(a.gist_id)}"`,
185
+ impact: '删除 Gist 为物理删除,无法通过 GitHub API 恢复,所有引用该 Gist URL 的外部链接将失效并返回 404。',
186
+ requiredAction: `如确认彻底删除,请在参数中附加 \`confirm: true\` 重新调用。示例:\`github_gist(action: 'delete', gist_id: '${str(a.gist_id)}', confirm: true)\``,
187
+ }),
188
+ },
189
+ },
190
+ github_project: {
191
+ 'item-delete': {
192
+ when: (a) => has(a.item_id),
193
+ describe: (a) => ({
194
+ status: GATE_BLOCKED,
195
+ warning: `⚠️ 删除确认拦截:你即将从看板中删除条目(ID: ${str(a.item_id)})。`,
196
+ impact: '该操作将从项目看板中物理移除该条目(若是关联的 Issue/PR,实体不会被删除,仅解除看板关联)。',
197
+ requiredAction: '如确认删除,请在参数中附加 `confirm: true` 重新调用。',
198
+ }),
199
+ },
200
+ },
201
+ github_skill: {
202
+ install: {
203
+ when: (a) => has(a.repository),
204
+ describe: (a) => ({
205
+ status: GATE_BLOCKED,
206
+ warning: `⚠️ 安装确认拦截:你即将从仓库「${str(a.repository)}」安装 Skill${a.skill_name ? `「${str(a.skill_name)}」` : ''}到本地${a.scope === 'user' ? '用户主目录(全局)' : '当前项目'}。`,
207
+ impact: '安装会在本地磁盘写入 SKILL.md 及附属文件,并可能影响目标 Agent 宿主的默认行为。',
208
+ requiredAction: '如确认安装,请向用户核实并附加 `confirm: true` 重新调用。',
209
+ }),
210
+ },
211
+ // 只在「正式发布」分支拦截:dry_run 缺省为真(预演),fix 模式也是本地产物修复。
212
+ // 原实现先校验 tag,这里一并带上,缺 tag 仍由工具本体抛出「必须提供版本 tag」。
213
+ publish: {
214
+ when: (a) => a.dry_run === false && a.fix !== true && has(a.tag),
215
+ describe: (a) => {
216
+ const cleanTag = str(a.tag);
217
+ return {
218
+ status: GATE_BLOCKED,
219
+ warning: `⚠️ 发布确认拦截:你即将正式发布 Agent Skill 并创建 GitHub Release(Tag: ${cleanTag})。`,
220
+ impact: `该操作将在仓库中打上「agent-skills」Topic、创建 Git Tag「${cleanTag}」并发布正式 GitHub Release,向所有用户公开提供该版本。`,
221
+ requiredAction: `如确认发布,请核实版本号并在参数中附加 \`confirm: true\` 重新调用。`,
222
+ };
223
+ },
224
+ },
225
+ },
226
+ /*
227
+ * 任务级原语(s8):默认只读,唯一会写远端的是 review_pr 的 write 模式。
228
+ *
229
+ * 为什么 when 要判 `write === true`:`review_pr` 默认只产出上下文包(不写回),
230
+ * 若恒拦,Agent 连"看一眼这个 PR 该审什么"都要先确认一次,闸门就变成了噪音。
231
+ */
232
+ github_task: {
233
+ review_pr: {
234
+ when: (a) => a.write === true && has(a.pr_number),
235
+ describe: (a) => ({
236
+ status: GATE_BLOCKED,
237
+ warning: `⚠️ 写回拦截:你即将向 PR #${str(a.pr_number)} 提交一条公开可见的 Review(含行级评注)。`,
238
+ impact: '该 Review 会立即出现在 PR 时间线上并通知相关人,删除只能逐条进行。',
239
+ requiredAction: '如确认写回,请附加 `write: true, confirm: true` 重新调用;只想看结论请去掉 `write`。',
240
+ }),
241
+ },
242
+ },
243
+ };
244
+ /**
245
+ * 统一闸门:命中则返回拦截载荷,否则返回 undefined。
246
+ *
247
+ * `confirm === true` 的判定放在这里而不是每个 describe 里——15 份重复的
248
+ * `if (args.confirm !== true)` 正是这次要消掉的东西。
249
+ */
250
+ export function guardDestructive(toolName, args) {
251
+ const table = DESTRUCTIVE_ACTIONS[toolName];
252
+ if (!table)
253
+ return undefined;
254
+ const action = typeof args?.action === 'string' ? args.action : '';
255
+ // 必须挡原型链:`action: "toString"` 会让 table[action] 命中原型上的函数,
256
+ // spec.describe 不是函数直接 TypeError(复核报告 C-05,11 工具 × 16 组合全崩)。
257
+ const spec = Object.prototype.hasOwnProperty.call(table, action) ? table[action] : undefined;
258
+ if (!spec)
259
+ return undefined;
260
+ if (args?.confirm === true)
261
+ return undefined;
262
+ if (spec.when && !spec.when(args))
263
+ return undefined;
264
+ return spec.describe(args);
265
+ }
266
+ /**
267
+ * 用闸门包一层 execute(注册期唯一入口)。
268
+ *
269
+ * 为什么包在注册期而不是 `executor.run()`:`run()` 只看得见 argv,看不见
270
+ * `action` / `confirm` 语义。把语义解析塞进 argv 层就是把简单问题复杂化。
271
+ *
272
+ * 保持 `name` / `description` / `parameters` / `output` 原样,只替换 `execute`,
273
+ * 宿主侧的 schema 校验与渲染链路完全不受影响。
274
+ */
275
+ export function wrapWithGate(tool) {
276
+ return {
277
+ ...tool,
278
+ async execute(args, ctx) {
279
+ const gate = guardDestructive(tool.name, args);
280
+ if (gate)
281
+ return gate;
282
+ return tool.execute(args, ctx);
283
+ },
284
+ };
285
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * gh argv 的「位置参数」守卫。
3
+ *
4
+ * 为什么需要它:`gh` 用 cobra/pflag 解析 argv,"以 - 开头的 token" 一律优先当
5
+ * 选项。工具把用户输入直接 push 成**位置参数**时,`pr_number: "--force"` 会变成
6
+ * `gh pr checkout --force`——破坏性闸门判的是**参数**,而危险在 **argv**,
7
+ * 两者不在同一层(复核报告 C-01 的三条绕过全是这个形状)。
8
+ *
9
+ * 为什么只拒绝 `-` 开头而不是做全量字符白名单:
10
+ * 各工具的位置参数语义差异很大(编号是数字、名字是任意非空串、路径可以有斜杠),
11
+ * 统一白名单会误伤合法输入;而"不能是选项"是所有位置参数**共有**的唯一硬约束。
12
+ * 具体格式(正整数 / owner-repo / 数字 ID)仍由各工具自己的校验负责。
13
+ *
14
+ * 空值不在此处报错:调用方各自的「必须提供 X」文案更准确,这里越俎代庖会把
15
+ * 两个不同原因的错误合并成一个,反而降低可诊断性。
16
+ */
17
+ export declare function assertPositionArg(value: unknown, label: string): string;
@@ -0,0 +1,23 @@
1
+ /**
2
+ * gh argv 的「位置参数」守卫。
3
+ *
4
+ * 为什么需要它:`gh` 用 cobra/pflag 解析 argv,"以 - 开头的 token" 一律优先当
5
+ * 选项。工具把用户输入直接 push 成**位置参数**时,`pr_number: "--force"` 会变成
6
+ * `gh pr checkout --force`——破坏性闸门判的是**参数**,而危险在 **argv**,
7
+ * 两者不在同一层(复核报告 C-01 的三条绕过全是这个形状)。
8
+ *
9
+ * 为什么只拒绝 `-` 开头而不是做全量字符白名单:
10
+ * 各工具的位置参数语义差异很大(编号是数字、名字是任意非空串、路径可以有斜杠),
11
+ * 统一白名单会误伤合法输入;而"不能是选项"是所有位置参数**共有**的唯一硬约束。
12
+ * 具体格式(正整数 / owner-repo / 数字 ID)仍由各工具自己的校验负责。
13
+ *
14
+ * 空值不在此处报错:调用方各自的「必须提供 X」文案更准确,这里越俎代庖会把
15
+ * 两个不同原因的错误合并成一个,反而降低可诊断性。
16
+ */
17
+ export function assertPositionArg(value, label) {
18
+ const raw = typeof value === 'number' ? String(value) : String(value ?? '').trim();
19
+ if (raw.startsWith('-')) {
20
+ throw new Error(`${label} 非法:位置参数不能以 "-" 开头(收到 "${raw}"),它会被 gh 当成命令行选项而不是取值。`);
21
+ }
22
+ return raw;
23
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * gh 错误语义分类(确定性配置错误 vs 可重试的传输层故障)。
3
+ *
4
+ * 为什么必须区分:`gh` 失败时一律以非零退出码返回,调用方只能看到一段 stderr
5
+ * 文本。过去上层(Web 概览路由)把所有非零退出统一当成「网络错误」,于是
6
+ * 「仓库关闭了 Issues」这种永久性配置事实被伪装成网络抖动——既弹出误导性的
7
+ * 「网络连接失败」横幅,又触发 3 轮阶梯退避重试与每 5-10 分钟一轮的自动同步,
8
+ * 把确定性失败当成偶发抖动反复空烧。
9
+ *
10
+ * 判据全部来自 gh 2.96 实测 stderr:
11
+ * - the 'o/r' repository has disabled issues
12
+ * - the 'o/r' repository has discussions disabled
13
+ * - GraphQL: Could not resolve to a Repository with the name 'o/r'. (repository)
14
+ * - HTTP 403 / Not Found (HTTP 404)
15
+ *
16
+ * 纯文本判定,不做任何 I/O——它是 GhResult.errorKind 的唯一事实源。
17
+ */
18
+ /** gh 失败结果的语义种类 */
19
+ export type GhErrorKind =
20
+ /** 传输层故障(超时、DNS、连接重置、代理阻断):值得重试,允许回退历史快照 */
21
+ 'transient'
22
+ /** 确定性失败(仓库关闭 issues、仓库不存在、权限不足):重试永远无意义 */
23
+ | 'deterministic'
24
+ /** 命令失败但无明确归属,按可重试处理(保守不误杀) */
25
+ | 'unknown';
26
+ /**
27
+ * 判定一次 gh 失败的语义种类。
28
+ *
29
+ * 优先级设计:确定性 > 限流放行 > 传输层 > unknown。
30
+ * 为什么不把「同时命中确定性与传输层」判为 transient:若仓库关闭了 Issues,
31
+ * 网络恢复后该命令依然失败。判成 transient 会让调用方每轮回退旧快照、
32
+ * 永远收敛不到正确状态。确定性一旦命中就该立刻停止重试。
33
+ */
34
+ export declare function classifyGhError(errorText: string | undefined | null): GhErrorKind;
35
+ /**
36
+ * 把确定性失败翻译成面向用户的友好说明。
37
+ * 返回 null 表示不属于确定性失败,调用方应走原有网络故障路径。
38
+ */
39
+ export declare function describeDeterministicError(errorText: string | undefined | null): string | null;
40
+ /** 该错误是否值得重试(供 UI 层决定是否走阶梯退避) */
41
+ export declare function isRetryableError(errorText: string | undefined | null): boolean;
42
+ export declare function isPermissionError(errorText: string | undefined | null): boolean;
43
+ /**
44
+ * 把「权限不足」翻译成可操作的处置指引。
45
+ *
46
+ * 为什么不能只回一句「无权限」:Agent 与用户都需要知道**下一步做什么**。
47
+ * GitHub 的权限错误有明确解法路径(提权到 Triage/Write、或改用 fork 工作流),
48
+ * 把这条路径说出来,比让用户自己搜错误文本有用得多。
49
+ *
50
+ * @param errorText gh 的 stderr
51
+ * @param action 可选:正在执行的动作标识(如 'merge' / 'close' / 'add_label'),
52
+ * 用于在错误文本特征不足时精准匹配
53
+ * @returns 指引文本;非权限错误返回 null(不产生噪音)
54
+ */
55
+ export declare function permissionRemedy(errorText: string | undefined | null, action?: string): string | null;
@@ -0,0 +1,199 @@
1
+ /**
2
+ * gh 错误语义分类(确定性配置错误 vs 可重试的传输层故障)。
3
+ *
4
+ * 为什么必须区分:`gh` 失败时一律以非零退出码返回,调用方只能看到一段 stderr
5
+ * 文本。过去上层(Web 概览路由)把所有非零退出统一当成「网络错误」,于是
6
+ * 「仓库关闭了 Issues」这种永久性配置事实被伪装成网络抖动——既弹出误导性的
7
+ * 「网络连接失败」横幅,又触发 3 轮阶梯退避重试与每 5-10 分钟一轮的自动同步,
8
+ * 把确定性失败当成偶发抖动反复空烧。
9
+ *
10
+ * 判据全部来自 gh 2.96 实测 stderr:
11
+ * - the 'o/r' repository has disabled issues
12
+ * - the 'o/r' repository has discussions disabled
13
+ * - GraphQL: Could not resolve to a Repository with the name 'o/r'. (repository)
14
+ * - HTTP 403 / Not Found (HTTP 404)
15
+ *
16
+ * 纯文本判定,不做任何 I/O——它是 GhResult.errorKind 的唯一事实源。
17
+ */
18
+ /**
19
+ * 确定性失败的 stderr 特征表。
20
+ *
21
+ * 为什么用特征串而不是退出码:gh 对上述场景统一返回 exit 1,退出码无法区分
22
+ * 「仓库关了 Issues」与「网线掉了」。文本特征是唯一可用的判据。
23
+ * 每条都必须来自实测输出——误判会把真实网络故障降级成静默空数据,比误报更糟。
24
+ */
25
+ const DETERMINISTIC_PATTERNS = [
26
+ // 仓库级开关关闭(Issues / Discussions / Projects / Wiki)。
27
+ // 注意 gh 的措辞不统一(实测):
28
+ // issues → "repository has disabled issues"
29
+ // discussions → "repository has discussions disabled"
30
+ // 词序相反,所以两种形态都要覆盖,不能只写一种。
31
+ {
32
+ re: /repository has (?:disabled )?(issues|discussions|projects|wiki)(?: disabled)?/i,
33
+ reason: '该仓库未开启该项协作功能',
34
+ },
35
+ // 仓库不可达(不存在 / 无权限 / 已改名):GraphQL 与 REST 两条通道
36
+ { re: /Could not resolve to a Repository/i, reason: '仓库不存在或当前账号无权访问' },
37
+ { re: /Not Found \(HTTP 404\)/i, reason: '仓库或资源不存在' },
38
+ // 分支 / PR / Issue 单体不存在
39
+ { re: /Branch not found/i, reason: '分支不存在' },
40
+ // GraphQL 通道的权限不足(实测形态):
41
+ // GraphQL: ItQianChen does not have the correct permissions to execute `AddLabelsToLabelable` (addLabelsToLabelable)
42
+ // GraphQL: ItQianChen does not have the correct permissions to execute `DeleteIssue` (deleteIssue)
43
+ // 这类文本里没有 "HTTP 403",若不单列会被归为 unknown,进而被当成可重试抖动——
44
+ // 而权限不足重试一万次也不会变。
45
+ { re: /does not have the correct permissions to execute/i, reason: '当前账号对该仓库的权限不足' },
46
+ ];
47
+ /** 把「仓库未开启某项功能」翻译成点名说明(比笼统的“未开启协作功能”有用) */
48
+ const DISABLED_FEATURE_LABEL = {
49
+ issues: 'Issues',
50
+ discussions: 'Discussions',
51
+ projects: 'Projects',
52
+ wiki: 'Wiki',
53
+ };
54
+ /**
55
+ * 限流类 403 必须先于通用 403 放行。
56
+ * 为什么单列:GitHub 的 `HTTP 403` 既可能是「无权限」(确定性),也可能是
57
+ * 「API rate limit exceeded / secondary rate limit」(几分钟后可恢复)。
58
+ * 把限流误判成确定性会让学生等一会儿就能好的请求被判死刑。
59
+ */
60
+ const RATE_LIMIT_RE = /rate limit|secondary rate limit|abuse detection/i;
61
+ /** 传输层故障特征(显式白名单,用于给出「可重试」的准确语义) */
62
+ const TRANSIENT_PATTERNS = [
63
+ /命令执行超时/,
64
+ /\bETIMEDOUT\b/i,
65
+ /\bECONNRESET\b/i,
66
+ /\bECONNREFUSED\b/i,
67
+ /\bENOTFOUND\b/i,
68
+ /\bEAI_AGAIN\b/i,
69
+ /socket hang up/i,
70
+ /TLS handshake/i,
71
+ /proxyconnect/i,
72
+ /spawn EPERM/i,
73
+ /network is unreachable/i,
74
+ /Could not resolve host/i,
75
+ /no such host/i,
76
+ ];
77
+ /**
78
+ * 判定一次 gh 失败的语义种类。
79
+ *
80
+ * 优先级设计:确定性 > 限流放行 > 传输层 > unknown。
81
+ * 为什么不把「同时命中确定性与传输层」判为 transient:若仓库关闭了 Issues,
82
+ * 网络恢复后该命令依然失败。判成 transient 会让调用方每轮回退旧快照、
83
+ * 永远收敛不到正确状态。确定性一旦命中就该立刻停止重试。
84
+ */
85
+ export function classifyGhError(errorText) {
86
+ const text = String(errorText || '');
87
+ if (!text)
88
+ return 'unknown';
89
+ // 限流 403 先放行,避免被下面的通用 403 规则当成确定性失败
90
+ if (RATE_LIMIT_RE.test(text))
91
+ return 'transient';
92
+ for (const { re } of DETERMINISTIC_PATTERNS) {
93
+ if (re.test(text))
94
+ return 'deterministic';
95
+ }
96
+ // 通用 403 放在确定性表之后、transient 之前:无权限属于确定性
97
+ if (/HTTP 403/i.test(text))
98
+ return 'deterministic';
99
+ for (const re of TRANSIENT_PATTERNS) {
100
+ if (re.test(text))
101
+ return 'transient';
102
+ }
103
+ return 'unknown';
104
+ }
105
+ /**
106
+ * 把确定性失败翻译成面向用户的友好说明。
107
+ * 返回 null 表示不属于确定性失败,调用方应走原有网络故障路径。
108
+ */
109
+ export function describeDeterministicError(errorText) {
110
+ const text = String(errorText || '');
111
+ if (!text)
112
+ return null;
113
+ if (RATE_LIMIT_RE.test(text))
114
+ return null;
115
+ // 先处理「仓库关闭某项功能」:点名到具体功能,比笼统措辞更容易让用户行动
116
+ const disabled = /repository has (?:disabled )?(issues|discussions|projects|wiki)(?: disabled)?/i.exec(text);
117
+ if (disabled) {
118
+ const label = DISABLED_FEATURE_LABEL[disabled[1].toLowerCase()] || disabled[1];
119
+ return `该仓库未开启 ${label}`;
120
+ }
121
+ for (const { re, reason } of DETERMINISTIC_PATTERNS) {
122
+ if (re.test(text))
123
+ return reason;
124
+ }
125
+ if (/HTTP 403/i.test(text))
126
+ return '当前账号无此操作权限';
127
+ return null;
128
+ }
129
+ /** 该错误是否值得重试(供 UI 层决定是否走阶梯退避) */
130
+ export function isRetryableError(errorText) {
131
+ return classifyGhError(errorText) !== 'deterministic';
132
+ }
133
+ /**
134
+ * 判断一段错误是否属于「权限不足」(而非仓库配置、不存在等确定性错误)。
135
+ *
136
+ * 为什么单独判定:确定性错误里只有权限这一类有「解法路径」(提权 / 换 fork 工作流),
137
+ * 其余(仓库关了 Issues、分支不存在)说再多也没用。
138
+ */
139
+ const PERMISSION_RE = /does not have the correct permissions to execute|HTTP 403|Resource not accessible|Forbidden|must have (?:admin|push|write|triage|maintain) (?:access|permissions?)/i;
140
+ export function isPermissionError(errorText) {
141
+ const text = String(errorText || '');
142
+ if (!text)
143
+ return false;
144
+ // 限流也常以 403 出现,但它不是权限问题——放行会让用户去改权限,做无用功
145
+ if (RATE_LIMIT_RE.test(text))
146
+ return false;
147
+ return PERMISSION_RE.test(text);
148
+ }
149
+ /**
150
+ * 已知操作 → 所需权限档位 的映射。
151
+ *
152
+ * 键取自 GitHub 的 GraphQL mutation 名与 gh 子命令动词(实测捕获)。
153
+ * 为什么不用完整枚举:GitHub 有上百个 mutation,穷举既不可能也没必要——
154
+ * 未命中的一律走默认指引,仍然是对的。
155
+ */
156
+ const ACTION_PERMISSION_MAP = [
157
+ {
158
+ // 同时覆盖 GraphQL mutation 名(AddLabelsToLabelable)与 CLI 参数形态
159
+ // (--add-label / --remove-label)——两者出现在不同层级的错误文本里。
160
+ re: /AddLabelsToLabelable|RemoveLabelsFromLabelable|UpdateIssue|CloseIssue|TransferIssue|add-?label|remove-?label|close-?issue|issue (?:edit|close|pin|lock|transfer)/i,
161
+ level: 'Triage',
162
+ hint: '管理他人 Issue/PR(加标签、关闭、指派)需要 Triage 及以上权限。可请仓库管理员授予,或改用评论表达意见。',
163
+ },
164
+ {
165
+ re: /mergePullRequest|MergePullRequest|\bmerge\b|CreateRef|createRef|\bpush\b|CreateCommitOnBranch/i,
166
+ level: 'Write',
167
+ hint: '合并 PR / 推送代码需要 Write 及以上权限。若是他人仓库,请先 fork 到自己的账号,在 fork 上提交后向上游发起 PR。',
168
+ },
169
+ {
170
+ re: /DeleteIssue|deleteIssue|DeleteRepository|deleteRepository|TransferRepository|\bdelete\b/i,
171
+ level: 'Admin',
172
+ hint: '物理删除与仓库级操作需要管理员权限。这类操作不可逆,请联系仓库管理员执行。',
173
+ },
174
+ ];
175
+ /**
176
+ * 把「权限不足」翻译成可操作的处置指引。
177
+ *
178
+ * 为什么不能只回一句「无权限」:Agent 与用户都需要知道**下一步做什么**。
179
+ * GitHub 的权限错误有明确解法路径(提权到 Triage/Write、或改用 fork 工作流),
180
+ * 把这条路径说出来,比让用户自己搜错误文本有用得多。
181
+ *
182
+ * @param errorText gh 的 stderr
183
+ * @param action 可选:正在执行的动作标识(如 'merge' / 'close' / 'add_label'),
184
+ * 用于在错误文本特征不足时精准匹配
185
+ * @returns 指引文本;非权限错误返回 null(不产生噪音)
186
+ */
187
+ export function permissionRemedy(errorText, action) {
188
+ if (!isPermissionError(errorText))
189
+ return null;
190
+ const haystack = `${String(errorText || '')} ${String(action || '')}`;
191
+ for (const { re, hint } of ACTION_PERMISSION_MAP) {
192
+ if (re.test(haystack))
193
+ return hint;
194
+ }
195
+ // 默认指引:不指定具体档位。
196
+ // 为什么:只知道「权限不足」时猜档位很容易猜错(我们实测就猜错过——
197
+ // READ 用户能建 Issue,而不是处处受限),说错比不说更糟。
198
+ return '当前账号对该仓库的权限不足。可运行 `gh auth status` 查看作用域,或请仓库管理员提升权限。';
199
+ }