@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,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
|
+
};
|