@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,292 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scripts/lib/cache/gdd-cache-manager.cjs — Plan 27.6-03
|
|
3
|
+
*
|
|
4
|
+
* Cache-warming heuristic (Phase 10.1 cost-governance refinement,
|
|
5
|
+
* Phase 27.6 D-06): score each candidate entry as the multiplicative
|
|
6
|
+
* product of three [0,1]-normalized components — recency, frequency,
|
|
7
|
+
* cost — and warm the top-N entries per cycle.
|
|
8
|
+
*
|
|
9
|
+
* Eviction policy: LRU within the warmed set. When a new top-rank
|
|
10
|
+
* candidate arrives, it displaces the oldest-touched entry from the
|
|
11
|
+
* warmed slot (D-06 wording).
|
|
12
|
+
*
|
|
13
|
+
* Telemetry: each per-cycle decision emits a `cache.warm_decision`
|
|
14
|
+
* event via Phase 22 event-stream so the Phase 27.6-01 perf-analyzer
|
|
15
|
+
* can surface "false-positive rate exceeds threshold" proposals
|
|
16
|
+
* (D-02: 20% default; configurable via
|
|
17
|
+
* `.design/budget.json#cache_warming_falsepositive_threshold`).
|
|
18
|
+
*
|
|
19
|
+
* Pure functions + side-effects-via-appendEvent. No external deps
|
|
20
|
+
* beyond `node:` builtins and the lazy event-stream require.
|
|
21
|
+
*
|
|
22
|
+
* @module scripts/lib/cache/gdd-cache-manager
|
|
23
|
+
*/
|
|
24
|
+
'use strict';
|
|
25
|
+
|
|
26
|
+
// `node:fs` and `node:path` are required by the contract (lazy
|
|
27
|
+
// future-extension surface for budget.json reads); event-stream is
|
|
28
|
+
// loaded lazily via try/catch so this library is consumable from pure
|
|
29
|
+
// CommonJS runtimes that cannot strip TypeScript on the fly.
|
|
30
|
+
const path = require('node:path'); // eslint-disable-line no-unused-vars
|
|
31
|
+
const fs = require('node:fs'); // eslint-disable-line no-unused-vars
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Default top-N candidates warmed per cycle. Override per-call via
|
|
35
|
+
* `rankWarmCandidates({ entries, topN })` or via
|
|
36
|
+
* `.design/budget.json#cache_warm_topn` at the caller level.
|
|
37
|
+
*
|
|
38
|
+
* @type {number}
|
|
39
|
+
*/
|
|
40
|
+
const DEFAULT_TOPN = 10;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Default false-positive tolerance threshold percentage (D-02).
|
|
44
|
+
* When more than this percent of warmed entries are evicted before
|
|
45
|
+
* being read in a single cycle, the heuristic emits a per-cycle
|
|
46
|
+
* `cache.warm_decision` summary event so the perf-analyzer can flag
|
|
47
|
+
* the heuristic as mis-tuned.
|
|
48
|
+
*
|
|
49
|
+
* Configurable per-call via the `falsePositiveThresholdPct` argument
|
|
50
|
+
* to `summarizeFalsePositiveRate`, or at the project level via
|
|
51
|
+
* `.design/budget.json#cache_warming_falsepositive_threshold`.
|
|
52
|
+
*
|
|
53
|
+
* @type {number}
|
|
54
|
+
*/
|
|
55
|
+
const DEFAULT_FALSE_POSITIVE_THRESHOLD_PCT = 20;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Clamp a number to the [0, 1] range. Non-finite / non-numeric inputs
|
|
59
|
+
* collapse to 0 — by design, since a non-numeric component cannot
|
|
60
|
+
* meaningfully participate in the multiplicative score.
|
|
61
|
+
*
|
|
62
|
+
* @param {unknown} n
|
|
63
|
+
* @returns {number}
|
|
64
|
+
*/
|
|
65
|
+
function clamp01(n) {
|
|
66
|
+
if (typeof n !== 'number' || !Number.isFinite(n)) return 0;
|
|
67
|
+
if (n < 0) return 0;
|
|
68
|
+
if (n > 1) return 1;
|
|
69
|
+
return n;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Lazily resolve the Phase 22 event-stream `appendEvent` function.
|
|
74
|
+
* The event-stream is shipped as `index.ts` (TypeScript); under a
|
|
75
|
+
* pure-CommonJS runtime that cannot strip TS on the fly, the require
|
|
76
|
+
* will throw. We swallow that and return a no-op so this library
|
|
77
|
+
* stays usable in any caller — tests, CJS scripts, or the
|
|
78
|
+
* cache-manager slash skill — without forcing them to install a
|
|
79
|
+
* TS loader.
|
|
80
|
+
*
|
|
81
|
+
* @returns {(ev: object) => void}
|
|
82
|
+
*/
|
|
83
|
+
function getAppendEvent() {
|
|
84
|
+
try {
|
|
85
|
+
// Resolved relative to this file: `scripts/lib/cache/` → `../event-stream`.
|
|
86
|
+
const m = require('../event-stream');
|
|
87
|
+
if (m && typeof m.appendEvent === 'function') return m.appendEvent;
|
|
88
|
+
} catch {
|
|
89
|
+
// Swallow — fall through to the no-op below. The event is
|
|
90
|
+
// best-effort telemetry; losing one decision is acceptable.
|
|
91
|
+
}
|
|
92
|
+
return function noopAppend(_ev) { /* event-stream unavailable */ };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Compute the multiplicative warming score for a single candidate
|
|
97
|
+
* (D-06). Each component is clamped to [0, 1] before multiplication;
|
|
98
|
+
* any zero component zeroes the entire score by design — all three
|
|
99
|
+
* dimensions (recency, frequency, cost) must be non-zero for an
|
|
100
|
+
* entry to be a warm candidate at all.
|
|
101
|
+
*
|
|
102
|
+
* @param {{recency_score:number, frequency_score:number, cost_score:number}} components
|
|
103
|
+
* @returns {number} score in [0, 1]
|
|
104
|
+
*/
|
|
105
|
+
function computeWarmingScore({ recency_score, frequency_score, cost_score } = {}) {
|
|
106
|
+
const r = clamp01(recency_score);
|
|
107
|
+
const f = clamp01(frequency_score);
|
|
108
|
+
const c = clamp01(cost_score);
|
|
109
|
+
return r * f * c;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Rank a list of cache candidates by multiplicative warming score and
|
|
114
|
+
* return the top-N as the warmed set, with the remainder as eviction
|
|
115
|
+
* candidates. Pure: no I/O, no event emission.
|
|
116
|
+
*
|
|
117
|
+
* Component normalization:
|
|
118
|
+
* recency_score = 1 / (1 + days_since_last_use)
|
|
119
|
+
* frequency_score = uses_in_window / window_size (clamped)
|
|
120
|
+
* cost_score = est_cost_usd / max(est_cost_usd) (clamped)
|
|
121
|
+
*
|
|
122
|
+
* If all entries have `est_cost_usd === 0`, all cost_scores collapse
|
|
123
|
+
* to 0 (and therefore all final scores collapse to 0) — the heuristic
|
|
124
|
+
* never warms cost-free entries, by design.
|
|
125
|
+
*
|
|
126
|
+
* Eviction policy: entries beyond the top-N rank are returned in
|
|
127
|
+
* `evictionCandidates`. The caller (cache-manager skill) treats the
|
|
128
|
+
* warmed set as LRU internally — when a new entry beats an existing
|
|
129
|
+
* warmed entry, the LRU entry in the warmed set is the one displaced.
|
|
130
|
+
*
|
|
131
|
+
* @param {{
|
|
132
|
+
* entries?: Array<{
|
|
133
|
+
* key: string,
|
|
134
|
+
* days_since_last_use?: number,
|
|
135
|
+
* uses_in_window?: number,
|
|
136
|
+
* window_size?: number,
|
|
137
|
+
* est_cost_usd?: number,
|
|
138
|
+
* last_touched_at?: string,
|
|
139
|
+
* }>,
|
|
140
|
+
* topN?: number,
|
|
141
|
+
* }} args
|
|
142
|
+
* @returns {{warmed: Array<object>, evictionCandidates: Array<object>}}
|
|
143
|
+
*/
|
|
144
|
+
function rankWarmCandidates({ entries, topN } = {}) {
|
|
145
|
+
const N = typeof topN === 'number' && topN > 0 ? Math.floor(topN) : DEFAULT_TOPN;
|
|
146
|
+
const list = Array.isArray(entries) ? entries : [];
|
|
147
|
+
if (list.length === 0) return { warmed: [], evictionCandidates: [] };
|
|
148
|
+
|
|
149
|
+
// Determine the per-cycle max cost for normalization. If everything
|
|
150
|
+
// is free, the cost dimension contributes 0 to every score (which
|
|
151
|
+
// zeroes the multiplicative product — that's intentional).
|
|
152
|
+
let maxCost = 0;
|
|
153
|
+
for (const e of list) {
|
|
154
|
+
const c = typeof e.est_cost_usd === 'number' && e.est_cost_usd > 0 ? e.est_cost_usd : 0;
|
|
155
|
+
if (c > maxCost) maxCost = c;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const scored = list.map((e) => {
|
|
159
|
+
const days = typeof e.days_since_last_use === 'number' && e.days_since_last_use >= 0
|
|
160
|
+
? e.days_since_last_use
|
|
161
|
+
: Infinity;
|
|
162
|
+
const recency_score = days === Infinity ? 0 : 1 / (1 + days);
|
|
163
|
+
|
|
164
|
+
const usesIn = typeof e.uses_in_window === 'number' && e.uses_in_window >= 0
|
|
165
|
+
? e.uses_in_window
|
|
166
|
+
: 0;
|
|
167
|
+
const win = typeof e.window_size === 'number' && e.window_size > 0
|
|
168
|
+
? e.window_size
|
|
169
|
+
: 1;
|
|
170
|
+
const frequency_score = clamp01(usesIn / win);
|
|
171
|
+
|
|
172
|
+
const cost = typeof e.est_cost_usd === 'number' && e.est_cost_usd >= 0
|
|
173
|
+
? e.est_cost_usd
|
|
174
|
+
: 0;
|
|
175
|
+
const cost_score = maxCost > 0 ? clamp01(cost / maxCost) : 0;
|
|
176
|
+
|
|
177
|
+
const score = computeWarmingScore({ recency_score, frequency_score, cost_score });
|
|
178
|
+
return { ...e, recency_score, frequency_score, cost_score, score };
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
// Sort by score descending. Stable order on ties is fine for v1.
|
|
182
|
+
scored.sort((a, b) => b.score - a.score);
|
|
183
|
+
|
|
184
|
+
const warmed = scored.slice(0, N);
|
|
185
|
+
const evictionCandidates = scored.slice(N);
|
|
186
|
+
return { warmed, evictionCandidates };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Emit one `cache.warm_decision` event recording the outcome of a
|
|
191
|
+
* single warmed entry: was it used before being evicted, or did the
|
|
192
|
+
* heuristic mis-warm (false-positive)?
|
|
193
|
+
*
|
|
194
|
+
* Called per warmed entry at eviction time by the cache layer. The
|
|
195
|
+
* 27.6-01 perf-analyzer aggregates these to compute per-cycle
|
|
196
|
+
* false-positive rates.
|
|
197
|
+
*
|
|
198
|
+
* Side effect only — returns void. The function is best-effort:
|
|
199
|
+
* if the event-stream is unavailable, the emission silently no-ops.
|
|
200
|
+
*
|
|
201
|
+
* @param {{
|
|
202
|
+
* entry: {key:string, score:number, recency_score:number, frequency_score:number, cost_score:number},
|
|
203
|
+
* usedBeforeEviction: boolean,
|
|
204
|
+
* evictionEvent?: {at?: string, reason?: string},
|
|
205
|
+
* sessionId?: string,
|
|
206
|
+
* }} args
|
|
207
|
+
* @returns {void}
|
|
208
|
+
*/
|
|
209
|
+
function evaluateWarmingDecision({ entry, usedBeforeEviction, evictionEvent, sessionId } = {}) {
|
|
210
|
+
const append = getAppendEvent();
|
|
211
|
+
const e = entry && typeof entry === 'object' ? entry : {};
|
|
212
|
+
append({
|
|
213
|
+
type: 'cache.warm_decision',
|
|
214
|
+
timestamp: new Date().toISOString(),
|
|
215
|
+
sessionId: typeof sessionId === 'string' && sessionId.length > 0
|
|
216
|
+
? sessionId
|
|
217
|
+
: 'cache-manager',
|
|
218
|
+
payload: {
|
|
219
|
+
entry_key: typeof e.key === 'string' ? e.key : 'unknown',
|
|
220
|
+
score: typeof e.score === 'number' ? e.score : 0,
|
|
221
|
+
recency_score: typeof e.recency_score === 'number' ? e.recency_score : 0,
|
|
222
|
+
frequency_score: typeof e.frequency_score === 'number' ? e.frequency_score : 0,
|
|
223
|
+
cost_score: typeof e.cost_score === 'number' ? e.cost_score : 0,
|
|
224
|
+
used_before_eviction: !!usedBeforeEviction,
|
|
225
|
+
evicted_at: evictionEvent && typeof evictionEvent.at === 'string'
|
|
226
|
+
? evictionEvent.at
|
|
227
|
+
: undefined,
|
|
228
|
+
},
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Aggregate a cycle's worth of per-entry decisions into a single
|
|
234
|
+
* false-positive rate. If the rate exceeds the configured threshold
|
|
235
|
+
* (D-02 default 20%), emit a per-cycle summary `cache.warm_decision`
|
|
236
|
+
* event so the perf-analyzer can flag the heuristic as mis-tuned.
|
|
237
|
+
*
|
|
238
|
+
* Returns the computed rate + threshold context regardless of whether
|
|
239
|
+
* an event was emitted, so the caller can route the result into its
|
|
240
|
+
* own reporting surface (e.g. the cache-manager slash skill's status
|
|
241
|
+
* output).
|
|
242
|
+
*
|
|
243
|
+
* @param {{
|
|
244
|
+
* decisions?: Array<{entry_key?:string, used_before_eviction?:boolean, score?:number}>,
|
|
245
|
+
* falsePositiveThresholdPct?: number,
|
|
246
|
+
* sessionId?: string,
|
|
247
|
+
* }} args
|
|
248
|
+
* @returns {{false_positive_rate:number, count:number, threshold_pct:number, exceeds_threshold:boolean}}
|
|
249
|
+
*/
|
|
250
|
+
function summarizeFalsePositiveRate({ decisions, falsePositiveThresholdPct, sessionId } = {}) {
|
|
251
|
+
const list = Array.isArray(decisions) ? decisions : [];
|
|
252
|
+
const total = list.length;
|
|
253
|
+
let evictedUnused = 0;
|
|
254
|
+
for (const d of list) {
|
|
255
|
+
if (d && d.used_before_eviction === false) evictedUnused++;
|
|
256
|
+
}
|
|
257
|
+
const false_positive_rate = total === 0 ? 0 : evictedUnused / total;
|
|
258
|
+
const threshold_pct = typeof falsePositiveThresholdPct === 'number' && Number.isFinite(falsePositiveThresholdPct)
|
|
259
|
+
? falsePositiveThresholdPct
|
|
260
|
+
: DEFAULT_FALSE_POSITIVE_THRESHOLD_PCT;
|
|
261
|
+
const exceeds_threshold = false_positive_rate * 100 > threshold_pct;
|
|
262
|
+
|
|
263
|
+
if (exceeds_threshold) {
|
|
264
|
+
const append = getAppendEvent();
|
|
265
|
+
append({
|
|
266
|
+
type: 'cache.warm_decision',
|
|
267
|
+
timestamp: new Date().toISOString(),
|
|
268
|
+
sessionId: typeof sessionId === 'string' && sessionId.length > 0
|
|
269
|
+
? sessionId
|
|
270
|
+
: 'cache-manager',
|
|
271
|
+
payload: {
|
|
272
|
+
entry_key: '<cycle-summary>',
|
|
273
|
+
score: 0,
|
|
274
|
+
recency_score: 0,
|
|
275
|
+
frequency_score: 0,
|
|
276
|
+
cost_score: 0,
|
|
277
|
+
false_positive_rate,
|
|
278
|
+
},
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return { false_positive_rate, count: total, threshold_pct, exceeds_threshold };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
module.exports = {
|
|
286
|
+
computeWarmingScore,
|
|
287
|
+
rankWarmCandidates,
|
|
288
|
+
evaluateWarmingDecision,
|
|
289
|
+
summarizeFalsePositiveRate,
|
|
290
|
+
DEFAULT_TOPN,
|
|
291
|
+
DEFAULT_FALSE_POSITIVE_THRESHOLD_PCT,
|
|
292
|
+
};
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
|
|
30
30
|
import { OperationFailedError } from '../gdd-errors/index.ts';
|
|
31
31
|
import { getLogger } from '../logger/index.ts';
|
|
32
|
+
import { resolveConcurrency } from '../parallelism-engine/concurrency-tuner.cjs';
|
|
32
33
|
|
|
33
34
|
import {
|
|
34
35
|
spawnAggregator,
|
|
@@ -137,9 +138,12 @@ export async function run(
|
|
|
137
138
|
): Promise<DiscussRunnerResult> {
|
|
138
139
|
const logger = getLogger();
|
|
139
140
|
const specs = opts.discussants ?? DEFAULT_DISCUSSANTS;
|
|
141
|
+
// Phase 27.6 D-07: data-driven concurrency default. Falls back to
|
|
142
|
+
// min(cpu-1, 8) when no `parallelism.verdict` events exist in
|
|
143
|
+
// .design/telemetry/events.jsonl. Explicit `opts.concurrency` still wins.
|
|
140
144
|
const concurrency = opts.concurrency !== undefined && opts.concurrency > 0
|
|
141
145
|
? opts.concurrency
|
|
142
|
-
:
|
|
146
|
+
: resolveConcurrency();
|
|
143
147
|
const cwd = opts.cwd ?? process.cwd();
|
|
144
148
|
|
|
145
149
|
logger.info('discuss.runner.started', {
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
import { resolve as resolvePath } from 'node:path';
|
|
26
26
|
|
|
27
27
|
import { getLogger } from '../logger/index.ts';
|
|
28
|
+
import { resolveConcurrency } from '../parallelism-engine/concurrency-tuner.cjs';
|
|
28
29
|
|
|
29
30
|
import {
|
|
30
31
|
isParallelismSafe,
|
|
@@ -120,7 +121,10 @@ export async function run(
|
|
|
120
121
|
): Promise<ExploreRunnerResult> {
|
|
121
122
|
const specs: readonly MapperSpec[] = opts.mappers ?? DEFAULT_MAPPERS;
|
|
122
123
|
const cwd: string = opts.cwd ?? process.cwd();
|
|
123
|
-
|
|
124
|
+
// Phase 27.6 D-07: data-driven concurrency default. Falls back to
|
|
125
|
+
// min(cpu-1, 8) when no `parallelism.verdict` events exist in
|
|
126
|
+
// .design/telemetry/events.jsonl. Explicit `opts.concurrency` still wins.
|
|
127
|
+
const concurrency: number = opts.concurrency ?? resolveConcurrency();
|
|
124
128
|
|
|
125
129
|
const logger = getLogger().child('explore.runner');
|
|
126
130
|
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scripts/lib/parallelism-engine/concurrency-tuner.cjs — Plan 27.6-04
|
|
3
|
+
*
|
|
4
|
+
* Data-driven concurrency resolver per Phase 27.6 D-07. Reads the
|
|
5
|
+
* most-recent `parallelism.verdict` event from .design/telemetry/
|
|
6
|
+
* events.jsonl (Phase 22 stream) and computes:
|
|
7
|
+
*
|
|
8
|
+
* resolveConcurrency = max(1, min(min(cpu-1, last_observed), ceiling))
|
|
9
|
+
*
|
|
10
|
+
* where:
|
|
11
|
+
* cpu = os.cpus().length (override via cpuCount opt)
|
|
12
|
+
* last_observed = payload.observed_concurrency from the latest
|
|
13
|
+
* parallelism.verdict event (null if absent)
|
|
14
|
+
* ceiling = process.env.GDD_CONCURRENCY_CEILING (default 8)
|
|
15
|
+
*
|
|
16
|
+
* Hard ceiling of 8 prevents pathological process-spawn storms on
|
|
17
|
+
* high-core machines (D-07 wording: "Hard ceiling prevents pathological
|
|
18
|
+
* process-spawn storms").
|
|
19
|
+
*
|
|
20
|
+
* Public surface:
|
|
21
|
+
* * resolveConcurrency({cpuCount?, lastObservedOptimum?, hardCeiling?,
|
|
22
|
+
* eventsPath?, baseDir?}) -> number (>=1)
|
|
23
|
+
* * readLastObservedOptimum({eventsPath?, baseDir?}) -> number|null
|
|
24
|
+
* * emitParallelismVerdict({task_ids, verdict, reason,
|
|
25
|
+
* intended_concurrency?, observed_concurrency?,
|
|
26
|
+
* contention_detected?, wall_clock_ms?,
|
|
27
|
+
* sessionId?}) -> void
|
|
28
|
+
* * DEFAULT_HARD_CEILING (=8)
|
|
29
|
+
* * DEFAULT_EVENTS_PATH (='.design/telemetry/events.jsonl')
|
|
30
|
+
*
|
|
31
|
+
* The `parallelism.verdict` payload extension is purely additive
|
|
32
|
+
* (`intended_concurrency`, `observed_concurrency`, `contention_detected`,
|
|
33
|
+
* `wall_clock_ms` are all optional). Existing consumers that only read
|
|
34
|
+
* `{task_ids, verdict, reason}` keep working unchanged.
|
|
35
|
+
*
|
|
36
|
+
* No external deps. Lazy event-stream require for emit (best-effort
|
|
37
|
+
* telemetry — a failed event-stream load must not break the resolver).
|
|
38
|
+
*/
|
|
39
|
+
'use strict';
|
|
40
|
+
|
|
41
|
+
const fs = require('node:fs');
|
|
42
|
+
const path = require('node:path');
|
|
43
|
+
const os = require('node:os');
|
|
44
|
+
|
|
45
|
+
const DEFAULT_HARD_CEILING = 8;
|
|
46
|
+
const DEFAULT_EVENTS_PATH = '.design/telemetry/events.jsonl';
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Lazy-require the event-stream module. Returns a no-op `appendEvent`
|
|
50
|
+
* when the module is unavailable so callers never have to wrap emit
|
|
51
|
+
* calls in try/catch themselves.
|
|
52
|
+
*
|
|
53
|
+
* @returns {(ev: object) => void}
|
|
54
|
+
*/
|
|
55
|
+
function getAppendEvent() {
|
|
56
|
+
try {
|
|
57
|
+
// Resolved relative to this file: scripts/lib/parallelism-engine/
|
|
58
|
+
// -> ../event-stream. The event-stream module is .ts; Node 22+
|
|
59
|
+
// with --experimental-strip-types (or Node 24 built-in TS) can
|
|
60
|
+
// require it. If require fails (e.g., older runtime, missing
|
|
61
|
+
// module), fall through to the no-op.
|
|
62
|
+
const m = require('../event-stream');
|
|
63
|
+
if (m && typeof m.appendEvent === 'function') return m.appendEvent;
|
|
64
|
+
} catch {
|
|
65
|
+
// Swallow — best-effort telemetry. Losing one verdict is
|
|
66
|
+
// acceptable; breaking concurrency resolution is not.
|
|
67
|
+
}
|
|
68
|
+
return function noopAppend(_ev) {
|
|
69
|
+
/* event-stream unavailable */
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Resolve the hard ceiling. Operator override via GDD_CONCURRENCY_CEILING
|
|
75
|
+
* env var (parsed as integer) takes precedence; the explicit `override`
|
|
76
|
+
* argument wins over the env. Default is 8 (D-07).
|
|
77
|
+
*
|
|
78
|
+
* @param {number|undefined} override
|
|
79
|
+
* @returns {number}
|
|
80
|
+
*/
|
|
81
|
+
function resolveCeiling(override) {
|
|
82
|
+
if (typeof override === 'number' && override >= 1) return Math.floor(override);
|
|
83
|
+
const env = process.env.GDD_CONCURRENCY_CEILING;
|
|
84
|
+
if (typeof env === 'string' && env.length > 0) {
|
|
85
|
+
const parsed = parseInt(env, 10);
|
|
86
|
+
if (Number.isFinite(parsed) && parsed >= 1) return parsed;
|
|
87
|
+
}
|
|
88
|
+
return DEFAULT_HARD_CEILING;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Compose the JSONL events path. Relative paths are joined to baseDir
|
|
93
|
+
* when supplied; absolute paths are returned as-is.
|
|
94
|
+
*
|
|
95
|
+
* @param {{eventsPath?: string, baseDir?: string}} opts
|
|
96
|
+
* @returns {string}
|
|
97
|
+
*/
|
|
98
|
+
function resolvePath({ eventsPath, baseDir }) {
|
|
99
|
+
let p = typeof eventsPath === 'string' && eventsPath.length > 0
|
|
100
|
+
? eventsPath
|
|
101
|
+
: DEFAULT_EVENTS_PATH;
|
|
102
|
+
if (baseDir && !path.isAbsolute(p)) p = path.join(baseDir, p);
|
|
103
|
+
return p;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Read .design/telemetry/events.jsonl and return the
|
|
108
|
+
* `observed_concurrency` from the MOST RECENT parallelism.verdict event
|
|
109
|
+
* (sequential read order). Tolerates malformed lines and absent file.
|
|
110
|
+
*
|
|
111
|
+
* @param {object} [opts]
|
|
112
|
+
* @param {string} [opts.eventsPath] override events.jsonl path
|
|
113
|
+
* @param {string} [opts.baseDir] base for relative eventsPath
|
|
114
|
+
* @returns {number|null}
|
|
115
|
+
*/
|
|
116
|
+
function readLastObservedOptimum({ eventsPath, baseDir } = {} ) {
|
|
117
|
+
const target = resolvePath({ eventsPath, baseDir });
|
|
118
|
+
if (!fs.existsSync(target)) return null;
|
|
119
|
+
let body;
|
|
120
|
+
try {
|
|
121
|
+
body = fs.readFileSync(target, 'utf8');
|
|
122
|
+
} catch {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
const lines = body.split(/\r?\n/);
|
|
126
|
+
let lastOptimum = null;
|
|
127
|
+
for (const line of lines) {
|
|
128
|
+
const trimmed = line.trim();
|
|
129
|
+
if (trimmed.length === 0) continue;
|
|
130
|
+
try {
|
|
131
|
+
const ev = JSON.parse(trimmed);
|
|
132
|
+
if (
|
|
133
|
+
ev
|
|
134
|
+
&& ev.type === 'parallelism.verdict'
|
|
135
|
+
&& ev.payload
|
|
136
|
+
&& typeof ev.payload.observed_concurrency === 'number'
|
|
137
|
+
) {
|
|
138
|
+
lastOptimum = Math.floor(ev.payload.observed_concurrency);
|
|
139
|
+
}
|
|
140
|
+
} catch {
|
|
141
|
+
// Tolerate malformed line — JSONL is best-effort.
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return lastOptimum;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Resolve the recommended concurrency per D-07.
|
|
149
|
+
*
|
|
150
|
+
* 1. base = max(1, cpuCount - 1) // never below 1
|
|
151
|
+
* 2. optimum = lastObservedOptimum // explicit override
|
|
152
|
+
* ?? readLastObservedOptimum() // or read from JSONL
|
|
153
|
+
* 3. candidate = optimum > 0 ? min(base, optimum) : base
|
|
154
|
+
* 4. ceiling = override ?? GDD_CONCURRENCY_CEILING ?? 8
|
|
155
|
+
* 5. return max(1, min(candidate, ceiling))
|
|
156
|
+
*
|
|
157
|
+
* @param {object} [opts]
|
|
158
|
+
* @param {number} [opts.cpuCount] override os.cpus().length
|
|
159
|
+
* @param {number|null} [opts.lastObservedOptimum] explicit override; null/undefined triggers JSONL read
|
|
160
|
+
* @param {number} [opts.hardCeiling] override the env/default ceiling
|
|
161
|
+
* @param {string} [opts.eventsPath] override events.jsonl path
|
|
162
|
+
* @param {string} [opts.baseDir] base for relative eventsPath
|
|
163
|
+
* @returns {number} integer >= 1
|
|
164
|
+
*/
|
|
165
|
+
function resolveConcurrency({
|
|
166
|
+
cpuCount,
|
|
167
|
+
lastObservedOptimum,
|
|
168
|
+
hardCeiling,
|
|
169
|
+
eventsPath,
|
|
170
|
+
baseDir,
|
|
171
|
+
} = {}) {
|
|
172
|
+
const cpu = typeof cpuCount === 'number' && cpuCount >= 1
|
|
173
|
+
? Math.floor(cpuCount)
|
|
174
|
+
: os.cpus().length;
|
|
175
|
+
const base = Math.max(1, cpu - 1);
|
|
176
|
+
let optimum = lastObservedOptimum;
|
|
177
|
+
if (optimum === undefined || optimum === null) {
|
|
178
|
+
optimum = readLastObservedOptimum({ eventsPath, baseDir });
|
|
179
|
+
}
|
|
180
|
+
const candidate = typeof optimum === 'number' && optimum >= 1
|
|
181
|
+
? Math.min(base, Math.floor(optimum))
|
|
182
|
+
: base;
|
|
183
|
+
const ceiling = resolveCeiling(hardCeiling);
|
|
184
|
+
return Math.max(1, Math.min(candidate, ceiling));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Emit a `parallelism.verdict` event with the Phase 27.6 superset
|
|
189
|
+
* payload. Existing fields ({task_ids, verdict, reason}) are always
|
|
190
|
+
* present; the new fields (intended_concurrency, observed_concurrency,
|
|
191
|
+
* contention_detected, wall_clock_ms) are appended only when supplied.
|
|
192
|
+
*
|
|
193
|
+
* Side effect: appendEvent({type: 'parallelism.verdict', ...}). When
|
|
194
|
+
* event-stream is unavailable, this is a no-op (lazy require fallback).
|
|
195
|
+
*
|
|
196
|
+
* @param {object} opts
|
|
197
|
+
* @param {string[]} opts.task_ids
|
|
198
|
+
* @param {'parallel'|'sequential'} opts.verdict
|
|
199
|
+
* @param {string} opts.reason
|
|
200
|
+
* @param {number} [opts.intended_concurrency]
|
|
201
|
+
* @param {number} [opts.observed_concurrency]
|
|
202
|
+
* @param {boolean} [opts.contention_detected]
|
|
203
|
+
* @param {number} [opts.wall_clock_ms]
|
|
204
|
+
* @param {string} [opts.sessionId]
|
|
205
|
+
* @returns {void}
|
|
206
|
+
*/
|
|
207
|
+
function emitParallelismVerdict({
|
|
208
|
+
task_ids,
|
|
209
|
+
verdict,
|
|
210
|
+
reason,
|
|
211
|
+
intended_concurrency,
|
|
212
|
+
observed_concurrency,
|
|
213
|
+
contention_detected,
|
|
214
|
+
wall_clock_ms,
|
|
215
|
+
sessionId,
|
|
216
|
+
} = {}) {
|
|
217
|
+
const append = getAppendEvent();
|
|
218
|
+
/** @type {Record<string, unknown>} */
|
|
219
|
+
const payload = {
|
|
220
|
+
task_ids: Array.isArray(task_ids) ? task_ids : [],
|
|
221
|
+
verdict: verdict === 'parallel' || verdict === 'sequential' ? verdict : 'sequential',
|
|
222
|
+
reason: typeof reason === 'string' ? reason : 'unspecified',
|
|
223
|
+
};
|
|
224
|
+
// Additive 27.6 fields — only include when set, to keep payloads
|
|
225
|
+
// compact and avoid noisy `undefined` keys on the wire.
|
|
226
|
+
if (typeof intended_concurrency === 'number') {
|
|
227
|
+
payload.intended_concurrency = intended_concurrency;
|
|
228
|
+
}
|
|
229
|
+
if (typeof observed_concurrency === 'number') {
|
|
230
|
+
payload.observed_concurrency = observed_concurrency;
|
|
231
|
+
}
|
|
232
|
+
if (typeof contention_detected === 'boolean') {
|
|
233
|
+
payload.contention_detected = contention_detected;
|
|
234
|
+
}
|
|
235
|
+
if (typeof wall_clock_ms === 'number') {
|
|
236
|
+
payload.wall_clock_ms = wall_clock_ms;
|
|
237
|
+
}
|
|
238
|
+
try {
|
|
239
|
+
append({
|
|
240
|
+
type: 'parallelism.verdict',
|
|
241
|
+
timestamp: new Date().toISOString(),
|
|
242
|
+
sessionId: typeof sessionId === 'string' && sessionId.length > 0
|
|
243
|
+
? sessionId
|
|
244
|
+
: 'concurrency-tuner',
|
|
245
|
+
payload,
|
|
246
|
+
});
|
|
247
|
+
} catch {
|
|
248
|
+
// Best-effort telemetry. A failed write must never break the
|
|
249
|
+
// caller's wave-execution flow.
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
module.exports = {
|
|
254
|
+
resolveConcurrency,
|
|
255
|
+
readLastObservedOptimum,
|
|
256
|
+
emitParallelismVerdict,
|
|
257
|
+
DEFAULT_HARD_CEILING,
|
|
258
|
+
DEFAULT_EVENTS_PATH,
|
|
259
|
+
};
|
|
@@ -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;
|