dsh-github-flow 0.6.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.
- package/README.en.md +98 -15
- package/README.md +101 -15
- package/docs/UPGRADE-NOTES.md +12 -11
- package/docs/architecture.md +56 -13
- package/lib/api/route-helpers.d.ts +19 -0
- package/lib/api/route-helpers.js +54 -0
- package/lib/api/routes-actions.js +75 -1
- package/lib/api/routes-lifecycle.js +8 -1
- package/lib/api/routes-overview.d.ts +26 -0
- package/lib/api/routes-overview.js +276 -34
- package/lib/api/routes-proxy.js +8 -1
- package/lib/api/routes.d.ts +11 -1
- package/lib/api/routes.js +15 -2
- package/lib/cache.d.ts +0 -21
- package/lib/cache.js +0 -37
- package/lib/client.d.ts +2 -1
- package/lib/client.js +1585 -429
- package/lib/destructive.d.ts +59 -0
- package/lib/destructive.js +285 -0
- package/lib/executor/args-guard.d.ts +17 -0
- package/lib/executor/args-guard.js +23 -0
- package/lib/executor/failure-anchors.d.ts +52 -0
- package/lib/executor/failure-anchors.js +116 -0
- package/lib/executor/pr-review.d.ts +95 -0
- package/lib/executor/pr-review.js +175 -0
- package/lib/executor/run-log.d.ts +36 -0
- package/lib/executor/run-log.js +78 -0
- package/lib/executor.d.ts +156 -3
- package/lib/executor.js +594 -28
- package/lib/github-service.d.ts +1 -1
- package/lib/main.d.ts +92 -18
- package/lib/main.js +186 -95
- package/lib/renderers/common.d.ts +0 -3
- package/lib/renderers/common.js +8 -44
- package/lib/renderers/markdown.d.ts +1 -0
- package/lib/renderers/markdown.js +1 -0
- package/lib/renderers/pr.js +26 -1
- package/lib/renderers/task.d.ts +9 -0
- package/lib/renderers/task.js +120 -0
- package/lib/review-rules.d.ts +60 -0
- package/lib/review-rules.js +204 -0
- package/lib/shared/state-badges.d.ts +42 -0
- package/lib/shared/state-badges.js +102 -0
- package/lib/task-primitives.d.ts +109 -0
- package/lib/task-primitives.js +194 -0
- package/lib/tool-tiers.d.ts +57 -0
- package/lib/tool-tiers.js +168 -0
- package/lib/tools/agent-task.js +4 -4
- package/lib/tools/api.js +18 -6
- package/lib/tools/cache.js +42 -40
- package/lib/tools/codespace.js +4 -12
- package/lib/tools/discussion.js +4 -4
- package/lib/tools/gist.d.ts +1 -39
- package/lib/tools/gist.js +4 -14
- package/lib/tools/help.js +52 -13
- package/lib/tools/issue.js +16 -28
- package/lib/tools/label.js +4 -15
- package/lib/tools/pr.d.ts +47 -0
- package/lib/tools/pr.js +170 -33
- package/lib/tools/project.js +4 -13
- package/lib/tools/release.js +11 -22
- package/lib/tools/repo.js +4 -4
- package/lib/tools/ruleset.js +9 -6
- package/lib/tools/run.js +76 -24
- package/lib/tools/secret.js +11 -17
- package/lib/tools/skill.js +18 -22
- package/lib/tools/status.js +4 -4
- package/lib/tools/task.d.ts +219 -0
- package/lib/tools/task.js +457 -0
- package/lib/types.d.ts +183 -1
- package/lib/ui/help-modal.d.ts +1 -1
- package/lib/ui/help-modal.js +20 -10
- package/lib/ui/issue-modal.js +7 -3
- package/lib/ui/pr-modal.js +50 -11
- package/lib/ui/run-modal.d.ts +26 -0
- package/lib/ui/run-modal.js +486 -0
- package/lib/ui/styles.d.ts +1 -1
- package/lib/ui/styles.js +370 -44
- package/lib/ui/sync-settings-modal.js +1 -1
- package/lib/ui/workspace-panel.js +78 -23
- package/lib/version.d.ts +1 -1
- package/lib/version.js +1 -1
- package/package.json +9 -2
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 任务级原语的纯函数层:提交归类、SemVer 推导、预算裁剪、信封封装。
|
|
3
|
+
*
|
|
4
|
+
* 为什么单独成文件:
|
|
5
|
+
* 1. 这三个原语的价值是「一轮拿到可执行结论」,结论对不对完全取决于归类与裁剪
|
|
6
|
+
* 这些纯逻辑——它们必须能脱离网络单测,否则只能靠"跑一次看看"验证;
|
|
7
|
+
* 2. `cost` 是原语存在的唯一判据(是否真的比手动串步骤省钱),必须由代码计算,
|
|
8
|
+
* 不能手写常量;
|
|
9
|
+
* 3. 工具层只负责取数与编排,不承载判定规则。
|
|
10
|
+
*/
|
|
11
|
+
const CC_RE = /^(\w+)(?:\([^)]*\))?(!)?:\s*(.+)$/;
|
|
12
|
+
/**
|
|
13
|
+
* 按 Conventional Commits 归类提交标题。
|
|
14
|
+
*
|
|
15
|
+
* 判定优先级:`!` 破坏性标记 > 类型关键字。为什么先判 `!`:
|
|
16
|
+
* `feat(api)!: drop v1` 既是 feat 也是 breaking,而它决定的是 **major** 版本,
|
|
17
|
+
* 归到 feat 会让版本号少跳一级——这是发版事故里最常见的一种。
|
|
18
|
+
*/
|
|
19
|
+
export function classifyCommits(subjects, bodies = []) {
|
|
20
|
+
const out = { breaking: [], feat: [], fix: [], perf: [], refactor: [], docs: [], other: [] };
|
|
21
|
+
const list = Array.isArray(subjects) ? subjects : [];
|
|
22
|
+
list.forEach((raw, i) => {
|
|
23
|
+
const subject = String(raw ?? '').trim();
|
|
24
|
+
if (!subject)
|
|
25
|
+
return;
|
|
26
|
+
const body = String(bodies[i] ?? '');
|
|
27
|
+
const m = CC_RE.exec(subject);
|
|
28
|
+
const type = m ? m[1].toLowerCase() : '';
|
|
29
|
+
const bang = Boolean(m && m[2]);
|
|
30
|
+
const isBreaking = bang || /BREAKING[ -]CHANGE/i.test(body);
|
|
31
|
+
if (isBreaking) {
|
|
32
|
+
out.breaking.push(subject);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
if (type === 'feat' || type === 'feature')
|
|
36
|
+
out.feat.push(subject);
|
|
37
|
+
else if (type === 'fix' || type === 'bugfix')
|
|
38
|
+
out.fix.push(subject);
|
|
39
|
+
else if (type === 'perf')
|
|
40
|
+
out.perf.push(subject);
|
|
41
|
+
else if (type === 'refactor')
|
|
42
|
+
out.refactor.push(subject);
|
|
43
|
+
else if (type === 'docs' || type === 'doc')
|
|
44
|
+
out.docs.push(subject);
|
|
45
|
+
else
|
|
46
|
+
out.other.push(subject);
|
|
47
|
+
});
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
/** 解析 `v1.2.3` / `1.2.3` / `v1.2.3-rc.1` 形式的版本号 */
|
|
51
|
+
export function parseSemver(tag) {
|
|
52
|
+
const m = /^v?(\d+)\.(\d+)\.(\d+)/i.exec(String(tag ?? '').trim());
|
|
53
|
+
if (!m)
|
|
54
|
+
return null;
|
|
55
|
+
return { major: Number(m[1]), minor: Number(m[2]), patch: Number(m[3]) };
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* 推导下一个版本号。
|
|
59
|
+
*
|
|
60
|
+
* 无 tag 时按 `0.1.0` 起步(而不是 1.0.0):新仓库的第一个版本不该被当成稳定 API。
|
|
61
|
+
* `auto` 的判据:breaking → major,feat → minor,其余 → patch。
|
|
62
|
+
*/
|
|
63
|
+
export function suggestVersion(currentTag, groups, bump = 'auto') {
|
|
64
|
+
const cur = parseSemver(currentTag) || { major: 0, minor: 0, patch: 0 };
|
|
65
|
+
const hasBreaking = groups.breaking.length > 0;
|
|
66
|
+
const hasFeat = groups.feat.length > 0;
|
|
67
|
+
let kind;
|
|
68
|
+
if (bump !== 'auto')
|
|
69
|
+
kind = bump;
|
|
70
|
+
else if (hasBreaking)
|
|
71
|
+
kind = 'major';
|
|
72
|
+
else if (hasFeat)
|
|
73
|
+
kind = 'minor';
|
|
74
|
+
else
|
|
75
|
+
kind = 'patch';
|
|
76
|
+
let next;
|
|
77
|
+
if (kind === 'major')
|
|
78
|
+
next = { major: cur.major + 1, minor: 0, patch: 0 };
|
|
79
|
+
else if (kind === 'minor')
|
|
80
|
+
next = { major: cur.major, minor: cur.minor + 1, patch: 0 };
|
|
81
|
+
else
|
|
82
|
+
next = { major: cur.major, minor: cur.minor, patch: cur.patch + 1 };
|
|
83
|
+
const reason = bump !== 'auto'
|
|
84
|
+
? `按显式指定的 ${bump} 规则递增`
|
|
85
|
+
: hasBreaking
|
|
86
|
+
? `存在 ${groups.breaking.length} 条破坏性变更 → major`
|
|
87
|
+
: hasFeat
|
|
88
|
+
? `存在 ${groups.feat.length} 条新功能 → minor`
|
|
89
|
+
: `只有修复/其它提交(fix ${groups.fix.length} / perf ${groups.perf.length})→ patch`;
|
|
90
|
+
return { version: `${next.major}.${next.minor}.${next.patch}`, bump: kind, reason };
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* 按字符预算挑选文件,保证「送进上下文的东西一定装得下」。
|
|
94
|
+
*
|
|
95
|
+
* 三条约束:
|
|
96
|
+
* 1. **按变更量降序**取——大改动更可能是问题的所在,小改动先丢;
|
|
97
|
+
* 2. 单个文件即使超预算也不硬塞(否则一个超大 diff 会把预算吃光,剩下的文件
|
|
98
|
+
* 一个都看不到)。超预算的文件只保留 path/增删行数,patch 留空并计入 omitted;
|
|
99
|
+
* 3. **条目数也要封顶**:60 个文件的元信息本身就有 3–4K 字符,只约束 patch 大小
|
|
100
|
+
* 等于把「固定开销」排除在预算之外(实测整包因此超预算 4.8K)。超出条目上限的
|
|
101
|
+
* 文件连元信息都不列,只计入 omitted。
|
|
102
|
+
*/
|
|
103
|
+
export function selectFilesByBudget(files, budgetChars, maxEntries = 40) {
|
|
104
|
+
const budget = Number.isFinite(budgetChars) && budgetChars > 0 ? Math.floor(budgetChars) : 24_000;
|
|
105
|
+
const limit = Number.isFinite(maxEntries) && maxEntries > 0 ? Math.floor(maxEntries) : 40;
|
|
106
|
+
const list = Array.isArray(files) ? [...files] : [];
|
|
107
|
+
list.sort((a, b) => b.additions + b.deletions - (a.additions + a.deletions));
|
|
108
|
+
const selected = [];
|
|
109
|
+
let used = 0;
|
|
110
|
+
let omitted = 0;
|
|
111
|
+
for (const f of list) {
|
|
112
|
+
if (selected.length >= limit) {
|
|
113
|
+
omitted++;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
const patch = typeof f.patch === 'string' ? f.patch : '';
|
|
117
|
+
const cost = patch.length + f.path.length + 40;
|
|
118
|
+
if (used + cost > budget) {
|
|
119
|
+
omitted++;
|
|
120
|
+
// 元信息仍保留:让调用方知道"还有这些文件",只是没给正文
|
|
121
|
+
selected.push({ path: f.path, additions: f.additions, deletions: f.deletions });
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
used += cost;
|
|
125
|
+
selected.push(patch ? { ...f, patch } : { path: f.path, additions: f.additions, deletions: f.deletions });
|
|
126
|
+
}
|
|
127
|
+
return { selected, omitted, chars: used };
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* 封装信封并计算 `cost.chars`。
|
|
131
|
+
*
|
|
132
|
+
* `chars` 的口径是**最终返回给模型的那份 JSON 的字符数**(不含 cost 自身,
|
|
133
|
+
* 否则会自指)。这样「原语是否省钱」才有可比的数字。
|
|
134
|
+
*/
|
|
135
|
+
export function finalizeEnvelope(summary, facts, nextActions, meta) {
|
|
136
|
+
const payload = {
|
|
137
|
+
summary,
|
|
138
|
+
facts,
|
|
139
|
+
...(meta.truncated ? { truncated: meta.truncated } : {}),
|
|
140
|
+
nextActions,
|
|
141
|
+
};
|
|
142
|
+
const chars = JSON.stringify(payload).length;
|
|
143
|
+
return {
|
|
144
|
+
...payload,
|
|
145
|
+
cost: {
|
|
146
|
+
chars,
|
|
147
|
+
ghCalls: Math.max(0, Math.floor(meta.ghCalls || 0)),
|
|
148
|
+
elapsedMs: Math.max(0, Math.floor(meta.elapsedMs || 0)),
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
/** 从日志片段里抽取 `path:line` 形态的定位锚点(供 diagnose_ci 汇总疑似文件) */
|
|
153
|
+
export function extractFileAnchors(text) {
|
|
154
|
+
const src = typeof text === 'string' ? text : '';
|
|
155
|
+
const out = [];
|
|
156
|
+
const seen = new Set();
|
|
157
|
+
// 形如 `src/a.ts:12:5` / `app/divide.go:9` / `tests/test_api.py::test_x` 的前两种
|
|
158
|
+
const re = /(?:^|\s)([\w./\\-]+\.(?:ts|tsx|js|jsx|mjs|cjs|py|go|rs|java|kt|rb|php|cs|cpp|c|h|swift|vue|svelte)):(\d+)/g;
|
|
159
|
+
let m;
|
|
160
|
+
while ((m = re.exec(src)) !== null) {
|
|
161
|
+
const file = m[1].replace(/\\/g, '/');
|
|
162
|
+
const line = Number(m[2]);
|
|
163
|
+
const key = `${file}:${line}`;
|
|
164
|
+
if (!Number.isFinite(line) || line <= 0 || seen.has(key))
|
|
165
|
+
continue;
|
|
166
|
+
seen.add(key);
|
|
167
|
+
out.push({ file, line });
|
|
168
|
+
if (out.length >= 20)
|
|
169
|
+
break;
|
|
170
|
+
}
|
|
171
|
+
return out;
|
|
172
|
+
}
|
|
173
|
+
/** 把归类结果渲染成 Release notes 草稿(按类型分组,breaking 置顶) */
|
|
174
|
+
export function renderReleaseNotes(version, groups) {
|
|
175
|
+
const section = (title, items) => items.length > 0 ? `### ${title}\n${items.map((s) => `- ${s}`).join('\n')}` : '';
|
|
176
|
+
const parts = [`## v${version}`];
|
|
177
|
+
const breaking = section('⚠️ Breaking Changes', groups.breaking);
|
|
178
|
+
if (breaking)
|
|
179
|
+
parts.push(breaking);
|
|
180
|
+
const feat = section('Features', groups.feat);
|
|
181
|
+
if (feat)
|
|
182
|
+
parts.push(feat);
|
|
183
|
+
const fix = section('Fixes', groups.fix);
|
|
184
|
+
if (fix)
|
|
185
|
+
parts.push(fix);
|
|
186
|
+
const perf = section('Performance', groups.perf);
|
|
187
|
+
if (perf)
|
|
188
|
+
parts.push(perf);
|
|
189
|
+
const rest = [...groups.refactor, ...groups.docs, ...groups.other];
|
|
190
|
+
const other = section('Others', rest);
|
|
191
|
+
if (other)
|
|
192
|
+
parts.push(other);
|
|
193
|
+
return parts.join('\n\n');
|
|
194
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 工具档位(toolsets)+ 只读档位(read_only)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么单独成文件:档位与写清单必须是**一张可读的表**。散落在 18 个工具文件里的话,
|
|
5
|
+
* 「当前到底有哪些工具、哪些是写」就无法一眼回答,也就无法被测试穷举。
|
|
6
|
+
* 本文件是纯函数 + 纯数据,零 I/O、零依赖,可离线单测。
|
|
7
|
+
*
|
|
8
|
+
* 默认零破坏:`toolsets` 缺省 → full 全量 18 个;`read_only` 缺省 → 不剪任何 action。
|
|
9
|
+
*/
|
|
10
|
+
/** 工具的最小结构面(只需要 name / description / parameters 三个字段)。 */
|
|
11
|
+
export interface ToolLike {
|
|
12
|
+
name: string;
|
|
13
|
+
description?: string;
|
|
14
|
+
parameters?: {
|
|
15
|
+
type?: string;
|
|
16
|
+
properties?: Record<string, any>;
|
|
17
|
+
required?: string[];
|
|
18
|
+
[key: string]: any;
|
|
19
|
+
};
|
|
20
|
+
[key: string]: any;
|
|
21
|
+
}
|
|
22
|
+
export interface TierConfig {
|
|
23
|
+
toolsets?: string[];
|
|
24
|
+
read_only?: boolean;
|
|
25
|
+
}
|
|
26
|
+
/** 预设档位。`full` 不代表具体名单,由 resolveToolNames 特判为「全部」。 */
|
|
27
|
+
export declare const TOOL_PRESETS: Record<string, string[]>;
|
|
28
|
+
/**
|
|
29
|
+
* 写 action 清单:只读档位下这些 action 会从 enum 里被**注册期剔除**(不是运行时 if)。
|
|
30
|
+
*
|
|
31
|
+
* 判定口径:逐个 `case '...'` 分支看它是否产生远端写、本地落盘或不可逆副作用;
|
|
32
|
+
* 读不准的一律保守归为写(例如 `run download` 会往本地磁盘写文件,计入写)。
|
|
33
|
+
* `github_api` 记 `'*'`:端点与 method 均可任意,无法降级,只读模式下整体剔除。
|
|
34
|
+
*/
|
|
35
|
+
export declare const WRITE_ACTIONS: Record<string, string[] | '*'>;
|
|
36
|
+
/**
|
|
37
|
+
* 把 `toolsets` 配置解析成具体工具名清单。
|
|
38
|
+
*
|
|
39
|
+
* - `undefined` / 空数组 → 返回 `'all'`(默认全量,零破坏)
|
|
40
|
+
* - 预设名与裸工具名可混用,取并集
|
|
41
|
+
* - 未知名字:console.warn 一次并忽略。**不静默**——静默减少工具会让模型
|
|
42
|
+
* 「看不见工具」却不知道为什么,是比多注册一个工具更难排查的故障。
|
|
43
|
+
*/
|
|
44
|
+
export declare function resolveToolNames(toolsets: string[] | undefined, allNames: string[]): string[] | 'all';
|
|
45
|
+
/**
|
|
46
|
+
* 按档位裁剪工具列表(纯函数,不修改入参)。
|
|
47
|
+
*
|
|
48
|
+
* 只读档位下对**混合工具**剪 action enum 而不是整只剔除:模型在 schema 层就看不到
|
|
49
|
+
* 写 action,宿主的参数校验会直接拒绝,可审计且不需要运行时分支。
|
|
50
|
+
*/
|
|
51
|
+
export declare function applyTier(tools: ToolLike[], cfg: TierConfig): {
|
|
52
|
+
kept: ToolLike[];
|
|
53
|
+
/** 被整体剔除的工具名(只读模式下的 github_api,或档位外的工具) */
|
|
54
|
+
dropped: string[];
|
|
55
|
+
/** 被剪掉写 action 的工具名 → 剪掉前的完整 enum(供诊断与测试消费) */
|
|
56
|
+
pruned: Record<string, string[]>;
|
|
57
|
+
};
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 工具档位(toolsets)+ 只读档位(read_only)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么单独成文件:档位与写清单必须是**一张可读的表**。散落在 18 个工具文件里的话,
|
|
5
|
+
* 「当前到底有哪些工具、哪些是写」就无法一眼回答,也就无法被测试穷举。
|
|
6
|
+
* 本文件是纯函数 + 纯数据,零 I/O、零依赖,可离线单测。
|
|
7
|
+
*
|
|
8
|
+
* 默认零破坏:`toolsets` 缺省 → full 全量 18 个;`read_only` 缺省 → 不剪任何 action。
|
|
9
|
+
*/
|
|
10
|
+
/** 预设档位。`full` 不代表具体名单,由 resolveToolNames 特判为「全部」。 */
|
|
11
|
+
export const TOOL_PRESETS = {
|
|
12
|
+
full: [],
|
|
13
|
+
/**
|
|
14
|
+
* 最小可用面。
|
|
15
|
+
*
|
|
16
|
+
* s8 之后 core 的取向变了:**用 3 个任务替代 15 个领域工具**——
|
|
17
|
+
* `github_task`(审 PR / 诊断 CI / 准备发版)+ `github_repo`(认仓库、读文件)
|
|
18
|
+
* + `github_help`(查指南)。要细粒度控制时再显式挂 github_pr / github_run。
|
|
19
|
+
*/
|
|
20
|
+
core: ['github_task', 'github_repo', 'github_help'],
|
|
21
|
+
/** 流水线值守:CI 诊断与排障所需的只读面更大 */
|
|
22
|
+
ci: [
|
|
23
|
+
'github_run', 'github_pr', 'github_issue', 'github_repo',
|
|
24
|
+
'github_status', 'github_ruleset', 'github_help',
|
|
25
|
+
],
|
|
26
|
+
/** 社区维护:议题/评审/发版/标签/看板 */
|
|
27
|
+
maintain: [
|
|
28
|
+
'github_issue', 'github_pr', 'github_label', 'github_release',
|
|
29
|
+
'github_discussion', 'github_project', 'github_skill', 'github_help',
|
|
30
|
+
],
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* 写 action 清单:只读档位下这些 action 会从 enum 里被**注册期剔除**(不是运行时 if)。
|
|
34
|
+
*
|
|
35
|
+
* 判定口径:逐个 `case '...'` 分支看它是否产生远端写、本地落盘或不可逆副作用;
|
|
36
|
+
* 读不准的一律保守归为写(例如 `run download` 会往本地磁盘写文件,计入写)。
|
|
37
|
+
* `github_api` 记 `'*'`:端点与 method 均可任意,无法降级,只读模式下整体剔除。
|
|
38
|
+
*/
|
|
39
|
+
export const WRITE_ACTIONS = {
|
|
40
|
+
github_pr: ['checkout', 'create', 'review', 'merge', 'ready', 'revert', 'update_branch', 'comment', 'edit'],
|
|
41
|
+
github_issue: ['create', 'edit', 'develop', 'comment', 'close', 'reopen', 'pin', 'unpin', 'lock', 'unlock', 'delete', 'transfer'],
|
|
42
|
+
github_run: ['workflow_enable', 'workflow_disable', 'trigger', 'rerun', 'cancel', 'download'],
|
|
43
|
+
github_repo: ['sync'],
|
|
44
|
+
github_cache: ['delete'],
|
|
45
|
+
github_release: ['create', 'upload', 'delete', 'download'],
|
|
46
|
+
github_secret: ['set', 'delete'],
|
|
47
|
+
github_label: ['create', 'edit', 'delete', 'clone'],
|
|
48
|
+
github_gist: ['create', 'edit', 'delete'],
|
|
49
|
+
github_project: ['item-add', 'item-create', 'item-delete'],
|
|
50
|
+
github_codespace: ['stop', 'delete'],
|
|
51
|
+
github_discussion: ['create', 'edit', 'comment'],
|
|
52
|
+
github_agent_task: ['create'],
|
|
53
|
+
github_skill: ['install', 'publish', 'update'],
|
|
54
|
+
// 任务原语里只有 review_pr 会写远端(且需 write+confirm 双门);diagnose_ci / prepare_release 全只读
|
|
55
|
+
github_task: ['review_pr'],
|
|
56
|
+
github_ruleset: [],
|
|
57
|
+
github_status: [],
|
|
58
|
+
github_help: [],
|
|
59
|
+
github_api: '*',
|
|
60
|
+
};
|
|
61
|
+
/**
|
|
62
|
+
* 把 `toolsets` 配置解析成具体工具名清单。
|
|
63
|
+
*
|
|
64
|
+
* - `undefined` / 空数组 → 返回 `'all'`(默认全量,零破坏)
|
|
65
|
+
* - 预设名与裸工具名可混用,取并集
|
|
66
|
+
* - 未知名字:console.warn 一次并忽略。**不静默**——静默减少工具会让模型
|
|
67
|
+
* 「看不见工具」却不知道为什么,是比多注册一个工具更难排查的故障。
|
|
68
|
+
*/
|
|
69
|
+
export function resolveToolNames(toolsets, allNames) {
|
|
70
|
+
if (!toolsets || toolsets.length === 0)
|
|
71
|
+
return 'all';
|
|
72
|
+
const picked = new Set();
|
|
73
|
+
const unknown = [];
|
|
74
|
+
for (const raw of toolsets) {
|
|
75
|
+
const name = typeof raw === 'string' ? raw.trim() : '';
|
|
76
|
+
if (!name)
|
|
77
|
+
continue;
|
|
78
|
+
const preset = TOOL_PRESETS[name];
|
|
79
|
+
if (preset) {
|
|
80
|
+
if (name === 'full')
|
|
81
|
+
return 'all';
|
|
82
|
+
for (const t of preset)
|
|
83
|
+
picked.add(t);
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (allNames.includes(name)) {
|
|
87
|
+
picked.add(name);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
unknown.push(name);
|
|
91
|
+
}
|
|
92
|
+
if (unknown.length > 0) {
|
|
93
|
+
console.warn(`[dsh-github-flow] toolsets 中存在未知档位/工具名:${unknown.join(', ')}。` +
|
|
94
|
+
`可用档位:${Object.keys(TOOL_PRESETS).join(' / ')};也可直接写工具名(如 github_pr)。已忽略未知项。`);
|
|
95
|
+
}
|
|
96
|
+
// 过滤掉本版本不存在的预设成员(预设表可能超前于实际注册的工具)
|
|
97
|
+
return allNames.filter((n) => picked.has(n));
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* 按档位裁剪工具列表(纯函数,不修改入参)。
|
|
101
|
+
*
|
|
102
|
+
* 只读档位下对**混合工具**剪 action enum 而不是整只剔除:模型在 schema 层就看不到
|
|
103
|
+
* 写 action,宿主的参数校验会直接拒绝,可审计且不需要运行时分支。
|
|
104
|
+
*/
|
|
105
|
+
export function applyTier(tools, cfg) {
|
|
106
|
+
const allNames = tools.map((t) => t.name);
|
|
107
|
+
const names = resolveToolNames(cfg.toolsets, allNames);
|
|
108
|
+
let kept = names === 'all' ? [...tools] : tools.filter((t) => names.includes(t.name));
|
|
109
|
+
const dropped = allNames.filter((n) => !kept.some((t) => t.name === n));
|
|
110
|
+
const pruned = {};
|
|
111
|
+
if (cfg.read_only !== true)
|
|
112
|
+
return { kept, dropped, pruned };
|
|
113
|
+
const readOnlyKept = [];
|
|
114
|
+
for (const tool of kept) {
|
|
115
|
+
const writes = WRITE_ACTIONS[tool.name] ?? [];
|
|
116
|
+
if (writes === '*') {
|
|
117
|
+
dropped.push(tool.name);
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
const params = tool.parameters;
|
|
121
|
+
const actionSchema = params?.properties?.action;
|
|
122
|
+
if (!params || !Array.isArray(actionSchema?.enum) || writes.length === 0) {
|
|
123
|
+
// 没有 action 维度的工具(github_help / github_status / github_ruleset)原样保留
|
|
124
|
+
readOnlyKept.push(tool);
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
const keptEnum = actionSchema.enum.filter((a) => !writes.includes(a));
|
|
128
|
+
if (keptEnum.length === 0) {
|
|
129
|
+
// 整只工具都是写操作 → 剔除,而不是留一个空 enum
|
|
130
|
+
dropped.push(tool.name);
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
pruned[tool.name] = [...actionSchema.enum];
|
|
134
|
+
const allowed = new Set(keptEnum);
|
|
135
|
+
readOnlyKept.push({
|
|
136
|
+
...tool,
|
|
137
|
+
description: `[只读模式] ${tool.description ?? ''}`.trim(),
|
|
138
|
+
parameters: {
|
|
139
|
+
...params,
|
|
140
|
+
properties: {
|
|
141
|
+
...params.properties,
|
|
142
|
+
action: { ...actionSchema, enum: keptEnum },
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
/**
|
|
146
|
+
* 只读档位的**执行期**拦截。
|
|
147
|
+
*
|
|
148
|
+
* 为什么剪了 enum 还要再拦一次:本插件的工具是手写 descriptor,不是
|
|
149
|
+
* `defineTool` 产物——宿主只把 parameters 当 JSON Schema 发给模型,**不做执行前
|
|
150
|
+
* 参数校验**。所以模型只要发出一个被剪掉的 action(最常见的是从历史消息里复读,
|
|
151
|
+
* 或用户手工构造),执行路径会照常走到 `gh pr merge`。实测确认过:
|
|
152
|
+
* `applyTier([pr], {read_only:true})` 之后调 `{action:'merge', confirm:true}`
|
|
153
|
+
* 仍然会真的发起合并。只剪 schema 等于只做了「看不见」,没做「做不到」。
|
|
154
|
+
*
|
|
155
|
+
* 位置在闸门之外(applyTier 在工厂 wrapWithGate 之后套),所以先拒只读违规,
|
|
156
|
+
* 再走 confirm 闸门。
|
|
157
|
+
*/
|
|
158
|
+
async execute(args, ctx) {
|
|
159
|
+
const action = args?.action;
|
|
160
|
+
if (typeof action === 'string' && !allowed.has(action)) {
|
|
161
|
+
throw new Error(`只读模式(read_only: true)下 ${tool.name} 不允许 action "${action}";可用: ${keptEnum.join(' / ')}`);
|
|
162
|
+
}
|
|
163
|
+
return tool.execute(args, ctx);
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
return { kept: readOnlyKept, dropped, pruned };
|
|
168
|
+
}
|
package/lib/tools/agent-task.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { wrapWithGate } from '../destructive.js';
|
|
1
2
|
import { renderAgentTaskMarkdown } from '../renderers/markdown.js';
|
|
2
3
|
function normalizeOutput(data) {
|
|
3
4
|
if (data && typeof data === 'object' && !Array.isArray(data)) {
|
|
@@ -10,7 +11,7 @@ function normalizeOutput(data) {
|
|
|
10
11
|
}
|
|
11
12
|
const AGENT_TASK_JSON_FIELDS = 'completedAt,createdAt,id,name,pullRequestNumber,pullRequestState,pullRequestTitle,pullRequestUrl,repository,state,updatedAt,user';
|
|
12
13
|
export function createAgentTaskTool(executor) {
|
|
13
|
-
return {
|
|
14
|
+
return wrapWithGate({
|
|
14
15
|
name: 'github_agent_task',
|
|
15
16
|
description: '管理 GitHub Copilot Coding Agent 云端智能体任务会话:查看已分派任务列表、检视任务状态与会话日志、向云端智能体发起新编码与重构任务。基于 gh CLI preview 特性。',
|
|
16
17
|
parameters: {
|
|
@@ -59,8 +60,7 @@ export function createAgentTaskTool(executor) {
|
|
|
59
60
|
},
|
|
60
61
|
},
|
|
61
62
|
async execute(args, execContext) {
|
|
62
|
-
const cwd
|
|
63
|
-
const sessionId = execContext?.sessionId || execContext?.session?.id || process.env.DSH_SESSION_ID;
|
|
63
|
+
const { cwd, sessionId } = executor.resolveExecScope(execContext);
|
|
64
64
|
const repoArgs = args.repo ? ['-R', args.repo] : [];
|
|
65
65
|
const runOpts = { cwd, sessionId };
|
|
66
66
|
switch (args.action) {
|
|
@@ -131,5 +131,5 @@ export function createAgentTaskTool(executor) {
|
|
|
131
131
|
throw new Error(`未知的 Agent Task 操作: ${args.action}`);
|
|
132
132
|
}
|
|
133
133
|
},
|
|
134
|
-
};
|
|
134
|
+
});
|
|
135
135
|
}
|
package/lib/tools/api.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { wrapWithGate } from '../destructive.js';
|
|
1
2
|
function normalizeOutput(data) {
|
|
2
3
|
if (data && typeof data === 'object' && !Array.isArray(data)) {
|
|
3
4
|
return data;
|
|
@@ -8,7 +9,7 @@ function normalizeOutput(data) {
|
|
|
8
9
|
return { result: data !== undefined && data !== null ? data : '' };
|
|
9
10
|
}
|
|
10
11
|
export function createApiTool(executor) {
|
|
11
|
-
return {
|
|
12
|
+
return wrapWithGate({
|
|
12
13
|
name: 'github_api',
|
|
13
14
|
description: '通过 GitHub CLI 认证环境直接调用任意 GitHub REST 或 GraphQL API 端点(兜底逃生门)。支持 --jq 过滤;fields 一律按字面字符串传参,避免 gh 的 -F 类型转换改写请求体。',
|
|
14
15
|
parameters: {
|
|
@@ -57,13 +58,24 @@ export function createApiTool(executor) {
|
|
|
57
58
|
},
|
|
58
59
|
},
|
|
59
60
|
async execute(args, execContext) {
|
|
60
|
-
const cwd
|
|
61
|
-
|
|
62
|
-
|
|
61
|
+
const { cwd, sessionId } = executor.resolveExecScope(execContext);
|
|
62
|
+
// schema 声明了 required: ['endpoint'],但宿主校验不一定兜住(模型可能传空串)。
|
|
63
|
+
// 不校验时 `gh api ""` 退出码 0 并返回整个 API 索引 JSON,模型会误以为"查到了数据"。
|
|
64
|
+
const endpoint = typeof args.endpoint === 'string' ? args.endpoint.trim() : '';
|
|
65
|
+
if (!endpoint) {
|
|
66
|
+
throw new Error('endpoint 为必填参数:请传入 REST 路径(如 "repos/owner/repo/pulls" 或 "user")或 graphql 查询。');
|
|
67
|
+
}
|
|
68
|
+
// 只拒绝以 - 开头的形态:那才是真正的注入面(会被 gh 当成 flag 吃掉)。
|
|
69
|
+
// 注意不要要求"必须含 /"——`user` / `rate_limit` / `notifications` 都是合法单段端点,
|
|
70
|
+
// 实测 `gh api user --jq .login` 正常返回;按"/"判形态会误杀这些合法调用。
|
|
71
|
+
if (endpoint.startsWith('-')) {
|
|
72
|
+
throw new Error(`endpoint 不能以 - 开头(会被 gh 当作命令行 flag):"${endpoint}"。`);
|
|
73
|
+
}
|
|
74
|
+
const cmd = ['api', endpoint];
|
|
63
75
|
// gh api 默认在提供 -f/-F 时会自动将 HTTP 方法隐式切换为 POST。
|
|
64
76
|
// 若调用方未指定 method,且 endpoint 不是以 graphql 开头,则按契约默认为 GET。
|
|
65
77
|
// 必须显式传入 -X,防止带有 fields 参数的 GET 请求被 gh 自动改写为 POST 导致 422 报错。
|
|
66
|
-
const isGraphql =
|
|
78
|
+
const isGraphql = endpoint.toLowerCase().startsWith('graphql');
|
|
67
79
|
const method = args.method || (isGraphql ? 'POST' : 'GET');
|
|
68
80
|
cmd.push('-X', method);
|
|
69
81
|
if (args.jq) {
|
|
@@ -97,5 +109,5 @@ export function createApiTool(executor) {
|
|
|
97
109
|
throw new Error(res.error);
|
|
98
110
|
return normalizeOutput(res.data !== undefined ? res.data : res.rawOutput);
|
|
99
111
|
},
|
|
100
|
-
};
|
|
112
|
+
});
|
|
101
113
|
}
|
package/lib/tools/cache.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { wrapWithGate } from '../destructive.js';
|
|
1
2
|
import { renderCacheMarkdown } from '../renderers/markdown.js';
|
|
2
3
|
function normalizeOutput(data) {
|
|
3
4
|
if (data && typeof data === 'object' && !Array.isArray(data)) {
|
|
@@ -9,9 +10,9 @@ function normalizeOutput(data) {
|
|
|
9
10
|
return { result: data !== undefined && data !== null ? data : '' };
|
|
10
11
|
}
|
|
11
12
|
export function createCacheTool(executor) {
|
|
12
|
-
return {
|
|
13
|
+
return wrapWithGate({
|
|
13
14
|
name: 'github_cache',
|
|
14
|
-
description: '管理 GitHub Actions 构建缓存 (Caches):查看缓存列表与占用容量、按 ID 或 Key
|
|
15
|
+
description: '管理 GitHub Actions 构建缓存 (Caches):查看缓存列表与占用容量、按 ID 或 Key 删除指定缓存、以及全量清空仓库缓存。删除不可逆,delete 的三种方式(cache_id / key / all)均需 confirm: true。',
|
|
15
16
|
parameters: {
|
|
16
17
|
type: 'object',
|
|
17
18
|
properties: {
|
|
@@ -56,7 +57,7 @@ export function createCacheTool(executor) {
|
|
|
56
57
|
},
|
|
57
58
|
confirm: {
|
|
58
59
|
type: 'boolean',
|
|
59
|
-
description: '高危破坏性操作确认标志(delete
|
|
60
|
+
description: '高危破坏性操作确认标志(delete 的三态 cache_id / key / all 均必需)。未传 true 时输出影响评估报告并拦截。',
|
|
60
61
|
},
|
|
61
62
|
},
|
|
62
63
|
required: ['action'],
|
|
@@ -68,8 +69,7 @@ export function createCacheTool(executor) {
|
|
|
68
69
|
},
|
|
69
70
|
},
|
|
70
71
|
async execute(args, execContext) {
|
|
71
|
-
const cwd
|
|
72
|
-
const sessionId = execContext?.sessionId || execContext?.session?.id || process.env.DSH_SESSION_ID;
|
|
72
|
+
const { cwd, sessionId } = executor.resolveExecScope(execContext);
|
|
73
73
|
let repoTarget = args.repo;
|
|
74
74
|
if (!repoTarget) {
|
|
75
75
|
const meta = await executor.getRepoMetadata(cwd, sessionId);
|
|
@@ -120,19 +120,26 @@ export function createCacheTool(executor) {
|
|
|
120
120
|
return normalizeOutput(rawData || []);
|
|
121
121
|
}
|
|
122
122
|
case 'delete': {
|
|
123
|
-
//
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
}
|
|
123
|
+
// ---- 第一段:选择器归一化 + 形态校验(在任何执行之前)----
|
|
124
|
+
const hasAll = args.all === true;
|
|
125
|
+
const cacheIdStr = args.cache_id !== undefined && args.cache_id !== null ? String(args.cache_id).trim() : '';
|
|
126
|
+
const keyStr = args.key !== undefined && args.key !== null ? String(args.key).trim() : '';
|
|
127
|
+
const hasId = cacheIdStr !== '';
|
|
128
|
+
const hasKey = keyStr !== '';
|
|
129
|
+
if (!hasAll && !hasId && !hasKey) {
|
|
130
|
+
throw new Error('删除缓存时必须指定 `cache_id`、`key` 或 `all: true`(三条删除分支均需配合 confirm: true)。');
|
|
131
|
+
}
|
|
132
|
+
// 形态校验必须先于执行:不校验时 `cache_id: "--all"` 会被当作 flag 透传,
|
|
133
|
+
// 等价于清空全仓缓存;而 `gh cache delete --all` 自身没有二次确认,绕过即秒清。
|
|
134
|
+
if (hasId && !/^\d+$/.test(cacheIdStr)) {
|
|
135
|
+
throw new Error(`cache_id 必须是纯数字的缓存 ID(实际收到 "${cacheIdStr}")。按 key 删除请改用 key 参数,全量清空请用 all: true。`);
|
|
136
|
+
}
|
|
137
|
+
if (hasKey && (keyStr.startsWith('-') || keyStr.length > 512)) {
|
|
138
|
+
throw new Error('key 不能以 - 开头(防被当作命令行 flag),且长度不得超过 512 字符。');
|
|
139
|
+
}
|
|
140
|
+
// ---- 执行段(argv 形状与改动前保持一致)----
|
|
141
|
+
// confirm 闸门由 wrapWithGate 在工厂入口统一处理,三条分支共用同一套语义。
|
|
142
|
+
if (hasAll) {
|
|
136
143
|
const delCmd = ['cache', 'delete', '--all', '--succeed-on-no-caches', ...repoArgs];
|
|
137
144
|
if (args.ref) {
|
|
138
145
|
delCmd.push('-r', String(args.ref));
|
|
@@ -147,40 +154,35 @@ export function createCacheTool(executor) {
|
|
|
147
154
|
detail: res.rawOutput || '',
|
|
148
155
|
};
|
|
149
156
|
}
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
const delCmd = ['cache', 'delete', String(args.cache_id), ...repoArgs];
|
|
157
|
+
if (hasId) {
|
|
158
|
+
const delCmd = ['cache', 'delete', cacheIdStr, ...repoArgs];
|
|
153
159
|
const res = await executor.run(delCmd, runOpts);
|
|
154
160
|
if (!res.ok)
|
|
155
161
|
throw new Error(res.error);
|
|
156
162
|
return {
|
|
157
163
|
success: true,
|
|
158
|
-
cache_id:
|
|
159
|
-
message: `🗑️ 成功删除仓库 ${repoTarget} 中 ID 为 "${
|
|
164
|
+
cache_id: cacheIdStr,
|
|
165
|
+
message: `🗑️ 成功删除仓库 ${repoTarget} 中 ID 为 "${cacheIdStr}" 的 Actions 缓存`,
|
|
160
166
|
detail: res.rawOutput || '',
|
|
161
167
|
};
|
|
162
168
|
}
|
|
163
|
-
|
|
164
|
-
if (args.
|
|
165
|
-
|
|
166
|
-
if (args.ref) {
|
|
167
|
-
delCmd.push('-r', String(args.ref));
|
|
168
|
-
}
|
|
169
|
-
const res = await executor.run(delCmd, runOpts);
|
|
170
|
-
if (!res.ok)
|
|
171
|
-
throw new Error(res.error);
|
|
172
|
-
return {
|
|
173
|
-
success: true,
|
|
174
|
-
key: String(args.key),
|
|
175
|
-
message: `🗑️ 成功删除仓库 ${repoTarget} 中 Key 为 "${args.key}" 的 Actions 缓存` + (args.ref ? ` (Ref: ${args.ref})` : ''),
|
|
176
|
-
detail: res.rawOutput || '',
|
|
177
|
-
};
|
|
169
|
+
const delCmd = ['cache', 'delete', keyStr, ...repoArgs];
|
|
170
|
+
if (args.ref) {
|
|
171
|
+
delCmd.push('-r', String(args.ref));
|
|
178
172
|
}
|
|
179
|
-
|
|
173
|
+
const res = await executor.run(delCmd, runOpts);
|
|
174
|
+
if (!res.ok)
|
|
175
|
+
throw new Error(res.error);
|
|
176
|
+
return {
|
|
177
|
+
success: true,
|
|
178
|
+
key: keyStr,
|
|
179
|
+
message: `🗑️ 成功删除仓库 ${repoTarget} 中 Key 为 "${keyStr}" 的 Actions 缓存` + (args.ref ? ` (Ref: ${args.ref})` : ''),
|
|
180
|
+
detail: res.rawOutput || '',
|
|
181
|
+
};
|
|
180
182
|
}
|
|
181
183
|
default:
|
|
182
184
|
throw new Error(`不支持的 cache 操作: "${args.action}"。可选值: list, delete。`);
|
|
183
185
|
}
|
|
184
186
|
},
|
|
185
|
-
};
|
|
187
|
+
});
|
|
186
188
|
}
|