@hegemonart/get-design-done 1.28.8 → 1.30.5
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 +116 -0
- package/README.de.md +25 -0
- package/README.fr.md +25 -0
- package/README.it.md +25 -0
- package/README.ja.md +25 -0
- package/README.ko.md +25 -0
- package/README.md +30 -0
- package/README.zh-CN.md +25 -0
- package/SKILL.md +2 -0
- package/agents/design-authority-watcher.md +42 -1
- 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 +521 -0
- package/reference/pseudonymization-rules.md +189 -0
- package/reference/registry.json +22 -1
- package/reference/schemas/events.schema.json +158 -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 +455 -0
- package/scripts/lib/authority-watcher/index.cjs +201 -0
- package/scripts/lib/bandit-router.cjs +92 -9
- package/scripts/lib/failure-mode-matcher.cjs +460 -0
- package/scripts/lib/gsd-health-mirror/index.cjs +37 -1
- package/scripts/lib/incubator-author.cjs +845 -0
- package/scripts/lib/install/interactive.cjs +27 -2
- 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 +352 -0
- package/scripts/lib/reflector-kfm-proposer.cjs +468 -0
- package/scripts/release-smoke-test.cjs +33 -2
- package/scripts/validate-incubator-scope.cjs +133 -0
- package/skills/apply-reflections/SKILL.md +20 -1
- package/skills/apply-reflections/apply-reflections-procedure.md +106 -4
- 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,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
|
+
};
|