@hegemonart/get-design-done 1.27.5 → 1.27.7

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 (67) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +6 -3
  3. package/CHANGELOG.md +99 -0
  4. package/agents/perf-analyzer.md +166 -0
  5. package/hooks/gdd-precompact-snapshot.js +334 -0
  6. package/hooks/gdd-sessionstart-recap.js +281 -0
  7. package/hooks/hooks.json +18 -0
  8. package/package.json +6 -5
  9. package/reference/perf-budget.md +142 -0
  10. package/reference/registry.json +14 -0
  11. package/reference/retrieval-contract.md +16 -0
  12. package/reference/schemas/mcp-gdd-tools.schema.json +381 -0
  13. package/scripts/install.cjs +42 -0
  14. package/scripts/lib/cache/gdd-cache-manager.cjs +292 -0
  15. package/scripts/lib/discuss-parallel-runner/index.ts +5 -1
  16. package/scripts/lib/explore-parallel-runner/index.ts +5 -1
  17. package/scripts/lib/gsd-health-mirror/index.cjs +105 -0
  18. package/scripts/lib/gsd-health-mirror/index.d.cts +14 -0
  19. package/scripts/lib/install/mcp-register.cjs +235 -0
  20. package/scripts/lib/install/mcp-register.d.cts +64 -0
  21. package/scripts/lib/intel-store/index.cjs +55 -0
  22. package/scripts/lib/intel-store/index.d.cts +11 -0
  23. package/scripts/lib/mcp-tools-lint/index.cjs +216 -0
  24. package/scripts/lib/mcp-tools-lint/index.d.cts +74 -0
  25. package/scripts/lib/parallelism-engine/concurrency-tuner.cjs +259 -0
  26. package/scripts/lib/parallelism-engine/concurrency-tuner.d.cts +53 -0
  27. package/scripts/lib/perf-analyzer/cost-regression.cjs +299 -0
  28. package/scripts/lib/perf-analyzer/index.cjs +139 -0
  29. package/scripts/lib/prompt-dedup/index.cjs +161 -0
  30. package/scripts/lib/reflections-reader/index.cjs +107 -0
  31. package/scripts/lib/reflections-reader/index.d.cts +18 -0
  32. package/scripts/lib/roadmap-reader/index.cjs +81 -0
  33. package/scripts/lib/roadmap-reader/index.d.cts +13 -0
  34. package/scripts/lib/snapshot-reader/index.cjs +70 -0
  35. package/scripts/lib/snapshot-reader/index.d.cts +28 -0
  36. package/scripts/mcp-servers/gdd-mcp/README.md +66 -0
  37. package/scripts/mcp-servers/gdd-mcp/schemas/gdd_cycle_recap.schema.json +30 -0
  38. package/scripts/mcp-servers/gdd-mcp/schemas/gdd_decisions_list.schema.json +32 -0
  39. package/scripts/mcp-servers/gdd-mcp/schemas/gdd_events_tail.schema.json +22 -0
  40. package/scripts/mcp-servers/gdd-mcp/schemas/gdd_health.schema.json +30 -0
  41. package/scripts/mcp-servers/gdd-mcp/schemas/gdd_intel_get.schema.json +24 -0
  42. package/scripts/mcp-servers/gdd-mcp/schemas/gdd_learnings_digest.schema.json +22 -0
  43. package/scripts/mcp-servers/gdd-mcp/schemas/gdd_phase_current.schema.json +22 -0
  44. package/scripts/mcp-servers/gdd-mcp/schemas/gdd_phases_list.schema.json +31 -0
  45. package/scripts/mcp-servers/gdd-mcp/schemas/gdd_plans_list.schema.json +33 -0
  46. package/scripts/mcp-servers/gdd-mcp/schemas/gdd_reflections_latest.schema.json +21 -0
  47. package/scripts/mcp-servers/gdd-mcp/schemas/gdd_status.schema.json +23 -0
  48. package/scripts/mcp-servers/gdd-mcp/schemas/gdd_telemetry_query.schema.json +23 -0
  49. package/scripts/mcp-servers/gdd-mcp/server.ts +317 -0
  50. package/scripts/mcp-servers/gdd-mcp/tools/gdd_cycle_recap.ts +37 -0
  51. package/scripts/mcp-servers/gdd-mcp/tools/gdd_decisions_list.ts +33 -0
  52. package/scripts/mcp-servers/gdd-mcp/tools/gdd_events_tail.ts +26 -0
  53. package/scripts/mcp-servers/gdd-mcp/tools/gdd_health.ts +19 -0
  54. package/scripts/mcp-servers/gdd-mcp/tools/gdd_intel_get.ts +32 -0
  55. package/scripts/mcp-servers/gdd-mcp/tools/gdd_learnings_digest.ts +23 -0
  56. package/scripts/mcp-servers/gdd-mcp/tools/gdd_phase_current.ts +29 -0
  57. package/scripts/mcp-servers/gdd-mcp/tools/gdd_phases_list.ts +26 -0
  58. package/scripts/mcp-servers/gdd-mcp/tools/gdd_plans_list.ts +39 -0
  59. package/scripts/mcp-servers/gdd-mcp/tools/gdd_reflections_latest.ts +25 -0
  60. package/scripts/mcp-servers/gdd-mcp/tools/gdd_status.ts +31 -0
  61. package/scripts/mcp-servers/gdd-mcp/tools/gdd_telemetry_query.ts +27 -0
  62. package/scripts/mcp-servers/gdd-mcp/tools/index.ts +75 -0
  63. package/scripts/mcp-servers/gdd-mcp/tools/shared.ts +134 -0
  64. package/skills/health/SKILL.md +36 -0
  65. package/skills/next/SKILL.md +28 -3
  66. package/skills/progress/SKILL.md +21 -6
  67. package/skills/resume/SKILL.md +26 -1
