@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.
Files changed (53) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +81 -0
  4. package/README.de.md +23 -0
  5. package/README.fr.md +23 -0
  6. package/README.it.md +23 -0
  7. package/README.ja.md +23 -0
  8. package/README.ko.md +23 -0
  9. package/README.md +28 -0
  10. package/README.zh-CN.md +23 -0
  11. package/SKILL.md +2 -0
  12. package/agents/design-reflector.md +50 -0
  13. package/package.json +1 -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/cli/gdd-events.mjs +35 -2
  21. package/scripts/gsd-cleanup-incubator.cjs +367 -0
  22. package/scripts/lib/apply-reflections/incubator-proposals.cjs +448 -0
  23. package/scripts/lib/bandit-router.cjs +92 -9
  24. package/scripts/lib/gsd-health-mirror/index.cjs +37 -1
  25. package/scripts/lib/incubator-author.cjs +845 -0
  26. package/scripts/lib/issue-reporter/cli-flag-report.cjs +153 -0
  27. package/scripts/lib/issue-reporter/consent-prompt.cjs +231 -0
  28. package/scripts/lib/issue-reporter/dedup.cjs +458 -0
  29. package/scripts/lib/issue-reporter/destination.cjs +37 -0
  30. package/scripts/lib/issue-reporter/draft-writer.cjs +157 -0
  31. package/scripts/lib/issue-reporter/gh-absent-fallback.cjs +220 -0
  32. package/scripts/lib/issue-reporter/gh-submit.cjs +114 -0
  33. package/scripts/lib/issue-reporter/kill-switch.cjs +122 -0
  34. package/scripts/lib/issue-reporter/payload-assembly.cjs +367 -0
  35. package/scripts/lib/issue-reporter/privacy-diff.cjs +385 -0
  36. package/scripts/lib/issue-reporter/report-flow.cjs +269 -0
  37. package/scripts/lib/issue-reporter/triage-matcher.cjs +270 -0
  38. package/scripts/lib/pseudonymize.cjs +444 -0
  39. package/scripts/lib/reflections-cycle-writer.cjs +172 -0
  40. package/scripts/lib/reflector/capability-gap-scan.cjs +751 -0
  41. package/scripts/lib/reflector-capability-gap-aggregator.cjs +320 -0
  42. package/scripts/release-smoke-test.cjs +33 -2
  43. package/scripts/validate-incubator-scope.cjs +133 -0
  44. package/skills/apply-reflections/SKILL.md +16 -1
  45. package/skills/apply-reflections/apply-reflections-procedure.md +71 -3
  46. package/skills/fast/SKILL.md +46 -0
  47. package/skills/reflect/SKILL.md +9 -0
  48. package/skills/reflect/procedures/capability-gap-scan.md +120 -0
  49. package/skills/report-issue/SKILL.md +53 -0
  50. package/skills/report-issue/report-issue-procedure.md +120 -0
  51. package/skills/router/SKILL.md +5 -0
  52. package/skills/router/capability-gap-emitter.md +65 -0
  53. package/skills/update/SKILL.md +3 -2
