dsh-github-flow 0.6.0 → 0.7.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.
Files changed (95) hide show
  1. package/README.en.md +102 -15
  2. package/README.md +105 -15
  3. package/docs/UPGRADE-NOTES.md +12 -11
  4. package/docs/architecture.md +67 -14
  5. package/lib/api/route-helpers.d.ts +19 -0
  6. package/lib/api/route-helpers.js +54 -0
  7. package/lib/api/routes-actions.js +126 -2
  8. package/lib/api/routes-lifecycle.js +8 -1
  9. package/lib/api/routes-overview.d.ts +26 -0
  10. package/lib/api/routes-overview.js +276 -34
  11. package/lib/api/routes-proxy.js +8 -1
  12. package/lib/api/routes.d.ts +11 -1
  13. package/lib/api/routes.js +15 -2
  14. package/lib/cache.d.ts +0 -21
  15. package/lib/cache.js +0 -37
  16. package/lib/client.d.ts +2 -1
  17. package/lib/client.js +2108 -658
  18. package/lib/destructive.d.ts +59 -0
  19. package/lib/destructive.js +285 -0
  20. package/lib/executor/args-guard.d.ts +17 -0
  21. package/lib/executor/args-guard.js +23 -0
  22. package/lib/executor/failure-anchors.d.ts +52 -0
  23. package/lib/executor/failure-anchors.js +116 -0
  24. package/lib/executor/pr-review.d.ts +95 -0
  25. package/lib/executor/pr-review.js +175 -0
  26. package/lib/executor/run-log.d.ts +36 -0
  27. package/lib/executor/run-log.js +78 -0
  28. package/lib/executor.d.ts +204 -3
  29. package/lib/executor.js +670 -28
  30. package/lib/github-service.d.ts +1 -1
  31. package/lib/main.d.ts +92 -18
  32. package/lib/main.js +186 -95
  33. package/lib/renderers/common.d.ts +0 -3
  34. package/lib/renderers/common.js +8 -44
  35. package/lib/renderers/markdown.d.ts +1 -0
  36. package/lib/renderers/markdown.js +1 -0
  37. package/lib/renderers/pr.js +26 -1
  38. package/lib/renderers/task.d.ts +9 -0
  39. package/lib/renderers/task.js +120 -0
  40. package/lib/review-rules.d.ts +60 -0
  41. package/lib/review-rules.js +204 -0
  42. package/lib/shared/state-badges.d.ts +42 -0
  43. package/lib/shared/state-badges.js +102 -0
  44. package/lib/task-primitives.d.ts +109 -0
  45. package/lib/task-primitives.js +194 -0
  46. package/lib/tool-tiers.d.ts +57 -0
  47. package/lib/tool-tiers.js +168 -0
  48. package/lib/tools/agent-task.d.ts +4 -0
  49. package/lib/tools/agent-task.js +22 -9
  50. package/lib/tools/api.d.ts +4 -0
  51. package/lib/tools/api.js +48 -12
  52. package/lib/tools/body-input.d.ts +58 -0
  53. package/lib/tools/body-input.js +123 -0
  54. package/lib/tools/cache.js +42 -40
  55. package/lib/tools/codespace.js +4 -12
  56. package/lib/tools/discussion.d.ts +4 -0
  57. package/lib/tools/discussion.js +33 -17
  58. package/lib/tools/gist.d.ts +1 -39
  59. package/lib/tools/gist.js +4 -14
  60. package/lib/tools/help.js +65 -13
  61. package/lib/tools/issue.d.ts +4 -0
  62. package/lib/tools/issue.js +37 -38
  63. package/lib/tools/label.js +4 -15
  64. package/lib/tools/pr.d.ts +51 -0
  65. package/lib/tools/pr.js +204 -46
  66. package/lib/tools/project.js +4 -13
  67. package/lib/tools/release.d.ts +4 -0
  68. package/lib/tools/release.js +26 -26
  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 +223 -0
  76. package/lib/tools/task.js +464 -0
  77. package/lib/types.d.ts +183 -1
  78. package/lib/ui/commit-modal.js +108 -4
  79. package/lib/ui/diff-utils.d.ts +51 -0
  80. package/lib/ui/diff-utils.js +155 -0
  81. package/lib/ui/file-diff-modal.d.ts +33 -0
  82. package/lib/ui/file-diff-modal.js +250 -0
  83. package/lib/ui/help-modal.d.ts +1 -1
  84. package/lib/ui/help-modal.js +24 -12
  85. package/lib/ui/issue-modal.js +7 -3
  86. package/lib/ui/pr-modal.js +50 -11
  87. package/lib/ui/run-modal.d.ts +26 -0
  88. package/lib/ui/run-modal.js +486 -0
  89. package/lib/ui/styles.d.ts +1 -1
  90. package/lib/ui/styles.js +376 -44
  91. package/lib/ui/sync-settings-modal.js +1 -1
  92. package/lib/ui/workspace-panel.js +78 -23
  93. package/lib/version.d.ts +1 -1
  94. package/lib/version.js +1 -1
  95. 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,52 @@
