@hegemonart/get-design-done 1.30.0 → 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.
@@ -310,10 +310,42 @@ function evaluateStageGate(history, config) {
310
310
  return { crossed, stable_cluster_ids, cycles_observed };
311
311
  }
312
312
 
313
+ // ---------------------------------------------------------------------------
314
+ // Plan 30.5-03 — Reflector KFM proposer wiring.
315
+ //
316
+ // After aggregation, downstream callers may pass the cluster list into the
317
+ // KFM proposer (`scripts/lib/reflector-kfm-proposer.cjs`). The proposer
318
+ // only emits a draft when a cluster has size ≥3 AND no existing catalogue
319
+ // entry matches (D-05). The original 5 Phase 29 proposal classes are
320
+ // untouched — this is an additive 6th pass.
321
+ //
322
+ // We deliberately load the proposer lazily inside `proposeKfmDraftsForClusters`
323
+ // so this aggregator module remains importable in environments that don't
324
+ // have the failure-mode catalogue checked in (e.g. minimal CI shards).
325
+ // ---------------------------------------------------------------------------
326
+
327
+ function proposeKfmDraftsForClusters(clusters, options) {
328
+ if (!Array.isArray(clusters) || clusters.length === 0) {
329
+ return { drafted: [], skipped: [] };
330
+ }
331
+ // require lazily — see comment above.
332
+ // eslint-disable-next-line global-require
333
+ const proposer = require('./reflector-kfm-proposer.cjs');
334
+ const drafted = [];
335
+ const skipped = [];
336
+ for (const c of clusters) {
337
+ const result = proposer.proposeKfmDraft(c, options);
338
+ if (result.action === 'drafted') drafted.push(result);
339
+ else skipped.push({ cluster_id: c && c.id, ...result });
340
+ }
341
+ return { drafted, skipped };
342
+ }
343
+
313
344
  module.exports = {
314
345
  aggregateCapabilityGaps,
315
346
  renderGapsSection,
316
347
  evaluateStageGate,
348
+ proposeKfmDraftsForClusters,
317
349
  // Exported for testing / introspection only:
318
350
  _betaStddev: betaStddev,
319
351
  _DEFAULT_GATE_CONFIG: DEFAULT_GATE_CONFIG,
@@ -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
+ };
@@ -80,6 +80,10 @@ Incubator drafts authored by `scripts/lib/incubator-author.cjs` (Phase 29-04) ap
80
80
 
81
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
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
+
83
87
  ## Do Not
84
88
 
85
89
  - Do not apply any proposal without the user explicitly choosing `a` or `e`.
@@ -1,10 +1,10 @@
1
1
  ---
2
2
  name: apply-reflections-procedure
3
3
  type: heuristic
4
- version: 1.2.0
5
- phase: 28.5
6
- tags: [apply-reflections, proposal, frontmatter, reference, budget, question, global-skill, incubator]
7
- last_updated: 2026-05-20
4
+ version: 1.3.0
5
+ phase: 30.5
6
+ tags: [apply-reflections, proposal, frontmatter, reference, budget, question, global-skill, incubator, kfm-candidate]
7
+ last_updated: 2026-05-21
8
8
  ---
9
9
 
10
10
  # Apply-Reflections — Per-Type Procedure
@@ -134,3 +134,37 @@ bandit.updateWithDelegate({
134
134
  ```
135
135
 
136
136
  Omitting `prior_class` reverts to Phase 23.5 informed-prior bootstrap (non-breaking). The reward math is unchanged — `prior_class` only affects bootstrap.
137
+
138
+ ### [KFM-CANDIDATE]
139
+
140
+ Known-failure-mode catalogue proposals come from `scripts/lib/reflector-kfm-proposer.cjs` (Phase 30.5-03 D-05). They live at `.design/reflections/incubator/kfm-<slug>/CATALOGUE-ENTRY.md` and contain a single fenced ```yaml block pre-filled with the Phase 30.5 schema-v2 11-field shape (`id` + `pattern` + `diagnosis` + `remedy` + `severity` + `propose_report` + `symptom` + `root_cause` + `fix` + `related_phases` + `first_observed_cycle`). Two of those — `pattern` and `fix` — are `TODO:` placeholders the reflector cannot infer; the user fills them via the **edit** action before accepting.
141
+
142
+ Two upstream signals share this draft surface (D-06):
143
+ - `capability_gap` clusters of size ≥3 with no existing-entry match (Phase 29-03 aggregator + `failure-mode-matcher.match()`).
144
+ - `kfm-candidate` events from the Phase 30.5-03 Task 2 authority-watcher whitelist (D-06 — single events bypass the ≥3 gate).
145
+
146
+ Use `scripts/lib/reflector-kfm-proposer.cjs` for all actions:
147
+
148
+ **Discovery + render** (once per cycle):
149
+
150
+ 1. Glob `.design/reflections/incubator/kfm-*/CATALOGUE-ENTRY.md` → list pending KFM drafts.
151
+ 2. For each draft: read the body, show the origin header (source, parent event ids OR article url) + the proposed yaml block.
152
+ 3. Prompt: `(a) accept (r) reject (d) defer (e) edit (q) quit`.
153
+
154
+ **Per-action behavior:**
155
+
156
+ 1. **accept** — call `applyAccept(draftPath, { repoRoot })`.
157
+ - The helper re-stamps the proposed `id` with the next available `KFM-NNN` from the catalogue (avoids collisions when multiple drafts promote in the same run).
158
+ - Appends a `### KFM-NNN — <symptom heading>` section into `reference/known-failure-modes.md` with the yaml block intact.
159
+ - Appends a `reference/registry.json` entry: `{ name: 'known-failure-modes/kfm-NNN', path: 'reference/known-failure-modes.md', type: 'failure-mode', phase: 30.5, origin: 'incubator-kfm', added: '<ISO date>' }`.
160
+ - Removes the incubator subdir LAST (partial-failure leaves the draft retryable).
161
+ - Print: "Accepted — promoted to KFM-NNN in reference/known-failure-modes.md."
162
+ - Append `**Applied**: <date>` to the proposal entry (when surfaced from a reflections file).
163
+
164
+ 2. **reject** — call `applyReject(draftPath)`. Only the incubator subdir is removed; catalogue + registry untouched. Print: "Rejected — draft removed."
165
+
166
+ 3. **defer** — call `applyDefer(draftPath, { deferredUntil })` where `deferredUntil` is an ISO date (default: today + 30d). The helper stamps `deferred_until: <ISO>` into the draft body. Print: "Deferred — draft re-surfaces next run."
167
+
168
+ 4. **edit** — call `applyEdit(draftPath)` which returns the draft path. The caller opens `$EDITOR` on the path; on clean exit, re-discover the draft and re-prompt. Typical edits: replace `pattern: 'TODO: ...'` with a conservative regex, replace `fix: 'TODO: ...'` with a step-by-step user-runnable remedy, set `severity` if `medium` default is wrong.
169
+
170
+ **Why this is gated.** `reference/known-failure-modes.md` feeds Phase 30's `triage-matcher.cjs` BEFORE the consent prompt — a bad entry could mute legitimate issue reports. The user-review gate is non-negotiable (D-05). The proposer is strictly proposal-only; the canonical catalogue only changes via the accept action.