@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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +81 -0
- package/README.de.md +23 -0
- package/README.fr.md +23 -0
- package/README.it.md +23 -0
- package/README.ja.md +23 -0
- package/README.ko.md +23 -0
- package/README.md +28 -0
- package/README.zh-CN.md +23 -0
- package/SKILL.md +2 -0
- package/agents/design-reflector.md +50 -0
- package/package.json +1 -1
- package/reference/capability-gap-stage-gate.md +261 -0
- package/reference/known-failure-modes.md +185 -0
- package/reference/pseudonymization-rules.md +189 -0
- package/reference/registry.json +22 -1
- package/reference/schemas/events.schema.json +97 -3
- package/reference/schemas/generated.d.ts +319 -4
- package/scripts/cli/gdd-events.mjs +35 -2
- package/scripts/gsd-cleanup-incubator.cjs +367 -0
- package/scripts/lib/apply-reflections/incubator-proposals.cjs +448 -0
- package/scripts/lib/bandit-router.cjs +92 -9
- package/scripts/lib/gsd-health-mirror/index.cjs +37 -1
- package/scripts/lib/incubator-author.cjs +845 -0
- package/scripts/lib/issue-reporter/cli-flag-report.cjs +153 -0
- package/scripts/lib/issue-reporter/consent-prompt.cjs +231 -0
- package/scripts/lib/issue-reporter/dedup.cjs +458 -0
- package/scripts/lib/issue-reporter/destination.cjs +37 -0
- package/scripts/lib/issue-reporter/draft-writer.cjs +157 -0
- package/scripts/lib/issue-reporter/gh-absent-fallback.cjs +220 -0
- package/scripts/lib/issue-reporter/gh-submit.cjs +114 -0
- package/scripts/lib/issue-reporter/kill-switch.cjs +122 -0
- package/scripts/lib/issue-reporter/payload-assembly.cjs +367 -0
- package/scripts/lib/issue-reporter/privacy-diff.cjs +385 -0
- package/scripts/lib/issue-reporter/report-flow.cjs +269 -0
- package/scripts/lib/issue-reporter/triage-matcher.cjs +270 -0
- package/scripts/lib/pseudonymize.cjs +444 -0
- package/scripts/lib/reflections-cycle-writer.cjs +172 -0
- package/scripts/lib/reflector/capability-gap-scan.cjs +751 -0
- package/scripts/lib/reflector-capability-gap-aggregator.cjs +320 -0
- package/scripts/release-smoke-test.cjs +33 -2
- package/scripts/validate-incubator-scope.cjs +133 -0
- package/skills/apply-reflections/SKILL.md +16 -1
- package/skills/apply-reflections/apply-reflections-procedure.md +71 -3
- package/skills/fast/SKILL.md +46 -0
- package/skills/reflect/SKILL.md +9 -0
- package/skills/reflect/procedures/capability-gap-scan.md +120 -0
- package/skills/report-issue/SKILL.md +53 -0
- package/skills/report-issue/report-issue-procedure.md +120 -0
- package/skills/router/SKILL.md +5 -0
- package/skills/router/capability-gap-emitter.md +65 -0
- package/skills/update/SKILL.md +3 -2
|
@@ -0,0 +1,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
|
+
};
|