@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.
Files changed (58) 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 +25 -0
  5. package/README.fr.md +25 -0
  6. package/README.it.md +25 -0
  7. package/README.ja.md +25 -0
  8. package/README.ko.md +25 -0
  9. package/README.md +30 -0
  10. package/README.zh-CN.md +25 -0
  11. package/SKILL.md +2 -0
  12. package/agents/design-authority-watcher.md +42 -1
  13. package/agents/design-reflector.md +50 -0
  14. package/package.json +1 -1
  15. package/reference/capability-gap-stage-gate.md +261 -0
  16. package/reference/known-failure-modes.md +521 -0
  17. package/reference/pseudonymization-rules.md +189 -0
  18. package/reference/registry.json +22 -1
  19. package/reference/schemas/events.schema.json +158 -3
  20. package/reference/schemas/generated.d.ts +319 -4
  21. package/scripts/cli/gdd-events.mjs +35 -2
  22. package/scripts/gsd-cleanup-incubator.cjs +367 -0
  23. package/scripts/lib/apply-reflections/incubator-proposals.cjs +455 -0
  24. package/scripts/lib/authority-watcher/index.cjs +201 -0
  25. package/scripts/lib/bandit-router.cjs +92 -9
  26. package/scripts/lib/failure-mode-matcher.cjs +460 -0
  27. package/scripts/lib/gsd-health-mirror/index.cjs +37 -1
  28. package/scripts/lib/incubator-author.cjs +845 -0
  29. package/scripts/lib/install/interactive.cjs +27 -2
  30. package/scripts/lib/issue-reporter/cli-flag-report.cjs +153 -0
  31. package/scripts/lib/issue-reporter/consent-prompt.cjs +231 -0
  32. package/scripts/lib/issue-reporter/dedup.cjs +458 -0
  33. package/scripts/lib/issue-reporter/destination.cjs +37 -0
  34. package/scripts/lib/issue-reporter/draft-writer.cjs +157 -0
  35. package/scripts/lib/issue-reporter/gh-absent-fallback.cjs +220 -0
  36. package/scripts/lib/issue-reporter/gh-submit.cjs +114 -0
  37. package/scripts/lib/issue-reporter/kill-switch.cjs +122 -0
  38. package/scripts/lib/issue-reporter/payload-assembly.cjs +367 -0
  39. package/scripts/lib/issue-reporter/privacy-diff.cjs +385 -0
  40. package/scripts/lib/issue-reporter/report-flow.cjs +269 -0
  41. package/scripts/lib/issue-reporter/triage-matcher.cjs +270 -0
  42. package/scripts/lib/pseudonymize.cjs +444 -0
  43. package/scripts/lib/reflections-cycle-writer.cjs +172 -0
  44. package/scripts/lib/reflector/capability-gap-scan.cjs +751 -0
  45. package/scripts/lib/reflector-capability-gap-aggregator.cjs +352 -0
  46. package/scripts/lib/reflector-kfm-proposer.cjs +468 -0
  47. package/scripts/release-smoke-test.cjs +33 -2
  48. package/scripts/validate-incubator-scope.cjs +133 -0
  49. package/skills/apply-reflections/SKILL.md +20 -1
  50. package/skills/apply-reflections/apply-reflections-procedure.md +106 -4
  51. package/skills/fast/SKILL.md +46 -0
  52. package/skills/reflect/SKILL.md +9 -0
  53. package/skills/reflect/procedures/capability-gap-scan.md +120 -0
  54. package/skills/report-issue/SKILL.md +53 -0
  55. package/skills/report-issue/report-issue-procedure.md +120 -0
  56. package/skills/router/SKILL.md +5 -0
  57. package/skills/router/capability-gap-emitter.md +65 -0
  58. package/skills/update/SKILL.md +3 -2
