@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,468 @@
1
+ /**
2
+ * scripts/lib/reflector-kfm-proposer.cjs — Plan 30.5-03 Task 1.
3
+ *
4
+ * Reflector KFM proposer: when a capability_gap cluster recurs ≥3 times
5
+ * with NO matching entry in `reference/known-failure-modes.md`, this
6
+ * module drops a draft catalogue entry into
7
+ * `.design/reflections/incubator/kfm-<slug>/CATALOGUE-ENTRY.md`. The
8
+ * draft is STRICTLY proposal-only — promotion to the canonical catalogue
9
+ * is gated through `applyAccept()` (the apply-reflections accept action,
10
+ * Plan 30.5-03 Task 1 step 5).
11
+ *
12
+ * Decisions honored:
13
+ * * D-05 — Reflector follows Phase 29 incubator-author on-disk pattern
14
+ * (drafts in `.design/reflections/incubator/<slug>/`). User reviews
15
+ * via `/gdd:apply-reflections`.
16
+ * * D-06 — Same draft surface consumed by Task 2's authority-watcher
17
+ * `kfm-candidate` event. One unified review path, not two.
18
+ * * 30.5 D-07/D-08 — Re-uses `failure-mode-matcher.match()` for
19
+ * existing-entry detection. Threshold is the matcher's default 0.4
20
+ * unless overridden via options.matcherThreshold.
21
+ * * Phase 29 SC-8 — Nothing the reflector authors auto-ships. Drafts
22
+ * sit in the incubator until the user accepts them.
23
+ *
24
+ * Public API:
25
+ * proposeKfmDraft(input, options) → Result
26
+ * shouldPropose(cluster, options) → boolean
27
+ * applyAccept(draftPath, options) → { action: 'accepted', promotedModeId }
28
+ * applyReject(draftPath, options) → { action: 'rejected' }
29
+ * applyDefer(draftPath, options) → { action: 'deferred' }
30
+ * applyEdit(draftPath, options) → { action: 'edited', path }
31
+ *
32
+ * Input shape (capability_gap cluster shape from Plan 29-03):
33
+ * { cluster_id, size, intent_summary, symptom?, suggested_kind,
34
+ * posterior?, parent_event_ids?, sources?, ... }
35
+ *
36
+ * Alternate input shape (kfm-candidate event from Task 2):
37
+ * { event_type: 'kfm-candidate', event_id, article_url, article_title,
38
+ * suggested_symptom, suggested_pattern_hint, raw_excerpt, ... }
39
+ *
40
+ * Both shapes are merged into the same `kfm-<slug>/CATALOGUE-ENTRY.md`
41
+ * draft surface (D-06).
42
+ *
43
+ * Pure CommonJS, deps = node:fs + node:path. No npm dependencies.
44
+ */
45
+
46
+ 'use strict';
47
+
48
+ const fs = require('node:fs');
49
+ const path = require('node:path');
50
+
51
+ const matcher = require('./failure-mode-matcher.cjs');
52
+
53
+ // -------------------------------------------------------------------
54
+ // Constants
55
+ // -------------------------------------------------------------------
56
+
57
+ const DEFAULT_STABILITY_K = 3;
58
+ const DEFAULT_MATCHER_THRESHOLD = 0.4; // matches failure-mode-matcher default
59
+ const INCUBATOR_PREFIX = 'kfm-';
60
+
61
+ // Phase 30.5 schema v2 fields. The two un-inferable ones (`pattern`,
62
+ // `fix`) get `TODO:` placeholders — user fills them via the apply-
63
+ // reflections edit action.
64
+ const REQUIRED_SCHEMA_FIELDS = Object.freeze([
65
+ 'id',
66
+ 'pattern',
67
+ 'diagnosis',
68
+ 'remedy',
69
+ 'severity',
70
+ 'propose_report',
71
+ 'symptom',
72
+ 'root_cause',
73
+ 'fix',
74
+ 'related_phases',
75
+ 'first_observed_cycle',
76
+ ]);
77
+
78
+ // -------------------------------------------------------------------
79
+ // Helpers
80
+ // -------------------------------------------------------------------
81
+
82
+ function findRepoRoot(startDir) {
83
+ let dir = startDir || __dirname;
84
+ for (let i = 0; i < 12; i++) {
85
+ if (fs.existsSync(path.join(dir, 'package.json'))) return dir;
86
+ const parent = path.dirname(dir);
87
+ if (parent === dir) break;
88
+ dir = parent;
89
+ }
90
+ return path.resolve(__dirname, '..', '..');
91
+ }
92
+
93
+ /**
94
+ * Kebab-case slug from a free-text symptom (mirrors incubator-author
95
+ * deriveSlug semantics — ASCII-only, dash-collapsed, ≤40 chars).
96
+ */
97
+ function deriveSlug(text) {
98
+ const raw = typeof text === 'string' ? text : '';
99
+ let s = raw.toLowerCase();
100
+ s = s.replace(/[^\x20-\x7e]+/g, '');
101
+ s = s.replace(/[^a-z0-9]+/g, '-');
102
+ s = s.replace(/-+/g, '-');
103
+ s = s.replace(/^-+|-+$/g, '');
104
+ if (s.length > 40) s = s.slice(0, 40);
105
+ s = s.replace(/-+$/g, '');
106
+ return s || 'unnamed';
107
+ }
108
+
109
+ /**
110
+ * Quote a YAML scalar — single-quote shape with `''` escape (matches the
111
+ * catalogue's serialization).
112
+ */
113
+ function quoteYaml(s) {
114
+ if (s === undefined || s === null) return "''";
115
+ const str = String(s);
116
+ // Use single quotes with `''` escape if value contains : # or starts/ends with whitespace.
117
+ if (/[:#'"\\\n\r]|^\s|\s$/.test(str) || str === '') {
118
+ return `'${str.replace(/'/g, "''")}'`;
119
+ }
120
+ return str;
121
+ }
122
+
123
+ /**
124
+ * Build a YAML block string from a fields object.
125
+ * Order: REQUIRED_SCHEMA_FIELDS, then any extras alphabetised.
126
+ */
127
+ function serializeYaml(fields) {
128
+ const out = [];
129
+ const extras = Object.keys(fields)
130
+ .filter((k) => !REQUIRED_SCHEMA_FIELDS.includes(k))
131
+ .sort();
132
+ for (const k of [...REQUIRED_SCHEMA_FIELDS, ...extras]) {
133
+ if (!(k in fields)) continue;
134
+ const v = fields[k];
135
+ if (Array.isArray(v)) {
136
+ out.push(`${k}: [${v.join(', ')}]`);
137
+ } else if (typeof v === 'boolean') {
138
+ out.push(`${k}: ${v}`);
139
+ } else if (typeof v === 'number') {
140
+ out.push(`${k}: ${v}`);
141
+ } else {
142
+ out.push(`${k}: ${quoteYaml(v)}`);
143
+ }
144
+ }
145
+ return out.join('\n');
146
+ }
147
+
148
+ /**
149
+ * Compute the next available `KFM-NNN` numeric id from the catalogue.
150
+ * Returns the modeId string.
151
+ */
152
+ function nextKfmId(cataloguePath) {
153
+ let max = 0;
154
+ try {
155
+ const text = fs.readFileSync(cataloguePath, 'utf8');
156
+ const ids = text.match(/id:\s*KFM-(\d+)/g) || [];
157
+ for (const m of ids) {
158
+ const n = parseInt(m.replace(/[^0-9]/g, ''), 10);
159
+ if (Number.isFinite(n) && n > max) max = n;
160
+ }
161
+ } catch (_e) {
162
+ // Catalogue missing — start from 1.
163
+ }
164
+ return `KFM-${String(max + 1).padStart(3, '0')}`;
165
+ }
166
+
167
+ // -------------------------------------------------------------------
168
+ // Input shape normalisation (cluster OR kfm-candidate event)
169
+ // -------------------------------------------------------------------
170
+
171
+ /**
172
+ * Normalise either a capability_gap cluster OR a kfm-candidate event
173
+ * into a uniform `{ symptom, slug, size, sourceLabel, articleUrl?,
174
+ * articleTitle?, suggestedPatternHint?, rawExcerpt? }` shape.
175
+ */
176
+ function normaliseInput(input) {
177
+ if (!input || typeof input !== 'object') return null;
178
+
179
+ // kfm-candidate event shape (Task 2, D-06).
180
+ if (input.event_type === 'kfm-candidate' ||
181
+ (input.source === 'authority_watcher' && input.suggested_symptom)) {
182
+ const symptom = String(input.suggested_symptom || '').trim();
183
+ if (!symptom) return null;
184
+ return {
185
+ symptom,
186
+ slug: deriveSlug(symptom),
187
+ size: 1, // single-event source; bypasses ≥3 gate (D-06: authority signal is a 1-shot whitelist match).
188
+ sourceLabel: 'authority_watcher',
189
+ articleUrl: input.article_url,
190
+ articleTitle: input.article_title,
191
+ suggestedPatternHint: input.suggested_pattern_hint,
192
+ rawExcerpt: input.raw_excerpt,
193
+ via: 'kfm-candidate',
194
+ };
195
+ }
196
+
197
+ // capability_gap cluster shape (Task 1, D-05).
198
+ const symptom = String(
199
+ input.symptom || input.intent_summary || ''
200
+ ).trim();
201
+ if (!symptom) return null;
202
+ return {
203
+ symptom,
204
+ slug: deriveSlug(symptom),
205
+ size: Number(input.size) || 0,
206
+ sourceLabel: 'reflector_capability_gap',
207
+ parentEventIds: Array.isArray(input.parent_event_ids) ? input.parent_event_ids : [],
208
+ suggestedKind: input.suggested_kind,
209
+ posterior: input.posterior,
210
+ sources: input.sources,
211
+ cycles: input.cycles_observed,
212
+ via: 'capability_gap',
213
+ intentSummary: input.intent_summary,
214
+ };
215
+ }
216
+
217
+ // -------------------------------------------------------------------
218
+ // Public API
219
+ // -------------------------------------------------------------------
220
+
221
+ /**
222
+ * Decide whether a normalised input qualifies for proposal.
223
+ * Returns { ok: boolean, reason?: string, matchedModeId?: string }.
224
+ */
225
+ function shouldPropose(input, options) {
226
+ const opts = options || {};
227
+ const repoRoot = opts.repoRoot || findRepoRoot();
228
+ const cataloguePath = path.join(repoRoot, 'reference', 'known-failure-modes.md');
229
+ const threshold = Number.isFinite(opts.matcherThreshold)
230
+ ? opts.matcherThreshold
231
+ : DEFAULT_MATCHER_THRESHOLD;
232
+ const stabilityK = Number.isFinite(opts.stabilityK)
233
+ ? opts.stabilityK
234
+ : DEFAULT_STABILITY_K;
235
+
236
+ const norm = normaliseInput(input);
237
+ if (!norm) return { ok: false, reason: 'invalid_input' };
238
+
239
+ // kfm-candidate events bypass ≥K gate (D-06 — authority-watcher is a
240
+ // human-curated whitelist hit, treated as 1-shot signal).
241
+ if (norm.via !== 'kfm-candidate' && norm.size < stabilityK) {
242
+ return { ok: false, reason: 'below_stability_k' };
243
+ }
244
+
245
+ // Existing-entry check via failure-mode-matcher.
246
+ const matches = matcher.match(
247
+ { message: norm.symptom, stack: '' },
248
+ { cataloguePath, threshold, topN: 1 }
249
+ );
250
+ if (Array.isArray(matches) && matches.length >= 1 && matches[0].confidence >= threshold) {
251
+ return { ok: false, reason: 'matched_existing', matchedModeId: matches[0].modeId };
252
+ }
253
+
254
+ return { ok: true };
255
+ }
256
+
257
+ /**
258
+ * Propose a KFM draft for a capability_gap cluster OR a kfm-candidate
259
+ * event. Returns:
260
+ * { action: 'drafted', path, slug, proposed_id }
261
+ * { action: 'skipped', reason, matchedModeId? }
262
+ */
263
+ function proposeKfmDraft(input, options) {
264
+ const opts = options || {};
265
+ const repoRoot = opts.repoRoot || findRepoRoot();
266
+ const incubatorRoot = path.join(repoRoot, '.design', 'reflections', 'incubator');
267
+ const cataloguePath = path.join(repoRoot, 'reference', 'known-failure-modes.md');
268
+ const now = opts.now || new Date().toISOString().slice(0, 10);
269
+ const cycleSlug = opts.cycleSlug || `cycle-${now.slice(0, 7)}`; // cycle-YYYY-MM
270
+
271
+ const gate = shouldPropose(input, opts);
272
+ if (!gate.ok) {
273
+ return { action: 'skipped', reason: gate.reason, matchedModeId: gate.matchedModeId };
274
+ }
275
+
276
+ const norm = normaliseInput(input);
277
+ const slug = norm.slug;
278
+ const proposedId = opts.proposedId || nextKfmId(cataloguePath);
279
+
280
+ // Provisional schema fields — `pattern` and `fix` MUST be placeholders
281
+ // per Plan 30.5-03 Task 1 step 3 (reflector can't infer these).
282
+ const fields = {
283
+ id: proposedId,
284
+ pattern: 'TODO: <regex against error.message + error.stack>',
285
+ diagnosis: norm.symptom.length > 0 ? norm.symptom.split('\n')[0].slice(0, 240) : 'TODO: <one-sentence root cause>',
286
+ remedy: 'TODO: <user-runnable one-liner>',
287
+ severity: 'medium',
288
+ propose_report: false,
289
+ symptom: norm.symptom,
290
+ root_cause: norm.suggestedPatternHint || 'TODO: <technical explanation>',
291
+ fix: 'TODO: <step-by-step user-runnable remedy>',
292
+ related_phases: [],
293
+ first_observed_cycle: cycleSlug,
294
+ };
295
+
296
+ const draftDir = path.join(incubatorRoot, `${INCUBATOR_PREFIX}${slug}`);
297
+ fs.mkdirSync(draftDir, { recursive: true });
298
+ const draftPath = path.join(draftDir, 'CATALOGUE-ENTRY.md');
299
+
300
+ const originHeader = [
301
+ `# KFM proposal — ${proposedId}`,
302
+ '',
303
+ `**Source:** ${norm.sourceLabel}`,
304
+ `**Via:** ${norm.via}`,
305
+ norm.parentEventIds ? `**Parent event ids:** ${norm.parentEventIds.join(', ') || '(none)'}` : null,
306
+ norm.articleUrl ? `**Article URL:** ${norm.articleUrl}` : null,
307
+ norm.articleTitle ? `**Article title:** ${norm.articleTitle}` : null,
308
+ norm.rawExcerpt ? `**Excerpt:** ${norm.rawExcerpt.replace(/\n/g, ' ').slice(0, 500)}` : null,
309
+ '',
310
+ `Drafted ${now}. Review via \`/gdd:apply-reflections\` → [KFM-CANDIDATE] proposal class.`,
311
+ '',
312
+ 'Fill the `TODO:` placeholders before accepting. The `pattern` regex is matched against',
313
+ '`[error.message, error.stack].filter(Boolean).join("\\n")` — keep it conservative so',
314
+ 'first-match-wins (Phase 30 D-13) does not steal traffic from other entries.',
315
+ '',
316
+ '## Proposed YAML',
317
+ '',
318
+ '```yaml',
319
+ serializeYaml(fields),
320
+ '```',
321
+ '',
322
+ ].filter((line) => line !== null).join('\n');
323
+
324
+ fs.writeFileSync(draftPath, originHeader);
325
+
326
+ return {
327
+ action: 'drafted',
328
+ path: draftPath,
329
+ slug: `${INCUBATOR_PREFIX}${slug}`,
330
+ proposed_id: proposedId,
331
+ };
332
+ }
333
+
334
+ // -------------------------------------------------------------------
335
+ // Apply-reflections actions: accept / reject / defer / edit
336
+ // -------------------------------------------------------------------
337
+
338
+ /**
339
+ * Promote a draft → canonical catalogue + registry.json.
340
+ * Returns { action: 'accepted', promotedModeId }.
341
+ */
342
+ function applyAccept(draftPath, options) {
343
+ const opts = options || {};
344
+ const repoRoot = opts.repoRoot || findRepoRoot();
345
+ const cataloguePath = path.join(repoRoot, 'reference', 'known-failure-modes.md');
346
+ const registryPath = path.join(repoRoot, 'reference', 'registry.json');
347
+
348
+ if (!fs.existsSync(draftPath)) {
349
+ throw new Error(`KFM draft not found: ${draftPath}`);
350
+ }
351
+ const draftText = fs.readFileSync(draftPath, 'utf8');
352
+ const yamlMatch = draftText.match(/```yaml\s*\n([\s\S]*?)\n```/);
353
+ if (!yamlMatch) {
354
+ throw new Error(`KFM draft missing yaml block: ${draftPath}`);
355
+ }
356
+ let yamlBody = yamlMatch[1];
357
+
358
+ // Re-stamp id to next available — the proposed id may have collided
359
+ // with intervening promotions on shared incubator surfaces.
360
+ const finalId = opts.finalId || nextKfmId(cataloguePath);
361
+ yamlBody = yamlBody.replace(/^id:\s*KFM-\d+/m, `id: ${finalId}`);
362
+
363
+ // Extract the symptom for the catalogue heading.
364
+ const symptomMatch = yamlBody.match(/^symptom:\s*'?(.+?)'?$/m);
365
+ const symptomHeading = symptomMatch ? symptomMatch[1].slice(0, 80) : finalId;
366
+
367
+ // Append to catalogue.
368
+ const block = `\n### ${finalId} — ${symptomHeading}\n\nPromoted from incubator KFM proposal.\n\n\`\`\`yaml\n${yamlBody}\n\`\`\`\n`;
369
+ fs.appendFileSync(cataloguePath, block);
370
+
371
+ // Register in registry.json.
372
+ let registry;
373
+ try {
374
+ registry = JSON.parse(fs.readFileSync(registryPath, 'utf8'));
375
+ } catch (_e) {
376
+ registry = { version: 1, entries: [] };
377
+ }
378
+ if (!Array.isArray(registry.entries)) registry.entries = [];
379
+ registry.entries.push({
380
+ name: `known-failure-modes/${finalId.toLowerCase()}`,
381
+ path: 'reference/known-failure-modes.md',
382
+ type: 'failure-mode',
383
+ phase: 30.5,
384
+ description: `${finalId} — ${symptomHeading}`,
385
+ origin: 'incubator-kfm',
386
+ added: new Date().toISOString().slice(0, 10),
387
+ });
388
+ fs.writeFileSync(registryPath, JSON.stringify(registry, null, 2));
389
+
390
+ // Remove incubator dir LAST (T-29.05-04 — partial failure leaves draft retryable).
391
+ const incubatorDir = path.dirname(draftPath);
392
+ try {
393
+ for (const f of fs.readdirSync(incubatorDir)) {
394
+ fs.unlinkSync(path.join(incubatorDir, f));
395
+ }
396
+ fs.rmdirSync(incubatorDir);
397
+ } catch (_e) {
398
+ // Best-effort; the catalogue + registry promotions already landed.
399
+ }
400
+
401
+ return { action: 'accepted', promotedModeId: finalId };
402
+ }
403
+
404
+ /**
405
+ * Remove the incubator draft directory.
406
+ */
407
+ function applyReject(draftPath, _options) {
408
+ if (!fs.existsSync(draftPath)) {
409
+ return { action: 'rejected', noop: true };
410
+ }
411
+ const dir = path.dirname(draftPath);
412
+ try {
413
+ for (const f of fs.readdirSync(dir)) {
414
+ fs.unlinkSync(path.join(dir, f));
415
+ }
416
+ fs.rmdirSync(dir);
417
+ } catch (_e) {
418
+ // Best-effort.
419
+ }
420
+ return { action: 'rejected' };
421
+ }
422
+
423
+ /**
424
+ * Stamp `deferred_until` into the draft body. Draft remains in place.
425
+ */
426
+ function applyDefer(draftPath, options) {
427
+ const opts = options || {};
428
+ if (!fs.existsSync(draftPath)) {
429
+ throw new Error(`KFM draft not found: ${draftPath}`);
430
+ }
431
+ const deferredUntil = opts.deferredUntil || new Date(Date.now() + 30 * 86_400_000).toISOString().slice(0, 10);
432
+ const orig = fs.readFileSync(draftPath, 'utf8');
433
+ let updated;
434
+ if (/^deferred_until:/m.test(orig)) {
435
+ updated = orig.replace(/^deferred_until:.*$/m, `deferred_until: ${deferredUntil}`);
436
+ } else {
437
+ updated = `${orig}\ndeferred_until: ${deferredUntil}\n`;
438
+ }
439
+ fs.writeFileSync(draftPath, updated);
440
+ return { action: 'deferred', deferredUntil };
441
+ }
442
+
443
+ /**
444
+ * Edit hook — returns the draft path so the caller can open `$EDITOR`.
445
+ * Caller re-renders the proposal after edit, per Phase 29-05 semantics.
446
+ */
447
+ function applyEdit(draftPath, _options) {
448
+ if (!fs.existsSync(draftPath)) {
449
+ throw new Error(`KFM draft not found: ${draftPath}`);
450
+ }
451
+ return { action: 'edited', path: draftPath };
452
+ }
453
+
454
+ module.exports = {
455
+ proposeKfmDraft,
456
+ shouldPropose,
457
+ applyAccept,
458
+ applyReject,
459
+ applyDefer,
460
+ applyEdit,
461
+ // Exposed for tests / higher-level integration.
462
+ _deriveSlug: deriveSlug,
463
+ _nextKfmId: nextKfmId,
464
+ _normaliseInput: normaliseInput,
465
+ _REQUIRED_SCHEMA_FIELDS: REQUIRED_SCHEMA_FIELDS,
466
+ _DEFAULT_STABILITY_K: DEFAULT_STABILITY_K,
467
+ _DEFAULT_MATCHER_THRESHOLD: DEFAULT_MATCHER_THRESHOLD,
468
+ };
@@ -50,6 +50,27 @@ if (!fs.existsSync(FIXTURE_SRC)) {
50
50
  const tmpDir = path.join(os.tmpdir(), `gdd-smoke-${Date.now()}`);
51
51
  fs.mkdirSync(tmpDir, { recursive: true });
52
52
 
53
+ // Snapshot REPO_ROOT/.design/ contents BEFORE the smoke test runs. This lets
54
+ // the post-test pollution assertion (below) detect actual pollution (new
55
+ // files created during the run) rather than tripping on legitimately-tracked
56
+ // `.design/` files that exist in a fresh checkout — e.g.
57
+ // `.design/config.example.json` shipped by Plan 29-02 for discoverability.
58
+ function snapshotDesignDir() {
59
+ const designDir = path.join(REPO_ROOT, '.design');
60
+ if (!fs.existsSync(designDir)) return '<absent>';
61
+ const entries = [];
62
+ function walk(d) {
63
+ for (const ent of fs.readdirSync(d, { withFileTypes: true })) {
64
+ const full = path.join(d, ent.name);
65
+ if (ent.isDirectory()) walk(full);
66
+ else entries.push(path.relative(designDir, full).replace(/\\/g, '/'));
67
+ }
68
+ }
69
+ walk(designDir);
70
+ return entries.sort().join('\n');
71
+ }
72
+ const designSnapshotBefore = snapshotDesignDir();
73
+
53
74
  function copyRecursive(src, dst) {
54
75
  fs.mkdirSync(dst, { recursive: true });
55
76
  for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
@@ -161,8 +182,18 @@ if (missing.length) {
161
182
  console.log(`smoke-test: ${diffs.length} diffs, ${missing.length} baseline artifacts not in fresh run`);
162
183
 
163
184
  // Ensure .design/ was not created in the real repo root.
164
- if (fs.existsSync(path.join(REPO_ROOT, '.design'))) {
165
- console.error('ERROR: .design/ polluted repo root — smoke test must use temp dir only');
185
+ // Pollution check: only fail if NEW files appeared in REPO_ROOT/.design/ during
186
+ // the smoke test. Tracked files (like `.design/config.example.json` shipped by
187
+ // Plan 29-02) are present in the fresh checkout and are NOT pollution. We
188
+ // compare the directory snapshot taken before the test (top of file) against
189
+ // the post-test snapshot.
190
+ const designSnapshotAfter = snapshotDesignDir();
191
+ if (designSnapshotBefore !== designSnapshotAfter) {
192
+ console.error('ERROR: .design/ contents changed during smoke test — pipeline wrote to REPO_ROOT instead of temp dir');
193
+ console.error('Before:');
194
+ for (const line of designSnapshotBefore.split('\n')) console.error(` ${line}`);
195
+ console.error('After:');
196
+ for (const line of designSnapshotAfter.split('\n')) console.error(` ${line}`);
166
197
  process.exit(1);
167
198
  }
168
199
 
@@ -0,0 +1,133 @@
1
+ #!/usr/bin/env node
2
+ // scripts/validate-incubator-scope.cjs — Plan 29-05
3
+ //
4
+ // Phase 29 D-05: scope guard for incubator-draft promotion.
5
+ //
6
+ // Purpose
7
+ // Enforce that a drafted incubator artifact can only resolve to one of:
8
+ // * `agents/<slug>.md` (Phase 28.5 agent files)
9
+ // * `skills/<slug>/SKILL.md` (Phase 28.5 skill files)
10
+ // Any other path (script, hook, runtime, transport, root-escape, absolute
11
+ // path outside the repo, traversal segment) is rejected with a non-zero
12
+ // exit and an informative error message.
13
+ //
14
+ // This script is invoked BEFORE any file write inside
15
+ // `scripts/lib/apply-reflections/incubator-proposals.cjs#applyAccept`, and
16
+ // is the second non-bypassable line of defense after the floor enforced by
17
+ // `scripts/lib/incubator-author.cjs#safeWritePath` at draft-time.
18
+ //
19
+ // Non-bypassable (D-05)
20
+ // No flag, env var, or argument disables the check. Promotion targets that
21
+ // fail the regex check throw — period. There is no opt-out flag, no
22
+ // environment override, and the CLI offers no escape hatch. (The scan in
23
+ // tests/apply-reflections-incubator.test.cjs grep-asserts the absence of
24
+ // bypass tokens in this file's source, so even adding such an option in
25
+ // future would break the build.)
26
+ //
27
+ // API
28
+ // validateScope(targetPath, { repoRoot } = {})
29
+ // → { ok: true } // accepted
30
+ // → throws Error(...) // rejected; message names offending path + allowed patterns
31
+ //
32
+ // CLI
33
+ // node scripts/validate-incubator-scope.cjs <path>
34
+ // exit 0 + `[scope-guard] ok: <relPath>` on success
35
+ // exit 1 + descriptive stderr on failure
36
+ //
37
+ // Style: zero deps beyond node:fs + node:path (matches scripts/lib/incubator-author.cjs).
38
+
39
+ 'use strict';
40
+
41
+ const path = require('node:path');
42
+
43
+ // Allowed target patterns — slug rules match the Phase 28.5 frontmatter slug
44
+ // regex (lowercase, digits, hyphens; must start with [a-z0-9]).
45
+ const SLUG_RE_FRAGMENT = '[a-z0-9][a-z0-9-]*';
46
+ const AGENT_RE = new RegExp(`^agents/${SLUG_RE_FRAGMENT}\\.md$`);
47
+ const SKILL_RE = new RegExp(`^skills/${SLUG_RE_FRAGMENT}/SKILL\\.md$`);
48
+
49
+ /**
50
+ * Validate that a target path is in scope for incubator promotion.
51
+ *
52
+ * Algorithm:
53
+ * 1. Resolve to absolute path under repoRoot.
54
+ * 2. Reject if the resolved path escapes repoRoot (path traversal or
55
+ * absolute path pointing outside the repository).
56
+ * 3. Compute repo-relative path with forward-slash normalization.
57
+ * 4. Reject if the relative path doesn't match exactly one of the two
58
+ * allowed patterns.
59
+ *
60
+ * @param {string} targetPath - file path to validate; relative paths are
61
+ * resolved against repoRoot.
62
+ * @param {{repoRoot?: string}} [opts] - configuration. repoRoot defaults to
63
+ * process.cwd().
64
+ * @returns {{ok: true}} on success.
65
+ * @throws {Error} on any rejection. Message includes the offending path and
66
+ * the allowed patterns.
67
+ */
68
+ function validateScope(targetPath, opts) {
69
+ const o = opts || {};
70
+ const repoRoot = path.resolve(o.repoRoot || process.cwd());
71
+
72
+ if (typeof targetPath !== 'string' || !targetPath.length) {
73
+ throw new Error(
74
+ `[scope-guard] invalid input: targetPath must be a non-empty string. ` +
75
+ `Allowed: ${AGENT_RE.source} or ${SKILL_RE.source}`,
76
+ );
77
+ }
78
+
79
+ // Resolve relative to repoRoot. Absolute paths bypass repoRoot prefixing;
80
+ // that's fine — the prefix check below catches them anyway.
81
+ const resolved = path.resolve(repoRoot, targetPath);
82
+
83
+ // Step 1: confirm resolved path is inside repoRoot. We compare with a
84
+ // trailing separator to avoid `repoRoot-evil/...` slipping past a startsWith
85
+ // check.
86
+ const rootWithSep = repoRoot + path.sep;
87
+ if (!(resolved === repoRoot || resolved.startsWith(rootWithSep))) {
88
+ throw new Error(
89
+ `[scope-guard] path escapes repository: ${targetPath} → ${resolved} ` +
90
+ `(outside ${repoRoot}). Allowed: agents/<slug>.md or skills/<slug>/SKILL.md`,
91
+ );
92
+ }
93
+
94
+ // Step 2: compute repo-relative path and normalize separators to '/'
95
+ // (Windows uses '\\' natively).
96
+ const rel = path.relative(repoRoot, resolved).replace(/\\/g, '/');
97
+
98
+ // Step 3: match exactly one of the allowed shapes.
99
+ if (AGENT_RE.test(rel) || SKILL_RE.test(rel)) {
100
+ return { ok: true };
101
+ }
102
+
103
+ throw new Error(
104
+ `[scope-guard] path not in allowed scope: ${rel} ` +
105
+ `(input: ${targetPath}). Allowed patterns: ` +
106
+ `agents/<slug>.md (regex ${AGENT_RE.source}) ` +
107
+ `or skills/<slug>/SKILL.md (regex ${SKILL_RE.source}). ` +
108
+ `Note: scope guard is non-bypassable per Phase 29 D-05.`,
109
+ );
110
+ }
111
+
112
+ module.exports = { validateScope };
113
+
114
+ // -------------------------------------------------------------------
115
+ // CLI entry
116
+ // -------------------------------------------------------------------
117
+
118
+ if (require.main === module) {
119
+ const input = process.argv[2];
120
+ if (!input) {
121
+ console.error('[scope-guard] usage: node scripts/validate-incubator-scope.cjs <path>');
122
+ process.exit(1);
123
+ }
124
+ try {
125
+ validateScope(input);
126
+ const rel = path.relative(process.cwd(), path.resolve(process.cwd(), input)).replace(/\\/g, '/');
127
+ console.log(`[scope-guard] ok: ${rel}`);
128
+ process.exit(0);
129
+ } catch (err) {
130
+ console.error(err.message);
131
+ process.exit(1);
132
+ }
133
+ }
@@ -66,8 +66,27 @@ Apply-reflections complete
66
66
  ─────────────────────────────────────────
67
67
  ```
68
68
 
69
+ ## [INCUBATOR]
70
+
71
+ Incubator drafts authored by `scripts/lib/incubator-author.cjs` (Phase 29-04) appear as a distinct proposal class. For each draft under `.design/reflections/incubator/<slug>/`, use `scripts/lib/apply-reflections/incubator-proposals.cjs`:
72
+
73
+ 1. `discoverIncubatorDrafts()` → list pending drafts.
74
+ 2. `renderProposal(draft)` → show full body + diff + origin signals.
75
+ 3. User chooses **accept** | **reject** | **defer** | **edit**.
76
+ 4. **accept** — scope-guard runs FIRST (`validateScope` from `scripts/validate-incubator-scope.cjs`); `applyAccept` then promotes draft → `agents/<slug>.md` or `skills/<slug>/SKILL.md` and appends a registry entry. Single-step per D-04.
77
+ 5. **reject** — `applyReject` removes the incubator subdir.
78
+ 6. **defer** — no-op; draft re-surfaces next run.
79
+ 7. **edit** — `applyEdit` opens `$EDITOR`; re-prompt user on close.
80
+
81
+ **Stage-1 gate.** At session start, call `checkStage1Gate()`. If `thresholdMet && !optInRecorded`, display the opt-in prompt once. NEVER auto-flip per D-01 — recording opt-in requires explicit user confirmation via `recordOptIn()`. Full procedure: `./apply-reflections-procedure.md` §[INCUBATOR].
82
+
83
+ ## [KFM-CANDIDATE]
84
+
85
+ KFM-catalogue proposals authored by `scripts/lib/reflector-kfm-proposer.cjs` (Phase 30.5-03 D-05) appear as a 6th proposal class. Drafts at `.design/reflections/incubator/kfm-<slug>/CATALOGUE-ENTRY.md`; pre-filled 11-field schema with `TODO:` placeholders for `pattern` + `fix`. Two upstream signals share the surface (D-06): `capability_gap` clusters (≥3, no existing match) + `kfm-candidate` events (whitelist-matched articles, 1-shot). User chooses **accept** | **reject** | **defer** | **edit**. `applyAccept` appends to `reference/known-failure-modes.md` + `reference/registry.json` (`origin: incubator-kfm`); `applyReject` removes the incubator subdir; `applyDefer` stamps `deferred_until`; `applyEdit` returns the draft path for `$EDITOR`. Full procedure: `./apply-reflections-procedure.md` §[KFM-CANDIDATE].
86
+
69
87
  ## Do Not
70
88
 
71
89
  - Do not apply any proposal without the user explicitly choosing `a` or `e`.
72
- - Do not modify source code files (`.ts`, `.tsx`, `.css`, `.js`) — only agent files, reference files, budget.json, discussant questions, and global skills.
90
+ - Do not modify source code files (`.ts`, `.tsx`, `.css`, `.js`) — only agent files, reference files, budget.json, discussant questions, global skills, and incubator drafts.
73
91
  - Do not re-run the reflector — this skill only applies existing proposals.
92
+ - Do not bypass the scope guard or auto-flip Stage-1 — both are non-negotiable per D-05 / D-01.