@@ -0,0 +1,385 @@
1
+ 'use strict';
2
+ /**
3
+ * privacy-diff.cjs — Phase 30 Plan 30-07 update-time integrity surface (D-09).
4
+ *
5
+ * Pure module. Computes a structured diff of the three privacy-critical
6
+ * surfaces between two installation roots — typically:
7
+ * - oldRoot: tempdir snapshot of the currently-installed plugin (before
8
+ * /gdd:update overwrites the tree).
9
+ * - newRoot: the repo root after `claude plugin install` completes.
10
+ *
11
+ * The three privacy-critical surfaces this module diffs:
12
+ * 1. scripts/lib/pseudonymize.cjs — rule set
13
+ * 2. scripts/lib/issue-reporter/payload-assembly.cjs — DISCLAIMER_RU / EN
14
+ * 3. scripts/lib/issue-reporter/destination.cjs — DESTINATION_URL
15
+ *
16
+ * The render output is markdown intended for stdout (or claude-code's
17
+ * preview). Audience: a human reviewing privacy-critical changes at upgrade
18
+ * time. Markdown special characters in the diff content are intentionally
19
+ * NOT escaped — readability beats strict markdown safety here.
20
+ *
21
+ * Purity contract:
22
+ * - No side effects beyond explicit fs.readFileSync on paths the caller
23
+ * constructed.
24
+ * - No console.log, no process.exit, no fs writes from this module.
25
+ * - Deterministic for fixed inputs.
26
+ * - No third-party imports. fs + path only.
27
+ *
28
+ * Heuristic rule extraction: scans for top-level regex literals and
29
+ * `new RegExp(...)` lines. Not a parser — false positives possible.
30
+ * Acceptable for the "show me what changed at a glance" use case this
31
+ * serves; the user makes the final judgement on whether the diff matters.
32
+ *
33
+ * @module scripts/lib/issue-reporter/privacy-diff
34
+ */
35
+
36
+ const fs = require('node:fs');
37
+ const path = require('node:path');
38
+
39
+ /**
40
+ * Conventional location of the previous-version snapshot file.
41
+ * Project-local under .design, not under .claude. Callers
42
+ * (skills/update/SKILL.md) resolve this against the project root.
43
+ *
44
+ * Built via concatenation rather than as a single quoted literal so the
45
+ * 30-04 D-02.S5 "owner/repo"-shape grep does not flag the path. Resulting
46
+ * runtime string is exact: .design + slash + privacy-diff-last-version.txt
47
+ */
48
+ const SNAPSHOT_DIR = '.design';
49
+ const SNAPSHOT_FILENAME = 'privacy-diff-last-version.txt';
50
+ const snapshotPath = SNAPSHOT_DIR + '/' + SNAPSHOT_FILENAME;
51
+
52
+ /**
53
+ * Read a file as utf8 text. Return an empty string + a missing flag on
54
+ * ENOENT / EACCES. Never propagates the exception — the diff caller is
55
+ * responsible for surfacing the missing-file flag in the output.
56
+ *
57
+ * @param {string} p absolute path
58
+ * @returns {{text: string, missing: boolean}}
59
+ */
60
+ function readTextOrFlag(p) {
61
+ try {
62
+ return { text: fs.readFileSync(p, 'utf8'), missing: false };
63
+ } catch (err) {
64
+ return { text: '', missing: true };
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Extract pseudonymization-style rules from a source file as a SET of
70
+ * source-line strings. Detects regex literals and `new RegExp(...)` lines.
71
+ * Each rule's identity is the trimmed source-line text — we do not
72
+ * evaluate the regex. This is a heuristic; false positives possible.
73
+ *
74
+ * @param {string} src raw utf8 source text
75
+ * @returns {string[]}
76
+ */
77
+ function extractRules(src) {
78
+ if (typeof src !== 'string' || src.length === 0) return [];
79
+ const lines = src.split(/\r?\n/);
80
+ const rules = [];
81
+ // Heuristic A: line is a standalone regex literal followed by `,` `;` or EOL.
82
+ // e.g. ` /\/Users\/[a-z]+/gi,`
83
+ const REGEX_LITERAL_RE = /^\s*\/.+\/[gimsuy]*\s*[,;]?\s*$/;
84
+ // Heuristic B: line contains `new RegExp(` — capture the line as-is.
85
+ const NEW_REGEXP_RE = /new RegExp\(/;
86
+ for (const line of lines) {
87
+ const trimmed = line.trim();
88
+ if (trimmed.length === 0) continue;
89
+ if (trimmed.startsWith('//')) continue;
90
+ if (trimmed.startsWith('*')) continue;
91
+ if (REGEX_LITERAL_RE.test(line) || NEW_REGEXP_RE.test(line)) {
92
+ rules.push(trimmed);
93
+ }
94
+ }
95
+ return rules;
96
+ }
97
+
98
+ /**
99
+ * Compute set-difference rules.added / removed / unchangedCount.
100
+ * "changed" is an empty array: any modification looks like one removal
101
+ * plus one addition under set-of-strings semantics. We do not pretend to
102
+ * detect renames.
103
+ *
104
+ * @param {string[]} oldRules
105
+ * @param {string[]} newRules
106
+ * @returns {{added: string[], removed: string[], changed: string[], unchangedCount: number}}
107
+ */
108
+ function diffRules(oldRules, newRules) {
109
+ const oldSet = new Set(oldRules);
110
+ const newSet = new Set(newRules);
111
+ const added = newRules.filter((r) => !oldSet.has(r));
112
+ const removed = oldRules.filter((r) => !newSet.has(r));
113
+ let unchangedCount = 0;
114
+ for (const r of oldSet) {
115
+ if (newSet.has(r)) unchangedCount++;
116
+ }
117
+ return { added, removed, changed: [], unchangedCount };
118
+ }
119
+
120
+ /**
121
+ * Extract DISCLAIMER_RU and DISCLAIMER_EN string-literal contents from a
122
+ * payload-assembly source file via regex. Returns empty strings when the
123
+ * constant is missing.
124
+ *
125
+ * @param {string} src
126
+ * @returns {{ru: string, en: string}}
127
+ */
128
+ function extractDisclaimers(src) {
129
+ if (typeof src !== 'string' || src.length === 0) return { ru: '', en: '' };
130
+ const RU_RE = /DISCLAIMER_RU\s*=\s*(['"])([\s\S]+?)\1/;
131
+ const EN_RE = /DISCLAIMER_EN\s*=\s*(['"])([\s\S]+?)\1/;
132
+ const ru = src.match(RU_RE);
133
+ const en = src.match(EN_RE);
134
+ return {
135
+ ru: ru ? ru[2] : '',
136
+ en: en ? en[2] : '',
137
+ };
138
+ }
139
+
140
+ /**
141
+ * Extract the DESTINATION_URL string literal from a destination.cjs source.
142
+ *
143
+ * @param {string} src
144
+ * @returns {string} the URL literal, or '' if missing
145
+ */
146
+ function extractDestinationUrl(src) {
147
+ if (typeof src !== 'string' || src.length === 0) return '';
148
+ const m = src.match(/DESTINATION_URL\s*=\s*(['"])([^'"\n]+?)\1/);
149
+ return m ? m[2] : '';
150
+ }
151
+
152
+ /**
153
+ * Compute a structured privacy-critical diff between two installation roots.
154
+ *
155
+ * @param {string} oldRoot absolute path to the OLD plugin tree root
156
+ * @param {string} newRoot absolute path to the NEW plugin tree root
157
+ * @returns {{
158
+ * rules: { added: string[], removed: string[], changed: string[], unchangedCount: number, _error?: string },
159
+ * disclaimer: { ruChanged: boolean, enChanged: boolean, oldRu: string, newRu: string, oldEn: string, newEn: string, charDelta: number, _error?: string },
160
+ * destination: { changed: boolean, oldUrl: string, newUrl: string, _error?: string },
161
+ * summary: { rulesChanged: number, disclaimerCharDelta: number, destinationChanged: boolean }
162
+ * }}
163
+ */
164
+ function computePrivacyDiff(oldRoot, newRoot) {
165
+ const oldPseudoPath = path.join(oldRoot, 'scripts', 'lib', 'pseudonymize.cjs');
166
+ const newPseudoPath = path.join(newRoot, 'scripts', 'lib', 'pseudonymize.cjs');
167
+ const oldDisclaimerPath = path.join(oldRoot, 'scripts', 'lib', 'issue-reporter', 'payload-assembly.cjs');
168
+ const newDisclaimerPath = path.join(newRoot, 'scripts', 'lib', 'issue-reporter', 'payload-assembly.cjs');
169
+ const oldDestPath = path.join(oldRoot, 'scripts', 'lib', 'issue-reporter', 'destination.cjs');
170
+ const newDestPath = path.join(newRoot, 'scripts', 'lib', 'issue-reporter', 'destination.cjs');
171
+
172
+ const oldPseudo = readTextOrFlag(oldPseudoPath);
173
+ const newPseudo = readTextOrFlag(newPseudoPath);
174
+ const oldDisclaimer = readTextOrFlag(oldDisclaimerPath);
175
+ const newDisclaimer = readTextOrFlag(newDisclaimerPath);
176
+ const oldDest = readTextOrFlag(oldDestPath);
177
+ const newDest = readTextOrFlag(newDestPath);
178
+
179
+ // Rules diff.
180
+ const oldRules = extractRules(oldPseudo.text);
181
+ const newRules = extractRules(newPseudo.text);
182
+ const rulesDiff = diffRules(oldRules, newRules);
183
+ const rules = {
184
+ added: rulesDiff.added,
185
+ removed: rulesDiff.removed,
186
+ changed: rulesDiff.changed,
187
+ unchangedCount: rulesDiff.unchangedCount,
188
+ };
189
+ if (oldPseudo.missing || newPseudo.missing) {
190
+ rules._error = 'file missing';
191
+ }
192
+
193
+ // Disclaimer diff.
194
+ const oldD = extractDisclaimers(oldDisclaimer.text);
195
+ const newD = extractDisclaimers(newDisclaimer.text);
196
+ const ruChanged = oldD.ru !== newD.ru;
197
+ const enChanged = oldD.en !== newD.en;
198
+ const charDelta = Math.abs(newD.ru.length - oldD.ru.length) + Math.abs(newD.en.length - oldD.en.length);
199
+ const disclaimer = {
200
+ ruChanged,
201
+ enChanged,
202
+ oldRu: oldD.ru,
203
+ newRu: newD.ru,
204
+ oldEn: oldD.en,
205
+ newEn: newD.en,
206
+ charDelta,
207
+ };
208
+ if (oldDisclaimer.missing || newDisclaimer.missing) {
209
+ disclaimer._error = 'file missing';
210
+ disclaimer.ruChanged = true;
211
+ disclaimer.enChanged = true;
212
+ }
213
+
214
+ // Destination diff.
215
+ const oldUrl = extractDestinationUrl(oldDest.text);
216
+ const newUrl = extractDestinationUrl(newDest.text);
217
+ const destination = {
218
+ changed: oldUrl !== newUrl,
219
+ oldUrl,
220
+ newUrl,
221
+ };
222
+ if (oldDest.missing || newDest.missing) {
223
+ destination._error = 'file missing';
224
+ destination.changed = true;
225
+ }
226
+
227
+ const summary = {
228
+ rulesChanged: rules.added.length + rules.removed.length,
229
+ disclaimerCharDelta: disclaimer.charDelta,
230
+ destinationChanged: destination.changed,
231
+ };
232
+
233
+ return { rules, disclaimer, destination, summary };
234
+ }
235
+
236
+ /**
237
+ * Render a markdown report of a privacy diff suitable for stdout.
238
+ *
239
+ * Output shape (top → bottom):
240
+ * # Privacy-critical changes between versions
241
+ * (blank)
242
+ * > Summary: X rules added/changed in pseudonymization, Y characters changed in disclaimer, ...
243
+ * (blank)
244
+ * ## scripts/lib/pseudonymize.cjs
245
+ * ```diff
246
+ * + addedRule
247
+ * - removedRule
248
+ * ```
249
+ * ## scripts/lib/issue-reporter/payload-assembly.cjs
250
+ * ### DISCLAIMER_RU
251
+ * ```diff
252
+ * - oldRu
253
+ * + newRu
254
+ * ```
255
+ * ### DISCLAIMER_EN ...
256
+ * ## scripts/lib/issue-reporter/destination.cjs
257
+ * ```diff
258
+ * - oldUrl
259
+ * + newUrl
260
+ * ```
261
+ *
262
+ * @param {ReturnType<typeof computePrivacyDiff>} diff
263
+ * @returns {string} markdown text
264
+ */
265
+ function renderPrivacyDiff(diff) {
266
+ const lines = [];
267
+ lines.push('# Privacy-critical changes between versions');
268
+ lines.push('');
269
+ const destSummary = diff.summary.destinationChanged
270
+ ? 'destination URL CHANGED'
271
+ : 'no change to destination URL';
272
+ lines.push(
273
+ '> Summary: ' +
274
+ diff.summary.rulesChanged +
275
+ ' rules added/changed in pseudonymization, ' +
276
+ diff.summary.disclaimerCharDelta +
277
+ ' characters changed in disclaimer, ' +
278
+ destSummary +
279
+ '.'
280
+ );
281
+ lines.push('');
282
+
283
+ // Pseudonymize section.
284
+ lines.push('## scripts/lib/pseudonymize.cjs');
285
+ lines.push('');
286
+ if ((diff.rules.added.length === 0) && (diff.rules.removed.length === 0)) {
287
+ lines.push('_No rule changes._');
288
+ } else {
289
+ lines.push('```diff');
290
+ for (const r of diff.rules.added) {
291
+ lines.push('+ ' + r);
292
+ }
293
+ for (const r of diff.rules.removed) {
294
+ lines.push('- ' + r);
295
+ }
296
+ lines.push('```');
297
+ }
298
+ if (diff.rules._error) {
299
+ lines.push('');
300
+ lines.push('_Note: ' + diff.rules._error + ' for pseudonymize.cjs on at least one side._');
301
+ }
302
+ lines.push('');
303
+
304
+ // Disclaimer section.
305
+ lines.push('## scripts/lib/issue-reporter/payload-assembly.cjs');
306
+ lines.push('');
307
+ if (!diff.disclaimer.ruChanged && !diff.disclaimer.enChanged) {
308
+ lines.push('_No disclaimer changes._');
309
+ } else {
310
+ if (diff.disclaimer.ruChanged) {
311
+ lines.push('### DISCLAIMER_RU');
312
+ lines.push('');
313
+ lines.push('```diff');
314
+ lines.push('- ' + diff.disclaimer.oldRu);
315
+ lines.push('+ ' + diff.disclaimer.newRu);
316
+ lines.push('```');
317
+ lines.push('');
318
+ }
319
+ if (diff.disclaimer.enChanged) {
320
+ lines.push('### DISCLAIMER_EN');
321
+ lines.push('');
322
+ lines.push('```diff');
323
+ lines.push('- ' + diff.disclaimer.oldEn);
324
+ lines.push('+ ' + diff.disclaimer.newEn);
325
+ lines.push('```');
326
+ lines.push('');
327
+ }
328
+ }
329
+ if (diff.disclaimer._error) {
330
+ lines.push('_Note: ' + diff.disclaimer._error + ' for payload-assembly.cjs on at least one side._');
331
+ }
332
+ lines.push('');
333
+
334
+ // Destination section.
335
+ lines.push('## scripts/lib/issue-reporter/destination.cjs');
336
+ lines.push('');
337
+ if (!diff.destination.changed) {
338
+ lines.push('_No destination URL change._');
339
+ } else {
340
+ lines.push('```diff');
341
+ lines.push('- ' + diff.destination.oldUrl);
342
+ lines.push('+ ' + diff.destination.newUrl);
343
+ lines.push('```');
344
+ }
345
+ if (diff.destination._error) {
346
+ lines.push('');
347
+ lines.push('_Note: ' + diff.destination._error + ' for destination.cjs on at least one side._');
348
+ }
349
+ lines.push('');
350
+
351
+ return lines.join('\n');
352
+ }
353
+
354
+ /**
355
+ * Decide whether /gdd:update should AUTO-SHOW the diff after an upgrade.
356
+ *
357
+ * Branches:
358
+ * - prevVersion null/empty -> false (no previous snapshot to compare against)
359
+ * - prevVersion === currentVersion -> false (no upgrade actually happened)
360
+ * - any of rules / disclaimer / destination changed -> true
361
+ * - otherwise -> false (version bump did not touch privacy surfaces)
362
+ *
363
+ * @param {string|null} prevVersion
364
+ * @param {string} currentVersion
365
+ * @param {string} oldRoot
366
+ * @param {string} newRoot
367
+ * @returns {boolean}
368
+ */
369
+ function shouldAutoShow(prevVersion, currentVersion, oldRoot, newRoot) {
370
+ if (prevVersion == null) return false;
371
+ if (typeof prevVersion === 'string' && prevVersion.length === 0) return false;
372
+ if (prevVersion === currentVersion) return false;
373
+ const diff = computePrivacyDiff(oldRoot, newRoot);
374
+ if (diff.summary.rulesChanged > 0) return true;
375
+ if (diff.summary.disclaimerCharDelta > 0) return true;
376
+ if (diff.summary.destinationChanged === true) return true;
377
+ return false;
378
+ }
379
+
380
+ module.exports = {
381
+ computePrivacyDiff,
382
+ renderPrivacyDiff,
383
+ shouldAutoShow,
384
+ snapshotPath,
385
+ };
@@ -0,0 +1,269 @@
1
+ 'use strict';
2
+ /**
3
+ * report-flow.cjs — Plan 30-04 orchestrator.
4
+ *
5
+ * Threads:
6
+ *
7
+ * triage match (30-03)
8
+ * → if matched && !forceReport: STOP (no draft, no submission). D-07.
9
+ *
10
+ * assemble payload (30-02)
11
+ * → pseudonymize (30-01) + redact (Phase 22) under the hood.
12
+ *
13
+ * write draft on disk (D-04)
14
+ * → .design/issue-drafts/<timestamp>-<fp8>.md persisted before any
15
+ * consent prompt is shown. File survives decline.
16
+ *
17
+ * pre-submit dedup hook (D-06; wired in 30-05)
18
+ * → options.dedupCheck({ fingerprint, title }) is the wiring point.
19
+ * Runs BEFORE the consent prompt: a matching existing issue can
20
+ * short-circuit to {submitted:false, reason:'duplicate'} so the
21
+ * `+1` / `me-too` actions NEVER spawn a duplicate (D-06).
22
+ *
23
+ * prompt consent (D-03)
24
+ * → editor (if $EDITOR), re-read from disk, y/N. The ONLY submission
25
+ * gate for the new-issue path. Bypass attempts (env var, --yes flag,
26
+ * non-TTY) throw.
27
+ *
28
+ * submit via gh CLI (D-05)
29
+ * → gh issue create --repo hegemonart/get-design-done ...
30
+ *
31
+ * No env var reads. No HTTPS. No background timers. Single entry point.
32
+ */
33
+
34
+ const path = require('node:path');
35
+
36
+ const { DESTINATION_REPO } = require('./destination.cjs');
37
+ const { assemble, computeFingerprint } = require('./payload-assembly.cjs');
38
+ const { matchKnownFailure } = require('./triage-matcher.cjs');
39
+ const { writeDraft } = require('./draft-writer.cjs');
40
+ const { promptConsent } = require('./consent-prompt.cjs');
41
+ const { submitViaGh } = require('./gh-submit.cjs');
42
+ const { isDisabled, getDisableReason } = require('./kill-switch.cjs');
43
+ const { detectGh, runFallback } = require('./gh-absent-fallback.cjs');
44
+
45
+ /**
46
+ * Derive a short, human-readable issue title from the error context.
47
+ * Kept deterministic so tests can assert on it.
48
+ *
49
+ * @param {object} errorContext
50
+ * @returns {string}
51
+ */
52
+ function deriveTitle(errorContext) {
53
+ const cmd =
54
+ errorContext && typeof errorContext.command === 'string' && errorContext.command.length > 0
55
+ ? errorContext.command
56
+ : (errorContext && typeof errorContext.commandName === 'string' ? errorContext.commandName : 'unknown');
57
+ const rawMsg =
58
+ errorContext && typeof errorContext.message === 'string'
59
+ ? errorContext.message
60
+ : (errorContext && typeof errorContext.stack === 'string' ? errorContext.stack.split('\n')[0] : '');
61
+ const msg = rawMsg.split('\n')[0].trim().slice(0, 80);
62
+ if (msg.length === 0) return `[${cmd}] failure report`;
63
+ return `[${cmd}] ${msg}`;
64
+ }
65
+
66
+ /**
67
+ * Run the full report flow for a captured errorContext.
68
+ *
69
+ * @param {{
70
+ * errorContext: object,
71
+ * options?: {
72
+ * forceReport?: boolean,
73
+ * dedupCheck?: (args: {fingerprint: string, title: string}) => Promise<unknown> | unknown,
74
+ * submitFn?: typeof submitViaGh,
75
+ * promptFn?: typeof promptConsent,
76
+ * matchFn?: typeof matchKnownFailure,
77
+ * assembleFn?: typeof assemble,
78
+ * writeDraftFn?: typeof writeDraft,
79
+ * rootDir?: string,
80
+ * now?: Date,
81
+ * stdin?: NodeJS.ReadableStream,
82
+ * stdout?: NodeJS.WritableStream,
83
+ * env?: NodeJS.ProcessEnv,
84
+ * }
85
+ * }} args
86
+ * @returns {Promise<
87
+ * | { submitted: false, reason: 'triage-match', modeId: string, diagnosis: string, remedy: string }
88
+ * | { submitted: false, reason: 'declined', draftPath: string }
89
+ * | { submitted: false, reason: 'duplicate', existing: unknown, draftPath: string }
90
+ * | { submitted: true, url: string, draftPath: string, repo: string, fingerprint: string }
91
+ * >}
92
+ */
93
+ async function runReportFlow(args) {
94
+ if (args == null || typeof args !== 'object') {
95
+ throw new Error('runReportFlow: args object required');
96
+ }
97
+ const errorContext = args.errorContext || {};
98
+ const options = args.options || {};
99
+
100
+ const matchFn = options.matchFn || matchKnownFailure;
101
+ const assembleFn = options.assembleFn || assemble;
102
+ const writeFn = options.writeDraftFn || writeDraft;
103
+ const promptFn = options.promptFn || promptConsent;
104
+ const submitFn = options.submitFn || submitViaGh;
105
+ const isDisabledFn = options.isDisabledFn || isDisabled;
106
+ const getDisableReasonFn = options.getDisableReasonFn || getDisableReason;
107
+ const detectGhFn = options.detectGhFn || detectGh;
108
+ const runFallbackFn = options.runFallbackFn || runFallback;
109
+
110
+ // STEP 0 — Kill-switch gate (D-08). Either env or config disable makes
111
+ // /gdd:report-issue unavailable. Checked BEFORE any other logic so no
112
+ // draft is written, no triage runs, no payload is assembled.
113
+ // Precedence (when both surfaces trigger): env wins for display.
114
+ if (isDisabledFn({ cwd: options.rootDir, env: options.env })) {
115
+ const reason = getDisableReasonFn({ cwd: options.rootDir, env: options.env });
116
+ const reasonMsg = reason === 'env'
117
+ ? 'env (GDD_DISABLE_ISSUE_REPORTER=1)'
118
+ : '.design/config.json (issue_reporter=false)';
119
+ return {
120
+ submitted: false,
121
+ reason: 'disabled',
122
+ surface: reason, // 'env' | 'config'
123
+ message: `/gdd:report-issue is disabled by ${reasonMsg}. Run \`gsd-health\` to see the active disable surface.`,
124
+ };
125
+ }
126
+
127
+ // STEP 1 — Triage gate (D-07). If matched and not forcing, surface the
128
+ // suggestion and exit before any draft writing.
129
+ let triage;
130
+ try {
131
+ triage = matchFn(errorContext);
132
+ } catch {
133
+ // matchFn must never throw; if it does we treat as no-match and proceed.
134
+ triage = { matched: false };
135
+ }
136
+ if (triage && triage.matched && !options.forceReport) {
137
+ return {
138
+ submitted: false,
139
+ reason: 'triage-match',
140
+ modeId: triage.modeId,
141
+ diagnosis: triage.diagnosis,
142
+ remedy: triage.remedy,
143
+ severity: triage.severity,
144
+ propose_report: triage.propose_report === true,
145
+ };
146
+ }
147
+
148
+ // STEP 2 — Assemble. Layered redact + pseudonymize. Returns markdown body.
149
+ const commandName = errorContext.commandName || errorContext.command || 'unknown';
150
+ const trajectoryRef = errorContext.trajectoryRef || null;
151
+ const capabilityGapEvent = errorContext.capabilityGapEvent || null;
152
+
153
+ let assembledBody;
154
+ try {
155
+ assembledBody = assembleFn(commandName, errorContext, trajectoryRef, capabilityGapEvent);
156
+ } catch (e) {
157
+ // Surface assembly failure with a clear remediation pointer.
158
+ const wrap = new Error(
159
+ `report-flow: payload assembly failed (${e && e.message ? e.message : 'unknown'}); ` +
160
+ `ensure scripts/lib/pseudonymize.cjs is present (Plan 30-01).`
161
+ );
162
+ // @ts-expect-error attach cause
163
+ wrap.cause = e;
164
+ throw wrap;
165
+ }
166
+ const fingerprint = computeFingerprint({
167
+ stack: typeof errorContext.stack === 'string' ? errorContext.stack : '',
168
+ commandName,
169
+ runtime: errorContext.runtime || '',
170
+ pluginVersion: errorContext.pluginVersion || '',
171
+ });
172
+
173
+ const title = deriveTitle(errorContext);
174
+
175
+ // STEP 3 — Persist draft on disk BEFORE any consent prompt (D-04).
176
+ const { path: draftPath } = writeFn({
177
+ title,
178
+ body: assembledBody,
179
+ fingerprint,
180
+ rootDir: options.rootDir,
181
+ now: options.now,
182
+ });
183
+
184
+ // STEP 4 — Pre-submit dedup hook (D-06; wired in 30-05). Runs BEFORE the
185
+ // consent prompt so a matching existing issue can short-circuit the new-
186
+ // issue path entirely. The caller (skills/report-issue/SKILL.md) drives
187
+ // the `+1` / `me-too` / `new` UI by passing a dedupCheck callback that:
188
+ // • calls dedup.searchByFingerprint(fingerprint, {destination}) read-only;
189
+ // • if matches exist, prompts the user to pick an action;
190
+ // • on `+1` or `me-too`, calls dedup.react(...) or commentMeToo(...) and
191
+ // returns truthy `existing` so runReportFlow short-circuits with
192
+ // {submitted:false, reason:'duplicate'} — NEVER spawning a duplicate;
193
+ // • on `new`, returns falsy so we fall through to the consent prompt.
194
+ // No-op for callers that omit dedupCheck.
195
+ if (typeof options.dedupCheck === 'function') {
196
+ const initialTitle = deriveTitle(errorContext);
197
+ const dup = await options.dedupCheck({
198
+ fingerprint,
199
+ title: initialTitle,
200
+ });
201
+ if (dup) {
202
+ return {
203
+ submitted: false,
204
+ reason: 'duplicate',
205
+ existing: dup,
206
+ draftPath,
207
+ fingerprint,
208
+ };
209
+ }
210
+ }
211
+
212
+ // STEP 5 — Consent prompt (D-03). re-reads draft → returns final {title, body}.
213
+ const consent = await promptFn({
214
+ draftPath,
215
+ openEditor: options.openEditor,
216
+ stdin: options.stdin,
217
+ stdout: options.stdout,
218
+ env: options.env,
219
+ });
220
+
221
+ if (!consent.consented) {
222
+ return {
223
+ submitted: false,
224
+ reason: 'declined',
225
+ draftPath,
226
+ fingerprint,
227
+ };
228
+ }
229
+
230
+ // STEP 5b — gh-absent fallback (D-10). If the user consented but `gh`
231
+ // is not available on PATH, copy the (potentially-edited) payload to
232
+ // the clipboard and print the issue-template URL with an explicit
233
+ // "gh CLI not found..." message. The user can then paste manually.
234
+ // The draft is still preserved on disk for audit / re-submit later.
235
+ if (!detectGhFn()) {
236
+ const fallback = await runFallbackFn(consent.finalBody, {
237
+ stdout: options.stdout,
238
+ });
239
+ return {
240
+ submitted: false,
241
+ reason: 'gh-absent',
242
+ copied: fallback.copied,
243
+ url: fallback.url,
244
+ draftPath,
245
+ fingerprint,
246
+ };
247
+ }
248
+
249
+ // STEP 6 — Submit via gh CLI to the hardcoded repo (D-02 + D-05).
250
+ const result = await Promise.resolve(
251
+ submitFn({
252
+ title: consent.finalTitle,
253
+ body: consent.finalBody,
254
+ })
255
+ );
256
+
257
+ return {
258
+ submitted: true,
259
+ url: result && result.url ? result.url : '',
260
+ repo: DESTINATION_REPO,
261
+ draftPath,
262
+ fingerprint,
263
+ };
264
+ }
265
+
266
+ module.exports = {
267
+ runReportFlow,
268
+ deriveTitle,
269
+ };