@@ -0,0 +1,444 @@
1
+ /**
2
+ * pseudonymize.cjs — Phase 30 pseudonymization-not-anonymization primitive.
3
+ *
4
+ * Scrubs identity-correlatable fields (git identity, paths, hostname, repo
5
+ * origin, env-var values, email, IPs) from Phase 30 issue payloads.
6
+ *
7
+ * Pipeline placement: layered downstream of `scripts/lib/redact.cjs` (Phase
8
+ * 22 secrets-stripping). The two are ORTHOGONAL — redaction handles "this
9
+ * must never escape" (tokens, keys); pseudonymization handles "this is fine
10
+ * to publish but should not personally identify the reporter" (names, paths,
11
+ * hosts). This module does NOT import `redact.cjs`; composition lives at
12
+ * the caller (Plan 30-02).
13
+ *
14
+ * Honest framing (CONTEXT D-01): PSEUDONYMIZATION, NOT ANONYMIZATION.
15
+ * Identity correlation is reduced, not eliminated — side-channel data
16
+ * (writing style, code patterns, repo fingerprints) may still re-identify.
17
+ * The disclaimer rendered at 30-04 consent time says this. See
18
+ * `reference/pseudonymization-rules.md` for the full R1..R8 rule catalog.
19
+ *
20
+ * Purity contract: no `fs`, no `child_process`, no env mutation, no network.
21
+ * Caller provides identity + hostname + repo origin + visibility via `opts`.
22
+ * Per CONTEXT D-13 the test suite uses synthetic fixtures with no live `gh`
23
+ * — `opts.repoVisibility` is the caller's resolved value.
24
+ */
25
+
26
+ 'use strict';
27
+
28
+ const crypto = require('node:crypto');
29
+
30
+ /**
31
+ * Manifest of the 8 rules. Order matches reference/pseudonymization-rules.md
32
+ * §§ R1..R8. Used by 30-07 privacy-diff to enumerate active rules. DO NOT
33
+ * reorder without updating the reference doc.
34
+ */
35
+ const RULES = Object.freeze([
36
+ Object.freeze({ id: 'R1', name: 'git-identity', replaces: 'user.name, user.email from git config', placeholder: '<user>, <user>@<domain>' }),
37
+ Object.freeze({ id: 'R2', name: 'absolute-paths', replaces: '/Users/X/, /home/X/, C:\\Users\\X\\', placeholder: '<home>/ or <home>\\' }),
38
+ Object.freeze({ id: 'R3', name: 'hostname', replaces: 'os.hostname()', placeholder: '<host>' }),
39
+ Object.freeze({ id: 'R4', name: 'repo-origin', replaces: 'git remote get-url origin', placeholder: '<category>-hash:<sha8>' }),
40
+ Object.freeze({ id: 'R5', name: 'env-vars', replaces: 'values of USER, LOGNAME, HOSTNAME, *_TOKEN, *_KEY, *_SECRET', placeholder: '<env:<KEY>>' }),
41
+ Object.freeze({ id: 'R6', name: 'email-in-logs', replaces: 'email addresses appearing in log/stack content', placeholder: '<email>' }),
42
+ Object.freeze({ id: 'R7', name: 'ip-addresses', replaces: 'IPv4/IPv6 addresses (network-class only retained)', placeholder: '<ipv4:a.b.c.0> / <ipv6:prefix>' }),
43
+ Object.freeze({ id: 'R8', name: 'stable-pseudonym', replaces: 'derived per-user identifier for maintainer-side dedup', placeholder: 'sha256(user_id + repo_origin)[:8]' }),
44
+ ]);
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // Internal helpers (NOT exported).
48
+ // ---------------------------------------------------------------------------
49
+
50
+ /**
51
+ * Escape a string for safe inclusion in a RegExp literal.
52
+ *
53
+ * @param {string} s
54
+ * @returns {string}
55
+ */
56
+ function escapeRe(s) {
57
+ return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
58
+ }
59
+
60
+ /**
61
+ * Normalize a git remote origin URL: strip leading protocol/host prefix,
62
+ * strip trailing `.git`, lowercase. Used by R4 + R8 so the same logical
63
+ * origin (across `git@`, `https` (web URL), `ssh` (ssh URL) shapes) maps to one hash.
64
+ *
65
+ * @param {string} origin
66
+ * @returns {string}
67
+ */
68
+ function normalizeRepoOrigin(origin) {
69
+ if (typeof origin !== 'string' || origin.length === 0) return '';
70
+ let s = origin.trim();
71
+ // Strip protocol / SSH prefix variants. Order matters: more-specific first.
72
+ s = s.replace(/^git@[^:]+:/i, '');
73
+ s = s.replace(/^https?:\/\/[^/]+\//i, '');
74
+ s = s.replace(/^ssh:\/\/(?:[^@]+@)?[^/]+\//i, '');
75
+ s = s.replace(/^git:\/\/[^/]+\//i, '');
76
+ // Strip trailing `.git`.
77
+ s = s.replace(/\.git$/i, '');
78
+ return s.toLowerCase();
79
+ }
80
+
81
+ /**
82
+ * Truncate strings used in the replacements log so a stray un-redacted secret
83
+ * (upstream Phase 22 miss) does not get echoed into the log at full length.
84
+ *
85
+ * @param {unknown} v
86
+ * @returns {string}
87
+ */
88
+ function truncForLog(v) {
89
+ const s = typeof v === 'string' ? v : String(v);
90
+ return s.length > 80 ? s.slice(0, 77) + '...' : s;
91
+ }
92
+
93
+ // ---------------------------------------------------------------------------
94
+ // Rule helpers — exported for fine-grained testing.
95
+ // ---------------------------------------------------------------------------
96
+
97
+ /**
98
+ * R1 — replace git user.name (word-boundary) and user.email (case-insensitive)
99
+ * with `<user>` and `<user>@<domain>` placeholders.
100
+ *
101
+ * @param {string} str
102
+ * @param {{ name?: string, email?: string, userId?: string }} [identity]
103
+ * @returns {string}
104
+ */
105
+ function replaceGitIdentity(str, identity) {
106
+ if (typeof str !== 'string') return str;
107
+ if (!identity) return str;
108
+ let out = str;
109
+ if (identity.email && typeof identity.email === 'string' && identity.email.length >= 3) {
110
+ const reEmail = new RegExp(escapeRe(identity.email), 'gi');
111
+ out = out.replace(reEmail, '<user>@<domain>');
112
+ }
113
+ if (identity.name && typeof identity.name === 'string' && identity.name.length >= 2) {
114
+ const reName = new RegExp('\\b' + escapeRe(identity.name) + '\\b', 'g');
115
+ out = out.replace(reName, '<user>');
116
+ }
117
+ return out;
118
+ }
119
+
120
+ /**
121
+ * R2 — replace home-directory absolute paths across all three OS shapes
122
+ * (Linux `/home/X/`, macOS `/Users/X/`, Windows `C:\Users\X\`) regardless of
123
+ * the current OS (payloads may be cross-OS). Identity-specific sweeps run
124
+ * BEFORE generic sweeps so identity-aware substitution takes precedence.
125
+ *
126
+ * @param {string} str
127
+ * @param {{ name?: string }} [identity]
128
+ * @returns {string}
129
+ */
130
+ function replacePaths(str, identity) {
131
+ if (typeof str !== 'string') return str;
132
+ let out = str;
133
+ const name = identity && typeof identity.name === 'string' && identity.name.length >= 1
134
+ ? identity.name
135
+ : null;
136
+
137
+ if (name) {
138
+ const escaped = escapeRe(name);
139
+ // macOS: /Users/<name>/
140
+ out = out.replace(new RegExp('/Users/' + escaped + '/', 'g'), '<home>/');
141
+ // Linux: /home/<name>/
142
+ out = out.replace(new RegExp('/home/' + escaped + '/', 'g'), '<home>/');
143
+ // Windows: <drive>:\Users\<name>\ (case-insensitive drive letter)
144
+ out = out.replace(
145
+ new RegExp('[A-Za-z]:\\\\Users\\\\' + escaped + '\\\\', 'g'),
146
+ '<home>\\',
147
+ );
148
+ }
149
+
150
+ // Generic sweeps (no identity name available, or identity name didn't match).
151
+ out = out.replace(/\/Users\/[^/\s]+\//g, '<home>/');
152
+ out = out.replace(/\/home\/[^/\s]+\//g, '<home>/');
153
+ out = out.replace(/[A-Za-z]:\\Users\\[^\\\s]+\\/g, '<home>\\');
154
+
155
+ return out;
156
+ }
157
+
158
+ /**
159
+ * R3 — replace `os.hostname()` value with `<host>`. Word-boundary substitution
160
+ * plus a special-case sweep for `@hostname` shapes inside ssh-like strings
161
+ * where the standard `\b` lookaround does not fire as expected.
162
+ *
163
+ * @param {string} str
164
+ * @param {string} hostname
165
+ * @returns {string}
166
+ */
167
+ function replaceHostname(str, hostname) {
168
+ if (typeof str !== 'string') return str;
169
+ if (typeof hostname !== 'string' || hostname.length < 2) return str;
170
+ const escaped = escapeRe(hostname);
171
+ let out = str;
172
+ // ssh-like `user@hostname` shape.
173
+ out = out.replace(new RegExp('@' + escaped + '\\b', 'g'), '@<host>');
174
+ // Standard word-boundary occurrences.
175
+ out = out.replace(new RegExp('\\b' + escaped + '\\b', 'g'), '<host>');
176
+ return out;
177
+ }
178
+
179
+ /**
180
+ * R4 — replace repository origin URL with `<category>-hash:<sha8>`.
181
+ * Caller resolves visibility via `gh repo view --json visibility`; this module
182
+ * maps visibility → category prefix:
183
+ * 'public-personal' → `public-personal-hash:<sha8>`
184
+ * everything else → `private-org-hash:<sha8>` (conservative default)
185
+ * Owner-is-user vs owner-is-org distinction is the CALLER's responsibility.
186
+ *
187
+ * @param {string} str
188
+ * @param {string} repoOrigin
189
+ * @param {string} [visibility]
190
+ * @returns {string}
191
+ */
192
+ function replaceRepoOrigin(str, repoOrigin, visibility) {
193
+ if (typeof str !== 'string') return str;
194
+ if (typeof repoOrigin !== 'string' || repoOrigin.length === 0) return str;
195
+
196
+ const normalized = normalizeRepoOrigin(repoOrigin);
197
+ if (!normalized) return str;
198
+ const hash = crypto.createHash('sha256').update(normalized).digest('hex').slice(0, 8);
199
+ const category = visibility === 'public-personal' ? 'public-personal-hash' : 'private-org-hash';
200
+ const placeholder = `${category}:${hash}`;
201
+
202
+ let out = str;
203
+ // Replace the raw origin substring (possibly multiple shapes appearing in a
204
+ // stack trace — substitute the input form first, then the normalized form).
205
+ if (repoOrigin && repoOrigin.length >= 3) {
206
+ out = out.replace(new RegExp(escapeRe(repoOrigin), 'g'), placeholder);
207
+ }
208
+ if (normalized && normalized !== repoOrigin && normalized.length >= 3) {
209
+ out = out.replace(new RegExp(escapeRe(normalized), 'g'), placeholder);
210
+ }
211
+ return out;
212
+ }
213
+
214
+ /**
215
+ * R5 — drop env-var VALUES (not key names) from anywhere in `value`. Targets
216
+ * USER, LOGNAME, HOSTNAME, *_TOKEN, *_KEY, *_SECRET. Values < 3 chars are
217
+ * skipped (corruption guard); longer values substituted first (no half-replace).
218
+ * Walks structures recursively, cycles detected via WeakSet.
219
+ *
220
+ * @param {unknown} value
221
+ * @param {Record<string, unknown>} [envSnapshot]
222
+ * @param {WeakSet<object>} [seen]
223
+ * @returns {unknown}
224
+ */
225
+ function dropEnvVars(value, envSnapshot, seen) {
226
+ const env = envSnapshot && typeof envSnapshot === 'object' ? envSnapshot : {};
227
+
228
+ // Build value→placeholder map (only entries with target keys + len ≥ 3).
229
+ /** @type {Array<{ val: string, placeholder: string }>} */
230
+ const drops = [];
231
+ for (const key of Object.keys(env)) {
232
+ const val = /** @type {Record<string, unknown>} */ (env)[key];
233
+ if (typeof val !== 'string' || val.length < 3) continue;
234
+ const isTarget =
235
+ key === 'USER' || key === 'LOGNAME' || key === 'HOSTNAME' ||
236
+ key.endsWith('_TOKEN') || key.endsWith('_KEY') || key.endsWith('_SECRET');
237
+ if (!isTarget) continue;
238
+ drops.push({ val, placeholder: `<env:${key}>` });
239
+ }
240
+ // Sort by descending length so longer values are processed first.
241
+ drops.sort((a, b) => b.val.length - a.val.length);
242
+
243
+ function walkString(s) {
244
+ let out = s;
245
+ for (const { val, placeholder } of drops) {
246
+ if (out.includes(val)) {
247
+ out = out.split(val).join(placeholder);
248
+ }
249
+ }
250
+ return out;
251
+ }
252
+
253
+ function walk(v, visited) {
254
+ if (v === null || v === undefined) return v;
255
+ if (typeof v === 'string') return walkString(v);
256
+ if (typeof v !== 'object') return v;
257
+
258
+ if (visited.has(v)) return v;
259
+ visited.add(v);
260
+
261
+ if (Array.isArray(v)) {
262
+ return v.map((x) => walk(x, visited));
263
+ }
264
+ /** @type {Record<string, unknown>} */
265
+ const out = {};
266
+ for (const k of Object.keys(v)) {
267
+ out[k] = walk(/** @type {Record<string, unknown>} */ (v)[k], visited);
268
+ }
269
+ return out;
270
+ }
271
+
272
+ return walk(value, seen ?? new WeakSet());
273
+ }
274
+
275
+ /**
276
+ * R6 — replace generic email addresses (not covered by R1's identity-aware
277
+ * substitution) with `<email>`. Apply AFTER R1 so R1 takes precedence.
278
+ *
279
+ * @param {string} str
280
+ * @returns {string}
281
+ */
282
+ function replaceEmails(str) {
283
+ if (typeof str !== 'string') return str;
284
+ return str.replace(/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g, '<email>');
285
+ }
286
+
287
+ /**
288
+ * R7 — replace IPv4/IPv6 addresses (retain only network class).
289
+ * IPv4 a.b.c.d → <ipv4:a.b.c.0> (zero last octet)
290
+ * IPv6 → <ipv6:<prefix>::> (drop last segment)
291
+ * Guards block false-positives on semver (`v1.2.3.4`), email-adjacent
292
+ * (`@1.2.3.4`), and longer dotted strings (`1.2.3.4.5`).
293
+ *
294
+ * @param {string} str
295
+ * @returns {string}
296
+ */
297
+ function replaceIPs(str) {
298
+ if (typeof str !== 'string') return str;
299
+ // IPv4: (?<![v@\d.]) blocks semver/email/dotted-context preceding; (?!\.) blocks following.
300
+ const ipv4Re = /(?<![v@\d.])\b((?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\.(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\.(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9]))\.((?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9]))\b(?!\.)/g;
301
+ let out = str.replace(ipv4Re, (match, first3 /*, last*/) => {
302
+ // Suppress trailing-zero semver shapes (`X.0.0.0`) — these are almost
303
+ // never real public IPv4 and almost always major-version strings. Real
304
+ // public IPs (`8.8.8.8`, `1.1.1.1`, etc.) still get scrubbed.
305
+ const octets = match.split('.');
306
+ if (octets[1] === '0' && octets[2] === '0' && octets[3] === '0') return match;
307
+ return `<ipv4:${first3}.0>`;
308
+ });
309
+ // IPv6: ≥5 colons avoids false-positive on time strings (12:34:56). Allow
310
+ // double-colon (::) for zero-compression — the captured segment may contain
311
+ // empty parts. Drop the LAST non-empty segment and append `::`.
312
+ const ipv6Re = /\b([0-9a-f]{1,4}(?:::?[0-9a-f]{1,4}){4,7})\b/gi;
313
+ out = out.replace(ipv6Re, (m) => {
314
+ const segs = m.split(':');
315
+ // Walk back to the last non-empty segment; drop it; append '::'.
316
+ let lastIdx = segs.length - 1;
317
+ while (lastIdx > 0 && segs[lastIdx] === '') lastIdx--;
318
+ const prefix = segs.slice(0, lastIdx).join(':');
319
+ return `<ipv6:${prefix}::>`;
320
+ });
321
+ return out;
322
+ }
323
+
324
+ /**
325
+ * R8 — derive a deterministic 8-char hex pseudonym = `sha256(userId + ':' +
326
+ * normalized_repo_origin)[:8]`. NOT applied to payload contents — a SEPARATE
327
+ * export used by 30-02 for caller-side metadata (maintainer-side dedup key).
328
+ * Defensive: falsy inputs → sentinel `'00000000'`.
329
+ *
330
+ * @param {string} userId
331
+ * @param {string} repoOrigin
332
+ * @returns {string}
333
+ */
334
+ function stablePseudonym(userId, repoOrigin) {
335
+ if (!userId || !repoOrigin) return '00000000';
336
+ const normalized = normalizeRepoOrigin(String(repoOrigin));
337
+ return crypto
338
+ .createHash('sha256')
339
+ .update(String(userId) + ':' + normalized)
340
+ .digest('hex')
341
+ .slice(0, 8);
342
+ }
343
+
344
+ // ---------------------------------------------------------------------------
345
+ // Public entry point: apply all 8 rules (well, R1..R7 — R8 is opt-in) to a
346
+ // payload value, returning the scrubbed value + a replacements log.
347
+ // ---------------------------------------------------------------------------
348
+
349
+ /**
350
+ * Apply pseudonymization rules to `payload`. R5 runs first as a tree-level
351
+ * pass; R1..R4, R6, R7 run per-string during the recursive walk. R8 is NOT
352
+ * applied here — it is a separate export for caller-side metadata.
353
+ * Returns `{ payload, replacements }` (the log feeds 30-04's "X replacements
354
+ * made (R1: 3, R2: 5, ...)" UI before submit).
355
+ *
356
+ * @param {unknown} payload
357
+ * @param {{
358
+ * identity?: { name?: string, email?: string, userId?: string },
359
+ * hostname?: string,
360
+ * repoOrigin?: string,
361
+ * repoVisibility?: ('public-personal'|'private-org'|'private'|'public'),
362
+ * envSnapshot?: Record<string, unknown>,
363
+ * }} [opts]
364
+ * @returns {{ payload: unknown, replacements: Array<{ ruleId: string, before: string, after: string }> }}
365
+ */
366
+ function pseudonymize(payload, opts) {
367
+ const options = opts || {};
368
+ const identity = options.identity || {};
369
+ const hostname = typeof options.hostname === 'string' ? options.hostname : '';
370
+ const repoOrigin = typeof options.repoOrigin === 'string' ? options.repoOrigin : '';
371
+ const visibility = options.repoVisibility;
372
+ const envSnapshot = options.envSnapshot && typeof options.envSnapshot === 'object'
373
+ ? options.envSnapshot
374
+ : {};
375
+
376
+ /** @type {Array<{ ruleId: string, before: string, after: string }>} */
377
+ const replacements = [];
378
+
379
+ // R5 first — tree-level value substitution.
380
+ const afterEnv = dropEnvVars(payload, envSnapshot);
381
+ if (JSON.stringify(afterEnv) !== JSON.stringify(payload)) {
382
+ replacements.push({
383
+ ruleId: 'R5',
384
+ before: truncForLog(JSON.stringify(payload)),
385
+ after: truncForLog(JSON.stringify(afterEnv)),
386
+ });
387
+ }
388
+
389
+ // Per-string rules table — applied in order during the recursive walk.
390
+ // R5 already ran as a tree-level pass above.
391
+ const stringRules = [
392
+ { id: 'R1', fn: (s) => replaceGitIdentity(s, identity) },
393
+ { id: 'R2', fn: (s) => replacePaths(s, identity) },
394
+ { id: 'R3', fn: (s) => replaceHostname(s, hostname) },
395
+ { id: 'R4', fn: (s) => replaceRepoOrigin(s, repoOrigin, visibility) },
396
+ { id: 'R6', fn: (s) => replaceEmails(s) },
397
+ { id: 'R7', fn: (s) => replaceIPs(s) },
398
+ ];
399
+
400
+ function rewriteString(s) {
401
+ let cur = s;
402
+ for (const { id, fn } of stringRules) {
403
+ const next = fn(cur);
404
+ if (next !== cur) {
405
+ replacements.push({ ruleId: id, before: truncForLog(cur), after: truncForLog(next) });
406
+ cur = next;
407
+ }
408
+ }
409
+ return cur;
410
+ }
411
+
412
+ function walk(v, seen) {
413
+ if (v === null || v === undefined) return v;
414
+ if (typeof v === 'string') return rewriteString(v);
415
+ if (typeof v !== 'object') return v;
416
+ if (seen.has(v)) return v;
417
+ seen.add(v);
418
+ if (Array.isArray(v)) {
419
+ return v.map((x) => walk(x, seen));
420
+ }
421
+ /** @type {Record<string, unknown>} */
422
+ const out = {};
423
+ for (const k of Object.keys(v)) {
424
+ out[k] = walk(/** @type {Record<string, unknown>} */ (v)[k], seen);
425
+ }
426
+ return out;
427
+ }
428
+
429
+ const transformed = walk(afterEnv, new WeakSet());
430
+ return { payload: transformed, replacements };
431
+ }
432
+
433
+ module.exports = {
434
+ pseudonymize,
435
+ replaceGitIdentity,
436
+ replacePaths,
437
+ replaceHostname,
438
+ replaceRepoOrigin,
439
+ dropEnvVars,
440
+ replaceEmails,
441
+ replaceIPs,
442
+ stablePseudonym,
443
+ RULES,
444
+ };
@@ -0,0 +1,172 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * reflections-cycle-writer.cjs — Plan 29-03.
4
+ *
5
+ * Thin shim invoked by the design-reflector agent (markdown) and by
6
+ * /gdd:apply-reflections to surface capability-gap clusters in the
7
+ * cycle markdown. The shim reads `.design/gep/events.jsonl`, calls
8
+ * `aggregateCapabilityGaps` + `renderGapsSection` + `evaluateStageGate`
9
+ * from `reflector-capability-gap-aggregator.cjs`, and prints the
10
+ * resulting markdown block to stdout.
11
+ *
12
+ * The agent invokes this via Bash and appends stdout to the cycle
13
+ * markdown body. Keeping the logic in a JS module (rather than inline
14
+ * in the agent prompt) preserves test coverage in
15
+ * `tests/reflector-capability-gap-aggregation.test.cjs`.
16
+ *
17
+ * Usage:
18
+ * node scripts/lib/reflections-cycle-writer.cjs [--chain=<p>] \
19
+ * [--history=<path>] \
20
+ * [--config=<path>] \
21
+ * [--cycle=<slug>]
22
+ *
23
+ * --chain=<p> path to chain JSONL (default .design/gep/events.jsonl)
24
+ * --history=<p> path to per-cycle history JSON written by prior
25
+ * /gdd:reflect invocations. Optional — when absent,
26
+ * the gate evaluation is skipped and only the current
27
+ * cycle's gaps section is emitted.
28
+ * --config=<p> path to .design/config.json (default same)
29
+ * --cycle=<slug> current cycle slug (used to label the entry in
30
+ * history if --history is writable)
31
+ *
32
+ * Exit codes:
33
+ * 0 — success (stdout is the markdown block or empty)
34
+ * 1 — unexpected error (stderr describes)
35
+ *
36
+ * D-01 honored: this shim NEVER writes to .design/config.json's
37
+ * `capability_gap_gate.stage` or `.opted_in_at`. The only timestamp it
38
+ * may touch is `user_prompted_at` (one-time-prompt suppression), and
39
+ * even that path is deferred to Plan 29-05 — for now this shim is
40
+ * read-only with respect to config.
41
+ */
42
+
43
+ 'use strict';
44
+
45
+ const { readFileSync, existsSync } = require('node:fs');
46
+ const { resolve, isAbsolute } = require('node:path');
47
+
48
+ const {
49
+ aggregateCapabilityGaps,
50
+ renderGapsSection,
51
+ evaluateStageGate,
52
+ _DEFAULT_GATE_CONFIG,
53
+ } = require('./reflector-capability-gap-aggregator.cjs');
54
+
55
+ const DEFAULT_CHAIN = '.design/gep/events.jsonl';
56
+ const DEFAULT_CONFIG = '.design/config.json';
57
+
58
+ function parseArgs(argv) {
59
+ const out = { chain: DEFAULT_CHAIN, config: DEFAULT_CONFIG, history: null, cycle: null };
60
+ for (const a of argv.slice(2)) {
61
+ if (a.startsWith('--chain=')) out.chain = a.slice('--chain='.length);
62
+ else if (a.startsWith('--history=')) out.history = a.slice('--history='.length);
63
+ else if (a.startsWith('--config=')) out.config = a.slice('--config='.length);
64
+ else if (a.startsWith('--cycle=')) out.cycle = a.slice('--cycle='.length);
65
+ }
66
+ return out;
67
+ }
68
+
69
+ function resolvePath(p, base = process.cwd()) {
70
+ if (!p) return null;
71
+ return isAbsolute(p) ? p : resolve(base, p);
72
+ }
73
+
74
+ function readJsonSafe(p) {
75
+ if (!p || !existsSync(p)) return null;
76
+ try {
77
+ return JSON.parse(readFileSync(p, 'utf8'));
78
+ } catch (err) {
79
+ process.stderr.write(`[reflections-cycle-writer] warning: malformed JSON at ${p}: ${err.message}\n`);
80
+ return null;
81
+ }
82
+ }
83
+
84
+ function gateConfigFromFile(configObj) {
85
+ if (!configObj || typeof configObj !== 'object') return _DEFAULT_GATE_CONFIG;
86
+ const gate = configObj.capability_gap_gate;
87
+ if (!gate || typeof gate !== 'object') return _DEFAULT_GATE_CONFIG;
88
+ return {
89
+ K: Number.isInteger(gate.K) ? gate.K : _DEFAULT_GATE_CONFIG.K,
90
+ M: Number.isInteger(gate.M) ? gate.M : _DEFAULT_GATE_CONFIG.M,
91
+ stddev_threshold: typeof gate.stddev_threshold === 'number'
92
+ ? gate.stddev_threshold : _DEFAULT_GATE_CONFIG.stddev_threshold,
93
+ };
94
+ }
95
+
96
+ /**
97
+ * Build the full markdown block: the current-cycle gaps section
98
+ * (always — empty when no clusters) followed by the gate-crossed
99
+ * prompt (only when crossed AND user has not been prompted before).
100
+ */
101
+ function buildBlock({ clusters, gateResult, gateConfig, configObj }) {
102
+ const parts = [];
103
+ const gapsMd = renderGapsSection(clusters);
104
+ if (gapsMd) parts.push(gapsMd);
105
+
106
+ if (gateResult && gateResult.crossed) {
107
+ const gate = (configObj && configObj.capability_gap_gate) || {};
108
+ const alreadyPrompted = typeof gate.user_prompted_at === 'string'
109
+ && gate.user_prompted_at.length > 0;
110
+ if (!alreadyPrompted) {
111
+ const idsBullets = gateResult.stable_cluster_ids
112
+ .map((id) => ` - \`${id}\``)
113
+ .join('\n');
114
+ parts.push([
115
+ '## Stage-0 → Stage-1 gate crossed — opt-in required',
116
+ '',
117
+ 'Capability-gap detection has accumulated enough signal across recent cycles to consider enabling Stage-1 (incubator authoring of new agents / skills). The gate is informational only — **nothing has changed in the runtime**, and Stage-1 will NOT auto-enable. Per Phase 29 CONTEXT.md decision D-01, the user opts in explicitly.',
118
+ '',
119
+ `- Stable clusters observed: **${gateResult.stable_cluster_ids.length}** (≥K = ${gateConfig.K})`,
120
+ `- Cycles observed: **${gateResult.cycles_observed}** (≥M = ${gateConfig.M})`,
121
+ '- Stable cluster IDs (truncated):',
122
+ idsBullets,
123
+ '',
124
+ 'If you want to enable Stage-1 incubator authoring (Plans 29-04 / 29-05), opt in with the project-local command landed by Plan 29-05. You can always opt out later by deleting the timestamps from `.design/config.json` (see `reference/capability-gap-stage-gate.md` § 7).',
125
+ '',
126
+ '<!-- TODO: Plan 29-05 (apply-reflections extension) lands the canonical opt-in command. Until then, this prompt is informational only. -->',
127
+ '',
128
+ ].join('\n'));
129
+ }
130
+ }
131
+ return parts.join('\n').trim();
132
+ }
133
+
134
+ function main() {
135
+ const args = parseArgs(process.argv);
136
+ const cwd = process.cwd();
137
+ const chainPath = resolvePath(args.chain, cwd);
138
+ const configPath = resolvePath(args.config, cwd);
139
+ const historyPath = args.history ? resolvePath(args.history, cwd) : null;
140
+
141
+ // 1. Aggregate the current cycle's capability_gap events.
142
+ const { clusters } = aggregateCapabilityGaps(chainPath);
143
+
144
+ // 2. Optionally evaluate the Stage-0 → Stage-1 gate against history.
145
+ let gateResult = null;
146
+ let gateConfig = _DEFAULT_GATE_CONFIG;
147
+ const configObj = readJsonSafe(configPath);
148
+ if (configObj) {
149
+ gateConfig = gateConfigFromFile(configObj);
150
+ }
151
+ if (historyPath && existsSync(historyPath)) {
152
+ const history = readJsonSafe(historyPath);
153
+ if (Array.isArray(history)) {
154
+ gateResult = evaluateStageGate(history, gateConfig);
155
+ }
156
+ }
157
+
158
+ const block = buildBlock({ clusters, gateResult, gateConfig, configObj });
159
+ if (block) process.stdout.write(block + '\n');
160
+ return 0;
161
+ }
162
+
163
+ if (require.main === module) {
164
+ try {
165
+ process.exit(main());
166
+ } catch (err) {
167
+ process.stderr.write(`[reflections-cycle-writer] fatal: ${err && err.message ? err.message : String(err)}\n`);
168
+ process.exit(1);
169
+ }
170
+ }
171
+
172
+ module.exports = { parseArgs, gateConfigFromFile, buildBlock };