@hegemonart/get-design-done 1.28.8 → 1.30.0

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 (53) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +81 -0
  4. package/README.de.md +23 -0
  5. package/README.fr.md +23 -0
  6. package/README.it.md +23 -0
  7. package/README.ja.md +23 -0
  8. package/README.ko.md +23 -0
  9. package/README.md +28 -0
  10. package/README.zh-CN.md +23 -0
  11. package/SKILL.md +2 -0
  12. package/agents/design-reflector.md +50 -0
  13. package/package.json +1 -1
  14. package/reference/capability-gap-stage-gate.md +261 -0
  15. package/reference/known-failure-modes.md +185 -0
  16. package/reference/pseudonymization-rules.md +189 -0
  17. package/reference/registry.json +22 -1
  18. package/reference/schemas/events.schema.json +97 -3
  19. package/reference/schemas/generated.d.ts +319 -4
  20. package/scripts/cli/gdd-events.mjs +35 -2
  21. package/scripts/gsd-cleanup-incubator.cjs +367 -0
  22. package/scripts/lib/apply-reflections/incubator-proposals.cjs +448 -0
  23. package/scripts/lib/bandit-router.cjs +92 -9
  24. package/scripts/lib/gsd-health-mirror/index.cjs +37 -1
  25. package/scripts/lib/incubator-author.cjs +845 -0
  26. package/scripts/lib/issue-reporter/cli-flag-report.cjs +153 -0
  27. package/scripts/lib/issue-reporter/consent-prompt.cjs +231 -0
  28. package/scripts/lib/issue-reporter/dedup.cjs +458 -0
  29. package/scripts/lib/issue-reporter/destination.cjs +37 -0
  30. package/scripts/lib/issue-reporter/draft-writer.cjs +157 -0
  31. package/scripts/lib/issue-reporter/gh-absent-fallback.cjs +220 -0
  32. package/scripts/lib/issue-reporter/gh-submit.cjs +114 -0
  33. package/scripts/lib/issue-reporter/kill-switch.cjs +122 -0
  34. package/scripts/lib/issue-reporter/payload-assembly.cjs +367 -0
  35. package/scripts/lib/issue-reporter/privacy-diff.cjs +385 -0
  36. package/scripts/lib/issue-reporter/report-flow.cjs +269 -0
  37. package/scripts/lib/issue-reporter/triage-matcher.cjs +270 -0
  38. package/scripts/lib/pseudonymize.cjs +444 -0
  39. package/scripts/lib/reflections-cycle-writer.cjs +172 -0
  40. package/scripts/lib/reflector/capability-gap-scan.cjs +751 -0
  41. package/scripts/lib/reflector-capability-gap-aggregator.cjs +320 -0
  42. package/scripts/release-smoke-test.cjs +33 -2
  43. package/scripts/validate-incubator-scope.cjs +133 -0
  44. package/skills/apply-reflections/SKILL.md +16 -1
  45. package/skills/apply-reflections/apply-reflections-procedure.md +71 -3
  46. package/skills/fast/SKILL.md +46 -0
  47. package/skills/reflect/SKILL.md +9 -0
  48. package/skills/reflect/procedures/capability-gap-scan.md +120 -0
  49. package/skills/report-issue/SKILL.md +53 -0
  50. package/skills/report-issue/report-issue-procedure.md +120 -0
  51. package/skills/router/SKILL.md +5 -0
  52. package/skills/router/capability-gap-emitter.md +65 -0
  53. package/skills/update/SKILL.md +3 -2
