@hegemonart/get-design-done 1.28.7 → 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.
Files changed (71) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +116 -0
  4. package/README.de.md +37 -0
  5. package/README.fr.md +37 -0
  6. package/README.it.md +37 -0
  7. package/README.ja.md +37 -0
  8. package/README.ko.md +37 -0
  9. package/README.md +44 -0
  10. package/README.zh-CN.md +37 -0
  11. package/SKILL.md +12 -10
  12. package/agents/design-reflector.md +50 -0
  13. package/package.json +3 -1
  14. package/reference/capability-gap-stage-gate.md +261 -0
  15. package/reference/known-failure-modes.md +185 -0
  16. package/reference/pseudonymization-rules.md +189 -0
  17. package/reference/registry.json +22 -1
  18. package/reference/schemas/events.schema.json +97 -3
  19. package/reference/schemas/generated.d.ts +319 -4
  20. package/scripts/build-distribution-bundles.cjs +549 -0
  21. package/scripts/cli/gdd-events.mjs +35 -2
  22. package/scripts/gsd-cleanup-incubator.cjs +367 -0
  23. package/scripts/install.cjs +61 -0
  24. package/scripts/lib/apply-reflections/incubator-proposals.cjs +448 -0
  25. package/scripts/lib/bandit-router.cjs +92 -9
  26. package/scripts/lib/gsd-health-mirror/index.cjs +37 -1
  27. package/scripts/lib/incubator-author.cjs +845 -0
  28. package/scripts/lib/install/config-dir.cjs +26 -0
  29. package/scripts/lib/install/converters/codex-plugin.cjs +407 -0
  30. package/scripts/lib/install/converters/cursor-marketplace.cjs +309 -0
  31. package/scripts/lib/install/doctor-codex-plugin.cjs +388 -0
  32. package/scripts/lib/install/doctor-cursor-marketplace.cjs +366 -0
  33. package/scripts/lib/install/doctor-tier2.cjs +586 -0
  34. package/scripts/lib/install/runtimes.cjs +48 -0
  35. package/scripts/lib/issue-reporter/cli-flag-report.cjs +153 -0
  36. package/scripts/lib/issue-reporter/consent-prompt.cjs +231 -0
  37. package/scripts/lib/issue-reporter/dedup.cjs +458 -0
  38. package/scripts/lib/issue-reporter/destination.cjs +37 -0
  39. package/scripts/lib/issue-reporter/draft-writer.cjs +157 -0
  40. package/scripts/lib/issue-reporter/gh-absent-fallback.cjs +220 -0
  41. package/scripts/lib/issue-reporter/gh-submit.cjs +114 -0
  42. package/scripts/lib/issue-reporter/kill-switch.cjs +122 -0
  43. package/scripts/lib/issue-reporter/payload-assembly.cjs +367 -0
  44. package/scripts/lib/issue-reporter/privacy-diff.cjs +385 -0
  45. package/scripts/lib/issue-reporter/report-flow.cjs +269 -0
  46. package/scripts/lib/issue-reporter/triage-matcher.cjs +270 -0
  47. package/scripts/lib/pseudonymize.cjs +444 -0
  48. package/scripts/lib/reflections-cycle-writer.cjs +172 -0
  49. package/scripts/lib/reflector/capability-gap-scan.cjs +751 -0
  50. package/scripts/lib/reflector-capability-gap-aggregator.cjs +320 -0
  51. package/scripts/lint-agentskills-spec.cjs +457 -0
  52. package/scripts/release-smoke-test.cjs +33 -2
  53. package/scripts/validate-incubator-scope.cjs +133 -0
  54. package/skills/apply-reflections/SKILL.md +16 -1
  55. package/skills/apply-reflections/apply-reflections-procedure.md +71 -3
  56. package/skills/compare/SKILL.md +2 -2
  57. package/skills/compare/compare-rubric.md +1 -1
  58. package/skills/darkmode/SKILL.md +2 -2
  59. package/skills/darkmode/darkmode-audit-procedure.md +1 -1
  60. package/skills/fast/SKILL.md +46 -0
  61. package/skills/figma-write/SKILL.md +2 -2
  62. package/skills/graphify/SKILL.md +2 -2
  63. package/skills/reflect/SKILL.md +9 -0
  64. package/skills/reflect/procedures/capability-gap-scan.md +120 -0
  65. package/skills/report-issue/SKILL.md +53 -0
  66. package/skills/report-issue/report-issue-procedure.md +120 -0
  67. package/skills/router/SKILL.md +5 -0
  68. package/skills/router/capability-gap-emitter.md +65 -0
  69. package/skills/style/SKILL.md +2 -2
  70. package/skills/style/style-doc-procedure.md +1 -1
  71. 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
+ };