@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,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 };
|