1
+ /**
2
+ * CI 失败日志的「锚点萃取」:把整段日志压成若干段带行号的失败上下文。
3
+ *
4
+ * 为什么需要它:
5
+ * 1. 矩阵构建(matrix)下一个 run 会有 N 个 job 的失败日志,动辄几万行;
6
+ * 「整段给模型」必然触顶默认输出上限,而截断发生在**萃取之前**——
7
+ * 结果就是 Agent 只能看到前 24KB,后面的失败根本不存在于上下文里;
8
+ * 2. 模型真正需要的不是全量日志,而是「每个失败的起点 + 之后几十行」;
9
+ * 3. 锚点全部取自各框架日志的首列特征(实测输出),不含猜测。
10
+ *
11
+ * 纯函数、零 I/O:日志文本进,结构化块出。
12
+ */
13
+ /** 去掉 gh 的 `job\tstep\ttimestamp ` 前缀(没有前缀时原样返回) */
14
+ export declare function stripLogPrefix(line: string): string;
15
+ export interface FailureBlock {
16
+ /** 命中的锚点原文(去掉首尾空白,超长截断) */
17
+ anchor: string;
18
+ /** 该块起始行号(1-based,方便人去日志里定位) */
19
+ startLine: number;
20
+ /** 块正文(含锚点行与其后上下文) */
21
+ text: string;
22
+ }
23
+ export interface FailureAnchorResult {
24
+ blocks: FailureBlock[];
25
+ /** 萃取到的失败块总数(可能大于返回的 blocks.length) */
26
+ totalBlocks: number;
27
+ /** 因 maxBlocks 上限被丢弃的块数 */
28
+ droppedBlocks: number;
29
+ }
30
+ export interface FailureAnchorOptions {
31
+ /** 每个锚点向后取多少行上下文 */
32
+ radius?: number;
33
+ /** 最多返回多少块 */
34
+ maxBlocks?: number;
35
+ }
36
+ /**
37
+ * 从日志文本中萃取失败块。
38
+ *
39
+ * 合并规则:相邻锚点相距不超过 `radius` 行时并入同一块——同一个异常常常先打
40
+ * `FAIL` 再打 `AssertionError`,拆成两块等于把一次失败说两遍。
41
+ * 每块长度上限 `2*radius+1` 行,保证单块不会因为连续堆栈而无限膨胀。
42
+ */
43
+ export declare function extractFailureAnchors(text: string, options?: FailureAnchorOptions): FailureAnchorResult;
44
+ /** 把萃取结果渲染成 Markdown(人可读、模型可读,行号显式) */
45
+ export declare function renderFailureAnchors(result: FailureAnchorResult): string;
46
+ /**
47
+ * 无锚点时的回退:取尾部若干行。
48
+ *
49
+ * 为什么取尾部而不是头部:日志的失败结论在末尾(构建工具通常先刷编译输出、
50
+ * 最后才打印错误汇总),取头部基本只能拿到环境准备信息。
51
+ */
52
+ export declare function tailLines(text: string, count?: number): string;
@@ -0,0 +1,116 @@
1
+ /**
2
+ * CI 失败日志的「锚点萃取」:把整段日志压成若干段带行号的失败上下文。
3
+ *
4
+ * 为什么需要它:
5
+ * 1. 矩阵构建(matrix)下一个 run 会有 N 个 job 的失败日志,动辄几万行;
6
+ * 「整段给模型」必然触顶默认输出上限,而截断发生在**萃取之前**——
7
+ * 结果就是 Agent 只能看到前 24KB,后面的失败根本不存在于上下文里;
8
+ * 2. 模型真正需要的不是全量日志,而是「每个失败的起点 + 之后几十行」;
9
+ * 3. 锚点全部取自各框架日志的首列特征(实测输出),不含猜测。
10
+ *
11
+ * 纯函数、零 I/O:日志文本进,结构化块出。
12
+ */
13
+ /**
14
+ * 锚点识别(行首特征)。
15
+ *
16
+ * 为什么分两条正则:
17
+ * 多数框架的失败标记在**行首**(pytest 的 `FAILED path::test`、node 的
18
+ * `AssertionError`、Go 的 `panic:`),但 TypeScript 编译器的真实输出是
19
+ * `src/file.ts(3,5): error TS2322: ...` —— 前面带 `文件(行,列):` 前缀,
20
+ * 行首锚定会一条都命中不了。所以 TS 单独用「行内出现」判定。
21
+ *
22
+ * `❌` 单列:本项目自己的 CI 测试输出就是 `❌ FAIL: xxx`,不加这一条
23
+ * 等于对自己仓库的失败日志完全失明。
24
+ */
25
+ const ANCHOR_LINE_RE = /^(?:FAIL|FAILED|ERROR|--- FAIL|AssertionError|Error:|panic:|Traceback \(most recent call last\)|npm ERR!|✖|❌)/;
26
+ const ANCHOR_INLINE_RE = /\berror TS\d+:/;
27
+ /**
28
+ * `gh run view --log-failed` 的每行前缀:`<job>\t<step>\t<ISO8601>Z <正文>`。
29
+ *
30
+ * 这是**实测**出来的(不是猜的):不打掉这个前缀,所有行首都变成 job 名,
31
+ * 上面两条正则一条也命中不了——锚点萃取会整体退化成「尾部 200 行回退」,
32
+ * 功能等于不存在。剥离只用于**匹配**,块正文仍保留原始行(job/step 是有用上下文)。
33
+ */
34
+ const GH_LOG_PREFIX_RE = /^[^\t]*\t[^\t]*\t\d{4}-\d{2}-\d{2}T[0-9:.]+Z\s?/;
35
+ /** 去掉 gh 的 `job\tstep\ttimestamp ` 前缀(没有前缀时原样返回) */
36
+ export function stripLogPrefix(line) {
37
+ const src = typeof line === 'string' ? line : '';
38
+ const m = GH_LOG_PREFIX_RE.exec(src);
39
+ return m ? src.slice(m[0].length) : src;
40
+ }
41
+ /**
42
+ * 从日志文本中萃取失败块。
43
+ *
44
+ * 合并规则:相邻锚点相距不超过 `radius` 行时并入同一块——同一个异常常常先打
45
+ * `FAIL` 再打 `AssertionError`,拆成两块等于把一次失败说两遍。
46
+ * 每块长度上限 `2*radius+1` 行,保证单块不会因为连续堆栈而无限膨胀。
47
+ */
48
+ export function extractFailureAnchors(text, options = {}) {
49
+ const radius = Number.isFinite(options.radius) && options.radius > 0 ? Math.floor(options.radius) : 15;
50
+ const maxBlocks = Number.isFinite(options.maxBlocks) && options.maxBlocks > 0 ? Math.floor(options.maxBlocks) : 8;
51
+ const src = typeof text === 'string' ? text : '';
52
+ if (!src)
53
+ return { blocks: [], totalBlocks: 0, droppedBlocks: 0 };
54
+ const lines = src.split(/\r?\n/);
55
+ const anchors = [];
56
+ for (let i = 0; i < lines.length; i++) {
57
+ const line = lines[i];
58
+ // 先剥 gh 的 job/step/timestamp 前缀,再按行首特征匹配(见 GH_LOG_PREFIX_RE 注释)
59
+ const body = stripLogPrefix(line).trim();
60
+ if (ANCHOR_LINE_RE.test(body) || ANCHOR_INLINE_RE.test(body))
61
+ anchors.push(i);
62
+ }
63
+ if (anchors.length === 0)
64
+ return { blocks: [], totalBlocks: 0, droppedBlocks: 0 };
65
+ // 分组:相邻锚点间距 > radius 才开新块
66
+ const groups = [];
67
+ for (const idx of anchors) {
68
+ const cur = groups[groups.length - 1];
69
+ if (!cur || idx - cur.last > radius)
70
+ groups.push({ start: idx, last: idx });
71
+ else
72
+ cur.last = idx;
73
+ }
74
+ const maxLines = 2 * radius + 1;
75
+ const all = groups.map((g) => {
76
+ const end = Math.min(lines.length, g.last + radius + 1);
77
+ const slice = lines.slice(g.start, end).slice(0, maxLines);
78
+ const anchorRaw = lines[g.start].trim();
79
+ return {
80
+ anchor: anchorRaw.length > 160 ? anchorRaw.slice(0, 160) + '…' : anchorRaw,
81
+ startLine: g.start + 1,
82
+ text: slice.join('\n'),
83
+ };
84
+ });
85
+ const blocks = all.slice(0, maxBlocks);
86
+ return { blocks, totalBlocks: all.length, droppedBlocks: all.length - blocks.length };
87
+ }
88
+ /** 把萃取结果渲染成 Markdown(人可读、模型可读,行号显式) */
89
+ export function renderFailureAnchors(result) {
90
+ if (!result || result.blocks.length === 0)
91
+ return '';
92
+ const parts = [];
93
+ parts.push(`**共命中 ${result.totalBlocks} 个失败块**,以下为前 ${result.blocks.length} 个:`);
94
+ result.blocks.forEach((b, i) => {
95
+ parts.push(`\n### 失败块 ${i + 1}(日志第 ${b.startLine} 行起)\n\`\`\`\n${b.text}\n\`\`\``);
96
+ });
97
+ if (result.droppedBlocks > 0) {
98
+ parts.push(`\n> ⚠️ 已省略 ${result.droppedBlocks} 个失败块(共 ${result.totalBlocks} 个)。需要时可用更小的范围重跑,或到 Actions 页面查看完整日志。`);
99
+ }
100
+ return parts.join('\n');
101
+ }
102
+ /**
103
+ * 无锚点时的回退:取尾部若干行。
104
+ *
105
+ * 为什么取尾部而不是头部:日志的失败结论在末尾(构建工具通常先刷编译输出、
106
+ * 最后才打印错误汇总),取头部基本只能拿到环境准备信息。
107
+ */
108
+ export function tailLines(text, count = 200) {
109
+ const src = typeof text === 'string' ? text : '';
110
+ if (!src)
111
+ return '';
112
+ const lines = src.split(/\r?\n/);
113
+ if (lines.length <= count)
114
+ return src;
115
+ return lines.slice(lines.length - count).join('\n');
116
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * PR 行级评注写入:payload 构造、校验与 422 降级(纯函数)。
3
+ *
4
+ * 为什么单独一个模块:
5
+ * 1. `gh pr review` 没有行级参数(`gh pr review --help` 里 line/side/path 零命中),
6
+ * 只能走 `POST /repos/{owner}/{repo}/pulls/{n}/reviews` 的 `comments[]`;
7
+ * 2. 「哪些错误可以在发请求前拦住」「422 怎么降级」是纯逻辑,必须能脱离网络单测;
8
+ * 3. executor 只负责发请求,校验与降级策略放在这里,避免 executor 继续膨胀。
9
+ *
10
+ * 严格按 REST 文档字段构造请求体,未自造任何键。
11
+ */
12
+ /** 单次 review 的行级评注上限。超过就拆多次,一次一个请求 = 通知只发一轮 */
13
+ export declare const MAX_REVIEW_COMMENTS = 50;
14
+ export type PrReviewEvent = 'COMMENT' | 'APPROVE' | 'REQUEST_CHANGES';
15
+ /** 调用方传入的单条行级评注(宽松类型,校验后才是 REST 形状) */
16
+ export interface PrReviewCommentInput {
17
+ path?: unknown;
18
+ line?: unknown;
19
+ side?: unknown;
20
+ start_line?: unknown;
21
+ start_side?: unknown;
22
+ body?: unknown;
23
+ }
24
+ /** REST 形状的单条评注(全部字段已归一化) */
25
+ export interface PrReviewCommentPayload {
26
+ path: string;
27
+ line?: number;
28
+ side?: 'LEFT' | 'RIGHT';
29
+ start_line?: number;
30
+ start_side?: 'LEFT' | 'RIGHT';
31
+ /**
32
+ * `file` = 文件级评论(不挂行),`line` = 行级评论。
33
+ *
34
+ * 为什么需要它:REST 的 `comments[]` 里 `line` 是**实际必填**的(缺了就 422),
35
+ * 想只评论某个文件必须显式声明 `subject_type: 'file'`。不写这个字段时,
36
+ * 「只给 path 不给 line」会被 GitHub 判为非法行级评论——那正是「明明参数合法却 422」的成因。
37
+ */
38
+ subject_type?: 'file' | 'line';
39
+ body: string;
40
+ }
41
+ /** REST 形状的 review 请求体 */
42
+ export interface PrReviewPayload {
43
+ commit_id?: string;
44
+ event: PrReviewEvent;
45
+ body?: string;
46
+ comments?: PrReviewCommentPayload[];
47
+ }
48
+ export interface BuildResult {
49
+ ok: boolean;
50
+ payload?: PrReviewPayload;
51
+ /** 校验失败时的**全部**问题(一次说完,不要让调用方改一条试一次) */
52
+ errors?: string[];
53
+ }
54
+ /** `createPrReview` 的返回形状 */
55
+ export interface PrReviewResult {
56
+ ok: boolean;
57
+ error?: string;
58
+ /** 是否走了 422 降级(行级评注被折叠进正文) */
59
+ degraded?: boolean;
60
+ /** 降级原因,供渲染层用 appendDegradedNote 显式呈现 */
61
+ degradedReason?: string;
62
+ data?: any;
63
+ }
64
+ /**
65
+ * 构造 REST review 请求体。
66
+ *
67
+ * @param input.event review 判定,缺省 COMMENT
68
+ * @param input.body 整体说明
69
+ * @param input.comments 行级评注(空数组等价于不传 → 调用方应走原 gh pr review 路径)
70
+ * @param input.commit_id 可选锚点 SHA
71
+ */
72
+ export declare function buildPrReviewPayload(input: {
73
+ event?: unknown;
74
+ body?: unknown;
75
+ comments?: unknown;
76
+ commit_id?: unknown;
77
+ }): BuildResult;
78
+ /**
79
+ * 422 降级:把行级评注折叠进 review 正文。
80
+ *
81
+ * 为什么必须降级而不是直接失败:422 的典型原因是「该行不在 diff 的 RIGHT 侧」
82
+ * (纯删除行、未变更的上下文行)。内容本身是对的,只是挂不到那一行上——
83
+ * 丢掉它等于让 Agent 白干一轮,而把内容塞进正文至少保证意见送达。
84
+ *
85
+ * 每条降级项都带 `path:line` 前缀,人能在正文里定位;调用方另需回传
86
+ * `degraded: true` + `degradedReason`,让「这是降级结果」显式可见。
87
+ */
88
+ export declare function foldCommentsIntoBody(comments: PrReviewCommentPayload[], baseBody?: string): string;
89
+ /**
90
+ * 判定失败是否属于「行级评注被拒绝」这一类(值得降级重试)。
91
+ *
92
+ * 判据来自 gh 实测 stderr:REST 422 会以 `HTTP 422` 或 `Validation Failed` 出现;
93
+ * 403/404/网络错误**不在**此列——那些降级也没用,必须原样抛给调用方。
94
+ */
95
+ export declare function isLineCommentRejected(errorText: string): boolean;