@hegemonart/get-design-done 1.27.5 → 1.27.6
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 +50 -0
- package/agents/perf-analyzer.md +166 -0
- package/hooks/gdd-precompact-snapshot.js +334 -0
- package/hooks/gdd-sessionstart-recap.js +281 -0
- package/hooks/hooks.json +18 -0
- package/package.json +2 -2
- package/reference/perf-budget.md +142 -0
- package/reference/registry.json +7 -0
- package/reference/retrieval-contract.md +16 -0
- package/scripts/lib/cache/gdd-cache-manager.cjs +292 -0
- package/scripts/lib/discuss-parallel-runner/index.ts +5 -1
- package/scripts/lib/explore-parallel-runner/index.ts +5 -1
- package/scripts/lib/parallelism-engine/concurrency-tuner.cjs +259 -0
- package/scripts/lib/parallelism-engine/concurrency-tuner.d.cts +53 -0
- package/scripts/lib/perf-analyzer/cost-regression.cjs +299 -0
- package/scripts/lib/perf-analyzer/index.cjs +139 -0
- package/scripts/lib/prompt-dedup/index.cjs +161 -0
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scripts/lib/perf-analyzer/cost-regression.cjs — Plan 27.6-01
|
|
3
|
+
*
|
|
4
|
+
* Stateless detection rules over the telemetry row arrays returned by
|
|
5
|
+
* scripts/lib/perf-analyzer/index.cjs. Three pure functions:
|
|
6
|
+
*
|
|
7
|
+
* detectCostRegressions — top-3 agents whose p50 USD-cost has
|
|
8
|
+
* regressed >= thresholdPct (default 25%
|
|
9
|
+
* per Phase 27.6 D-01) vs baseline across
|
|
10
|
+
* cyclesRequired distinct cycles (default 3).
|
|
11
|
+
* computeCacheHitDelta — per-agent current hit rate vs baseline.
|
|
12
|
+
* computeP95Spikes — per-agent p95 wall-time multiplier vs
|
|
13
|
+
* baseline. Flag when multiplier >= 1.5.
|
|
14
|
+
*
|
|
15
|
+
* All inputs are plain arrays / objects. No I/O. No external deps.
|
|
16
|
+
*/
|
|
17
|
+
'use strict';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Median (p50) of a numeric array. Returns 0 for empty input.
|
|
21
|
+
* Even-length arrays return the mean of the two middle values.
|
|
22
|
+
*
|
|
23
|
+
* @param {number[]} arr
|
|
24
|
+
* @returns {number}
|
|
25
|
+
*/
|
|
26
|
+
function p50(arr) {
|
|
27
|
+
if (!arr || arr.length === 0) return 0;
|
|
28
|
+
const sorted = [...arr].sort((a, b) => a - b);
|
|
29
|
+
const mid = Math.floor(sorted.length / 2);
|
|
30
|
+
return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* p95 of a numeric array (nearest-rank, floor index, clamped to last).
|
|
35
|
+
* Returns 0 for empty input.
|
|
36
|
+
*
|
|
37
|
+
* @param {number[]} arr
|
|
38
|
+
* @returns {number}
|
|
39
|
+
*/
|
|
40
|
+
function p95(arr) {
|
|
41
|
+
if (!arr || arr.length === 0) return 0;
|
|
42
|
+
const sorted = [...arr].sort((a, b) => a - b);
|
|
43
|
+
const idx = Math.min(sorted.length - 1, Math.floor(sorted.length * 0.95));
|
|
44
|
+
return sorted[idx];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Group cost rows by agent, then by cycle. Filters out rows missing
|
|
49
|
+
* the required shape (agent, est_cost_usd, cycle).
|
|
50
|
+
*
|
|
51
|
+
* @param {object[]} rows
|
|
52
|
+
* @returns {Map<string, { cycles: Map<string, number[]> }>}
|
|
53
|
+
*/
|
|
54
|
+
function groupRowsByAgentCycle(rows) {
|
|
55
|
+
const byAgent = new Map();
|
|
56
|
+
for (const row of rows || []) {
|
|
57
|
+
if (
|
|
58
|
+
!row ||
|
|
59
|
+
typeof row.agent !== 'string' ||
|
|
60
|
+
typeof row.est_cost_usd !== 'number' ||
|
|
61
|
+
typeof row.cycle !== 'string'
|
|
62
|
+
) {
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
let bucket = byAgent.get(row.agent);
|
|
66
|
+
if (!bucket) {
|
|
67
|
+
bucket = { cycles: new Map() };
|
|
68
|
+
byAgent.set(row.agent, bucket);
|
|
69
|
+
}
|
|
70
|
+
let cycleArr = bucket.cycles.get(row.cycle);
|
|
71
|
+
if (!cycleArr) {
|
|
72
|
+
cycleArr = [];
|
|
73
|
+
bucket.cycles.set(row.cycle, cycleArr);
|
|
74
|
+
}
|
|
75
|
+
cycleArr.push(row.est_cost_usd);
|
|
76
|
+
}
|
|
77
|
+
return byAgent;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Top-3 token-cost regressions across the most recent `cyclesRequired`
|
|
82
|
+
* distinct cycles per agent. Honours D-01 defaults (25% / 3 cycles).
|
|
83
|
+
*
|
|
84
|
+
* @param {object} opts
|
|
85
|
+
* @param {object[]} opts.rows - cost rows (from loadCosts)
|
|
86
|
+
* @param {Record<string, {p50_usd:number, hit_rate?:number, p95_ms?:number}>} opts.baseline
|
|
87
|
+
* @param {number} [opts.thresholdPct=25] - regression threshold (D-01)
|
|
88
|
+
* @param {number} [opts.cyclesRequired=3] - minimum distinct cycles (D-01)
|
|
89
|
+
* @returns {{
|
|
90
|
+
* regressions: Array<{agent:string, baseline_p50_usd:number, current_p50_usd:number, delta_pct:number, cycles_observed:number}>,
|
|
91
|
+
* summary: {agents_evaluated:number, agents_skipped_insufficient_data:number, regressions_count:number, threshold_pct:number, cycles_required:number}
|
|
92
|
+
* }}
|
|
93
|
+
*/
|
|
94
|
+
function detectCostRegressions({ rows, baseline, thresholdPct, cyclesRequired } = {}) {
|
|
95
|
+
const _thresholdPct = thresholdPct ?? 25;
|
|
96
|
+
const _cyclesRequired = cyclesRequired ?? 3;
|
|
97
|
+
const _baseline = baseline || {};
|
|
98
|
+
|
|
99
|
+
const byAgent = groupRowsByAgentCycle(rows);
|
|
100
|
+
|
|
101
|
+
/** @type {Array<{agent:string, baseline_p50_usd:number, current_p50_usd:number, delta_pct:number, cycles_observed:number}>} */
|
|
102
|
+
const candidates = [];
|
|
103
|
+
let agents_evaluated = 0;
|
|
104
|
+
let agents_skipped = 0;
|
|
105
|
+
|
|
106
|
+
for (const [agent, bucket] of byAgent.entries()) {
|
|
107
|
+
// Newest cycles first (lexicographic descending) — take up to N.
|
|
108
|
+
const cycleKeys = [...bucket.cycles.keys()].sort().reverse();
|
|
109
|
+
const recentCycles = cycleKeys.slice(0, _cyclesRequired);
|
|
110
|
+
|
|
111
|
+
if (recentCycles.length < _cyclesRequired) {
|
|
112
|
+
agents_skipped += 1;
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const baselineEntry = _baseline[agent];
|
|
117
|
+
if (!baselineEntry || typeof baselineEntry.p50_usd !== 'number') {
|
|
118
|
+
agents_skipped += 1;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const flatCosts = recentCycles.flatMap((c) => bucket.cycles.get(c));
|
|
123
|
+
const current = p50(flatCosts);
|
|
124
|
+
const base = baselineEntry.p50_usd;
|
|
125
|
+
|
|
126
|
+
// Contract (plan 27.6-01 behavior): "an agent's p50 USD-cost across
|
|
127
|
+
// the LAST cyclesRequired cycles is >= baseline_p50 × (1 + thresholdPct/100)".
|
|
128
|
+
// Apply the multiplicative form directly so the threshold-boundary case
|
|
129
|
+
// (e.g. baseline=0.05, current=0.0625, thresholdPct=25) is exact rather
|
|
130
|
+
// than dropping a ULP into the < side after a divide-and-multiply.
|
|
131
|
+
let delta_pct;
|
|
132
|
+
let isRegression;
|
|
133
|
+
if (base === 0) {
|
|
134
|
+
delta_pct = current === 0 ? 0 : Infinity;
|
|
135
|
+
isRegression = current > 0; // base=0+current>0 → always regression (D-01 edge)
|
|
136
|
+
} else {
|
|
137
|
+
const threshold = base * (1 + _thresholdPct / 100);
|
|
138
|
+
delta_pct = ((current - base) / base) * 100;
|
|
139
|
+
isRegression = current >= threshold;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
agents_evaluated += 1;
|
|
143
|
+
|
|
144
|
+
if (isRegression) {
|
|
145
|
+
candidates.push({
|
|
146
|
+
agent,
|
|
147
|
+
baseline_p50_usd: base,
|
|
148
|
+
current_p50_usd: current,
|
|
149
|
+
delta_pct,
|
|
150
|
+
cycles_observed: recentCycles.length,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
candidates.sort((a, b) => b.delta_pct - a.delta_pct);
|
|
156
|
+
const regressions = candidates.slice(0, 3);
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
regressions,
|
|
160
|
+
summary: {
|
|
161
|
+
agents_evaluated,
|
|
162
|
+
agents_skipped_insufficient_data: agents_skipped,
|
|
163
|
+
regressions_count: regressions.length,
|
|
164
|
+
threshold_pct: _thresholdPct,
|
|
165
|
+
cycles_required: _cyclesRequired,
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Cache-hit-rate delta per agent: current hit rate over the most recent
|
|
172
|
+
* `cyclesRequired` distinct cycles vs baseline hit rate.
|
|
173
|
+
*
|
|
174
|
+
* @param {object} opts
|
|
175
|
+
* @param {object[]} opts.rows
|
|
176
|
+
* @param {Record<string, {hit_rate?:number}>} opts.baseline
|
|
177
|
+
* @param {number} [opts.cyclesRequired=3]
|
|
178
|
+
* @returns {{ perAgent: Array<{agent:string, baseline_hit_rate:number, current_hit_rate:number, delta_pct:number, cycles_observed:number}> }}
|
|
179
|
+
*/
|
|
180
|
+
function computeCacheHitDelta({ rows, baseline, cyclesRequired } = {}) {
|
|
181
|
+
const _cyclesRequired = cyclesRequired ?? 3;
|
|
182
|
+
const _baseline = baseline || {};
|
|
183
|
+
|
|
184
|
+
// Group by agent: { agent -> Map<cycle, { hits: number, total: number }> }
|
|
185
|
+
const byAgent = new Map();
|
|
186
|
+
for (const row of rows || []) {
|
|
187
|
+
if (!row || typeof row.agent !== 'string' || typeof row.cycle !== 'string') continue;
|
|
188
|
+
let bucket = byAgent.get(row.agent);
|
|
189
|
+
if (!bucket) {
|
|
190
|
+
bucket = { cycles: new Map() };
|
|
191
|
+
byAgent.set(row.agent, bucket);
|
|
192
|
+
}
|
|
193
|
+
let cycleEntry = bucket.cycles.get(row.cycle);
|
|
194
|
+
if (!cycleEntry) {
|
|
195
|
+
cycleEntry = { hits: 0, total: 0 };
|
|
196
|
+
bucket.cycles.set(row.cycle, cycleEntry);
|
|
197
|
+
}
|
|
198
|
+
cycleEntry.total += 1;
|
|
199
|
+
if (row.cache_hit === true) cycleEntry.hits += 1;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** @type {Array<{agent:string, baseline_hit_rate:number, current_hit_rate:number, delta_pct:number, cycles_observed:number}>} */
|
|
203
|
+
const perAgent = [];
|
|
204
|
+
for (const [agent, bucket] of byAgent.entries()) {
|
|
205
|
+
const cycleKeys = [...bucket.cycles.keys()].sort().reverse();
|
|
206
|
+
const recentCycles = cycleKeys.slice(0, _cyclesRequired);
|
|
207
|
+
if (recentCycles.length === 0) continue;
|
|
208
|
+
|
|
209
|
+
let hits = 0;
|
|
210
|
+
let total = 0;
|
|
211
|
+
for (const c of recentCycles) {
|
|
212
|
+
const entry = bucket.cycles.get(c);
|
|
213
|
+
hits += entry.hits;
|
|
214
|
+
total += entry.total;
|
|
215
|
+
}
|
|
216
|
+
if (total === 0) continue;
|
|
217
|
+
|
|
218
|
+
const current_hit_rate = hits / total;
|
|
219
|
+
const baselineEntry = _baseline[agent];
|
|
220
|
+
const baseline_hit_rate =
|
|
221
|
+
baselineEntry && typeof baselineEntry.hit_rate === 'number' ? baselineEntry.hit_rate : 0;
|
|
222
|
+
|
|
223
|
+
let delta_pct;
|
|
224
|
+
if (baseline_hit_rate === 0) {
|
|
225
|
+
delta_pct = current_hit_rate === 0 ? 0 : Infinity;
|
|
226
|
+
} else {
|
|
227
|
+
delta_pct = ((current_hit_rate - baseline_hit_rate) / baseline_hit_rate) * 100;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
perAgent.push({
|
|
231
|
+
agent,
|
|
232
|
+
baseline_hit_rate,
|
|
233
|
+
current_hit_rate,
|
|
234
|
+
delta_pct,
|
|
235
|
+
cycles_observed: recentCycles.length,
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return { perAgent };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Aggregate wall_time_ms per agent across all cycles in `byCycle` and
|
|
244
|
+
* compare current p95 to baseline p95. Flag agents whose
|
|
245
|
+
* `current_p95 / baseline_p95 >= multiplierThreshold` (default 1.5).
|
|
246
|
+
*
|
|
247
|
+
* @param {object} opts
|
|
248
|
+
* @param {Record<string, object[]>} opts.byCycle
|
|
249
|
+
* @param {Record<string, {p95_ms?:number}>} opts.baseline
|
|
250
|
+
* @param {number} [opts.multiplierThreshold=1.5]
|
|
251
|
+
* @returns {{ spikes: Array<{agent:string, baseline_p95_ms:number, current_p95_ms:number, multiplier:number, cycles_observed:number}> }}
|
|
252
|
+
*/
|
|
253
|
+
function computeP95Spikes({ byCycle, baseline, multiplierThreshold } = {}) {
|
|
254
|
+
const _multiplier = multiplierThreshold ?? 1.5;
|
|
255
|
+
const _baseline = baseline || {};
|
|
256
|
+
const _byCycle = byCycle || {};
|
|
257
|
+
|
|
258
|
+
// Aggregate per agent: agent -> { walls: number[], cycles: Set<string> }
|
|
259
|
+
/** @type {Map<string, { walls: number[], cycles: Set<string> }>} */
|
|
260
|
+
const byAgent = new Map();
|
|
261
|
+
for (const [cycle, entries] of Object.entries(_byCycle)) {
|
|
262
|
+
for (const entry of entries || []) {
|
|
263
|
+
if (!entry || typeof entry.agent !== 'string' || typeof entry.wall_time_ms !== 'number') {
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
let bucket = byAgent.get(entry.agent);
|
|
267
|
+
if (!bucket) {
|
|
268
|
+
bucket = { walls: [], cycles: new Set() };
|
|
269
|
+
byAgent.set(entry.agent, bucket);
|
|
270
|
+
}
|
|
271
|
+
bucket.walls.push(entry.wall_time_ms);
|
|
272
|
+
bucket.cycles.add(cycle);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** @type {Array<{agent:string, baseline_p95_ms:number, current_p95_ms:number, multiplier:number, cycles_observed:number}>} */
|
|
277
|
+
const spikes = [];
|
|
278
|
+
for (const [agent, bucket] of byAgent.entries()) {
|
|
279
|
+
const baselineEntry = _baseline[agent];
|
|
280
|
+
if (!baselineEntry || typeof baselineEntry.p95_ms !== 'number') continue;
|
|
281
|
+
const base = baselineEntry.p95_ms;
|
|
282
|
+
if (base === 0) continue; // can't form a multiplier against zero
|
|
283
|
+
const current_p95_ms = p95(bucket.walls);
|
|
284
|
+
const multiplier = current_p95_ms / base;
|
|
285
|
+
if (multiplier >= _multiplier) {
|
|
286
|
+
spikes.push({
|
|
287
|
+
agent,
|
|
288
|
+
baseline_p95_ms: base,
|
|
289
|
+
current_p95_ms,
|
|
290
|
+
multiplier,
|
|
291
|
+
cycles_observed: bucket.cycles.size,
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return { spikes };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
module.exports = { detectCostRegressions, computeCacheHitDelta, computeP95Spikes };
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scripts/lib/perf-analyzer/index.cjs — Plan 27.6-01
|
|
3
|
+
*
|
|
4
|
+
* Telemetry reader for the Phase 27.6 perf-analyzer reflector agent.
|
|
5
|
+
* Reads `.design/telemetry/costs.jsonl` (cost rows, Phase 10.1) and
|
|
6
|
+
* `.design/telemetry/trajectories/<cycle>.jsonl` files (agent trace
|
|
7
|
+
* lines per Phase 22).
|
|
8
|
+
*
|
|
9
|
+
* JSONL discipline (same as scripts/lib/event-stream/reader.ts):
|
|
10
|
+
* - One JSON object per line.
|
|
11
|
+
* - Blank lines / whitespace-only lines ignored silently.
|
|
12
|
+
* - Malformed lines tolerated — counted in skipped_count, NOT thrown.
|
|
13
|
+
*
|
|
14
|
+
* No external deps. Stateless. Safe to require from CommonJS callers
|
|
15
|
+
* (agents, hooks, CI gates) without dragging the gdd-state MCP graph.
|
|
16
|
+
*/
|
|
17
|
+
'use strict';
|
|
18
|
+
|
|
19
|
+
const fs = require('node:fs');
|
|
20
|
+
const path = require('node:path');
|
|
21
|
+
|
|
22
|
+
const DEFAULT_COSTS_PATH = '.design/telemetry/costs.jsonl';
|
|
23
|
+
const DEFAULT_TRAJECTORIES_DIR = '.design/telemetry/trajectories';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Resolve a path against an optional baseDir. Absolute paths win.
|
|
27
|
+
* @param {string} p
|
|
28
|
+
* @param {string|undefined} baseDir
|
|
29
|
+
* @returns {string}
|
|
30
|
+
*/
|
|
31
|
+
function resolvePath(p, baseDir) {
|
|
32
|
+
if (path.isAbsolute(p)) return p;
|
|
33
|
+
if (baseDir) return path.join(baseDir, p);
|
|
34
|
+
return p;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Parse a JSONL file tolerantly: blank lines silently skipped,
|
|
39
|
+
* malformed lines counted in skipped_count without throwing.
|
|
40
|
+
*
|
|
41
|
+
* @param {string} contents - raw file contents (utf-8)
|
|
42
|
+
* @returns {{ rows: object[], skipped: number }}
|
|
43
|
+
*/
|
|
44
|
+
function parseJsonl(contents) {
|
|
45
|
+
const rows = [];
|
|
46
|
+
let skipped = 0;
|
|
47
|
+
const lines = contents.split(/\r?\n/);
|
|
48
|
+
for (const line of lines) {
|
|
49
|
+
if (line.trim() === '') continue;
|
|
50
|
+
try {
|
|
51
|
+
const obj = JSON.parse(line);
|
|
52
|
+
rows.push(obj);
|
|
53
|
+
} catch {
|
|
54
|
+
skipped += 1;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return { rows, skipped };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Read `.design/telemetry/costs.jsonl` (or override) into row objects.
|
|
62
|
+
*
|
|
63
|
+
* @param {object} [opts]
|
|
64
|
+
* @param {string} [opts.path] Override (default: DEFAULT_COSTS_PATH)
|
|
65
|
+
* @param {string} [opts.sinceCycle] Drop rows with row.cycle < this string (lex)
|
|
66
|
+
* @param {string} [opts.baseDir] Resolve relative paths against this dir
|
|
67
|
+
* @returns {{ rows: object[], parsed_count: number, skipped_count: number }}
|
|
68
|
+
*/
|
|
69
|
+
function loadCosts(opts) {
|
|
70
|
+
const o = opts || {};
|
|
71
|
+
const rawPath = o.path !== undefined ? o.path : DEFAULT_COSTS_PATH;
|
|
72
|
+
const targetPath = resolvePath(rawPath, o.baseDir);
|
|
73
|
+
|
|
74
|
+
if (!fs.existsSync(targetPath)) {
|
|
75
|
+
return { rows: [], parsed_count: 0, skipped_count: 0 };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const contents = fs.readFileSync(targetPath, 'utf8');
|
|
79
|
+
const { rows: parsed, skipped: skipped_count } = parseJsonl(contents);
|
|
80
|
+
|
|
81
|
+
let rows = parsed;
|
|
82
|
+
if (o.sinceCycle !== undefined) {
|
|
83
|
+
const since = o.sinceCycle;
|
|
84
|
+
rows = parsed.filter(
|
|
85
|
+
(row) => row && typeof row.cycle === 'string' && row.cycle >= since,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return { rows, parsed_count: rows.length, skipped_count };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Read `.design/telemetry/trajectories/<cycle>.jsonl` files (or override
|
|
94
|
+
* directory) into a per-cycle map keyed by basename-without-extension.
|
|
95
|
+
*
|
|
96
|
+
* @param {object} [opts]
|
|
97
|
+
* @param {string} [opts.dir] Override (default: DEFAULT_TRAJECTORIES_DIR)
|
|
98
|
+
* @param {string} [opts.baseDir] Resolve relative paths against this dir
|
|
99
|
+
* @returns {{ byCycle: Record<string, object[]>, files_read: number }}
|
|
100
|
+
*/
|
|
101
|
+
function loadTrajectories(opts) {
|
|
102
|
+
const o = opts || {};
|
|
103
|
+
const rawDir = o.dir !== undefined ? o.dir : DEFAULT_TRAJECTORIES_DIR;
|
|
104
|
+
const targetDir = resolvePath(rawDir, o.baseDir);
|
|
105
|
+
|
|
106
|
+
if (!fs.existsSync(targetDir)) {
|
|
107
|
+
return { byCycle: {}, files_read: 0 };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** @type {Record<string, object[]>} */
|
|
111
|
+
const byCycle = {};
|
|
112
|
+
let files_read = 0;
|
|
113
|
+
|
|
114
|
+
const entries = fs.readdirSync(targetDir);
|
|
115
|
+
for (const entry of entries) {
|
|
116
|
+
if (!entry.endsWith('.jsonl')) continue;
|
|
117
|
+
const filePath = path.join(targetDir, entry);
|
|
118
|
+
let contents;
|
|
119
|
+
try {
|
|
120
|
+
contents = fs.readFileSync(filePath, 'utf8');
|
|
121
|
+
} catch {
|
|
122
|
+
// Permission / IO error on one file should not abort the whole read.
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
const cycleSlug = path.basename(entry, '.jsonl');
|
|
126
|
+
const { rows } = parseJsonl(contents);
|
|
127
|
+
byCycle[cycleSlug] = rows;
|
|
128
|
+
files_read += 1;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return { byCycle, files_read };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
module.exports = {
|
|
135
|
+
loadCosts,
|
|
136
|
+
loadTrajectories,
|
|
137
|
+
DEFAULT_COSTS_PATH,
|
|
138
|
+
DEFAULT_TRAJECTORIES_DIR,
|
|
139
|
+
};
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scripts/lib/prompt-dedup/index.cjs — Plan 27.6-06
|
|
3
|
+
*
|
|
4
|
+
* Phase 27.6 D-11 prompt-deduplication analyzer. Detects cases where
|
|
5
|
+
* >= 3 distinct agents in the same cycle read the same reference/*.md
|
|
6
|
+
* file. Produces a preamble injection that gets prepended to the
|
|
7
|
+
* Phase 14.5 retrieval-contract preamble during cycle execution.
|
|
8
|
+
*
|
|
9
|
+
* v1.27.6 ships the analyzer + injection text builder. The event-
|
|
10
|
+
* emission side-effect is wired here for downstream consumers. The
|
|
11
|
+
* actual `reference.read` event emission from agent-read paths is
|
|
12
|
+
* deferred to a follow-up phase (this library is ready to consume
|
|
13
|
+
* those events when they exist).
|
|
14
|
+
*
|
|
15
|
+
* No external deps. Pure analyzer + lazy event-stream require.
|
|
16
|
+
*/
|
|
17
|
+
'use strict';
|
|
18
|
+
|
|
19
|
+
const DEFAULT_THRESHOLD = 3; // D-11 — '>= 3 agents'
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Lazy require for the event-stream appendEvent helper. Returns a
|
|
23
|
+
* no-op if event-stream is unavailable so emitDedupInjection can be
|
|
24
|
+
* called in tests / Codex no-PreCompact paths without throwing.
|
|
25
|
+
*
|
|
26
|
+
* @returns {(ev: object) => void}
|
|
27
|
+
*/
|
|
28
|
+
function getAppendEvent() {
|
|
29
|
+
try {
|
|
30
|
+
const m = require('../event-stream');
|
|
31
|
+
if (m && typeof m.appendEvent === 'function') return m.appendEvent;
|
|
32
|
+
} catch { /* swallow — event-stream not on path */ }
|
|
33
|
+
return function noopAppend(_ev) {};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Detect reference/*.md files that have been read by >= threshold
|
|
38
|
+
* distinct agents in the same cycle. The detection is pure — it
|
|
39
|
+
* consumes an in-memory events array and returns a structured result.
|
|
40
|
+
*
|
|
41
|
+
* @param {object} [opts]
|
|
42
|
+
* @param {Array<object>} [opts.events] Event-stream entries (any shape)
|
|
43
|
+
* @param {number} [opts.threshold] Override DEFAULT_THRESHOLD (3)
|
|
44
|
+
* @param {string} [opts.cycle] Filter — only consider events
|
|
45
|
+
* whose event.cycle === this value
|
|
46
|
+
* @returns {{duplicates: Array<{ref_path: string, agents: string[], hash?: string, cycle?: string}>}}
|
|
47
|
+
*/
|
|
48
|
+
function detectDuplicateReferenceReads({ events, threshold, cycle } = {}) {
|
|
49
|
+
const list = Array.isArray(events) ? events : [];
|
|
50
|
+
const N = typeof threshold === 'number' && threshold >= 1
|
|
51
|
+
? Math.floor(threshold)
|
|
52
|
+
: DEFAULT_THRESHOLD;
|
|
53
|
+
const cycleFilter = typeof cycle === 'string' && cycle.length > 0 ? cycle : null;
|
|
54
|
+
|
|
55
|
+
// Group by (cycle, ref_path) → Set<agent>
|
|
56
|
+
const groups = new Map();
|
|
57
|
+
for (const ev of list) {
|
|
58
|
+
if (!ev || ev.type !== 'reference.read') continue;
|
|
59
|
+
if (!ev.payload || typeof ev.payload.ref_path !== 'string' || typeof ev.payload.agent !== 'string') continue;
|
|
60
|
+
const evCycle = typeof ev.cycle === 'string'
|
|
61
|
+
? ev.cycle
|
|
62
|
+
: (typeof ev.payload.cycle === 'string' ? ev.payload.cycle : '');
|
|
63
|
+
if (cycleFilter !== null && evCycle !== cycleFilter) continue;
|
|
64
|
+
const key = evCycle + ' ' + ev.payload.ref_path;
|
|
65
|
+
let group = groups.get(key);
|
|
66
|
+
if (!group) {
|
|
67
|
+
group = { cycle: evCycle, ref_path: ev.payload.ref_path, agents: new Set(), hash: undefined };
|
|
68
|
+
groups.set(key, group);
|
|
69
|
+
}
|
|
70
|
+
group.agents.add(ev.payload.agent);
|
|
71
|
+
if (typeof ev.payload.content_hash === 'string' && !group.hash) {
|
|
72
|
+
group.hash = ev.payload.content_hash;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const duplicates = [];
|
|
77
|
+
for (const group of groups.values()) {
|
|
78
|
+
if (group.agents.size >= N) {
|
|
79
|
+
duplicates.push({
|
|
80
|
+
ref_path: group.ref_path,
|
|
81
|
+
agents: [...group.agents].sort(),
|
|
82
|
+
hash: group.hash,
|
|
83
|
+
cycle: group.cycle || undefined,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
duplicates.sort((a, b) => a.ref_path.localeCompare(b.ref_path));
|
|
88
|
+
return { duplicates };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Build the markdown preamble injection text that gets prepended to
|
|
93
|
+
* the Phase 14.5 retrieval-contract preamble during cycle execution.
|
|
94
|
+
* Returns an empty string when duplicates is empty (no injection).
|
|
95
|
+
*
|
|
96
|
+
* @param {object} [opts]
|
|
97
|
+
* @param {Array<object>} [opts.duplicates] From detectDuplicateReferenceReads
|
|
98
|
+
* @param {string} [opts.sessionId] Optional breadcrumb
|
|
99
|
+
* @returns {string}
|
|
100
|
+
*/
|
|
101
|
+
function buildPreambleInjection({ duplicates, sessionId } = {}) {
|
|
102
|
+
const list = Array.isArray(duplicates) ? duplicates : [];
|
|
103
|
+
if (list.length === 0) return '';
|
|
104
|
+
const lines = [
|
|
105
|
+
'## Shared Context (Phase 27.6 dedup)',
|
|
106
|
+
'',
|
|
107
|
+
'The following reference files have been read by >= 3 agents in this cycle and are now loaded ONCE as shared context. Subsequent agents see a content-hash reference instead of the full file body:',
|
|
108
|
+
'',
|
|
109
|
+
];
|
|
110
|
+
for (const d of list) {
|
|
111
|
+
const hashSuffix = d.hash ? ` [hash: ${d.hash}]` : '';
|
|
112
|
+
lines.push(`- \`${d.ref_path}\` (read by: ${d.agents.join(', ')})${hashSuffix}`);
|
|
113
|
+
}
|
|
114
|
+
lines.push('');
|
|
115
|
+
lines.push('To opt out of dedup for a specific read, set `GDD_DEDUP_OPT_OUT=1` in the agent\'s environment.');
|
|
116
|
+
lines.push('');
|
|
117
|
+
// sessionId is consumed as a breadcrumb hint; not embedded in the
|
|
118
|
+
// preamble text by default to keep the markdown minimal.
|
|
119
|
+
if (typeof sessionId === 'string' && sessionId.length > 0) {
|
|
120
|
+
lines.push(`<!-- dedup-session: ${sessionId} -->`);
|
|
121
|
+
lines.push('');
|
|
122
|
+
}
|
|
123
|
+
return lines.join('\n');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Emit one `dedup.injection` event per duplicate via the event-stream
|
|
128
|
+
* appendEvent helper. Lazy-required; safe when event-stream is
|
|
129
|
+
* unavailable (no-op fallback). Returns void.
|
|
130
|
+
*
|
|
131
|
+
* @param {object} [opts]
|
|
132
|
+
* @param {Array<object>} [opts.duplicates]
|
|
133
|
+
* @param {string} [opts.sessionId]
|
|
134
|
+
* @returns {void}
|
|
135
|
+
*/
|
|
136
|
+
function emitDedupInjection({ duplicates, sessionId } = {}) {
|
|
137
|
+
const list = Array.isArray(duplicates) ? duplicates : [];
|
|
138
|
+
if (list.length === 0) return;
|
|
139
|
+
const append = getAppendEvent();
|
|
140
|
+
for (const d of list) {
|
|
141
|
+
append({
|
|
142
|
+
type: 'dedup.injection',
|
|
143
|
+
timestamp: new Date().toISOString(),
|
|
144
|
+
sessionId: typeof sessionId === 'string' && sessionId.length > 0 ? sessionId : 'prompt-dedup',
|
|
145
|
+
payload: {
|
|
146
|
+
ref_path: d.ref_path,
|
|
147
|
+
agents: d.agents,
|
|
148
|
+
agent_count: d.agents.length,
|
|
149
|
+
content_hash: d.hash,
|
|
150
|
+
cycle: d.cycle,
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
module.exports = {
|
|
157
|
+
detectDuplicateReferenceReads,
|
|
158
|
+
buildPreambleInjection,
|
|
159
|
+
emitDedupInjection,
|
|
160
|
+
DEFAULT_THRESHOLD,
|
|
161
|
+
};
|