@@ -0,0 +1,845 @@
1
+ // scripts/lib/incubator-author.cjs
2
+ //
3
+ // Plan 29-04 — Capability-Gap Self-Authoring: incubator-author module.
4
+ //
5
+ // Phase 29 SC #4 — Stable capability-gap clusters → reviewable drafts under
6
+ // `.design/reflections/incubator/<slug>/`. Strictly proposal-only: this module
7
+ // never writes to production `agents/` or `skills/` paths. Promotion is handled
8
+ // by Plan 29-05's `/gdd:apply-reflections accept` action.
9
+ //
10
+ // Contract:
11
+ // draftClusters(input, options) → { drafts, skipped, deferred }
12
+ //
13
+ // Pipeline (per cluster, in `cluster_id` ascending order):
14
+ //
15
+ // 1. Gate A — stability (size). size < stabilityK → skipped.
16
+ // 2. Gate B — stability (stddev). posterior.stddev missing OR >=
17
+ // stddevThreshold → skipped.
18
+ // 3. Gate C — suggested_kind. Not 'skill'|'agent' → skipped.
19
+ // 4. Tools / description / tier / slug inference.
20
+ // 5. Gate D — similarity (D-09). score ≥ similarityThreshold against
21
+ // any existing artifact → deferred
22
+ // with forward_to:
23
+ // 'phase_11_frontmatter_update'.
24
+ // 6. Frontmatter assembly + body render + scoped write (or dry-run skip).
25
+ //
26
+ // Decisions honoured (per Phase 29 CONTEXT.md):
27
+ // * D-04 (single-step promotion gate). Drafts live in the incubator until
28
+ // 29-05 ratifies them — this module never writes to production paths.
29
+ // * D-05 (scope guard). `safeWritePath` resolves every
30
+ // output under INCUBATOR_ROOT and throws `incubator_path_escape: …`
31
+ // on any traversal attempt. 29-05's `validate-incubator-scope.cjs`
32
+ // enforces the promotion-time ceiling; this is the floor.
33
+ // * D-09 (frontmatter-update vs new capability). High-overlap clusters
34
+ // are routed to Phase 11's frontmatter-update proposal class via the
35
+ // deferred[] array — no draft is written for those.
36
+ // * D-12 (`delegate_to: null` always). Phase 27 forward-compat; every
37
+ // emitted frontmatter carries the literal `delegate_to: null` key.
38
+ //
39
+ // Input cluster shape (produced by 29-03 — `reflector-capability-gap-aggregator.cjs`):
40
+ //
41
+ // {
42
+ // reflection_path, cycle_slug,
43
+ // clusters: [
44
+ // {
45
+ // cluster_id, context_hash, intent_summary,
46
+ // suggested_kind: 'skill' | 'agent',
47
+ // size, sources: { fast, router, reflector_pattern },
48
+ // posterior: { alpha, beta, stddev, arms?: [{tier, alpha, beta}] },
49
+ // evidence_refs[], parent_event_ids[],
50
+ // trajectory_refs: [ { trajectory_id, tools, observed_triggers } ],
51
+ // cycles_observed[], first_seen_cycle, last_seen_cycle,
52
+ // agent_type?, observed_tools?
53
+ // }
54
+ // ]
55
+ // }
56
+ //
57
+ // Options:
58
+ //
59
+ // * stabilityK min cluster size (default 3, per Phase 29 SC #3)
60
+ // * stddevThreshold Beta posterior gate (default 0.05)
61
+ // * similarityThreshold D-09 cutoff (default 0.8)
62
+ // * similarityWeights advanced; default max() combiner across signals
63
+ // * fallbackTools when no observed tools (default [Read,Grep,Glob])
64
+ // * existingArtifacts inject for tests; else loadExistingArtifacts(cwd)
65
+ // * incubatorRoot write target (default INCUBATOR_ROOT)
66
+ // * dryRun skip file writes (default false)
67
+ // * now injected ISO timestamp (default new Date()…)
68
+ // * repoRoot used by loadExistingArtifacts (default cwd)
69
+ //
70
+ // Output shape:
71
+ //
72
+ // {
73
+ // drafts: [{ cluster_id, slug, kind, path, frontmatter, body,
74
+ // written, inference: { slug_source, tools_source,
75
+ // default_tier_source, description_truncated } }],
76
+ // skipped: [{ cluster_id, reason, gate? }],
77
+ // deferred: [{ cluster_id, slug, reason, nearest:{path,score,
78
+ // score_breakdown:{name,tools,description}},
79
+ // forward_to: 'phase_11_frontmatter_update' }],
80
+ // }
81
+ //
82
+ // Style:
83
+ // * CommonJS, deps = node:fs + node:path only.
84
+ // * Pure logic except the optional `fs.writeFileSync` (skipped on dryRun).
85
+ // * Deterministic ordering: arrays sorted by cluster_id ascending. Where
86
+ // ties exist (e.g. similarity tiebreakers), break by path ascending.
87
+
88
+ 'use strict';
89
+
90
+ const fs = require('node:fs');
91
+ const path = require('node:path');
92
+
93
+ // -------------------------------------------------------------------
94
+ // Constants
95
+ // -------------------------------------------------------------------
96
+
97
+ const DEFAULT_STABILITY_K = 3;
98
+ const DEFAULT_STDDEV_THRESHOLD = 0.05;
99
+ const DEFAULT_SIMILARITY_THRESHOLD = 0.8;
100
+ const DEFAULT_FALLBACK_TOOLS = Object.freeze(['Read', 'Grep', 'Glob']);
101
+ const INCUBATOR_ROOT = '.design/reflections/incubator';
102
+ const DESCRIPTION_SOFT_CAP = 200;
103
+ const TIERS = Object.freeze(['haiku', 'sonnet', 'opus']);
104
+
105
+ // Stopword set for tokenize() — small, English-only, deterministic.
106
+ const STOPWORDS = new Set([
107
+ 'a', 'an', 'the', 'of', 'to', 'for', 'and', 'or',
108
+ 'in', 'on', 'by', 'with', 'is', 'are', 'be', 'this',
109
+ 'that', 'it', 'as', 'at', 'use', 'when',
110
+ ]);
111
+
112
+ // Tools that we refuse to infer unless explicitly observed in trajectories.
113
+ const PRIVILEGED_TOOLS = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit']);
114
+
115
+ // -------------------------------------------------------------------
116
+ // Small math + string helpers
117
+ // -------------------------------------------------------------------
118
+
119
+ function tokenize(str) {
120
+ if (typeof str !== 'string' || str.length === 0) return [];
121
+ const seen = new Set();
122
+ for (const tok of str.toLowerCase().split(/[^a-z0-9]+/)) {
123
+ if (!tok) continue;
124
+ if (STOPWORDS.has(tok)) continue;
125
+ seen.add(tok);
126
+ }
127
+ return Array.from(seen).sort();
128
+ }
129
+
130
+ function cosineSim(tokensA, tokensB) {
131
+ if (!Array.isArray(tokensA) || !Array.isArray(tokensB)) return 0;
132
+ if (tokensA.length === 0 || tokensB.length === 0) return 0;
133
+ // Both inputs are already sorted-unique sets from tokenize(); each weight 1.
134
+ const setA = new Set(tokensA);
135
+ const setB = new Set(tokensB);
136
+ let intersect = 0;
137
+ for (const tok of setA) {
138
+ if (setB.has(tok)) intersect += 1;
139
+ }
140
+ const magA = Math.sqrt(setA.size);
141
+ const magB = Math.sqrt(setB.size);
142
+ if (magA === 0 || magB === 0) return 0;
143
+ return intersect / (magA * magB);
144
+ }
145
+
146
+ function jaccard(setA, setB) {
147
+ if (!setA || !setB) return 0;
148
+ const a = setA instanceof Set ? setA : new Set(setA);
149
+ const b = setB instanceof Set ? setB : new Set(setB);
150
+ if (a.size === 0 && b.size === 0) return 0;
151
+ let intersect = 0;
152
+ for (const v of a) {
153
+ if (b.has(v)) intersect += 1;
154
+ }
155
+ const union = a.size + b.size - intersect;
156
+ if (union === 0) return 0;
157
+ return intersect / union;
158
+ }
159
+
160
+ function levenshteinNormalized(a, b) {
161
+ const sa = String(a == null ? '' : a);
162
+ const sb = String(b == null ? '' : b);
163
+ if (sa.length === 0 && sb.length === 0) return 1.0;
164
+ if (sa.length === 0 || sb.length === 0) return 0;
165
+ const n = sa.length;
166
+ const m = sb.length;
167
+ // Single-row DP table.
168
+ let prev = new Array(m + 1);
169
+ for (let j = 0; j <= m; j += 1) prev[j] = j;
170
+ let curr = new Array(m + 1);
171
+ for (let i = 1; i <= n; i += 1) {
172
+ curr[0] = i;
173
+ for (let j = 1; j <= m; j += 1) {
174
+ const cost = sa.charCodeAt(i - 1) === sb.charCodeAt(j - 1) ? 0 : 1;
175
+ curr[j] = Math.min(
176
+ prev[j] + 1, // deletion
177
+ curr[j - 1] + 1, // insertion
178
+ prev[j - 1] + cost, // substitution
179
+ );
180
+ }
181
+ [prev, curr] = [curr, prev];
182
+ }
183
+ const distance = prev[m];
184
+ const maxLen = Math.max(n, m);
185
+ return 1 - distance / maxLen;
186
+ }
187
+
188
+ // -------------------------------------------------------------------
189
+ // Slug derivation
190
+ // -------------------------------------------------------------------
191
+
192
+ function deriveSlug(intentSummary, existingSlugs) {
193
+ const seen = existingSlugs instanceof Set ? existingSlugs : new Set(existingSlugs || []);
194
+ const raw = typeof intentSummary === 'string' ? intentSummary : '';
195
+ // 1. Lowercase.
196
+ let s = raw.toLowerCase();
197
+ // 2. Strip non-ASCII.
198
+ s = s.replace(/[^\x20-\x7e]+/g, '');
199
+ // 3. Replace whitespace + punctuation with `-`.
200
+ s = s.replace(/[^a-z0-9]+/g, '-');
201
+ // 4. Collapse repeated dashes (already done by single pass above, but
202
+ // a defensive second sweep covers replace edge cases).
203
+ s = s.replace(/-+/g, '-');
204
+ // 5. Trim leading/trailing dashes.
205
+ s = s.replace(/^-+/, '').replace(/-+$/, '');
206
+ // 6. Truncate to 40 chars.
207
+ if (s.length > 40) s = s.slice(0, 40);
208
+ // 7. Re-trim dashes after truncation.
209
+ s = s.replace(/-+$/, '');
210
+ if (!s) s = 'unnamed-capability';
211
+ // 8. Dedupe against existingSlugs.
212
+ if (!seen.has(s)) return s;
213
+ let i = 2;
214
+ while (seen.has(`${s}-${i}`)) i += 1;
215
+ return `${s}-${i}`;
216
+ }
217
+
218
+ // -------------------------------------------------------------------
219
+ // Tools inference
220
+ // -------------------------------------------------------------------
221
+
222
+ function inferTools(cluster, fallbackTools) {
223
+ const fallback = Array.isArray(fallbackTools) && fallbackTools.length
224
+ ? Array.from(fallbackTools)
225
+ : Array.from(DEFAULT_FALLBACK_TOOLS);
226
+
227
+ // Collect observed tools either from per-trajectory `tools[]` arrays or from
228
+ // a flat `observed_tools[]` field on the cluster.
229
+ const counts = new Map();
230
+ let observed = false;
231
+ if (Array.isArray(cluster && cluster.trajectory_refs)) {
232
+ for (const traj of cluster.trajectory_refs) {
233
+ if (!traj || !Array.isArray(traj.tools)) continue;
234
+ for (const tool of traj.tools) {
235
+ if (typeof tool !== 'string' || !tool) continue;
236
+ counts.set(tool, (counts.get(tool) || 0) + 1);
237
+ observed = true;
238
+ }
239
+ }
240
+ }
241
+ if (!observed && Array.isArray(cluster && cluster.observed_tools)) {
242
+ for (const tool of cluster.observed_tools) {
243
+ if (typeof tool !== 'string' || !tool) continue;
244
+ counts.set(tool, (counts.get(tool) || 0) + 1);
245
+ observed = true;
246
+ }
247
+ }
248
+ if (!observed) return fallback.slice();
249
+
250
+ // Sort by frequency desc, then alphabetic asc (deterministic ties).
251
+ const ranked = Array.from(counts.entries()).sort((a, b) => {
252
+ if (b[1] !== a[1]) return b[1] - a[1];
253
+ if (a[0] < b[0]) return -1;
254
+ if (a[0] > b[0]) return 1;
255
+ return 0;
256
+ });
257
+ const distinct = ranked.length;
258
+ const N = Math.max(1, Math.min(5, distinct));
259
+ // Privileged tools (Write/Edit/etc) only survive if they were genuinely
260
+ // observed — counts.get(tool) > 0 already guarantees that here, so the
261
+ // filter is a no-op except for safety against future call-sites that might
262
+ // inject phantom entries.
263
+ const picked = [];
264
+ for (const [tool] of ranked) {
265
+ if (PRIVILEGED_TOOLS.has(tool) && !counts.has(tool)) continue;
266
+ picked.push(tool);
267
+ if (picked.length >= N) break;
268
+ }
269
+ return picked;
270
+ }
271
+
272
+ // -------------------------------------------------------------------
273
+ // Default-tier inference
274
+ // -------------------------------------------------------------------
275
+
276
+ function inferDefaultTier(cluster, options, existingArtifacts) {
277
+ // Branch 1 — posterior best-arm.
278
+ const arms = cluster && cluster.posterior && Array.isArray(cluster.posterior.arms)
279
+ ? cluster.posterior.arms
280
+ : null;
281
+ if (arms && arms.length >= 2) {
282
+ let best = null;
283
+ let bestMean = -Infinity;
284
+ for (const arm of arms) {
285
+ if (!arm || typeof arm.tier !== 'string') continue;
286
+ const a = Number(arm.alpha);
287
+ const b = Number(arm.beta);
288
+ if (!Number.isFinite(a) || !Number.isFinite(b)) continue;
289
+ if (a + b <= 0) continue;
290
+ const mean = a / (a + b);
291
+ if (mean > bestMean) {
292
+ bestMean = mean;
293
+ best = arm.tier;
294
+ }
295
+ }
296
+ if (best && TIERS.includes(best)) {
297
+ return { tier: best, source: 'posterior_best_arm' };
298
+ }
299
+ }
300
+
301
+ // Branch 2 — agent_type frontmatter lookup.
302
+ if (cluster && typeof cluster.agent_type === 'string' && Array.isArray(existingArtifacts)) {
303
+ for (const art of existingArtifacts) {
304
+ if (!art || !art.frontmatter) continue;
305
+ if (art.frontmatter.name === cluster.agent_type) {
306
+ const tier = art.frontmatter['default-tier'];
307
+ if (typeof tier === 'string' && TIERS.includes(tier)) {
308
+ return { tier, source: 'cluster.agent_type frontmatter' };
309
+ }
310
+ }
311
+ }
312
+ }
313
+
314
+ // Branch 3 — fallback.
315
+ return { tier: 'sonnet', source: 'fallback:sonnet' };
316
+ }
317
+
318
+ // -------------------------------------------------------------------
319
+ // Description builder
320
+ // -------------------------------------------------------------------
321
+
322
+ function buildDescription(cluster) {
323
+ const raw = (cluster && typeof cluster.intent_summary === 'string') ? cluster.intent_summary.trim() : '';
324
+ let what = raw || 'Capability gap detected';
325
+ if (!/[.!?]$/.test(what)) what = `${what}.`;
326
+
327
+ // Collect trigger frequencies.
328
+ const trigCounts = new Map();
329
+ if (Array.isArray(cluster && cluster.trajectory_refs)) {
330
+ for (const traj of cluster.trajectory_refs) {
331
+ if (!traj || typeof traj.observed_triggers !== 'string') continue;
332
+ const t = traj.observed_triggers.trim();
333
+ if (!t) continue;
334
+ trigCounts.set(t, (trigCounts.get(t) || 0) + 1);
335
+ }
336
+ }
337
+ const triggers = Array.from(trigCounts.entries())
338
+ .sort((a, b) => {
339
+ if (b[1] !== a[1]) return b[1] - a[1];
340
+ if (a[0] < b[0]) return -1;
341
+ if (a[0] > b[0]) return 1;
342
+ return 0;
343
+ })
344
+ .slice(0, 2)
345
+ .map((e) => e[0]);
346
+
347
+ let triggerPhrase = triggers.length ? triggers.join(' or ') : 'needed';
348
+ let desc = `${what} Use when ${triggerPhrase}.`;
349
+ let truncated = false;
350
+
351
+ if (desc.length > DESCRIPTION_SOFT_CAP) {
352
+ // Truncate the trigger portion first; preserve `<what>. Use when ` prefix.
353
+ const prefix = `${what} Use when `;
354
+ const suffix = '... [truncated].';
355
+ const budget = DESCRIPTION_SOFT_CAP - prefix.length - suffix.length;
356
+ if (budget > 0) {
357
+ triggerPhrase = triggerPhrase.slice(0, budget).trimEnd();
358
+ desc = `${prefix}${triggerPhrase}${suffix}`;
359
+ } else {
360
+ // <what> alone already exceeds soft cap — truncate <what> as a last resort.
361
+ desc = `${what.slice(0, DESCRIPTION_SOFT_CAP - 3)}...`;
362
+ }
363
+ truncated = true;
364
+ }
365
+ return { description: desc, truncated };
366
+ }
367
+
368
+ // -------------------------------------------------------------------
369
+ // Similarity scoring (D-09)
370
+ // -------------------------------------------------------------------
371
+
372
+ function computeSimilarity(probe, existingArtifacts) {
373
+ if (!Array.isArray(existingArtifacts) || existingArtifacts.length === 0) return null;
374
+ const probeSlug = probe && typeof probe.slug === 'string' ? probe.slug : '';
375
+ const probeTools = probe && Array.isArray(probe.inferredTools) ? probe.inferredTools : [];
376
+ const probeDesc = probe && typeof probe.description === 'string' ? probe.description : '';
377
+ const probeDescTokens = tokenize(probeDesc);
378
+
379
+ let best = null;
380
+ for (const art of existingArtifacts) {
381
+ if (!art || !art.frontmatter) continue;
382
+ const fm = art.frontmatter;
383
+ const existingName = typeof fm.name === 'string' ? fm.name : '';
384
+ const existingDesc = typeof fm.description === 'string' ? fm.description : '';
385
+ const existingTools = Array.isArray(fm.tools)
386
+ ? fm.tools
387
+ : (typeof fm.tools === 'string'
388
+ ? fm.tools.split(',').map((s) => s.trim()).filter(Boolean)
389
+ : []);
390
+
391
+ const nameSim = levenshteinNormalized(probeSlug, existingName);
392
+ const toolsSim = jaccard(new Set(probeTools), new Set(existingTools));
393
+ const descSim = cosineSim(probeDescTokens, tokenize(existingDesc));
394
+ const score = Math.max(nameSim, toolsSim, descSim);
395
+ const entry = {
396
+ path: art.path || '',
397
+ score,
398
+ score_breakdown: { name: nameSim, tools: toolsSim, description: descSim },
399
+ };
400
+ if (!best) { best = entry; continue; }
401
+ if (entry.score > best.score) { best = entry; continue; }
402
+ // Tie-break: smaller path string wins for determinism.
403
+ if (entry.score === best.score && entry.path < best.path) { best = entry; }
404
+ }
405
+ return best;
406
+ }
407
+
408
+ // -------------------------------------------------------------------
409
+ // Body renderer
410
+ // -------------------------------------------------------------------
411
+
412
+ function renderOrigin(cluster, options, now) {
413
+ const slug = (cluster && cluster.__slug) || (cluster && deriveSlug(cluster.intent_summary || '')) || 'unnamed-capability';
414
+ const intent = (cluster && cluster.intent_summary) || '(intent unknown)';
415
+ const cycles = Array.isArray(cluster && cluster.cycles_observed) ? cluster.cycles_observed : [];
416
+ const cyclesCount = cycles.length;
417
+ const size = Number(cluster && cluster.size) || 0;
418
+ const sources = (cluster && cluster.sources) || { fast: 0, router: 0, reflector_pattern: 0 };
419
+ const usage = size / Math.max(1, cyclesCount);
420
+ const usageStr = Number.isFinite(usage) ? usage.toFixed(2) : '0.00';
421
+
422
+ // Most-frequent parent_event_id.
423
+ let topParent = '(none observed)';
424
+ if (Array.isArray(cluster && cluster.parent_event_ids) && cluster.parent_event_ids.length) {
425
+ const pcounts = new Map();
426
+ for (const pid of cluster.parent_event_ids) {
427
+ if (typeof pid !== 'string' || !pid) continue;
428
+ pcounts.set(pid, (pcounts.get(pid) || 0) + 1);
429
+ }
430
+ const ranked = Array.from(pcounts.entries()).sort((a, b) => {
431
+ if (b[1] !== a[1]) return b[1] - a[1];
432
+ if (a[0] < b[0]) return -1;
433
+ if (a[0] > b[0]) return 1;
434
+ return 0;
435
+ });
436
+ if (ranked.length) topParent = ranked[0][0];
437
+ }
438
+
439
+ // Top 3 trajectory refs.
440
+ const trajLines = [];
441
+ const trajs = Array.isArray(cluster && cluster.trajectory_refs) ? cluster.trajectory_refs.slice(0, 3) : [];
442
+ for (const t of trajs) {
443
+ const tid = (t && t.trajectory_id) || '(unknown)';
444
+ const tools = Array.isArray(t && t.tools) ? t.tools.join(', ') : '(none)';
445
+ const trigger = (t && typeof t.observed_triggers === 'string') ? t.observed_triggers : '(none)';
446
+ trajLines.push(` - \`${tid}\` — tools: ${tools}, trigger: "${trigger}"`);
447
+ }
448
+
449
+ const stabilityK = (options && options.stabilityK) != null ? options.stabilityK : DEFAULT_STABILITY_K;
450
+ const stddevThreshold = (options && options.stddevThreshold) != null ? options.stddevThreshold : DEFAULT_STDDEV_THRESHOLD;
451
+ const alpha = (cluster && cluster.posterior && cluster.posterior.alpha != null) ? cluster.posterior.alpha : '(unknown)';
452
+ const beta = (cluster && cluster.posterior && cluster.posterior.beta != null) ? cluster.posterior.beta : '(unknown)';
453
+ const stddev = (cluster && cluster.posterior && cluster.posterior.stddev != null) ? cluster.posterior.stddev : '(unknown)';
454
+
455
+ const clusterId = (cluster && cluster.cluster_id) || '(unknown)';
456
+ const contextHash = (cluster && cluster.context_hash) || '(unknown)';
457
+ const firstSeen = (cluster && cluster.first_seen_cycle) || '(unknown)';
458
+ const lastSeen = (cluster && cluster.last_seen_cycle) || '(unknown)';
459
+
460
+ const lines = [
461
+ `@reference/shared-preamble.md`,
462
+ ``,
463
+ `# ${slug}`,
464
+ ``,
465
+ `> **DRAFT — INCUBATOR.** Generated by \`scripts/lib/incubator-author.cjs\` from capability-gap cluster \`${clusterId}\` on \`${now}\`. NOT yet promoted to production. Review via \`/gdd:apply-reflections\` (Plan 29-05).`,
466
+ ``,
467
+ `## Role`,
468
+ ``,
469
+ `${intent} — drafted from ${size} capability_gap events across ${cyclesCount} cycles.`,
470
+ ``,
471
+ `(Plan 29-05 will render an editable diff vs nearest existing artifact before promotion. Reviewer fills in the role narrative; this is a stub.)`,
472
+ ``,
473
+ `## Origin`,
474
+ ``,
475
+ `This draft was synthesized from a recurring capability gap.`,
476
+ ``,
477
+ `- **Cluster ID:** \`${clusterId}\``,
478
+ `- **Context hash:** \`${contextHash}\``,
479
+ `- **First seen:** \`${firstSeen}\``,
480
+ `- **Last seen:** \`${lastSeen}\``,
481
+ `- **Cluster size:** ${size} capability_gap events`,
482
+ `- **Source distribution:** fast=${sources.fast || 0}, router=${sources.router || 0}, reflector_pattern=${sources.reflector_pattern || 0}`,
483
+ `- **Usage frequency:** ${usageStr} events / cycle-window`,
484
+ `- **Suggested integration point:** spawned/invoked alongside \`${topParent}\``,
485
+ `- **Example trajectories:**`,
486
+ ];
487
+ if (trajLines.length === 0) {
488
+ lines.push(' - (no trajectory refs observed)');
489
+ } else {
490
+ for (const line of trajLines) lines.push(line);
491
+ }
492
+
493
+ lines.push(
494
+ ``,
495
+ `## Posterior signal`,
496
+ ``,
497
+ `- α = ${alpha}, β = ${beta}, stddev = ${stddev}`,
498
+ `- Stability gate (size ≥ ${stabilityK} AND stddev < ${stddevThreshold}): **passed**`,
499
+ ``,
500
+ `## Reviewer checklist (Plan 29-05)`,
501
+ ``,
502
+ `- [ ] Role narrative matches observed intent`,
503
+ `- [ ] Frontmatter \`description\` reads naturally (≤ 200 chars)`,
504
+ `- [ ] Tools set matches observed trajectories (no over-privileging)`,
505
+ `- [ ] Similarity guard cleared: no nearest existing artifact ≥ 0.8 similarity`,
506
+ `- [ ] Promotion target path is \`agents/<slug>.md\` OR \`skills/<slug>/SKILL.md\` (scope guard via \`scripts/validate-incubator-scope.cjs\`)`,
507
+ ``,
508
+ );
509
+
510
+ return lines.join('\n');
511
+ }
512
+
513
+ // -------------------------------------------------------------------
514
+ // Frontmatter serializer
515
+ // -------------------------------------------------------------------
516
+
517
+ function quoteYamlString(s) {
518
+ // Wrap in double quotes; escape inner " and \.
519
+ const escaped = String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
520
+ return `"${escaped}"`;
521
+ }
522
+
523
+ function serializeFrontmatter(fm) {
524
+ const lines = ['---'];
525
+ // 1. name (required, bare slug — safe characters).
526
+ lines.push(`name: ${fm.name}`);
527
+ // 2. description (always quoted).
528
+ lines.push(`description: ${quoteYamlString(fm.description || '')}`);
529
+ // 3. tools (comma-separated string, parity with existing agents/skills).
530
+ const toolsList = Array.isArray(fm.tools) ? fm.tools : [];
531
+ lines.push(`tools: ${toolsList.join(', ')}`);
532
+ // 4. default-tier.
533
+ lines.push(`default-tier: ${fm['default-tier'] || 'sonnet'}`);
534
+ // 5. reasoning-class — omit if undefined.
535
+ if (fm['reasoning-class'] !== undefined) {
536
+ lines.push(`reasoning-class: ${fm['reasoning-class']}`);
537
+ }
538
+ // 6. parallel-safe — omit if undefined.
539
+ if (fm['parallel-safe'] !== undefined) {
540
+ lines.push(`parallel-safe: ${fm['parallel-safe']}`);
541
+ }
542
+ // 7. reads-only — omit if undefined.
543
+ if (fm['reads-only'] !== undefined) {
544
+ lines.push(`reads-only: ${fm['reads-only']}`);
545
+ }
546
+ // 8. delegate_to: null — ALWAYS last, ALWAYS present (D-12).
547
+ lines.push(`delegate_to: null`);
548
+ lines.push('---');
549
+ lines.push('');
550
+ return lines.join('\n');
551
+ }
552
+
553
+ // -------------------------------------------------------------------
554
+ // Scoped writer (D-05 floor)
555
+ // -------------------------------------------------------------------
556
+
557
+ function safeWritePath(slug, kind, incubatorRoot) {
558
+ const root = incubatorRoot || INCUBATOR_ROOT;
559
+ const rootResolved = path.resolve(root);
560
+ const targetDir = path.resolve(rootResolved, slug);
561
+ const targetFile = kind === 'skill'
562
+ ? path.resolve(targetDir, 'SKILL.md')
563
+ : path.resolve(targetDir, 'agents', `${slug}.md`);
564
+
565
+ const relFromRoot = path.relative(rootResolved, targetFile);
566
+ if (
567
+ !relFromRoot
568
+ || relFromRoot.startsWith('..')
569
+ || relFromRoot.startsWith(`..${path.sep}`)
570
+ || path.isAbsolute(relFromRoot)
571
+ ) {
572
+ throw new Error(`incubator_path_escape: ${slug}`);
573
+ }
574
+ const writeDir = kind === 'skill'
575
+ ? targetDir
576
+ : path.resolve(targetDir, 'agents');
577
+ return { targetDir: writeDir, targetFile };
578
+ }
579
+
580
+ // -------------------------------------------------------------------
581
+ // Existing-artifact loader (similarity guard input)
582
+ // -------------------------------------------------------------------
583
+
584
+ function parseFrontmatterBlock(content) {
585
+ // Minimal YAML-frontmatter parser: lines between leading `---` markers.
586
+ // Tolerates missing/malformed blocks — returns empty {} when uncertain.
587
+ if (typeof content !== 'string' || !content.startsWith('---')) return {};
588
+ const endIdx = content.indexOf('\n---', 3);
589
+ if (endIdx < 0) return {};
590
+ const block = content.slice(3, endIdx).trim();
591
+ const fm = {};
592
+ for (const rawLine of block.split(/\r?\n/)) {
593
+ const line = rawLine.replace(/\s+$/, '');
594
+ if (!line || line.startsWith('#')) continue;
595
+ const colonIdx = line.indexOf(':');
596
+ if (colonIdx <= 0) continue;
597
+ const key = line.slice(0, colonIdx).trim();
598
+ let val = line.slice(colonIdx + 1).trim();
599
+ if (val.startsWith('"') && val.endsWith('"') && val.length >= 2) {
600
+ val = val.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, '\\');
601
+ }
602
+ if (key === 'tools') {
603
+ if (val.startsWith('[') && val.endsWith(']')) {
604
+ val = val.slice(1, -1).split(',').map((s) => s.trim().replace(/^['"]|['"]$/g, '')).filter(Boolean);
605
+ } else {
606
+ val = val.split(',').map((s) => s.trim()).filter(Boolean);
607
+ }
608
+ }
609
+ fm[key] = val;
610
+ }
611
+ return fm;
612
+ }
613
+
614
+ function loadExistingArtifacts(repoRoot) {
615
+ const root = repoRoot || process.cwd();
616
+ const incubatorAbs = path.resolve(root, INCUBATOR_ROOT);
617
+ const results = [];
618
+
619
+ // agents/*.md
620
+ const agentsDir = path.join(root, 'agents');
621
+ if (fs.existsSync(agentsDir)) {
622
+ let entries = [];
623
+ try { entries = fs.readdirSync(agentsDir, { withFileTypes: true }); } catch (_e) { entries = []; }
624
+ for (const entry of entries) {
625
+ if (!entry.isFile()) continue;
626
+ if (!entry.name.endsWith('.md')) continue;
627
+ const abs = path.join(agentsDir, entry.name);
628
+ // Exclude anything inside the incubator subtree (paranoia; agents/ should not contain it).
629
+ if (abs.startsWith(incubatorAbs)) continue;
630
+ let content;
631
+ try { content = fs.readFileSync(abs, 'utf8'); } catch (_e) { continue; }
632
+ const fm = parseFrontmatterBlock(content);
633
+ results.push({ path: path.relative(root, abs).replace(/\\/g, '/'), frontmatter: fm });
634
+ }
635
+ }
636
+
637
+ // skills/*/SKILL.md
638
+ const skillsDir = path.join(root, 'skills');
639
+ if (fs.existsSync(skillsDir)) {
640
+ let entries = [];
641
+ try { entries = fs.readdirSync(skillsDir, { withFileTypes: true }); } catch (_e) { entries = []; }
642
+ for (const entry of entries) {
643
+ if (!entry.isDirectory()) continue;
644
+ const abs = path.join(skillsDir, entry.name, 'SKILL.md');
645
+ if (!fs.existsSync(abs)) continue;
646
+ if (abs.startsWith(incubatorAbs)) continue;
647
+ let content;
648
+ try { content = fs.readFileSync(abs, 'utf8'); } catch (_e) { continue; }
649
+ const fm = parseFrontmatterBlock(content);
650
+ results.push({ path: path.relative(root, abs).replace(/\\/g, '/'), frontmatter: fm });
651
+ }
652
+ }
653
+
654
+ return results;
655
+ }
656
+
657
+ // -------------------------------------------------------------------
658
+ // Main entrypoint
659
+ // -------------------------------------------------------------------
660
+
661
+ function draftClusters(input, options) {
662
+ if (!input || typeof input !== 'object' || !Array.isArray(input.clusters)) {
663
+ throw new Error('invalid_input: clusters must be array');
664
+ }
665
+ const opts = options || {};
666
+ const stabilityK = opts.stabilityK != null ? opts.stabilityK : DEFAULT_STABILITY_K;
667
+ const stddevThreshold = opts.stddevThreshold != null ? opts.stddevThreshold : DEFAULT_STDDEV_THRESHOLD;
668
+ const similarityThreshold = opts.similarityThreshold != null ? opts.similarityThreshold : DEFAULT_SIMILARITY_THRESHOLD;
669
+ const fallbackTools = Array.isArray(opts.fallbackTools) && opts.fallbackTools.length
670
+ ? opts.fallbackTools
671
+ : Array.from(DEFAULT_FALLBACK_TOOLS);
672
+ const incubatorRoot = opts.incubatorRoot || INCUBATOR_ROOT;
673
+ const dryRun = Boolean(opts.dryRun);
674
+ const now = opts.now || new Date().toISOString();
675
+
676
+ const existingArtifacts = Array.isArray(opts.existingArtifacts)
677
+ ? opts.existingArtifacts
678
+ : loadExistingArtifacts(opts.repoRoot || process.cwd());
679
+
680
+ // Seed the running slug set with existing names to avoid promotion collisions.
681
+ const existingSlugs = new Set();
682
+ for (const art of existingArtifacts) {
683
+ if (art && art.frontmatter && typeof art.frontmatter.name === 'string') {
684
+ existingSlugs.add(art.frontmatter.name);
685
+ }
686
+ }
687
+
688
+ // Stable iteration order: clusters sorted by cluster_id ascending.
689
+ const clusters = input.clusters.slice().sort((a, b) => {
690
+ const ai = (a && a.cluster_id) || '';
691
+ const bi = (b && b.cluster_id) || '';
692
+ if (ai < bi) return -1;
693
+ if (ai > bi) return 1;
694
+ return 0;
695
+ });
696
+
697
+ const drafts = [];
698
+ const skipped = [];
699
+ const deferred = [];
700
+
701
+ for (const cluster of clusters) {
702
+ const clusterId = (cluster && cluster.cluster_id) || '(unknown)';
703
+
704
+ // Gate A — stability (size).
705
+ if (!cluster || typeof cluster.size !== 'number' || cluster.size < stabilityK) {
706
+ skipped.push({
707
+ cluster_id: clusterId,
708
+ reason: 'below_stability_threshold',
709
+ gate: { size: (cluster && cluster.size) || 0, threshold: stabilityK },
710
+ });
711
+ continue;
712
+ }
713
+ // Gate B — stability (stddev).
714
+ const postStddev = cluster.posterior && typeof cluster.posterior.stddev === 'number'
715
+ ? cluster.posterior.stddev
716
+ : null;
717
+ if (postStddev == null || postStddev >= stddevThreshold) {
718
+ skipped.push({
719
+ cluster_id: clusterId,
720
+ reason: 'posterior_stddev_too_wide',
721
+ gate: { stddev: postStddev, threshold: stddevThreshold },
722
+ });
723
+ continue;
724
+ }
725
+ // Gate C — suggested_kind.
726
+ const kind = cluster.suggested_kind;
727
+ if (kind !== 'skill' && kind !== 'agent') {
728
+ skipped.push({
729
+ cluster_id: clusterId,
730
+ reason: 'wrong_suggested_kind',
731
+ gate: { suggested_kind: kind },
732
+ });
733
+ continue;
734
+ }
735
+
736
+ // Inference passes.
737
+ const inferredTools = inferTools(cluster, fallbackTools);
738
+ const descObj = buildDescription(cluster);
739
+ const tierObj = inferDefaultTier(cluster, opts, existingArtifacts);
740
+ const slug = deriveSlug(cluster.intent_summary || '', existingSlugs);
741
+
742
+ // Gate D — similarity (D-09).
743
+ const probe = { slug, inferredTools, description: descObj.description };
744
+ const nearest = computeSimilarity(probe, existingArtifacts);
745
+ if (nearest && nearest.score >= similarityThreshold) {
746
+ deferred.push({
747
+ cluster_id: clusterId,
748
+ slug,
749
+ reason: 'similarity_to_existing',
750
+ nearest,
751
+ forward_to: 'phase_11_frontmatter_update',
752
+ });
753
+ // Do NOT register this slug; the cluster never got drafted, no collision risk for siblings.
754
+ continue;
755
+ }
756
+
757
+ // Reads-only inference: only reading-style tools.
758
+ const READING_TOOLS = new Set(['Read', 'Grep', 'Glob', 'Bash']);
759
+ const readsOnly = inferredTools.every((t) => READING_TOOLS.has(t));
760
+ const parallelSafe = readsOnly; // default true when reads-only
761
+
762
+ // Frontmatter object (deterministic field order via serializeFrontmatter).
763
+ const frontmatter = {
764
+ name: slug,
765
+ description: descObj.description,
766
+ tools: inferredTools,
767
+ 'default-tier': tierObj.tier,
768
+ 'parallel-safe': parallelSafe,
769
+ 'reads-only': readsOnly,
770
+ delegate_to: null,
771
+ };
772
+ // Attach slug onto cluster for body renderer (transient).
773
+ cluster.__slug = slug;
774
+ const body = renderOrigin(cluster, { stabilityK, stddevThreshold }, now);
775
+ delete cluster.__slug;
776
+
777
+ const fileContent = serializeFrontmatter(frontmatter) + body;
778
+
779
+ // Scoped path resolution + optional write.
780
+ let resolved;
781
+ try {
782
+ resolved = safeWritePath(slug, kind, incubatorRoot);
783
+ } catch (err) {
784
+ // Propagate path-escape errors — caller's contract is fail-loud on injection.
785
+ throw err;
786
+ }
787
+
788
+ if (!dryRun) {
789
+ fs.mkdirSync(resolved.targetDir, { recursive: true });
790
+ fs.writeFileSync(resolved.targetFile, fileContent, 'utf8');
791
+ }
792
+
793
+ const recordedPath = path.relative(process.cwd(), resolved.targetFile).replace(/\\/g, '/');
794
+ drafts.push({
795
+ cluster_id: clusterId,
796
+ slug,
797
+ kind,
798
+ path: recordedPath,
799
+ frontmatter,
800
+ body,
801
+ written: !dryRun,
802
+ inference: {
803
+ slug_source: 'intent_summary',
804
+ tools_source: (Array.isArray(cluster.trajectory_refs) && cluster.trajectory_refs.some((t) => t && Array.isArray(t.tools) && t.tools.length))
805
+ ? 'trajectory_observed'
806
+ : (Array.isArray(cluster.observed_tools) && cluster.observed_tools.length ? 'cluster.observed_tools' : 'fallback'),
807
+ default_tier_source: tierObj.source,
808
+ description_truncated: descObj.truncated,
809
+ },
810
+ });
811
+
812
+ // Reserve the slug so subsequent clusters in this call won't collide.
813
+ existingSlugs.add(slug);
814
+ }
815
+
816
+ return { drafts, skipped, deferred };
817
+ }
818
+
819
+ // -------------------------------------------------------------------
820
+ // Exports
821
+ // -------------------------------------------------------------------
822
+
823
+ module.exports = {
824
+ draftClusters,
825
+ computeSimilarity,
826
+ deriveSlug,
827
+ inferTools,
828
+ inferDefaultTier,
829
+ buildDescription,
830
+ renderOrigin,
831
+ serializeFrontmatter,
832
+ safeWritePath,
833
+ loadExistingArtifacts,
834
+ tokenize,
835
+ cosineSim,
836
+ jaccard,
837
+ levenshteinNormalized,
838
+ DEFAULT_STABILITY_K,
839
+ DEFAULT_STDDEV_THRESHOLD,
840
+ DEFAULT_SIMILARITY_THRESHOLD,
841
+ DEFAULT_FALLBACK_TOOLS,
842
+ INCUBATOR_ROOT,
843
+ DESCRIPTION_SOFT_CAP,
844
+ TIERS,
845
+ };