@hegemonart/get-design-done 1.28.8 → 1.30.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/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +81 -0
- package/README.de.md +23 -0
- package/README.fr.md +23 -0
- package/README.it.md +23 -0
- package/README.ja.md +23 -0
- package/README.ko.md +23 -0
- package/README.md +28 -0
- package/README.zh-CN.md +23 -0
- package/SKILL.md +2 -0
- package/agents/design-reflector.md +50 -0
- package/package.json +1 -1
- package/reference/capability-gap-stage-gate.md +261 -0
- package/reference/known-failure-modes.md +185 -0
- package/reference/pseudonymization-rules.md +189 -0
- package/reference/registry.json +22 -1
- package/reference/schemas/events.schema.json +97 -3
- package/reference/schemas/generated.d.ts +319 -4
- package/scripts/cli/gdd-events.mjs +35 -2
- package/scripts/gsd-cleanup-incubator.cjs +367 -0
- package/scripts/lib/apply-reflections/incubator-proposals.cjs +448 -0
- package/scripts/lib/bandit-router.cjs +92 -9
- package/scripts/lib/gsd-health-mirror/index.cjs +37 -1
- package/scripts/lib/incubator-author.cjs +845 -0
- package/scripts/lib/issue-reporter/cli-flag-report.cjs +153 -0
- package/scripts/lib/issue-reporter/consent-prompt.cjs +231 -0
- package/scripts/lib/issue-reporter/dedup.cjs +458 -0
- package/scripts/lib/issue-reporter/destination.cjs +37 -0
- package/scripts/lib/issue-reporter/draft-writer.cjs +157 -0
- package/scripts/lib/issue-reporter/gh-absent-fallback.cjs +220 -0
- package/scripts/lib/issue-reporter/gh-submit.cjs +114 -0
- package/scripts/lib/issue-reporter/kill-switch.cjs +122 -0
- package/scripts/lib/issue-reporter/payload-assembly.cjs +367 -0
- package/scripts/lib/issue-reporter/privacy-diff.cjs +385 -0
- package/scripts/lib/issue-reporter/report-flow.cjs +269 -0
- package/scripts/lib/issue-reporter/triage-matcher.cjs +270 -0
- package/scripts/lib/pseudonymize.cjs +444 -0
- package/scripts/lib/reflections-cycle-writer.cjs +172 -0
- package/scripts/lib/reflector/capability-gap-scan.cjs +751 -0
- package/scripts/lib/reflector-capability-gap-aggregator.cjs +320 -0
- package/scripts/release-smoke-test.cjs +33 -2
- package/scripts/validate-incubator-scope.cjs +133 -0
- package/skills/apply-reflections/SKILL.md +16 -1
- package/skills/apply-reflections/apply-reflections-procedure.md +71 -3
- package/skills/fast/SKILL.md +46 -0
- package/skills/reflect/SKILL.md +9 -0
- package/skills/reflect/procedures/capability-gap-scan.md +120 -0
- package/skills/report-issue/SKILL.md +53 -0
- package/skills/report-issue/report-issue-procedure.md +120 -0
- package/skills/router/SKILL.md +5 -0
- package/skills/router/capability-gap-emitter.md +65 -0
- package/skills/update/SKILL.md +3 -2
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* scripts/lib/issue-reporter/dedup.cjs — Plan 30-05 pre-submit dedup module.
|
|
4
|
+
*
|
|
5
|
+
* Runs BETWEEN payload assembly (30-02) and the consent prompt (30-04).
|
|
6
|
+
* Searches the destination repo (read-only) for an existing issue carrying
|
|
7
|
+
* the same fingerprint. When matches exist, offers two non-spawning actions:
|
|
8
|
+
*
|
|
9
|
+
* +1 → `gh api -X POST /repos/<dest>/issues/<n>/reactions -f content=+1`
|
|
10
|
+
* me-too → `gh issue comment <n> --repo <dest> --body <body>`
|
|
11
|
+
*
|
|
12
|
+
* (The caller — skills/report-issue/SKILL.md — also offers `new` which
|
|
13
|
+
* falls through to 30-04's consent prompt with the prepared draft.)
|
|
14
|
+
*
|
|
15
|
+
* ============================================================================
|
|
16
|
+
* DECISIONS HONORED HERE
|
|
17
|
+
* ============================================================================
|
|
18
|
+
*
|
|
19
|
+
* D-02 — Hardcoded destination URL. `destination` is a function parameter
|
|
20
|
+
* only. This module MUST NOT read env vars or config files for it.
|
|
21
|
+
* The caller (report-flow.cjs) sources it from destination.cjs.
|
|
22
|
+
*
|
|
23
|
+
* D-05 — Outbound = `gh` CLI only. No outbound HTTP-S URL literals, no
|
|
24
|
+
* global fetch primitive, no third-party HTTP client libraries.
|
|
25
|
+
* See `tests/issue-reporter-network-isolation.test.cjs` (Plan
|
|
26
|
+
* 30-07) for the enforced forbidden-token list. Module imports
|
|
27
|
+
* limited to: `child_process`, `path`, `fs`.
|
|
28
|
+
*
|
|
29
|
+
* D-06 — Pre-submit dedup is mandatory. `+1` and `me-too` NEVER spawn
|
|
30
|
+
* a duplicate issue. me-too body contains EXACTLY 3 fields
|
|
31
|
+
* (last error line + runtime + plugin version) — nothing else.
|
|
32
|
+
*
|
|
33
|
+
* D-13 — Tests use synthetic fixtures + tmpdir. No live `gh` calls in CI.
|
|
34
|
+
* Every export accepts an injectable `spawn` to support hermetic
|
|
35
|
+
* tests; production uses `child_process.spawnSync`.
|
|
36
|
+
*
|
|
37
|
+
* D-01 — Pseudonymization-not-anonymization. `me-too` bodies use the
|
|
38
|
+
* ALREADY-pseudonymized `errorContext.lastErrorLine` produced by
|
|
39
|
+
* 30-02's payload pipeline. dedup.cjs does NOT re-derive raw
|
|
40
|
+
* stderr; it only forwards what the caller hands it.
|
|
41
|
+
*
|
|
42
|
+
* ============================================================================
|
|
43
|
+
* WINDOWS .cmd SHIM (per Phase 27-03 transport-decisions.md)
|
|
44
|
+
* ============================================================================
|
|
45
|
+
*
|
|
46
|
+
* `gh` ships as `gh.cmd` on Windows. `child_process.spawnSync(absPath, args)`
|
|
47
|
+
* fails with EINVAL when absPath ends in `.cmd` and shell:true is not set.
|
|
48
|
+
* We mirror the pattern in scripts/lib/peer-cli/spawn-cmd.cjs — switching to
|
|
49
|
+
* `shell:true` only when the binary is a Windows .cmd, so POSIX paths keep
|
|
50
|
+
* the faster direct-exec form.
|
|
51
|
+
*
|
|
52
|
+
* Default spawn assumes `gh` is on PATH (matches 30-04 gh-submit.cjs). The
|
|
53
|
+
* Windows `.cmd` case is handled by Windows' own PATHEXT resolution under
|
|
54
|
+
* shell:true — we don't try to find an absolute path here.
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
const child_process = require('node:child_process');
|
|
58
|
+
|
|
59
|
+
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
60
|
+
|
|
61
|
+
// -------------------------------------------------------------------------
|
|
62
|
+
// Defensive guards
|
|
63
|
+
// -------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
/** @param {unknown} s @returns {boolean} */
|
|
66
|
+
function isNonEmptyString(s) {
|
|
67
|
+
return typeof s === 'string' && s.length > 0;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** @param {unknown} n @returns {boolean} */
|
|
71
|
+
function isPositiveInt(n) {
|
|
72
|
+
return typeof n === 'number' && Number.isInteger(n) && n > 0;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Throw TypeError if destination is missing/empty. Used as the D-02 boundary
|
|
77
|
+
* guard for the public API surface.
|
|
78
|
+
* @param {unknown} destination
|
|
79
|
+
*/
|
|
80
|
+
function requireDestination(destination) {
|
|
81
|
+
if (!isNonEmptyString(destination)) {
|
|
82
|
+
throw new TypeError(
|
|
83
|
+
'dedup: destination (string, owner-slash-repo form) is required. ' +
|
|
84
|
+
'Pass the constant from scripts/lib/issue-reporter/destination.cjs.'
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Throw TypeError if fingerprint is missing/empty.
|
|
91
|
+
* @param {unknown} fingerprint
|
|
92
|
+
*/
|
|
93
|
+
function requireFingerprint(fingerprint) {
|
|
94
|
+
if (!isNonEmptyString(fingerprint)) {
|
|
95
|
+
throw new TypeError('dedup: fingerprint (non-empty string) is required.');
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Throw TypeError if issueNumber is not a positive integer.
|
|
101
|
+
* @param {unknown} issueNumber
|
|
102
|
+
*/
|
|
103
|
+
function requireIssueNumber(issueNumber) {
|
|
104
|
+
if (!isPositiveInt(issueNumber)) {
|
|
105
|
+
throw new TypeError('dedup: issueNumber must be a positive integer.');
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// -------------------------------------------------------------------------
|
|
110
|
+
// Default spawn — Windows `.cmd` aware. (See module header for context.)
|
|
111
|
+
// -------------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Wrapper around child_process.spawnSync that handles the Windows `.cmd`
|
|
115
|
+
* shim case for `gh` (`gh.cmd` on Windows). Mirrors the pattern from
|
|
116
|
+
* scripts/lib/peer-cli/spawn-cmd.cjs.
|
|
117
|
+
*
|
|
118
|
+
* Real callers omit the third argument; tests inject a custom spawn.
|
|
119
|
+
*
|
|
120
|
+
* @param {string} cmd command name (typically 'gh')
|
|
121
|
+
* @param {readonly string[]} args
|
|
122
|
+
* @param {{timeout?: number, encoding?: BufferEncoding}} [opts]
|
|
123
|
+
* @returns {{status: number|null, stdout: string, stderr: string}}
|
|
124
|
+
*/
|
|
125
|
+
function defaultSpawn(cmd, args, opts) {
|
|
126
|
+
const safeOpts = opts && typeof opts === 'object' ? opts : {};
|
|
127
|
+
const safeArgs = Array.isArray(args) ? args : [];
|
|
128
|
+
|
|
129
|
+
const isWindows = process.platform === 'win32';
|
|
130
|
+
// For Windows, `gh` resolves to `gh.cmd` via PATHEXT. Use shell:true so
|
|
131
|
+
// cmd.exe can perform that resolution; without it, Node refuses to spawn
|
|
132
|
+
// a .cmd shim directly (the historical EINVAL behavior — see Phase 27-03).
|
|
133
|
+
if (isWindows) {
|
|
134
|
+
return child_process.spawnSync(cmd, safeArgs, {
|
|
135
|
+
timeout: safeOpts.timeout || DEFAULT_TIMEOUT_MS,
|
|
136
|
+
encoding: 'utf8',
|
|
137
|
+
shell: true,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return child_process.spawnSync(cmd, safeArgs, {
|
|
142
|
+
timeout: safeOpts.timeout || DEFAULT_TIMEOUT_MS,
|
|
143
|
+
encoding: 'utf8',
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// -------------------------------------------------------------------------
|
|
148
|
+
// Failure classification — stderr substring → short reason tag.
|
|
149
|
+
// -------------------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Map a gh failure (status !== 0) to a short reason string.
|
|
153
|
+
* Caller uses these to route degraded:true cases and to annotate
|
|
154
|
+
* rejected promises so the consent UI can show actionable hints.
|
|
155
|
+
*
|
|
156
|
+
* @param {{status:number|null, stdout:string, stderr:string}} result
|
|
157
|
+
* @returns {'auth'|'rate'|'network'|'not-found'|'gh-missing'|'unknown'}
|
|
158
|
+
*/
|
|
159
|
+
function classifyFailure(result) {
|
|
160
|
+
const stderr = (result && typeof result.stderr === 'string' ? result.stderr : '').toLowerCase();
|
|
161
|
+
const stdout = (result && typeof result.stdout === 'string' ? result.stdout : '').toLowerCase();
|
|
162
|
+
const blob = stderr + '\n' + stdout;
|
|
163
|
+
|
|
164
|
+
if (/\b(401|unauthorized|bad credentials|auth)\b/.test(blob)) return 'auth';
|
|
165
|
+
if (/rate limit|abuse detection/.test(blob)) return 'rate';
|
|
166
|
+
if (/could not resolve host|enotfound|econnrefused|network|getaddrinfo/.test(blob)) return 'network';
|
|
167
|
+
if (/\b(404|not found|no issues match)\b/.test(blob)) return 'not-found';
|
|
168
|
+
if (/command not found|enoent|is not recognized/.test(blob)) return 'gh-missing';
|
|
169
|
+
return 'unknown';
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// -------------------------------------------------------------------------
|
|
173
|
+
// Public API
|
|
174
|
+
// -------------------------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Run a read-only fingerprint search against the destination repo.
|
|
178
|
+
*
|
|
179
|
+
* Never throws on gh failure — resolves with `{matches:[], degraded:true,
|
|
180
|
+
* reason}` so the caller can surface a one-line warning and fall through
|
|
181
|
+
* to the new-issue path (per D-06: dedup is gate, not blocker).
|
|
182
|
+
*
|
|
183
|
+
* @param {string} fingerprint hex string from 30-02's computeFingerprint()
|
|
184
|
+
* @param {{
|
|
185
|
+
* destination: string,
|
|
186
|
+
* spawn?: typeof defaultSpawn,
|
|
187
|
+
* timeoutMs?: number,
|
|
188
|
+
* }} options
|
|
189
|
+
* @returns {Promise<{
|
|
190
|
+
* matches: Array<{number: number, title: string, url: string}>,
|
|
191
|
+
* degraded?: true,
|
|
192
|
+
* reason?: 'auth'|'rate'|'network'|'not-found'|'gh-missing'|'parse-error'|'unknown',
|
|
193
|
+
* }>}
|
|
194
|
+
*/
|
|
195
|
+
async function searchByFingerprint(fingerprint, options) {
|
|
196
|
+
if (options == null || typeof options !== 'object') {
|
|
197
|
+
throw new TypeError('dedup.searchByFingerprint: options object required.');
|
|
198
|
+
}
|
|
199
|
+
requireFingerprint(fingerprint);
|
|
200
|
+
requireDestination(options.destination);
|
|
201
|
+
|
|
202
|
+
const spawn = typeof options.spawn === 'function' ? options.spawn : defaultSpawn;
|
|
203
|
+
const timeoutMs = typeof options.timeoutMs === 'number' && options.timeoutMs > 0
|
|
204
|
+
? options.timeoutMs
|
|
205
|
+
: DEFAULT_TIMEOUT_MS;
|
|
206
|
+
|
|
207
|
+
// Build argv. Mirrors the canonical `gh issue list --search "fingerprint:<hash>"` call.
|
|
208
|
+
// D-02: destination is the caller-supplied parameter — never read from env/config here.
|
|
209
|
+
const args = [
|
|
210
|
+
'issue', 'list',
|
|
211
|
+
'--search', `fingerprint:${fingerprint}`,
|
|
212
|
+
'--json', 'number,title,url',
|
|
213
|
+
'--repo', options.destination,
|
|
214
|
+
];
|
|
215
|
+
|
|
216
|
+
let result;
|
|
217
|
+
try {
|
|
218
|
+
result = spawn('gh', args, { timeout: timeoutMs, encoding: 'utf8' });
|
|
219
|
+
} catch (e) {
|
|
220
|
+
// spawn itself blew up (rare: e.g. EACCES). Treat as gh-missing.
|
|
221
|
+
return { matches: [], degraded: true, reason: 'gh-missing' };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (!result || typeof result !== 'object') {
|
|
225
|
+
return { matches: [], degraded: true, reason: 'unknown' };
|
|
226
|
+
}
|
|
227
|
+
if (result.status !== 0) {
|
|
228
|
+
return { matches: [], degraded: true, reason: classifyFailure(result) };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Status 0 — parse stdout JSON.
|
|
232
|
+
const stdout = typeof result.stdout === 'string' ? result.stdout : '';
|
|
233
|
+
let parsed;
|
|
234
|
+
try {
|
|
235
|
+
parsed = JSON.parse(stdout || '[]');
|
|
236
|
+
} catch {
|
|
237
|
+
return { matches: [], degraded: true, reason: 'parse-error' };
|
|
238
|
+
}
|
|
239
|
+
if (!Array.isArray(parsed)) {
|
|
240
|
+
return { matches: [], degraded: true, reason: 'parse-error' };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Normalize: keep only {number, title, url} from each entry. Drop anything
|
|
244
|
+
// that doesn't have those fields — gh may add fields in future versions
|
|
245
|
+
// and we don't want to leak them to the dedup UI.
|
|
246
|
+
const matches = parsed
|
|
247
|
+
.map((m) => ({
|
|
248
|
+
number: typeof m.number === 'number' ? m.number : Number(m.number),
|
|
249
|
+
title: typeof m.title === 'string' ? m.title : '',
|
|
250
|
+
url: typeof m.url === 'string' ? m.url : '',
|
|
251
|
+
}))
|
|
252
|
+
.filter((m) => Number.isInteger(m.number) && m.number > 0 && m.title.length > 0);
|
|
253
|
+
|
|
254
|
+
return { matches };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Add a `+1` reaction to an existing issue via `gh api`.
|
|
259
|
+
*
|
|
260
|
+
* Resolves `{ok:true, reactionId?}` on success. Rejects with an Error
|
|
261
|
+
* annotated `.reason` (auth|rate|network|not-found|gh-missing|unknown)
|
|
262
|
+
* and `.stderr` so the consent UI can route to retry/cancel without
|
|
263
|
+
* parsing the error string.
|
|
264
|
+
*
|
|
265
|
+
* @param {number} issueNumber
|
|
266
|
+
* @param {{
|
|
267
|
+
* destination: string,
|
|
268
|
+
* spawn?: typeof defaultSpawn,
|
|
269
|
+
* timeoutMs?: number,
|
|
270
|
+
* }} options
|
|
271
|
+
* @returns {Promise<{ok: true, reactionId?: number}>}
|
|
272
|
+
*/
|
|
273
|
+
async function react(issueNumber, options) {
|
|
274
|
+
if (options == null || typeof options !== 'object') {
|
|
275
|
+
throw new TypeError('dedup.react: options object required.');
|
|
276
|
+
}
|
|
277
|
+
requireIssueNumber(issueNumber);
|
|
278
|
+
requireDestination(options.destination);
|
|
279
|
+
|
|
280
|
+
const spawn = typeof options.spawn === 'function' ? options.spawn : defaultSpawn;
|
|
281
|
+
const timeoutMs = typeof options.timeoutMs === 'number' && options.timeoutMs > 0
|
|
282
|
+
? options.timeoutMs
|
|
283
|
+
: DEFAULT_TIMEOUT_MS;
|
|
284
|
+
|
|
285
|
+
const args = [
|
|
286
|
+
'api',
|
|
287
|
+
'-X', 'POST',
|
|
288
|
+
`/repos/${options.destination}/issues/${issueNumber}/reactions`,
|
|
289
|
+
'-f', 'content=+1',
|
|
290
|
+
];
|
|
291
|
+
|
|
292
|
+
let result;
|
|
293
|
+
try {
|
|
294
|
+
result = spawn('gh', args, { timeout: timeoutMs, encoding: 'utf8' });
|
|
295
|
+
} catch (e) {
|
|
296
|
+
const err = new Error(`gh api spawn failed: ${e && e.message ? e.message : 'unknown'}`);
|
|
297
|
+
err.reason = 'gh-missing';
|
|
298
|
+
err.stderr = '';
|
|
299
|
+
throw err;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (!result || typeof result !== 'object' || result.status !== 0) {
|
|
303
|
+
const reason = classifyFailure(result || { status: null, stdout: '', stderr: '' });
|
|
304
|
+
const stderr = result && typeof result.stderr === 'string' ? result.stderr : '';
|
|
305
|
+
const err = new Error(`gh api -X POST .../reactions failed (${reason}): ${stderr.trim() || '(no stderr)'}`);
|
|
306
|
+
err.reason = reason;
|
|
307
|
+
err.stderr = stderr;
|
|
308
|
+
throw err;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Try to extract reactionId from stdout JSON. Optional.
|
|
312
|
+
let reactionId;
|
|
313
|
+
const stdout = typeof result.stdout === 'string' ? result.stdout : '';
|
|
314
|
+
try {
|
|
315
|
+
const parsed = JSON.parse(stdout);
|
|
316
|
+
if (parsed && typeof parsed.id === 'number') reactionId = parsed.id;
|
|
317
|
+
} catch {
|
|
318
|
+
// Ignore — reaction succeeded even if stdout isn't JSON.
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return reactionId != null ? { ok: true, reactionId } : { ok: true };
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Build the me-too comment body. EXACTLY three labeled sections:
|
|
326
|
+
*
|
|
327
|
+
* Last error: <lastErrorLine>
|
|
328
|
+
* Runtime: <runtime>
|
|
329
|
+
* Plugin version: <pluginVersion>
|
|
330
|
+
*
|
|
331
|
+
* No stack frames, no file paths, no env dump, no command-line, nothing
|
|
332
|
+
* else. Pure function — exported so test 5 can assert the verbatim string
|
|
333
|
+
* without spawning anything.
|
|
334
|
+
*
|
|
335
|
+
* Caller must pass the ALREADY-pseudonymized `lastErrorLine` from 30-02's
|
|
336
|
+
* pipeline (D-01).
|
|
337
|
+
*
|
|
338
|
+
* @param {{lastErrorLine: string, runtime: string, pluginVersion: string}} parts
|
|
339
|
+
* @returns {string}
|
|
340
|
+
*/
|
|
341
|
+
function buildMeTooBody(parts) {
|
|
342
|
+
if (parts == null || typeof parts !== 'object') {
|
|
343
|
+
throw new TypeError('buildMeTooBody: {lastErrorLine, runtime, pluginVersion} required.');
|
|
344
|
+
}
|
|
345
|
+
if (!isNonEmptyString(parts.lastErrorLine)) {
|
|
346
|
+
throw new TypeError('buildMeTooBody: lastErrorLine (non-empty string) required.');
|
|
347
|
+
}
|
|
348
|
+
if (!isNonEmptyString(parts.runtime)) {
|
|
349
|
+
throw new TypeError('buildMeTooBody: runtime (non-empty string) required.');
|
|
350
|
+
}
|
|
351
|
+
if (!isNonEmptyString(parts.pluginVersion)) {
|
|
352
|
+
throw new TypeError('buildMeTooBody: pluginVersion (non-empty string) required.');
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Truncate lastErrorLine to a single line (collapse newlines) and ≤200 chars,
|
|
356
|
+
// matching the contract in must_haves.truths. We do NOT modify content beyond
|
|
357
|
+
// truncation — the lastErrorLine is already pseudonymized upstream.
|
|
358
|
+
const single = String(parts.lastErrorLine).replace(/\r?\n/g, ' ').trim();
|
|
359
|
+
const truncated = single.length > 200 ? single.slice(0, 200) : single;
|
|
360
|
+
|
|
361
|
+
return (
|
|
362
|
+
`Last error: ${truncated}\n` +
|
|
363
|
+
`Runtime: ${parts.runtime}\n` +
|
|
364
|
+
`Plugin version: ${parts.pluginVersion}`
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Add a `me-too` comment to an existing issue via `gh issue comment`.
|
|
370
|
+
*
|
|
371
|
+
* Resolves `{ok:true, commentUrl?}` on success. Rejects with annotated
|
|
372
|
+
* Error on non-zero exit (same annotation contract as `react`).
|
|
373
|
+
*
|
|
374
|
+
* Body is built by `buildMeTooBody` — exactly 3 fields, nothing else.
|
|
375
|
+
* `errorContext.lastErrorLine` MUST already be pseudonymized by 30-02
|
|
376
|
+
* upstream (D-01); this function does NOT re-derive it.
|
|
377
|
+
*
|
|
378
|
+
* @param {number} issueNumber
|
|
379
|
+
* @param {{
|
|
380
|
+
* destination: string,
|
|
381
|
+
* errorContext: {lastErrorLine: string},
|
|
382
|
+
* runtime: string,
|
|
383
|
+
* pluginVersion: string,
|
|
384
|
+
* spawn?: typeof defaultSpawn,
|
|
385
|
+
* timeoutMs?: number,
|
|
386
|
+
* }} options
|
|
387
|
+
* @returns {Promise<{ok: true, commentUrl?: string}>}
|
|
388
|
+
*/
|
|
389
|
+
async function commentMeToo(issueNumber, options) {
|
|
390
|
+
if (options == null || typeof options !== 'object') {
|
|
391
|
+
throw new TypeError('dedup.commentMeToo: options object required.');
|
|
392
|
+
}
|
|
393
|
+
requireIssueNumber(issueNumber);
|
|
394
|
+
requireDestination(options.destination);
|
|
395
|
+
|
|
396
|
+
if (options.errorContext == null || typeof options.errorContext !== 'object') {
|
|
397
|
+
throw new TypeError('dedup.commentMeToo: errorContext object required.');
|
|
398
|
+
}
|
|
399
|
+
if (!isNonEmptyString(options.errorContext.lastErrorLine)) {
|
|
400
|
+
throw new TypeError('dedup.commentMeToo: errorContext.lastErrorLine required.');
|
|
401
|
+
}
|
|
402
|
+
if (!isNonEmptyString(options.runtime)) {
|
|
403
|
+
throw new TypeError('dedup.commentMeToo: runtime required.');
|
|
404
|
+
}
|
|
405
|
+
if (!isNonEmptyString(options.pluginVersion)) {
|
|
406
|
+
throw new TypeError('dedup.commentMeToo: pluginVersion required.');
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const spawn = typeof options.spawn === 'function' ? options.spawn : defaultSpawn;
|
|
410
|
+
const timeoutMs = typeof options.timeoutMs === 'number' && options.timeoutMs > 0
|
|
411
|
+
? options.timeoutMs
|
|
412
|
+
: DEFAULT_TIMEOUT_MS;
|
|
413
|
+
|
|
414
|
+
const body = buildMeTooBody({
|
|
415
|
+
lastErrorLine: options.errorContext.lastErrorLine,
|
|
416
|
+
runtime: options.runtime,
|
|
417
|
+
pluginVersion: options.pluginVersion,
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
const args = [
|
|
421
|
+
'issue', 'comment', String(issueNumber),
|
|
422
|
+
'--repo', options.destination,
|
|
423
|
+
'--body', body,
|
|
424
|
+
];
|
|
425
|
+
|
|
426
|
+
let result;
|
|
427
|
+
try {
|
|
428
|
+
result = spawn('gh', args, { timeout: timeoutMs, encoding: 'utf8' });
|
|
429
|
+
} catch (e) {
|
|
430
|
+
const err = new Error(`gh issue comment spawn failed: ${e && e.message ? e.message : 'unknown'}`);
|
|
431
|
+
err.reason = 'gh-missing';
|
|
432
|
+
err.stderr = '';
|
|
433
|
+
throw err;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
if (!result || typeof result !== 'object' || result.status !== 0) {
|
|
437
|
+
const reason = classifyFailure(result || { status: null, stdout: '', stderr: '' });
|
|
438
|
+
const stderr = result && typeof result.stderr === 'string' ? result.stderr : '';
|
|
439
|
+
const err = new Error(`gh issue comment failed (${reason}): ${stderr.trim() || '(no stderr)'}`);
|
|
440
|
+
err.reason = reason;
|
|
441
|
+
err.stderr = stderr;
|
|
442
|
+
throw err;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// gh issue comment prints the comment URL on stdout on success.
|
|
446
|
+
const stdout = typeof result.stdout === 'string' ? result.stdout : '';
|
|
447
|
+
const urlMatch = stdout.match(/https?:\/\/\S+/);
|
|
448
|
+
const commentUrl = urlMatch ? urlMatch[0] : undefined;
|
|
449
|
+
|
|
450
|
+
return commentUrl ? { ok: true, commentUrl } : { ok: true };
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
module.exports = {
|
|
454
|
+
searchByFingerprint,
|
|
455
|
+
react,
|
|
456
|
+
commentMeToo,
|
|
457
|
+
buildMeTooBody,
|
|
458
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* destination.cjs — Phase 30 Plans 30-04 + 30-07 hardcoded destination (D-02).
|
|
4
|
+
*
|
|
5
|
+
* Single source of truth for the GitHub repo that /gdd:report-issue
|
|
6
|
+
* submits to. No env-var lookup, no config override, no flag override.
|
|
7
|
+
*
|
|
8
|
+
* Frozen module export -> runtime immutability. Static tests in
|
|
9
|
+
* tests/report-issue-destination-static.test.cjs assert that this is
|
|
10
|
+
* the ONLY file under scripts/lib/issue-reporter/ that contains the
|
|
11
|
+
* literal repo string and that no env-var bypass code exists anywhere
|
|
12
|
+
* under the report-issue tree (D-03 belt + suspenders).
|
|
13
|
+
*
|
|
14
|
+
* SOLE FILE allowed to contain the destination URL literal under the
|
|
15
|
+
* scanned tree. CI gate (tests/issue-reporter-network-isolation.test.cjs,
|
|
16
|
+
* Plan 30-07) whitelists this exact path. Any other file under
|
|
17
|
+
* skills/report-issue/, scripts/lib/pseudonymize.cjs, or
|
|
18
|
+
* scripts/lib/issue-reporter/ that contains the URL literal fails
|
|
19
|
+
* the build. The carrier-comment above MUST NOT be removed from this
|
|
20
|
+
* file: it tells future maintainers why the static-analysis exemption
|
|
21
|
+
* exists.
|
|
22
|
+
*
|
|
23
|
+
* If you are tempted to add an env var here, read CONTEXT.md D-02 +
|
|
24
|
+
* D-03 first — the static enforcement test will fail your build.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const DESTINATION_OWNER = 'hegemonart';
|
|
28
|
+
const DESTINATION_REPO = 'hegemonart/get-design-done';
|
|
29
|
+
const DESTINATION_URL = 'https://github.com/hegemonart/get-design-done';
|
|
30
|
+
const ISSUE_TEMPLATE_URL = 'https://github.com/hegemonart/get-design-done/issues/new?template=bug_report.md';
|
|
31
|
+
|
|
32
|
+
module.exports = Object.freeze({
|
|
33
|
+
DESTINATION_OWNER,
|
|
34
|
+
DESTINATION_REPO,
|
|
35
|
+
DESTINATION_URL,
|
|
36
|
+
ISSUE_TEMPLATE_URL,
|
|
37
|
+
});
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* draft-writer.cjs — Plan 30-04 D-04 payload-on-disk persister.
|
|
4
|
+
*
|
|
5
|
+
* Writes the assembled issue body to a deterministic path under
|
|
6
|
+
* `.design/issue-drafts/<timestamp>-<fp8>.md` BEFORE any consent prompt
|
|
7
|
+
* is shown. The file persists across decline (D-04: user keeps their
|
|
8
|
+
* work) and is the on-disk source-of-truth that promptConsent re-reads
|
|
9
|
+
* on submit.
|
|
10
|
+
*
|
|
11
|
+
* Pure-ish: only fs + clock + Object.freeze. No env reads (D-03 static
|
|
12
|
+
* test would fail). No spawn, no network.
|
|
13
|
+
*
|
|
14
|
+
* The file content is the assembled markdown body verbatim plus a small
|
|
15
|
+
* provenance header (HTML comments). Edits to the file between write
|
|
16
|
+
* and consent are picked up via the re-read in promptConsent.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const fs = require('node:fs');
|
|
20
|
+
const path = require('node:path');
|
|
21
|
+
|
|
22
|
+
const { DESTINATION_REPO } = require('./destination.cjs');
|
|
23
|
+
|
|
24
|
+
const DRAFTS_SUBDIR = path.join('.design', 'issue-drafts');
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param {Date} [now] — clock injection point for hermetic tests
|
|
28
|
+
* @returns {string} — YYYYMMDDTHHMMSSZ (no separators)
|
|
29
|
+
*/
|
|
30
|
+
function timestampStamp(now) {
|
|
31
|
+
const d = now instanceof Date ? now : new Date();
|
|
32
|
+
const iso = d.toISOString();
|
|
33
|
+
// 2026-05-20T13:14:15.678Z -> 20260520T131415Z
|
|
34
|
+
return iso
|
|
35
|
+
.replace(/[-:]/g, '')
|
|
36
|
+
.replace(/\.\d{3}/, '');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Compute the destination path for a draft.
|
|
41
|
+
*
|
|
42
|
+
* @param {{ rootDir?: string, fingerprint: string, now?: Date }} opts
|
|
43
|
+
* @returns {string} — absolute path
|
|
44
|
+
*/
|
|
45
|
+
function draftPath(opts) {
|
|
46
|
+
const rootDir = (opts && opts.rootDir) || process.cwd();
|
|
47
|
+
const fingerprint = String(opts && opts.fingerprint != null ? opts.fingerprint : '');
|
|
48
|
+
if (fingerprint.length < 8 || !/^[a-f0-9]+$/.test(fingerprint)) {
|
|
49
|
+
throw new Error(
|
|
50
|
+
`draft-writer: fingerprint must be a hex string of length ≥ 8 (got: ${JSON.stringify(fingerprint)})`
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
const fp8 = fingerprint.slice(0, 8);
|
|
54
|
+
const stamp = timestampStamp(opts && opts.now);
|
|
55
|
+
return path.join(rootDir, DRAFTS_SUBDIR, `${stamp}-${fp8}.md`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Render the markdown that lives on disk.
|
|
60
|
+
*
|
|
61
|
+
* @param {{ title: string, body: string, fingerprint: string, now?: Date }} args
|
|
62
|
+
* @returns {string}
|
|
63
|
+
*/
|
|
64
|
+
function renderDraft(args) {
|
|
65
|
+
const now = args && args.now instanceof Date ? args.now : new Date();
|
|
66
|
+
const title = String(args && args.title != null ? args.title : '');
|
|
67
|
+
const body = String(args && args.body != null ? args.body : '');
|
|
68
|
+
const fingerprint = String(args && args.fingerprint != null ? args.fingerprint : '');
|
|
69
|
+
|
|
70
|
+
return [
|
|
71
|
+
`<!-- generated by /gdd:report-issue at ${now.toISOString()} -->`,
|
|
72
|
+
`<!-- destination: ${DESTINATION_REPO} -->`,
|
|
73
|
+
`<!-- fingerprint: ${fingerprint} -->`,
|
|
74
|
+
`# ${title}`,
|
|
75
|
+
'',
|
|
76
|
+
body,
|
|
77
|
+
].join('\n');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Write the draft to disk.
|
|
82
|
+
*
|
|
83
|
+
* Parses the leading `# ...` line and the leading HTML comments off the
|
|
84
|
+
* stored file when promptConsent re-reads it — keeps a simple round-trip
|
|
85
|
+
* shape so users can edit the title by changing the `# ...` line.
|
|
86
|
+
*
|
|
87
|
+
* @param {{
|
|
88
|
+
* title: string,
|
|
89
|
+
* body: string,
|
|
90
|
+
* fingerprint: string,
|
|
91
|
+
* rootDir?: string,
|
|
92
|
+
* now?: Date
|
|
93
|
+
* }} args
|
|
94
|
+
* @returns {{ path: string, fingerprint: string, title: string }}
|
|
95
|
+
*/
|
|
96
|
+
function writeDraft(args) {
|
|
97
|
+
if (args == null || typeof args !== 'object') {
|
|
98
|
+
throw new Error('draft-writer.writeDraft: args object required');
|
|
99
|
+
}
|
|
100
|
+
const title = String(args.title == null ? '' : args.title);
|
|
101
|
+
const body = String(args.body == null ? '' : args.body);
|
|
102
|
+
const fingerprint = String(args.fingerprint == null ? '' : args.fingerprint);
|
|
103
|
+
const rootDir = args.rootDir || process.cwd();
|
|
104
|
+
const now = args.now instanceof Date ? args.now : new Date();
|
|
105
|
+
|
|
106
|
+
if (title.length === 0) {
|
|
107
|
+
throw new Error('draft-writer.writeDraft: title is required');
|
|
108
|
+
}
|
|
109
|
+
if (body.length === 0) {
|
|
110
|
+
throw new Error('draft-writer.writeDraft: body is required');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const full = draftPath({ rootDir, fingerprint, now });
|
|
114
|
+
fs.mkdirSync(path.dirname(full), { recursive: true });
|
|
115
|
+
const content = renderDraft({ title, body, fingerprint, now });
|
|
116
|
+
fs.writeFileSync(full, content, 'utf8');
|
|
117
|
+
|
|
118
|
+
return Object.freeze({ path: full, fingerprint, title });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Re-read a previously-written draft from disk, parsing the on-disk
|
|
123
|
+
* shape back into `{ title, body }`. Used by promptConsent after the
|
|
124
|
+
* editor exits so user edits are picked up.
|
|
125
|
+
*
|
|
126
|
+
* @param {string} filePath
|
|
127
|
+
* @returns {{ title: string, body: string }}
|
|
128
|
+
*/
|
|
129
|
+
function readDraft(filePath) {
|
|
130
|
+
const raw = fs.readFileSync(filePath, 'utf8');
|
|
131
|
+
const lines = raw.split(/\r?\n/);
|
|
132
|
+
let i = 0;
|
|
133
|
+
// Skip leading HTML comments + blank lines.
|
|
134
|
+
while (i < lines.length && (lines[i].startsWith('<!--') || lines[i].trim() === '')) {
|
|
135
|
+
i++;
|
|
136
|
+
}
|
|
137
|
+
let title = '';
|
|
138
|
+
if (i < lines.length && lines[i].startsWith('# ')) {
|
|
139
|
+
title = lines[i].slice(2).trim();
|
|
140
|
+
i++;
|
|
141
|
+
}
|
|
142
|
+
// Skip a single blank line if present.
|
|
143
|
+
if (i < lines.length && lines[i].trim() === '') {
|
|
144
|
+
i++;
|
|
145
|
+
}
|
|
146
|
+
const body = lines.slice(i).join('\n').replace(/\s+$/, '');
|
|
147
|
+
return { title, body };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
module.exports = {
|
|
151
|
+
writeDraft,
|
|
152
|
+
readDraft,
|
|
153
|
+
draftPath,
|
|
154
|
+
renderDraft,
|
|
155
|
+
timestampStamp,
|
|
156
|
+
DRAFTS_SUBDIR,
|
|
157
|
+
};
|