@@ -0,0 +1,53 @@
1
+ // scripts/lib/parallelism-engine/concurrency-tuner.d.cts — types for concurrency-tuner.cjs (Phase 27.6 D-07).
2
+
3
+ export interface ResolveConcurrencyOptions {
4
+ /** Override CPU count detection (defaults to `os.cpus().length`). */
5
+ cpuCount?: number;
6
+ /** Override last-observed optimum (else read from event-chain). */
7
+ lastObservedOptimum?: number;
8
+ /** Hard ceiling cap. Defaults to `DEFAULT_HARD_CEILING` (8). */
9
+ hardCeiling?: number;
10
+ /** Event-chain path override (else use `DEFAULT_EVENTS_PATH`). */
11
+ eventsPath?: string;
12
+ /** Base directory override (else `process.cwd()`). */
13
+ baseDir?: string;
14
+ }
15
+
16
+ export interface ReadLastObservedOptimumOptions {
17
+ eventsPath?: string;
18
+ baseDir?: string;
19
+ }
20
+
21
+ export interface EmitParallelismVerdictPayload {
22
+ task_ids?: string[];
23
+ verdict?: string;
24
+ reason?: string;
25
+ intended_concurrency?: number;
26
+ observed_concurrency?: number;
27
+ contention_detected?: boolean;
28
+ wall_clock_ms?: number;
29
+ }
30
+
31
+ /**
32
+ * Resolve the concurrency default per D-07: `min(cpu-1, last_observed_optimum, hard_ceiling)`.
33
+ * Falls back to `cpu-1` capped at `hard_ceiling` when no prior verdict exists.
34
+ */
35
+ export function resolveConcurrency(opts?: ResolveConcurrencyOptions): number;
36
+
37
+ /**
38
+ * Read the latest `parallelism.verdict` event's optimum from the event chain.
39
+ * Returns null when no prior verdict exists.
40
+ */
41
+ export function readLastObservedOptimum(
42
+ opts?: ReadLastObservedOptimumOptions,
43
+ ): number | null;
44
+
45
+ /**
46
+ * Emit a `parallelism.verdict` event (additive payload — back-compat preserved).
47
+ */
48
+ export function emitParallelismVerdict(
49
+ payload?: EmitParallelismVerdictPayload,
50
+ ): void;
51
+
52
+ export const DEFAULT_HARD_CEILING: number;
53
+ export const DEFAULT_EVENTS_PATH: string;
@@ -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
+ };