@opengsd/gsd-core 1.4.0-rc.1 → 1.4.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/.claude-plugin/plugin.json +23 -0
- package/GEMINI.md +53 -0
- package/agents/gsd-ai-researcher.md +1 -1
- package/agents/gsd-code-fixer.md +1 -1
- package/agents/gsd-code-reviewer.md +1 -1
- package/agents/gsd-domain-researcher.md +1 -1
- package/agents/gsd-eval-auditor.md +1 -1
- package/agents/gsd-eval-planner.md +1 -1
- package/agents/gsd-framework-selector.md +1 -1
- package/agents/gsd-nyquist-auditor.md +1 -1
- package/agents/gsd-pattern-mapper.md +1 -1
- package/agents/gsd-security-auditor.md +1 -1
- package/agents/gsd-ui-auditor.md +1 -1
- package/agents/gsd-ui-checker.md +1 -1
- package/agents/gsd-ui-researcher.md +1 -1
- package/agents/gsd-user-profiler.md +1 -1
- package/bin/install.js +1911 -354
- package/commands/gsd/autonomous.md +2 -0
- package/commands/gsd/execute-phase.md +2 -0
- package/commands/gsd/plan-phase.md +2 -0
- package/commands/gsd/progress.md +1 -0
- package/commands/gsd/stats.md +1 -0
- package/commands/gsd/update.md +3 -2
- package/gemini-extension.json +6 -0
- package/gsd-core/bin/check-latest-version.cjs +58 -4
- package/gsd-core/bin/lib/install-profiles.cjs +58 -0
- package/gsd-core/bin/lib/installer-migration-report.cjs +3 -0
- package/gsd-core/bin/lib/installer-migrations/000-first-time-baseline.cjs +1 -1
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +67 -9
- package/gsd-core/bin/lib/runtime-config-adapter-registry.cjs +56 -0
- package/gsd-core/bin/lib/runtime-homes.cjs +32 -11
- package/gsd-core/bin/lib/shell-command-projection.cjs +6 -0
- package/gsd-core/bin/lib/surface.cjs +54 -11
- package/gsd-core/workflows/help/modes/full.md +2 -1
- package/gsd-core/workflows/review.md +2 -2
- package/gsd-core/workflows/update.md +32 -5
- package/hooks/dist/gsd-config-reload.js +133 -0
- package/hooks/dist/gsd-cursor-post-tool.js +75 -0
- package/hooks/dist/gsd-cursor-session-start.js +52 -0
- package/hooks/dist/managed-hooks-registry.cjs +3 -0
- package/hooks/gsd-config-reload.js +133 -0
- package/hooks/gsd-cursor-post-tool.js +75 -0
- package/hooks/gsd-cursor-session-start.js +52 -0
- package/hooks/hooks.json +69 -0
- package/hooks/managed-hooks-registry.cjs +3 -0
- package/package.json +5 -1
- package/scripts/build-hooks.js +7 -0
- package/scripts/changeset/cli.cjs +53 -10
- package/scripts/ci-test-scope.cjs +120 -18
- package/scripts/issue-dedupe.cjs +278 -0
- package/scripts/lint-test-file-count.allowlist.json +1 -0
- package/scripts/release-notes/discord-release-summary.cjs +373 -0
- package/scripts/research-profiles.cjs +3 -3
- package/scripts/sync-manifest-versions.cjs +119 -0
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Constants
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
const POSSIBLE_DUPLICATE_LABEL = 'possible-duplicate';
|
|
9
|
+
const HUMAN_REVIEW_LABEL = 'needs-maintainer-review';
|
|
10
|
+
const CHALLENGE_MARKER = '<!-- gsd-dedupe-challenge -->';
|
|
11
|
+
const DEFAULT_WINDOW_HOURS = 24;
|
|
12
|
+
const DEFAULT_THRESHOLD = 0.6;
|
|
13
|
+
const DEFAULT_MAX_CANDIDATES = 5;
|
|
14
|
+
const MIN_TOKEN_LENGTH = 3;
|
|
15
|
+
|
|
16
|
+
const EXEMPT_LABELS = [
|
|
17
|
+
'priority: critical',
|
|
18
|
+
'pinned',
|
|
19
|
+
'confirmed-bug',
|
|
20
|
+
'confirmed',
|
|
21
|
+
'fix-pending',
|
|
22
|
+
'needs-maintainer-review',
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
const STOPWORDS = new Set([
|
|
26
|
+
'the', 'a', 'an', 'and', 'or', 'but', 'if', 'then', 'is', 'are', 'was',
|
|
27
|
+
'be', 'to', 'of', 'in', 'on', 'for', 'with', 'as', 'at', 'by', 'from',
|
|
28
|
+
'this', 'that', 'it', 'its', 'not', 'no', 'when', 'what', 'why', 'how',
|
|
29
|
+
'does', 'do', 'doing', 'did', 'can', 'will', 'would', 'should',
|
|
30
|
+
'i', 'we', 'you', 'your', 'my', 'me',
|
|
31
|
+
'issue', 'bug', 'error', 'problem', 'feature', 'request',
|
|
32
|
+
'help', 'support', 'please', 'question',
|
|
33
|
+
'after', 'before', 'into', 'only', 'then', 'than', 'them', 'they',
|
|
34
|
+
'use', 'used', 'using',
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// tokenize(title) -> string[]
|
|
39
|
+
//
|
|
40
|
+
// Lowercase the title, replace any non-[a-z0-9] run with a space, split on
|
|
41
|
+
// whitespace, drop tokens shorter than MIN_TOKEN_LENGTH, drop STOPWORDS, and
|
|
42
|
+
// dedupe while preserving stable first-occurrence order.
|
|
43
|
+
//
|
|
44
|
+
// Non-string, null, or empty input returns []. Must not throw on any input.
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
function tokenize(title) {
|
|
48
|
+
if (typeof title !== 'string' || !title) return [];
|
|
49
|
+
|
|
50
|
+
const normalized = title.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
|
|
51
|
+
if (!normalized) return [];
|
|
52
|
+
|
|
53
|
+
const seen = new Set();
|
|
54
|
+
const result = [];
|
|
55
|
+
|
|
56
|
+
for (const token of normalized.split(' ')) {
|
|
57
|
+
if (!token || token.length < MIN_TOKEN_LENGTH) continue;
|
|
58
|
+
if (STOPWORDS.has(token)) continue;
|
|
59
|
+
if (seen.has(token)) continue;
|
|
60
|
+
seen.add(token);
|
|
61
|
+
result.push(token);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return result;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
// diceSimilarity(aTokens, bTokens) -> number 0..1
|
|
69
|
+
//
|
|
70
|
+
// Sørensen–Dice over the token sets: 2 * |A ∩ B| / (|A| + |B|).
|
|
71
|
+
// Both inputs are treated as sets (duplicates ignored). Empty either side -> 0.
|
|
72
|
+
// Identical sets -> 1.
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
function diceSimilarity(aTokens, bTokens) {
|
|
76
|
+
const setA = new Set(aTokens);
|
|
77
|
+
const setB = new Set(bTokens);
|
|
78
|
+
|
|
79
|
+
if (setA.size === 0 || setB.size === 0) return 0;
|
|
80
|
+
|
|
81
|
+
let intersection = 0;
|
|
82
|
+
for (const token of setA) {
|
|
83
|
+
if (setB.has(token)) intersection += 1;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return (2 * intersection) / (setA.size + setB.size);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
// scoreCandidates(newTitle, candidates, opts) -> [{number, title, score}]
|
|
91
|
+
//
|
|
92
|
+
// opts: { threshold=DEFAULT_THRESHOLD, limit=DEFAULT_MAX_CANDIDATES, excludeNumber }
|
|
93
|
+
//
|
|
94
|
+
// Tokenizes newTitle once. If no tokens -> []. Filters out null/garbage
|
|
95
|
+
// candidates, those missing a number, and the excluded number. Scores each
|
|
96
|
+
// using diceSimilarity. Keeps score >= threshold. Sorts DESC by score,
|
|
97
|
+
// tie-break ASC by number. Caps to limit.
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
function scoreCandidates(newTitle, candidates, opts) {
|
|
101
|
+
const threshold = (opts && opts.threshold != null) ? opts.threshold : DEFAULT_THRESHOLD;
|
|
102
|
+
const limit = (opts && opts.limit != null) ? opts.limit : DEFAULT_MAX_CANDIDATES;
|
|
103
|
+
const excludeNumber = opts && opts.excludeNumber;
|
|
104
|
+
|
|
105
|
+
const newTokens = tokenize(newTitle);
|
|
106
|
+
if (newTokens.length === 0) return [];
|
|
107
|
+
|
|
108
|
+
const scored = [];
|
|
109
|
+
|
|
110
|
+
const safeCandidates = Array.isArray(candidates) ? candidates : [];
|
|
111
|
+
for (const candidate of safeCandidates) {
|
|
112
|
+
if (!candidate || typeof candidate !== 'object') continue;
|
|
113
|
+
if (!(typeof candidate.number === 'number' && Number.isFinite(candidate.number))) continue;
|
|
114
|
+
if (candidate.number === excludeNumber) continue;
|
|
115
|
+
|
|
116
|
+
const score = diceSimilarity(newTokens, tokenize(candidate.title));
|
|
117
|
+
if (score < threshold) continue;
|
|
118
|
+
|
|
119
|
+
scored.push({ number: candidate.number, title: candidate.title, score });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
scored.sort((a, b) => {
|
|
123
|
+
if (Math.abs(a.score - b.score) > 1e-9) return b.score - a.score;
|
|
124
|
+
return a.number - b.number;
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
return scored.slice(0, limit);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
// renderChallengeComment(candidates, opts) -> string
|
|
132
|
+
//
|
|
133
|
+
// opts: { windowHours=DEFAULT_WINDOW_HOURS }
|
|
134
|
+
//
|
|
135
|
+
// Deterministic. Must start with CHALLENGE_MARKER on its own line. Must list
|
|
136
|
+
// each candidate as a line with #<number>, title, and percentage similarity.
|
|
137
|
+
// Must mention windowHours and the 👎 veto.
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
function renderChallengeComment(candidates, opts) {
|
|
141
|
+
const windowHours = (opts && opts.windowHours != null) ? opts.windowHours : DEFAULT_WINDOW_HOURS;
|
|
142
|
+
|
|
143
|
+
const lines = [CHALLENGE_MARKER, ''];
|
|
144
|
+
|
|
145
|
+
lines.push('**Possible duplicate detected.** This issue may already be reported:');
|
|
146
|
+
lines.push('');
|
|
147
|
+
|
|
148
|
+
for (const candidate of candidates) {
|
|
149
|
+
const pct = Math.round(candidate.score * 100);
|
|
150
|
+
lines.push(`- #${candidate.number} — ${candidate.title} (similarity ${pct}%)`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
lines.push('');
|
|
154
|
+
lines.push(
|
|
155
|
+
`If this is **not** a duplicate, react with 👎 on this comment to veto and keep the issue open. ` +
|
|
156
|
+
`If no response is received within ${windowHours} hours, this issue may be closed as a duplicate.`,
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
return lines.join('\n');
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
// isChallengeComment(body) -> boolean
|
|
164
|
+
//
|
|
165
|
+
// True iff body is a string containing CHALLENGE_MARKER.
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
function isChallengeComment(body) {
|
|
169
|
+
if (typeof body !== 'string') return false;
|
|
170
|
+
return body.includes(CHALLENGE_MARKER);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
// hasExemptLabel(labels) -> boolean
|
|
175
|
+
//
|
|
176
|
+
// labels may be an array of strings or array of {name}. True if any name is
|
|
177
|
+
// in EXEMPT_LABELS.
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
function hasExemptLabel(labels) {
|
|
181
|
+
if (!Array.isArray(labels)) return false;
|
|
182
|
+
const exemptSet = new Set(EXEMPT_LABELS);
|
|
183
|
+
for (const label of labels) {
|
|
184
|
+
const name = typeof label === 'string' ? label : (label && label.name);
|
|
185
|
+
if (name && exemptSet.has(name)) return true;
|
|
186
|
+
}
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
// toMs(value) -> number
|
|
192
|
+
//
|
|
193
|
+
// Coerce a Date, ISO string, or ms-number to milliseconds since epoch.
|
|
194
|
+
// ---------------------------------------------------------------------------
|
|
195
|
+
|
|
196
|
+
function toMs(value) {
|
|
197
|
+
if (value instanceof Date) return value.getTime();
|
|
198
|
+
if (typeof value === 'string') return new Date(value).getTime();
|
|
199
|
+
return Number(value);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ---------------------------------------------------------------------------
|
|
203
|
+
// shouldClose(input) -> {close: boolean, reason: string}
|
|
204
|
+
//
|
|
205
|
+
// input: { now, labels, challengeComment, laterUserComments, windowHours=DEFAULT_WINDOW_HOURS }
|
|
206
|
+
//
|
|
207
|
+
// Decision order (returns first match):
|
|
208
|
+
// 1. hasExemptLabel(labels) -> {close:false, reason:'exempt-label'}
|
|
209
|
+
// 2. !challengeComment -> {close:false, reason:'no-challenge-comment'}
|
|
210
|
+
// 3. challengeComment.downvoted -> {close:false, reason:'vetoed'}
|
|
211
|
+
// 4. laterUserComments > 0 -> {close:false, reason:'reporter-responded'}
|
|
212
|
+
// 5. ageHours < windowHours -> {close:false, reason:'within-window'}
|
|
213
|
+
// 6. else -> {close:true, reason:'duplicate-no-response'}
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
|
|
216
|
+
function shouldClose(input) {
|
|
217
|
+
const {
|
|
218
|
+
now,
|
|
219
|
+
labels = [],
|
|
220
|
+
challengeComment,
|
|
221
|
+
laterUserComments = 0,
|
|
222
|
+
} = input;
|
|
223
|
+
const windowHours = (input.windowHours != null) ? input.windowHours : DEFAULT_WINDOW_HOURS;
|
|
224
|
+
|
|
225
|
+
if (hasExemptLabel(labels)) {
|
|
226
|
+
return { close: false, reason: 'exempt-label' };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (!challengeComment) {
|
|
230
|
+
return { close: false, reason: 'no-challenge-comment' };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (challengeComment.downvoted) {
|
|
234
|
+
return { close: false, reason: 'vetoed' };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (laterUserComments > 0) {
|
|
238
|
+
return { close: false, reason: 'reporter-responded' };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const nowMs = toMs(now);
|
|
242
|
+
const createdMs = toMs(challengeComment.createdAt);
|
|
243
|
+
|
|
244
|
+
if (!Number.isFinite(nowMs) || !Number.isFinite(createdMs)) {
|
|
245
|
+
return { close: false, reason: 'invalid-timestamp' };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const ageHours = (nowMs - createdMs) / 3600000;
|
|
249
|
+
|
|
250
|
+
if (ageHours < windowHours) {
|
|
251
|
+
return { close: false, reason: 'within-window' };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return { close: true, reason: 'duplicate-no-response' };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ---------------------------------------------------------------------------
|
|
258
|
+
// Exports
|
|
259
|
+
// ---------------------------------------------------------------------------
|
|
260
|
+
|
|
261
|
+
module.exports = {
|
|
262
|
+
POSSIBLE_DUPLICATE_LABEL,
|
|
263
|
+
HUMAN_REVIEW_LABEL,
|
|
264
|
+
CHALLENGE_MARKER,
|
|
265
|
+
DEFAULT_WINDOW_HOURS,
|
|
266
|
+
DEFAULT_THRESHOLD,
|
|
267
|
+
DEFAULT_MAX_CANDIDATES,
|
|
268
|
+
MIN_TOKEN_LENGTH,
|
|
269
|
+
EXEMPT_LABELS,
|
|
270
|
+
STOPWORDS,
|
|
271
|
+
tokenize,
|
|
272
|
+
diceSimilarity,
|
|
273
|
+
scoreCandidates,
|
|
274
|
+
renderChallengeComment,
|
|
275
|
+
isChallengeComment,
|
|
276
|
+
hasExemptLabel,
|
|
277
|
+
shouldClose,
|
|
278
|
+
};
|
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const cp = require('node:child_process');
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const { ExitError, runMain } = require('../lib/cli-exit.cjs');
|
|
8
|
+
const { classifyTitle } = require('./format-github-release-notes.cjs');
|
|
9
|
+
|
|
10
|
+
const DEFAULT_MAX_CONTENT = 1850;
|
|
11
|
+
const SECTION_LIMITS = Object.freeze([
|
|
12
|
+
['Breaking', 4],
|
|
13
|
+
['Security', 4],
|
|
14
|
+
['Feature', 4],
|
|
15
|
+
['Fix', 4],
|
|
16
|
+
['Enhancement', 3],
|
|
17
|
+
['Internal', 2],
|
|
18
|
+
['New Contributors', 3],
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
function normalizeNewlines(value) {
|
|
22
|
+
return String(value || '').replace(/\r\n/g, '\n');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function releaseTag(release) {
|
|
26
|
+
return release.tagName || release.tag_name || release.name || 'release';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function releaseName(release) {
|
|
30
|
+
return release.name || releaseTag(release);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function releaseUrl(release) {
|
|
34
|
+
return release.url || release.html_url || '';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function releaseIsPrerelease(release) {
|
|
38
|
+
return release.isPrerelease === true || release.prerelease === true || /-/.test(releaseTag(release));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function loadPackageName(repoRoot = path.resolve(__dirname, '..', '..')) {
|
|
42
|
+
const pkgJson = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8'));
|
|
43
|
+
return pkgJson.name || 'package';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function normalizeHeading(value) {
|
|
47
|
+
const heading = value.trim().replace(/:$/, '');
|
|
48
|
+
const lower = heading.toLowerCase();
|
|
49
|
+
|
|
50
|
+
if (lower.includes('breaking')) return 'Breaking';
|
|
51
|
+
if (lower.includes('security')) return 'Security';
|
|
52
|
+
if (lower === 'feature' || lower === 'features') return 'Feature';
|
|
53
|
+
if (lower === 'fix' || lower === 'fixes' || lower === 'bug fixes') return 'Fix';
|
|
54
|
+
if (lower === 'enhancement' || lower === 'enhancements' || lower === 'improvements') return 'Enhancement';
|
|
55
|
+
if (lower === 'internal' || lower === 'maintenance') return 'Internal';
|
|
56
|
+
if (lower === 'new contributors') return 'New Contributors';
|
|
57
|
+
if (lower === "what's changed" || lower === 'whats changed') return "What's Changed";
|
|
58
|
+
return heading;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function collectSections(body) {
|
|
62
|
+
const sections = new Map();
|
|
63
|
+
let current = null;
|
|
64
|
+
|
|
65
|
+
for (const line of normalizeNewlines(body).split('\n')) {
|
|
66
|
+
const trimmed = line.trim();
|
|
67
|
+
const heading = trimmed.match(/^#{2,3}\s+(.+)$/);
|
|
68
|
+
|
|
69
|
+
if (heading) {
|
|
70
|
+
current = normalizeHeading(heading[1]);
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const bullet = trimmed.match(/^([*-])\s+(.+)$/);
|
|
75
|
+
if (!bullet || !current) continue;
|
|
76
|
+
|
|
77
|
+
let section = current;
|
|
78
|
+
if (section === "What's Changed") {
|
|
79
|
+
section = classifyTitle(trimmed);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (!sections.has(section)) sections.set(section, []);
|
|
83
|
+
sections.get(section).push(cleanBullet(trimmed));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return sections;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function cleanBullet(value) {
|
|
90
|
+
let text = String(value)
|
|
91
|
+
.replace(/^[*-]\s+/, '')
|
|
92
|
+
.replace(/\s+by\s+@[A-Za-z0-9-]+\s+in\s+https:\/\/github\.com\/[^/\s]+\/[^/\s]+\/pull\/(\d+)/g, ' (#$1)')
|
|
93
|
+
.replace(/\[([^\]]+)\]\((https:\/\/github\.com\/[^/\s]+\/[^/\s]+\/(?:pull|issues)\/(\d+))\)/g, '$1 (#$3)')
|
|
94
|
+
.replace(/\[([^\]]+)\]\((https?:\/\/[^)]+)\)/g, '$1')
|
|
95
|
+
.replace(/https:\/\/github\.com\/[^/\s]+\/[^/\s]+\/pull\/(\d+)/g, '#$1')
|
|
96
|
+
.replace(/https:\/\/github\.com\/[^/\s]+\/[^/\s]+\/issues\/(\d+)/g, '#$1')
|
|
97
|
+
.replace(/https:\/\/github\.com\/[^/\s]+\/[^/\s]+\/commit\/([0-9a-f]{7})[0-9a-f]*/gi, '$1')
|
|
98
|
+
.replace(/\*\*/g, '')
|
|
99
|
+
.replace(/\s+/g, ' ')
|
|
100
|
+
.trim();
|
|
101
|
+
|
|
102
|
+
text = text
|
|
103
|
+
.replace(/^\[codex\]\s+/i, '')
|
|
104
|
+
.replace(/^(?:feat|fix|docs|chore|ci|refactor|test)(?:\([^)]+\))?!?:\s+/i, '');
|
|
105
|
+
text = text.replace(/#(\d+)\s+\(#\1\)/g, '#$1');
|
|
106
|
+
text = text.replace(/\s+\(#(\d+)\)\s+\(#\1\)$/g, ' (#$1)');
|
|
107
|
+
|
|
108
|
+
return truncateAtWord(text, 220);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function truncateAtWord(value, maxLength) {
|
|
112
|
+
if (value.length <= maxLength) return value;
|
|
113
|
+
const splitAt = value.lastIndexOf(' ', maxLength - 3);
|
|
114
|
+
if (splitAt < 80) return `${value.slice(0, maxLength - 3)}...`;
|
|
115
|
+
return `${value.slice(0, splitAt)}...`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function extractInstallCommand(body, packageName, release) {
|
|
119
|
+
const lines = normalizeNewlines(body).split('\n');
|
|
120
|
+
let inInstall = false;
|
|
121
|
+
let inFence = false;
|
|
122
|
+
|
|
123
|
+
for (const line of lines) {
|
|
124
|
+
const trimmed = line.trim();
|
|
125
|
+
|
|
126
|
+
if (/^##\s+Install\b/i.test(trimmed)) {
|
|
127
|
+
inInstall = true;
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (inInstall && /^##\s+/.test(trimmed)) break;
|
|
132
|
+
if (!inInstall) continue;
|
|
133
|
+
|
|
134
|
+
if (trimmed.startsWith('```')) {
|
|
135
|
+
inFence = !inFence;
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (inFence && /^npm\s+(?:i|install)\s+/.test(trimmed)) {
|
|
140
|
+
return trimmed;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const channel = releaseIsPrerelease(release) ? 'next' : 'latest';
|
|
145
|
+
return `npm i ${packageName}@${channel}`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function buildDiscordReleasePayload({ release, packageName = loadPackageName(), maxContent = DEFAULT_MAX_CONTENT }) {
|
|
149
|
+
const tag = releaseTag(release);
|
|
150
|
+
const name = releaseName(release);
|
|
151
|
+
const url = releaseUrl(release);
|
|
152
|
+
const prerelease = releaseIsPrerelease(release);
|
|
153
|
+
const channel = prerelease ? 'next' : 'latest';
|
|
154
|
+
const body = release.body || '';
|
|
155
|
+
const sections = collectSections(body);
|
|
156
|
+
const footer = url ? `Full changelog: ${url}` : `Full changelog: ${tag}`;
|
|
157
|
+
const baseLines = [
|
|
158
|
+
`**${packageName} ${name}${prerelease ? ' pre-release' : ''} is out**`,
|
|
159
|
+
'',
|
|
160
|
+
'Install:',
|
|
161
|
+
`\`${extractInstallCommand(body, packageName, release)}\``,
|
|
162
|
+
];
|
|
163
|
+
|
|
164
|
+
const lines = baseLines.slice();
|
|
165
|
+
for (const [section, limit] of SECTION_LIMITS) {
|
|
166
|
+
appendSection(lines, section, sections.get(section) || [], limit, footer, maxContent);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (lines.length === baseLines.length) {
|
|
170
|
+
appendLineGroup(lines, ['', 'No release notes were provided.'], footer, maxContent);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (joinedLength(lines, footer) > maxContent) {
|
|
174
|
+
while (lines.length > baseLines.length && joinedLength(lines, footer) > maxContent) {
|
|
175
|
+
lines.pop();
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const content = [...lines, '', footer].join('\n');
|
|
180
|
+
return {
|
|
181
|
+
username: 'GSD Releases',
|
|
182
|
+
content,
|
|
183
|
+
embeds: [
|
|
184
|
+
{
|
|
185
|
+
title: 'Release details',
|
|
186
|
+
url: url || undefined,
|
|
187
|
+
color: prerelease ? 0xd9a441 : 0x2f9e44,
|
|
188
|
+
fields: [
|
|
189
|
+
{ name: 'Package', value: `\`${packageName}\``, inline: true },
|
|
190
|
+
{ name: 'Channel', value: `\`${channel}\``, inline: true },
|
|
191
|
+
{ name: 'Version', value: `\`${tag}\``, inline: true },
|
|
192
|
+
],
|
|
193
|
+
},
|
|
194
|
+
],
|
|
195
|
+
allowed_mentions: { parse: [] },
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function appendSection(lines, section, bullets, limit, footer, maxContent) {
|
|
200
|
+
if (bullets.length === 0) return;
|
|
201
|
+
|
|
202
|
+
const label = sectionLabel(section);
|
|
203
|
+
const selected = [];
|
|
204
|
+
for (const bullet of bullets.slice(0, limit)) {
|
|
205
|
+
const candidate = ['', `**${label}**`, ...selected.map((item) => `- ${item}`), `- ${bullet}`];
|
|
206
|
+
if (joinedLength([...lines, ...candidate], footer) > maxContent) break;
|
|
207
|
+
selected.push(bullet);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (selected.length === 0) return;
|
|
211
|
+
|
|
212
|
+
const group = ['', `**${label}**`, ...selected.map((item) => `- ${item}`)];
|
|
213
|
+
const remaining = bullets.length - selected.length;
|
|
214
|
+
if (remaining > 0) {
|
|
215
|
+
const moreLine = `- ...and ${remaining} more ${label.toLowerCase()}`;
|
|
216
|
+
if (joinedLength([...lines, ...group, moreLine], footer) <= maxContent) {
|
|
217
|
+
group.push(moreLine);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
appendLineGroup(lines, group, footer, maxContent);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function appendLineGroup(lines, group, footer, maxContent) {
|
|
225
|
+
if (joinedLength([...lines, ...group], footer) <= maxContent) {
|
|
226
|
+
lines.push(...group);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function joinedLength(lines, footer) {
|
|
231
|
+
return [...lines, '', footer].join('\n').length;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function sectionLabel(section) {
|
|
235
|
+
if (section === 'Feature') return 'Features';
|
|
236
|
+
if (section === 'Fix') return 'Fixes';
|
|
237
|
+
if (section === 'Enhancement') return 'Enhancements';
|
|
238
|
+
return section;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function fetchRelease({ tag, repo, latest = false }) {
|
|
242
|
+
const args = ['release', 'view'];
|
|
243
|
+
if (!latest && tag) args.push(tag);
|
|
244
|
+
args.push('--json', 'tagName,name,isPrerelease,body,url');
|
|
245
|
+
if (repo) args.push('--repo', repo);
|
|
246
|
+
const raw = cp.execFileSync('gh', args, { encoding: 'utf8' });
|
|
247
|
+
return JSON.parse(raw);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async function postWebhook(webhookUrl, payload) {
|
|
251
|
+
const response = await fetch(webhookUrl, {
|
|
252
|
+
method: 'POST',
|
|
253
|
+
headers: { 'content-type': 'application/json' },
|
|
254
|
+
body: JSON.stringify(payload),
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
if (!response.ok) {
|
|
258
|
+
throw new ExitError(1, `Discord webhook failed with ${response.status}: ${await response.text()}`);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function parseArgs(argv) {
|
|
263
|
+
const opts = {
|
|
264
|
+
tag: null,
|
|
265
|
+
repo: null,
|
|
266
|
+
latest: false,
|
|
267
|
+
stdin: false,
|
|
268
|
+
post: false,
|
|
269
|
+
json: false,
|
|
270
|
+
allowMissingWebhook: false,
|
|
271
|
+
packageName: null,
|
|
272
|
+
maxContent: DEFAULT_MAX_CONTENT,
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
const args = argv.slice();
|
|
276
|
+
while (args.length > 0) {
|
|
277
|
+
const arg = args.shift();
|
|
278
|
+
if (arg === '--tag') {
|
|
279
|
+
opts.tag = requireValue(args, arg);
|
|
280
|
+
} else if (arg === '--repo') {
|
|
281
|
+
opts.repo = requireValue(args, arg);
|
|
282
|
+
} else if (arg === '--package') {
|
|
283
|
+
opts.packageName = requireValue(args, arg);
|
|
284
|
+
} else if (arg === '--max-content') {
|
|
285
|
+
opts.maxContent = Number(requireValue(args, arg));
|
|
286
|
+
} else if (arg === '--latest') {
|
|
287
|
+
opts.latest = true;
|
|
288
|
+
} else if (arg === '--stdin') {
|
|
289
|
+
opts.stdin = true;
|
|
290
|
+
} else if (arg === '--post') {
|
|
291
|
+
opts.post = true;
|
|
292
|
+
} else if (arg === '--json') {
|
|
293
|
+
opts.json = true;
|
|
294
|
+
} else if (arg === '--allow-missing-webhook') {
|
|
295
|
+
opts.allowMissingWebhook = true;
|
|
296
|
+
} else if (arg === '--help' || arg === '-h') {
|
|
297
|
+
process.stdout.write(
|
|
298
|
+
'Usage: node scripts/release-notes/discord-release-summary.cjs [--tag <tag>|--latest|--stdin] [options]\n' +
|
|
299
|
+
'\n' +
|
|
300
|
+
'Options:\n' +
|
|
301
|
+
' --tag <tag> GitHub release tag to announce\n' +
|
|
302
|
+
' --latest Announce the latest GitHub release\n' +
|
|
303
|
+
' --stdin Read release JSON from stdin\n' +
|
|
304
|
+
' --repo <owner/repo> Repository for gh release view\n' +
|
|
305
|
+
' --package <name> Package name override\n' +
|
|
306
|
+
' --post POST to DISCORD_WEBHOOK_URL\n' +
|
|
307
|
+
' --allow-missing-webhook Warn and skip instead of failing when posting without a webhook\n' +
|
|
308
|
+
' --json Print webhook payload JSON instead of content preview\n' +
|
|
309
|
+
' --max-content <n> Max Discord content characters before webhook footer\n'
|
|
310
|
+
);
|
|
311
|
+
throw new ExitError(0);
|
|
312
|
+
} else {
|
|
313
|
+
throw new ExitError(2, `error: unknown argument ${arg}`);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (!Number.isFinite(opts.maxContent) || opts.maxContent < 500 || opts.maxContent > 2000) {
|
|
318
|
+
throw new ExitError(2, 'error: --max-content must be between 500 and 2000');
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return opts;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function requireValue(args, flag) {
|
|
325
|
+
const value = args.shift();
|
|
326
|
+
if (!value || value.startsWith('-')) {
|
|
327
|
+
throw new ExitError(2, `error: ${flag} requires a value`);
|
|
328
|
+
}
|
|
329
|
+
return value;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
async function main() {
|
|
333
|
+
const opts = parseArgs(process.argv.slice(2));
|
|
334
|
+
let release;
|
|
335
|
+
|
|
336
|
+
if (opts.stdin) {
|
|
337
|
+
release = JSON.parse(fs.readFileSync('/dev/stdin', 'utf8'));
|
|
338
|
+
} else {
|
|
339
|
+
release = fetchRelease(opts);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const payload = buildDiscordReleasePayload({
|
|
343
|
+
release,
|
|
344
|
+
packageName: opts.packageName || loadPackageName(),
|
|
345
|
+
maxContent: opts.maxContent,
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
if (opts.post) {
|
|
349
|
+
const webhookUrl = process.env.DISCORD_WEBHOOK_URL;
|
|
350
|
+
if (!webhookUrl) {
|
|
351
|
+
if (opts.allowMissingWebhook) {
|
|
352
|
+
process.stderr.write('warning: DISCORD_WEBHOOK_URL is not set; skipping Discord announcement\n');
|
|
353
|
+
} else {
|
|
354
|
+
throw new ExitError(1, 'error: DISCORD_WEBHOOK_URL is not set');
|
|
355
|
+
}
|
|
356
|
+
} else {
|
|
357
|
+
await postWebhook(webhookUrl, payload);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
process.stdout.write(opts.json ? `${JSON.stringify(payload, null, 2)}\n` : `${payload.content}\n`);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if (require.main === module) {
|
|
365
|
+
runMain(main);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
module.exports = {
|
|
369
|
+
buildDiscordReleasePayload,
|
|
370
|
+
cleanBullet,
|
|
371
|
+
collectSections,
|
|
372
|
+
extractInstallCommand,
|
|
373
|
+
};
|
|
@@ -82,7 +82,7 @@ const PROFILES = [
|
|
|
82
82
|
name: 'gsd-ai-researcher',
|
|
83
83
|
description:
|
|
84
84
|
'Researches a chosen AI framework\'s official docs to produce implementation-ready guidance — best practices, syntax, core patterns, and pitfalls distilled for the specific use case. Writes the Framework Quick Reference and Implementation Guidance sections of AI-SPEC.md. Spawned by /gsd:ai-integration-phase orchestrator.',
|
|
85
|
-
color: '
|
|
85
|
+
color: 'green',
|
|
86
86
|
tools:
|
|
87
87
|
'Read, Write, Edit, Bash, Grep, Glob, WebFetch, WebSearch, mcp__context7__*',
|
|
88
88
|
requiredIncludes: [
|
|
@@ -99,7 +99,7 @@ const PROFILES = [
|
|
|
99
99
|
name: 'gsd-domain-researcher',
|
|
100
100
|
description:
|
|
101
101
|
'Researches the business domain and real-world application context of the AI system being built. Surfaces domain expert evaluation criteria, industry-specific failure modes, regulatory context, and what "good" looks like for practitioners in this field — before the eval-planner turns it into measurable rubrics. Spawned by /gsd:ai-integration-phase orchestrator.',
|
|
102
|
-
color: '
|
|
102
|
+
color: 'purple',
|
|
103
103
|
tools:
|
|
104
104
|
'Read, Write, Edit, Bash, Grep, Glob, WebSearch, WebFetch, mcp__context7__*',
|
|
105
105
|
requiredIncludes: [
|
|
@@ -115,7 +115,7 @@ const PROFILES = [
|
|
|
115
115
|
name: 'gsd-ui-researcher',
|
|
116
116
|
description:
|
|
117
117
|
'Produces UI-SPEC.md design contract for frontend phases. Reads upstream artifacts, detects design system state, asks only unanswered questions. Spawned by /gsd:ui-phase orchestrator.',
|
|
118
|
-
color: '
|
|
118
|
+
color: 'purple',
|
|
119
119
|
tools:
|
|
120
120
|
'Read, Write, Edit, Bash, Grep, Glob, WebSearch, WebFetch, mcp__context7__*, mcp__firecrawl__*, mcp__exa__*, mcp__tavily__*, mcp__ref__*, mcp__jina__*',
|
|
121
121
|
requiredIncludes: [
|