@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,751 @@
1
+ /**
2
+ * capability-gap-scan.cjs — reflector pattern-detection capability-gap scan
3
+ * (Phase 29 Plan 02).
4
+ *
5
+ * Purpose
6
+ * -------
7
+ * Scans three signal sources for recurring patterns that lack a dedicated
8
+ * executable owner (agent or skill) and emits one `capability_gap` event
9
+ * per qualifying cluster with `source: "reflector_pattern"`:
10
+ *
11
+ * 1. `.design/intel/*.md` — slice files with `Touches:` clusters
12
+ * that recur across files without a dedicated agent owner.
13
+ * 2. `.design/telemetry/posterior.json` — Phase 23.5 bandit posterior
14
+ * arms whose `count` exceeds the threshold but whose `agent` is a
15
+ * generic fallback rather than a specialized one.
16
+ * 3. `.design/gep/events.jsonl` — Phase 22 typed-causal event chain
17
+ * slices: repeated decision sequences with no specialized owner.
18
+ *
19
+ * Architecture
20
+ * ------------
21
+ * This module is SEPARATE from the 29-01 `fast` / `router` emitter
22
+ * surfaces. It owns the `reflector_pattern` source ONLY. The schema is
23
+ * shipped by 29-01 in `reference/schemas/events.schema.json` (D-02
24
+ * 7-field shape). The real emitter API is `appendChainEvent` from
25
+ * `scripts/lib/event-chain.cjs` — 29-01 did NOT ship a separate helper
26
+ * file (`scripts/lib/capability-gap-event.cjs` was the plan's assumed
27
+ * path; in practice, the 29-01 emitter sections in fast/router SKILL.md
28
+ * call `appendChainEvent` directly). This module mirrors that pattern.
29
+ *
30
+ * D-07: `evidence_refs[]` carry POINTERS to source slices, never
31
+ * duplicated content. The internal `Finding.evidence_refs` shape is
32
+ * line-based (`{path, lineStart, lineEnd, sha256}`) — ergonomic for
33
+ * scan-side mutation detection. At emit time these are translated into
34
+ * the schema's `TrajectoryRef` shape
35
+ * (`{trajectory_path, byte_start, byte_end, content_hash: "sha256:..."}`).
36
+ *
37
+ * D-08: MCP-probe connection failures DO NOT contribute to any of the
38
+ * three scans. The trajectory scan filters by three exclusion shapes
39
+ * (liberal exclusion):
40
+ * - `outcome === 'connection-error'`
41
+ * - `agent === 'mcp-probe'`
42
+ * - `mcp_probe: true`
43
+ *
44
+ * D-11: Tests live at `tests/reflector-capability-gap.test.cjs` and use
45
+ * synthetic in-tmpdir fixtures only. No live event-chain or telemetry
46
+ * writes in CI.
47
+ */
48
+
49
+ 'use strict';
50
+
51
+ const fs = require('node:fs');
52
+ const path = require('node:path');
53
+ const { createHash, randomUUID } = require('node:crypto');
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // Module constants
57
+
58
+ const DEFAULT_THRESHOLD = 3;
59
+
60
+ const GENERIC_AGENT_FALLBACKS = Object.freeze(new Set([
61
+ 'general-purpose',
62
+ 'default-executor',
63
+ 'fallback',
64
+ 'generic',
65
+ ]));
66
+
67
+ const TRAJECTORY_LOOKBACK_DAYS_DEFAULT = 30;
68
+
69
+ // MCP-probe exclusion predicate (D-08).
70
+ function isMcpProbeRow(ev) {
71
+ if (!ev || typeof ev !== 'object') return false;
72
+ if (ev.outcome === 'connection-error') return true;
73
+ if (ev.agent === 'mcp-probe') return true;
74
+ if (ev.mcp_probe === true) return true;
75
+ return false;
76
+ }
77
+
78
+ // ---------------------------------------------------------------------------
79
+ // computeContextHash — pure deterministic hash for cluster identity
80
+
81
+ /**
82
+ * sha256 hex of JSON.stringify({touches: sorted(touches), agent_type}).
83
+ *
84
+ * Order-invariant on `touches`. Same input → same output across runs.
85
+ *
86
+ * @param {{touches: string[], agent_type: string}} signal
87
+ * @returns {string} sha256 hex (64 chars)
88
+ */
89
+ function computeContextHash(signal) {
90
+ if (
91
+ !signal ||
92
+ typeof signal !== 'object' ||
93
+ !Array.isArray(signal.touches) ||
94
+ typeof signal.agent_type !== 'string'
95
+ ) {
96
+ throw new TypeError(
97
+ 'computeContextHash: signal must be { touches: string[], agent_type: string }',
98
+ );
99
+ }
100
+ for (const t of signal.touches) {
101
+ if (typeof t !== 'string') {
102
+ throw new TypeError('computeContextHash: every touches entry must be a string');
103
+ }
104
+ }
105
+ const normalized = {
106
+ touches: [...signal.touches].sort((a, b) => a.localeCompare(b, 'en')),
107
+ agent_type: signal.agent_type,
108
+ };
109
+ return createHash('sha256').update(JSON.stringify(normalized), 'utf8').digest('hex');
110
+ }
111
+
112
+ // ---------------------------------------------------------------------------
113
+ // Internal helpers: line/byte-based evidence_refs
114
+
115
+ /**
116
+ * Build a line-based evidence_ref (the internal Finding-shape).
117
+ *
118
+ * sha256 algorithm: read lines [lineStart..lineEnd] (1-based inclusive),
119
+ * join with `'\n'` (no trailing newline — stable across OSes), and sha256
120
+ * the UTF-8 bytes.
121
+ *
122
+ * @param {string} absPath
123
+ * @param {number} lineStart 1-based inclusive
124
+ * @param {number} lineEnd 1-based inclusive
125
+ * @param {string} repoBase absolute base for `path.relative`
126
+ * @returns {{path: string, lineStart: number, lineEnd: number, sha256: string}}
127
+ */
128
+ function buildEvidenceRef(absPath, lineStart, lineEnd, repoBase) {
129
+ const raw = fs.readFileSync(absPath, 'utf8');
130
+ const lines = raw.split('\n');
131
+ const sliceLines = lines.slice(lineStart - 1, lineEnd);
132
+ const sliceText = sliceLines.join('\n');
133
+ const sha256 = createHash('sha256').update(sliceText, 'utf8').digest('hex');
134
+ return {
135
+ path: path.relative(repoBase, absPath).split(path.sep).join('/'),
136
+ lineStart,
137
+ lineEnd,
138
+ sha256,
139
+ };
140
+ }
141
+
142
+ /**
143
+ * Translate an internal line-based evidence_ref into the schema's
144
+ * TrajectoryRef shape for emit. Byte offsets are computed by re-reading
145
+ * the file and summing UTF-8 byte-lengths of the lines before `lineStart`
146
+ * (inclusive offset) and through `lineEnd` (exclusive offset).
147
+ *
148
+ * @param {{path: string, lineStart: number, lineEnd: number, sha256: string}} ref
149
+ * @param {string} repoBase absolute base to resolve `ref.path`
150
+ * @returns {{trajectory_path: string, byte_start: number, byte_end: number, content_hash: string}}
151
+ */
152
+ function lineRefToTrajectoryRef(ref, repoBase) {
153
+ const absPath = path.resolve(repoBase, ref.path);
154
+ let byteStart = 0;
155
+ let byteEnd = 0;
156
+ try {
157
+ const raw = fs.readFileSync(absPath, 'utf8');
158
+ const lines = raw.split('\n');
159
+ const prefix = lines.slice(0, ref.lineStart - 1).join('\n');
160
+ // If there is any prefix, account for the trailing newline that separates
161
+ // it from the first slice line; if lineStart === 1, byteStart === 0.
162
+ byteStart =
163
+ Buffer.byteLength(prefix, 'utf8') +
164
+ (ref.lineStart > 1 ? Buffer.byteLength('\n', 'utf8') : 0);
165
+ const sliceText = lines.slice(ref.lineStart - 1, ref.lineEnd).join('\n');
166
+ byteEnd = byteStart + Buffer.byteLength(sliceText, 'utf8');
167
+ } catch {
168
+ // Pointer survives even if the file becomes unreadable; consumers
169
+ // re-read at validation time and detect mutation via content_hash.
170
+ byteStart = 0;
171
+ byteEnd = 0;
172
+ }
173
+ return {
174
+ trajectory_path: ref.path,
175
+ byte_start: byteStart,
176
+ byte_end: byteEnd,
177
+ content_hash: `sha256:${ref.sha256}`,
178
+ };
179
+ }
180
+
181
+ // ---------------------------------------------------------------------------
182
+ // suggested_kind inference
183
+
184
+ /**
185
+ * Deterministic inference rule:
186
+ * - >1 distinct decision-class across the matched occurrences → 'agent'
187
+ * - 1 decision-class repeated across occurrences → 'skill'
188
+ * - tie-break: 'skill' (smaller surface, lower authoring risk)
189
+ *
190
+ * @param {{ distinctDecisionClasses?: number }} ctx
191
+ * @returns {'agent' | 'skill'}
192
+ */
193
+ function inferSuggestedKind(ctx) {
194
+ const n = ctx && typeof ctx.distinctDecisionClasses === 'number'
195
+ ? ctx.distinctDecisionClasses
196
+ : 1;
197
+ return n > 1 ? 'agent' : 'skill';
198
+ }
199
+
200
+ // ---------------------------------------------------------------------------
201
+ // scanIntelTouchesClusters
202
+
203
+ /**
204
+ * Read every `*.md` file in `intelDir` (non-recursive), extract `Touches:`
205
+ * clusters, group by normalized signal, and emit one Finding per cluster
206
+ * whose occurrence count >= threshold AND whose touches set is not already
207
+ * owned by an existing agent.
208
+ *
209
+ * @param {Object} input
210
+ * @param {string} input.intelDir Absolute path to `.design/intel/`.
211
+ * @param {string[]} input.existingAgents Slugs of existing agents.
212
+ * @param {number} input.threshold Min recurrence count to flag.
213
+ * @param {string} [input.baseDir] Repo base for `evidence_refs.path`.
214
+ * @returns {Finding[]}
215
+ */
216
+ function scanIntelTouchesClusters(input) {
217
+ const { intelDir, existingAgents = [], threshold = DEFAULT_THRESHOLD } = input || {};
218
+ const baseDir = input.baseDir || path.dirname(path.dirname(intelDir || ''));
219
+
220
+ if (!intelDir || !fs.existsSync(intelDir)) return [];
221
+
222
+ const stat = fs.statSync(intelDir);
223
+ if (!stat.isDirectory()) return [];
224
+
225
+ // Build a lowercase token set from existing agent slugs for the
226
+ // soft-ownership heuristic. Conservative: when in doubt, KEEP the
227
+ // group as a candidate; the /gdd:apply-reflections user gate is the
228
+ // safety net.
229
+ const agentTokens = new Set();
230
+ for (const slug of existingAgents) {
231
+ if (typeof slug !== 'string') continue;
232
+ for (const tok of slug.toLowerCase().split(/[-_/]/)) {
233
+ if (tok.length >= 4) agentTokens.add(tok);
234
+ }
235
+ }
236
+
237
+ /** @type {Map<string, {signal: {touches: string[], agent_type: string}, occurrences: Array<{file: string, lineStart: number, lineEnd: number, decisionClass: string}>}>} */
238
+ const groups = new Map();
239
+
240
+ const entries = fs.readdirSync(intelDir, { withFileTypes: true });
241
+ for (const ent of entries) {
242
+ if (!ent.isFile() || !ent.name.endsWith('.md')) continue;
243
+ const filePath = path.join(intelDir, ent.name);
244
+ const raw = fs.readFileSync(filePath, 'utf8');
245
+ const lines = raw.split('\n');
246
+
247
+ let touchesLineIdx = -1;
248
+ let touchesValue = null;
249
+ let agentType = '';
250
+ let decisionClass = ent.name; // default classifier
251
+ for (let i = 0; i < lines.length; i++) {
252
+ const line = lines[i];
253
+ const mTouches = /^Touches:\s*(.+)$/i.exec(line);
254
+ if (mTouches && touchesLineIdx === -1) {
255
+ touchesLineIdx = i;
256
+ touchesValue = mTouches[1].trim();
257
+ }
258
+ const mAgent = /^Agent-Type:\s*(.+)$/i.exec(line);
259
+ if (mAgent) {
260
+ agentType = mAgent[1].trim();
261
+ }
262
+ const mDecision = /^Decision-Class:\s*(.+)$/i.exec(line);
263
+ if (mDecision) {
264
+ decisionClass = mDecision[1].trim();
265
+ }
266
+ }
267
+ if (touchesLineIdx === -1 || !touchesValue) continue;
268
+
269
+ const touches = touchesValue
270
+ .split(',')
271
+ .map((t) => t.trim())
272
+ .filter((t) => t.length > 0);
273
+ if (touches.length === 0) continue;
274
+
275
+ const sortedTouches = [...touches].sort((a, b) => a.localeCompare(b, 'en'));
276
+ const signal = { touches: sortedTouches, agent_type: agentType };
277
+ const key = computeContextHash(signal);
278
+
279
+ if (!groups.has(key)) {
280
+ groups.set(key, { signal, occurrences: [] });
281
+ }
282
+ const lineStart = touchesLineIdx + 1; // 1-based
283
+ const lineEnd = lineStart; // single-line Touches: block
284
+ groups.get(key).occurrences.push({
285
+ file: filePath,
286
+ lineStart,
287
+ lineEnd,
288
+ decisionClass,
289
+ });
290
+ }
291
+
292
+ /** @type {Finding[]} */
293
+ const findings = [];
294
+ for (const [hash, group] of groups.entries()) {
295
+ if (group.occurrences.length < threshold) continue;
296
+
297
+ // Soft-ownership filter: if any agent slug's tokens overlap >=2 with
298
+ // the touches tokens, drop the cluster. Otherwise keep.
299
+ let owned = false;
300
+ if (agentTokens.size > 0) {
301
+ const touchTokens = new Set();
302
+ for (const t of group.signal.touches) {
303
+ for (const tok of t.toLowerCase().split(/[\W_]+/)) {
304
+ if (tok.length >= 4) touchTokens.add(tok);
305
+ }
306
+ }
307
+ let overlap = 0;
308
+ for (const tok of touchTokens) {
309
+ if (agentTokens.has(tok)) overlap += 1;
310
+ }
311
+ if (overlap >= 2) owned = true;
312
+ }
313
+ if (owned) continue;
314
+
315
+ const evidence_refs = group.occurrences.map((occ) =>
316
+ buildEvidenceRef(occ.file, occ.lineStart, occ.lineEnd, baseDir),
317
+ );
318
+ const distinctDecisionClasses = new Set(
319
+ group.occurrences.map((o) => o.decisionClass),
320
+ ).size;
321
+ const suggested_kind = inferSuggestedKind({ distinctDecisionClasses });
322
+ const top3 = group.signal.touches.slice(0, 3).join(', ');
323
+ const intent_summary = `Recurring touches cluster: ${top3} (N=${group.occurrences.length}, no dedicated owner)`.slice(
324
+ 0,
325
+ 256,
326
+ );
327
+
328
+ findings.push({
329
+ signal: group.signal,
330
+ context_hash: hash,
331
+ intent_summary,
332
+ suggested_kind,
333
+ evidence_refs,
334
+ source_origin: 'intel',
335
+ occurrences: group.occurrences.length,
336
+ });
337
+ }
338
+
339
+ return findings;
340
+ }
341
+
342
+ // ---------------------------------------------------------------------------
343
+ // scanPosteriorArms
344
+
345
+ /**
346
+ * Read `.design/telemetry/posterior.json` and flag arms where `count >=
347
+ * threshold` AND `agent` is in `GENERIC_AGENT_FALLBACKS` (or NOT in
348
+ * `specializedAgents` if that set is provided).
349
+ *
350
+ * @param {Object} input
351
+ * @param {string} input.posteriorPath Absolute path.
352
+ * @param {Set<string>} [input.specializedAgents]
353
+ * @param {number} input.threshold Min `count` field to flag.
354
+ * @param {string} [input.baseDir] Repo base for evidence_refs.path.
355
+ * @returns {Finding[]}
356
+ */
357
+ function scanPosteriorArms(input) {
358
+ const { posteriorPath, specializedAgents, threshold = DEFAULT_THRESHOLD } = input || {};
359
+ const baseDir = input.baseDir || path.dirname(path.dirname(posteriorPath || ''));
360
+ if (!posteriorPath || !fs.existsSync(posteriorPath)) return [];
361
+
362
+ let posterior;
363
+ try {
364
+ posterior = JSON.parse(fs.readFileSync(posteriorPath, 'utf8'));
365
+ } catch {
366
+ return [];
367
+ }
368
+ if (!posterior || !Array.isArray(posterior.arms)) return [];
369
+
370
+ const raw = fs.readFileSync(posteriorPath, 'utf8');
371
+ const lineCount = raw.split('\n').length;
372
+
373
+ /** @type {Finding[]} */
374
+ const findings = [];
375
+ for (const arm of posterior.arms) {
376
+ if (!arm || typeof arm !== 'object') continue;
377
+ if (typeof arm.count !== 'number' || arm.count < threshold) continue;
378
+
379
+ // Generic fallback test: explicit set OR default GENERIC_AGENT_FALLBACKS.
380
+ const isGeneric = specializedAgents
381
+ ? !specializedAgents.has(arm.agent)
382
+ : GENERIC_AGENT_FALLBACKS.has(arm.agent);
383
+ if (!isGeneric) continue;
384
+
385
+ const signal = {
386
+ touches: [`bin:${arm.bin}`],
387
+ agent_type: String(arm.agent || ''),
388
+ };
389
+ const context_hash = computeContextHash(signal);
390
+ const intent_summary =
391
+ `High-usage bandit arm: agent=${arm.agent}, bin=${arm.bin}, count=${arm.count} (no specialized agent)`.slice(
392
+ 0,
393
+ 256,
394
+ );
395
+
396
+ // evidence_refs: a single pointer covering the whole posterior file
397
+ // (acceptable approximation per plan — 29-03 clusters by context_hash).
398
+ const evidence_refs = [
399
+ buildEvidenceRef(posteriorPath, 1, Math.max(1, lineCount), baseDir),
400
+ ];
401
+
402
+ findings.push({
403
+ signal,
404
+ context_hash,
405
+ intent_summary,
406
+ suggested_kind: 'agent', // posterior signals are multi-step orchestration
407
+ evidence_refs,
408
+ source_origin: 'posterior',
409
+ occurrences: arm.count,
410
+ });
411
+ }
412
+
413
+ return findings;
414
+ }
415
+
416
+ // ---------------------------------------------------------------------------
417
+ // scanTrajectorySlices
418
+
419
+ /**
420
+ * Scan `.design/gep/events.jsonl` for repeated decision sequences with no
421
+ * specialized owner. Applies D-08 MCP-probe exclusion. Filters by
422
+ * `windowDays` lookback.
423
+ *
424
+ * @param {Object} input
425
+ * @param {string} input.chainPath Path to `.design/gep/events.jsonl`.
426
+ * @param {number} [input.windowDays] Lookback (default 30).
427
+ * @param {number} input.threshold Min repetition count.
428
+ * @param {Set<string>} [input.specializedAgents]
429
+ * @param {string} [input.baseDir]
430
+ * @returns {Finding[]}
431
+ */
432
+ function scanTrajectorySlices(input) {
433
+ const {
434
+ chainPath,
435
+ windowDays = TRAJECTORY_LOOKBACK_DAYS_DEFAULT,
436
+ threshold = DEFAULT_THRESHOLD,
437
+ specializedAgents,
438
+ } = input || {};
439
+ const baseDir = input.baseDir || path.dirname(path.dirname(chainPath || ''));
440
+ if (!chainPath || !fs.existsSync(chainPath)) return [];
441
+
442
+ const raw = fs.readFileSync(chainPath, 'utf8');
443
+ const lines = raw.split('\n');
444
+ const cutoffMs = Date.now() - windowDays * 24 * 60 * 60 * 1000;
445
+
446
+ /** @type {Array<{ev: Record<string,unknown>, lineNum: number}>} */
447
+ const eligible = [];
448
+ for (let i = 0; i < lines.length; i++) {
449
+ const line = lines[i];
450
+ if (line.trim() === '') continue;
451
+ let ev;
452
+ try {
453
+ ev = JSON.parse(line);
454
+ } catch {
455
+ continue;
456
+ }
457
+ // D-08: MCP-probe exclusion (liberal).
458
+ if (isMcpProbeRow(ev)) continue;
459
+ // Window filter (skip if ts missing or invalid → treat as in-window).
460
+ if (typeof ev.ts === 'string') {
461
+ const t = Date.parse(ev.ts);
462
+ if (!Number.isNaN(t) && t < cutoffMs) continue;
463
+ }
464
+ eligible.push({ ev, lineNum: i + 1 });
465
+ }
466
+
467
+ // Group by a sequence-signature: concatenated decision_refs + agent.
468
+ // Each row is treated as a "sequence" for the purposes of this scan —
469
+ // a more sophisticated parent-chain walk is out of scope for Stage-0
470
+ // telemetry (the deterministic hash is the join key for 29-03).
471
+ /** @type {Map<string, {signal: {touches: string[], agent_type: string}, occurrences: Array<{lineNum: number, decisionClass: string}>}>} */
472
+ const groups = new Map();
473
+ for (const { ev, lineNum } of eligible) {
474
+ const decision_refs = Array.isArray(ev.decision_refs)
475
+ ? ev.decision_refs.filter((d) => typeof d === 'string')
476
+ : [];
477
+ if (decision_refs.length === 0) continue;
478
+ const agent = typeof ev.agent === 'string' ? ev.agent : '';
479
+
480
+ // Skip if specializedAgents set is provided and this agent is in it.
481
+ if (specializedAgents && specializedAgents.has(agent)) continue;
482
+ // Also: if specializedAgents not provided, only skip when agent is
483
+ // clearly a known specialized one (cheap heuristic = non-generic and
484
+ // not the all-blank slot). Since we don't have the list, keep all.
485
+
486
+ const signal = {
487
+ touches: [...decision_refs].sort((a, b) => a.localeCompare(b, 'en')),
488
+ agent_type: agent,
489
+ };
490
+ const key = computeContextHash(signal);
491
+ if (!groups.has(key)) {
492
+ groups.set(key, { signal, occurrences: [] });
493
+ }
494
+ groups.get(key).occurrences.push({
495
+ lineNum,
496
+ decisionClass: decision_refs[0],
497
+ });
498
+ }
499
+
500
+ /** @type {Finding[]} */
501
+ const findings = [];
502
+ for (const [hash, group] of groups.entries()) {
503
+ if (group.occurrences.length < threshold) continue;
504
+
505
+ const evidence_refs = group.occurrences.map((occ) =>
506
+ buildEvidenceRef(chainPath, occ.lineNum, occ.lineNum, baseDir),
507
+ );
508
+ const distinctDecisionClasses = new Set(
509
+ group.occurrences.map((o) => o.decisionClass),
510
+ ).size;
511
+ const suggested_kind = inferSuggestedKind({ distinctDecisionClasses });
512
+ const first = group.signal.touches[0];
513
+ const last = group.signal.touches[group.signal.touches.length - 1];
514
+ const middleIndicator = group.signal.touches.length > 2 ? ' → … → ' : ' → ';
515
+ const intent_summary =
516
+ `Repeated decision sequence: ${first}${middleIndicator}${last} (N=${group.occurrences.length})`.slice(
517
+ 0,
518
+ 256,
519
+ );
520
+
521
+ findings.push({
522
+ signal: group.signal,
523
+ context_hash: hash,
524
+ intent_summary,
525
+ suggested_kind,
526
+ evidence_refs,
527
+ source_origin: 'trajectory',
528
+ occurrences: group.occurrences.length,
529
+ });
530
+ }
531
+ return findings;
532
+ }
533
+
534
+ // ---------------------------------------------------------------------------
535
+ // Default emitter — late-bound via opts.emit so tests inject a spy
536
+
537
+ /**
538
+ * Default emitter that calls `appendChainEvent` from
539
+ * `scripts/lib/event-chain.cjs` with the schema-compliant envelope shape
540
+ * (matches the pattern used by 29-01's fast / router SKILL.md emitter
541
+ * sections). Returns the assigned `event_id`.
542
+ *
543
+ * The emitter accepts the SCAN-shape input (with internal `evidence_refs`
544
+ * line-based refs) and translates them into the schema's `TrajectoryRef`
545
+ * shape before persisting.
546
+ *
547
+ * @param {Object} input
548
+ * @param {'fast'|'router'|'reflector_pattern'} input.source
549
+ * @param {string} input.context_hash
550
+ * @param {string} input.intent_summary
551
+ * @param {'agent'|'skill'} input.suggested_kind
552
+ * @param {Array<{path:string,lineStart:number,lineEnd:number,sha256:string}>} input.evidence_refs
553
+ * @param {string|null} [input.parent_event_id]
554
+ * @param {string} [input.baseDir]
555
+ * @param {string} [input.chainPath]
556
+ * @returns {string} event_id
557
+ */
558
+ function defaultEmitCapabilityGapEvent(input) {
559
+ const { appendChainEvent } = require('../event-chain.cjs');
560
+ const baseDir = input.baseDir || process.cwd();
561
+
562
+ const trajectoryRefs = (input.evidence_refs || []).map((ref) =>
563
+ lineRefToTrajectoryRef(ref, baseDir),
564
+ );
565
+
566
+ const event_id = randomUUID();
567
+ const payload = {
568
+ event_id,
569
+ parent_event_id: input.parent_event_id ?? null,
570
+ source: input.source,
571
+ context_hash: input.context_hash,
572
+ intent_summary: input.intent_summary,
573
+ suggested_kind: input.suggested_kind,
574
+ evidence_refs: trajectoryRefs,
575
+ };
576
+
577
+ appendChainEvent({
578
+ path: input.chainPath,
579
+ baseDir,
580
+ agent: 'design-reflector',
581
+ outcome: 'capability_gap',
582
+ type: 'capability_gap',
583
+ timestamp: new Date().toISOString(),
584
+ sessionId: process.env.GDD_SESSION_ID || `reflector-${event_id.slice(0, 8)}`,
585
+ payload,
586
+ });
587
+
588
+ return event_id;
589
+ }
590
+
591
+ // ---------------------------------------------------------------------------
592
+ // runCapabilityGapScan — orchestrator
593
+
594
+ /**
595
+ * Orchestrator. Runs the three scans, concatenates findings, and emits
596
+ * one capability_gap event per finding via the provided (or default)
597
+ * emitter.
598
+ *
599
+ * Threshold resolution:
600
+ * 1. opts.threshold (test-injection / CLI override)
601
+ * 2. `.design/config.json` → `reflector.capability_gap_threshold`
602
+ * 3. DEFAULT_THRESHOLD (= 3)
603
+ *
604
+ * Throws if the resolved threshold is non-integer or < 1.
605
+ *
606
+ * @param {Object} [opts]
607
+ * @param {string} [opts.baseDir]
608
+ * @param {number} [opts.threshold]
609
+ * @param {Function} [opts.emit]
610
+ * @param {string} [opts.chainPath]
611
+ * @returns {{findings: Finding[], emittedEventIds: string[], skippedBelowThreshold: number}}
612
+ */
613
+ function runCapabilityGapScan(opts = {}) {
614
+ const baseDir = opts.baseDir || process.cwd();
615
+
616
+ let configThreshold;
617
+ const configPath = path.join(baseDir, '.design', 'config.json');
618
+ if (fs.existsSync(configPath)) {
619
+ try {
620
+ const cfg = JSON.parse(fs.readFileSync(configPath, 'utf8'));
621
+ if (
622
+ cfg &&
623
+ cfg.reflector &&
624
+ Object.prototype.hasOwnProperty.call(cfg.reflector, 'capability_gap_threshold')
625
+ ) {
626
+ configThreshold = cfg.reflector.capability_gap_threshold;
627
+ }
628
+ } catch {
629
+ // Ignore malformed config; fall through to default.
630
+ }
631
+ }
632
+
633
+ const resolvedThreshold = opts.threshold !== undefined
634
+ ? opts.threshold
635
+ : configThreshold !== undefined
636
+ ? configThreshold
637
+ : DEFAULT_THRESHOLD;
638
+
639
+ if (!Number.isInteger(resolvedThreshold) || resolvedThreshold < 1) {
640
+ throw new TypeError(
641
+ `runCapabilityGapScan: threshold must be an integer >= 1, got ${JSON.stringify(resolvedThreshold)}`,
642
+ );
643
+ }
644
+
645
+ // Build existingAgents set by reading agents/*.md frontmatter `name` fields.
646
+ const existingAgents = [];
647
+ const agentsDir = path.join(baseDir, 'agents');
648
+ if (fs.existsSync(agentsDir) && fs.statSync(agentsDir).isDirectory()) {
649
+ for (const ent of fs.readdirSync(agentsDir, { withFileTypes: true })) {
650
+ if (!ent.isFile() || !ent.name.endsWith('.md')) continue;
651
+ try {
652
+ const raw = fs.readFileSync(path.join(agentsDir, ent.name), 'utf8');
653
+ const m = /^name:\s*(.+)$/m.exec(raw);
654
+ if (m) existingAgents.push(m[1].trim());
655
+ } catch {
656
+ /* skip unreadable */
657
+ }
658
+ }
659
+ }
660
+ const specializedAgents = new Set(
661
+ existingAgents.filter((slug) => !GENERIC_AGENT_FALLBACKS.has(slug)),
662
+ );
663
+
664
+ const intelDir = path.join(baseDir, '.design', 'intel');
665
+ const posteriorPath = path.join(baseDir, '.design', 'telemetry', 'posterior.json');
666
+ const chainPath = opts.chainPath || path.join(baseDir, '.design', 'gep', 'events.jsonl');
667
+
668
+ const intelFindings = scanIntelTouchesClusters({
669
+ intelDir,
670
+ existingAgents,
671
+ threshold: resolvedThreshold,
672
+ baseDir,
673
+ });
674
+
675
+ const posteriorFindings = scanPosteriorArms({
676
+ posteriorPath,
677
+ specializedAgents,
678
+ threshold: resolvedThreshold,
679
+ baseDir,
680
+ });
681
+
682
+ const trajectoryFindings = scanTrajectorySlices({
683
+ chainPath,
684
+ windowDays: TRAJECTORY_LOOKBACK_DAYS_DEFAULT,
685
+ threshold: resolvedThreshold,
686
+ specializedAgents,
687
+ baseDir,
688
+ });
689
+
690
+ const findings = [...intelFindings, ...posteriorFindings, ...trajectoryFindings];
691
+
692
+ // Late-bind emitter — tests inject; production omits.
693
+ const emit = opts.emit || defaultEmitCapabilityGapEvent;
694
+ const emittedEventIds = [];
695
+ for (const f of findings) {
696
+ const id = emit({
697
+ source: 'reflector_pattern',
698
+ context_hash: f.context_hash,
699
+ intent_summary: f.intent_summary,
700
+ suggested_kind: f.suggested_kind,
701
+ evidence_refs: f.evidence_refs,
702
+ baseDir,
703
+ chainPath: opts.chainPath,
704
+ });
705
+ if (typeof id === 'string') emittedEventIds.push(id);
706
+ }
707
+
708
+ // skippedBelowThreshold is best-effort — the individual scanners filter
709
+ // internally; surface 0 here (the gate is exposed to operators via the
710
+ // threshold knob; per-cluster skip counts are not currently surfaced).
711
+ return { findings, emittedEventIds, skippedBelowThreshold: 0 };
712
+ }
713
+
714
+ // ---------------------------------------------------------------------------
715
+ // CLI dry-run
716
+
717
+ if (require.main === module) {
718
+ const dryRun = process.argv.includes('--dry-run');
719
+ const result = runCapabilityGapScan({
720
+ emit: dryRun ? () => 'DRY-RUN-NOT-EMITTED' : undefined,
721
+ });
722
+ if (dryRun) {
723
+ process.stdout.write(
724
+ JSON.stringify({ findings: result.findings, mode: 'dry-run' }, null, 2) + '\n',
725
+ );
726
+ } else {
727
+ process.stdout.write(
728
+ `emitted ${result.emittedEventIds.length} capability_gap event(s); ` +
729
+ `skipped ${result.skippedBelowThreshold} below threshold\n`,
730
+ );
731
+ }
732
+ }
733
+
734
+ // ---------------------------------------------------------------------------
735
+ // Exports
736
+
737
+ module.exports = {
738
+ DEFAULT_THRESHOLD,
739
+ GENERIC_AGENT_FALLBACKS,
740
+ TRAJECTORY_LOOKBACK_DAYS_DEFAULT,
741
+ computeContextHash,
742
+ scanIntelTouchesClusters,
743
+ scanPosteriorArms,
744
+ scanTrajectorySlices,
745
+ runCapabilityGapScan,
746
+ // Internal helpers exported for whitebox testing.
747
+ lineRefToTrajectoryRef,
748
+ isMcpProbeRow,
749
+ inferSuggestedKind,
750
+ defaultEmitCapabilityGapEvent,
751
+ };