@hegemonart/get-design-done 1.27.1 → 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 +95 -0
- package/SKILL.md +1 -0
- package/agents/design-reflector.md +52 -0
- package/agents/perf-analyzer.md +166 -0
- package/hooks/budget-enforcer.ts +249 -5
- 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/bandit-integration.md +163 -0
- package/reference/perf-budget.md +142 -0
- package/reference/registry.json +14 -0
- package/reference/retrieval-contract.md +16 -0
- package/scripts/lib/bandit-arbitrage.cjs +423 -0
- package/scripts/lib/bandit-router/integration.cjs +309 -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
- package/scripts/lib/session-runner/index.ts +206 -0
- package/skills/bandit-status/SKILL.md +129 -0
- package/skills/peers/SKILL.md +27 -8
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scripts/lib/bandit-router/integration.cjs — Plan 27.5-01
|
|
3
|
+
*
|
|
4
|
+
* Production-integration shim for the Phase 23.5 bandit posterior +
|
|
5
|
+
* Phase 27-07 delegate dimension. Hides the `pull` vs `pullWithDelegate`
|
|
6
|
+
* + `update` vs `updateWithDelegate` choice from callers.
|
|
7
|
+
*
|
|
8
|
+
* Two functions:
|
|
9
|
+
* consultBandit({agent, bin, delegate, agentFrontmatter, adaptiveMode, baseDir?, posteriorPath?})
|
|
10
|
+
* → {tier, decision_log}
|
|
11
|
+
* recordOutcome({agent, bin, delegate, tier, status, costUsd, adaptiveMode, baseDir?, posteriorPath?})
|
|
12
|
+
* → void (best-effort write per D-04)
|
|
13
|
+
*
|
|
14
|
+
* Routing rules (D-05 + D-07):
|
|
15
|
+
* 1. agentFrontmatter.tier_override is set → bypass bandit, return tier_override
|
|
16
|
+
* 2. adaptiveMode !== 'full' → bandit silent, return frontmatter.default_tier
|
|
17
|
+
* (covers 'static' and 'hedge' per D-07)
|
|
18
|
+
* 3. adaptiveMode === 'full' && delegate is 'none' / undefined
|
|
19
|
+
* → call pull()
|
|
20
|
+
* 4. adaptiveMode === 'full' && delegate is a peer name
|
|
21
|
+
* → call pullWithDelegate({delegates:[delegate]})
|
|
22
|
+
*
|
|
23
|
+
* recordOutcome is symmetric on the adaptive_mode gate:
|
|
24
|
+
* - non-'full' → no-op
|
|
25
|
+
* - 'full' && delegate 'none'/undefined → update()
|
|
26
|
+
* - 'full' && delegate is a peer → updateWithDelegate()
|
|
27
|
+
*
|
|
28
|
+
* Reward function = Phase 23.5's computeReward unchanged (D-08).
|
|
29
|
+
*
|
|
30
|
+
* Posterior writes are best-effort — all throws are swallowed. The
|
|
31
|
+
* shim's job is to plumb the call; telemetry resilience is downstream.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
'use strict';
|
|
35
|
+
|
|
36
|
+
const banditRouter = require('../bandit-router.cjs');
|
|
37
|
+
const adaptiveModeLib = require('../adaptive-mode.cjs');
|
|
38
|
+
|
|
39
|
+
const DELEGATE_NONE = banditRouter.DELEGATE_NONE; // 'none'
|
|
40
|
+
const VALID_DELEGATES = banditRouter.DEFAULT_DELEGATES; // ['none','gemini','codex','cursor','copilot','qwen']
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Validate that `delegate` is either undefined, DELEGATE_NONE, or a
|
|
44
|
+
* member of VALID_DELEGATES. Returns the canonical delegate string
|
|
45
|
+
* (undefined → 'none').
|
|
46
|
+
*
|
|
47
|
+
* @param {string|undefined} delegate
|
|
48
|
+
* @param {string} fnName — for error message context
|
|
49
|
+
* @returns {string}
|
|
50
|
+
*/
|
|
51
|
+
function resolveDelegate(delegate, fnName) {
|
|
52
|
+
if (delegate === undefined || delegate === null) return DELEGATE_NONE;
|
|
53
|
+
if (typeof delegate !== 'string') {
|
|
54
|
+
throw new TypeError(
|
|
55
|
+
`integration.${fnName}: delegate must be a string when provided, got ${typeof delegate}`,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
if (!VALID_DELEGATES.includes(delegate)) {
|
|
59
|
+
throw new RangeError(
|
|
60
|
+
`integration.${fnName}: unknown delegate '${delegate}'; expected one of ${VALID_DELEGATES.join(',')}`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
return delegate;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Resolve the adaptive_mode for a call. If the caller passed it
|
|
68
|
+
* explicitly we use that; otherwise we read it from disk via
|
|
69
|
+
* adaptive-mode.getMode (D-07: single gating surface).
|
|
70
|
+
*
|
|
71
|
+
* @param {string|undefined} adaptiveMode
|
|
72
|
+
* @param {{baseDir?: string}} opts
|
|
73
|
+
* @returns {'static'|'hedge'|'full'}
|
|
74
|
+
*/
|
|
75
|
+
function resolveAdaptiveMode(adaptiveMode, opts) {
|
|
76
|
+
if (typeof adaptiveMode === 'string' && adaptiveMode.length > 0) {
|
|
77
|
+
return /** @type {'static'|'hedge'|'full'} */ (adaptiveMode);
|
|
78
|
+
}
|
|
79
|
+
return adaptiveModeLib.getMode({ baseDir: opts && opts.baseDir, quiet: true });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* consultBandit — single canonical lookup that returns a tier + a
|
|
84
|
+
* decision_log explaining how the tier was chosen. Five paths:
|
|
85
|
+
*
|
|
86
|
+
* Path 1 — static mode → frontmatter.default_tier (or 'sonnet' fallback)
|
|
87
|
+
* Path 2 — tier_override set on frontmatter → bypass bandit
|
|
88
|
+
* Path 3 — full mode + delegate='none' (or undefined) → pull()
|
|
89
|
+
* Path 4 — full mode + delegate=<peer> → pullWithDelegate()
|
|
90
|
+
* Path 5 — hedge mode → frontmatter.default_tier (bandit silent)
|
|
91
|
+
*
|
|
92
|
+
* Path 2 takes precedence over Path 1 / 3 / 4 / 5 (tier_override is the
|
|
93
|
+
* explicit operator override per D-05).
|
|
94
|
+
*
|
|
95
|
+
* @param {{
|
|
96
|
+
* agent: string,
|
|
97
|
+
* bin: string,
|
|
98
|
+
* delegate?: string,
|
|
99
|
+
* agentFrontmatter?: {tier_override?: string, default_tier?: string},
|
|
100
|
+
* adaptiveMode?: 'static'|'hedge'|'full',
|
|
101
|
+
* baseDir?: string,
|
|
102
|
+
* posteriorPath?: string,
|
|
103
|
+
* }} input
|
|
104
|
+
* @returns {{
|
|
105
|
+
* tier: string,
|
|
106
|
+
* decision_log: {
|
|
107
|
+
* source: 'frontmatter'|'tier_override_bypass'|'bandit_pull'|'bandit_pull_with_delegate',
|
|
108
|
+
* samples?: object,
|
|
109
|
+
* delegate?: string,
|
|
110
|
+
* adaptive_mode: 'static'|'hedge'|'full',
|
|
111
|
+
* reason?: string,
|
|
112
|
+
* }
|
|
113
|
+
* }}
|
|
114
|
+
*/
|
|
115
|
+
function consultBandit(input) {
|
|
116
|
+
if (!input || typeof input !== 'object') {
|
|
117
|
+
throw new TypeError('integration.consultBandit: input object required');
|
|
118
|
+
}
|
|
119
|
+
if (typeof input.agent !== 'string' || input.agent.length === 0) {
|
|
120
|
+
throw new TypeError('integration.consultBandit: agent (string) required');
|
|
121
|
+
}
|
|
122
|
+
if (typeof input.bin !== 'string' || input.bin.length === 0) {
|
|
123
|
+
throw new TypeError('integration.consultBandit: bin (string) required');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const agentFrontmatter = input.agentFrontmatter && typeof input.agentFrontmatter === 'object'
|
|
127
|
+
? input.agentFrontmatter
|
|
128
|
+
: {};
|
|
129
|
+
const adaptiveMode = resolveAdaptiveMode(input.adaptiveMode, input);
|
|
130
|
+
const delegate = resolveDelegate(input.delegate, 'consultBandit');
|
|
131
|
+
|
|
132
|
+
// Step 1 — tier_override bypass (D-05). Highest priority; beats both
|
|
133
|
+
// bandit consultation and static/hedge frontmatter.default_tier.
|
|
134
|
+
if (typeof agentFrontmatter.tier_override === 'string' && agentFrontmatter.tier_override.length > 0) {
|
|
135
|
+
return {
|
|
136
|
+
tier: agentFrontmatter.tier_override,
|
|
137
|
+
decision_log: {
|
|
138
|
+
source: 'tier_override_bypass',
|
|
139
|
+
adaptive_mode: adaptiveMode,
|
|
140
|
+
reason: 'frontmatter_tier_override_set',
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Step 2 — non-full short-circuit (D-07). Static and hedge are both
|
|
146
|
+
// "bandit silent"; frontmatter.default_tier (or 'sonnet' fallback)
|
|
147
|
+
// is authoritative. No posterior read or write.
|
|
148
|
+
if (adaptiveMode !== 'full') {
|
|
149
|
+
const fallbackTier = (typeof agentFrontmatter.default_tier === 'string' && agentFrontmatter.default_tier.length > 0)
|
|
150
|
+
? agentFrontmatter.default_tier
|
|
151
|
+
: 'sonnet';
|
|
152
|
+
return {
|
|
153
|
+
tier: fallbackTier,
|
|
154
|
+
decision_log: {
|
|
155
|
+
source: 'frontmatter',
|
|
156
|
+
adaptive_mode: adaptiveMode,
|
|
157
|
+
reason: adaptiveMode === 'hedge' ? 'hedge_mode_skips_bandit' : 'static_mode_authoritative',
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Step 3/4 — full mode → consult the bandit. Choice of pull vs
|
|
163
|
+
// pullWithDelegate is driven by `delegate`:
|
|
164
|
+
// delegate === 'none' (or undefined → 'none') → pull()
|
|
165
|
+
// delegate ∈ {gemini,codex,cursor,copilot,qwen} → pullWithDelegate
|
|
166
|
+
if (delegate === DELEGATE_NONE) {
|
|
167
|
+
const result = banditRouter.pull({
|
|
168
|
+
agent: input.agent,
|
|
169
|
+
bin: input.bin,
|
|
170
|
+
baseDir: input.baseDir,
|
|
171
|
+
posteriorPath: input.posteriorPath,
|
|
172
|
+
});
|
|
173
|
+
return {
|
|
174
|
+
tier: result.tier,
|
|
175
|
+
decision_log: {
|
|
176
|
+
source: 'bandit_pull',
|
|
177
|
+
samples: result.samples,
|
|
178
|
+
delegate: DELEGATE_NONE,
|
|
179
|
+
adaptive_mode: 'full',
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Path 4 — peer delegate. Constrain the delegate axis to the single
|
|
185
|
+
// requested peer so the bandit samples the (tier × delegate) joint
|
|
186
|
+
// restricted to {delegate}. Same posterior file, same arm shape; the
|
|
187
|
+
// arm's `delegate` field is set so the slice is distinct from local.
|
|
188
|
+
const result = banditRouter.pullWithDelegate({
|
|
189
|
+
agent: input.agent,
|
|
190
|
+
bin: input.bin,
|
|
191
|
+
delegates: [delegate],
|
|
192
|
+
baseDir: input.baseDir,
|
|
193
|
+
posteriorPath: input.posteriorPath,
|
|
194
|
+
});
|
|
195
|
+
return {
|
|
196
|
+
tier: result.tier,
|
|
197
|
+
decision_log: {
|
|
198
|
+
source: 'bandit_pull_with_delegate',
|
|
199
|
+
samples: result.samples,
|
|
200
|
+
delegate: result.delegate,
|
|
201
|
+
adaptive_mode: 'full',
|
|
202
|
+
},
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* recordOutcome — post-spawn telemetry update. Computes a reward via
|
|
208
|
+
* computeReward (Phase 23.5 D-08, unchanged) and writes the posterior
|
|
209
|
+
* arm. Best-effort: all errors swallowed so telemetry can never break
|
|
210
|
+
* a session (D-04).
|
|
211
|
+
*
|
|
212
|
+
* No-op when adaptive_mode is not 'full' (D-07).
|
|
213
|
+
*
|
|
214
|
+
* @param {{
|
|
215
|
+
* agent: string,
|
|
216
|
+
* bin: string,
|
|
217
|
+
* delegate?: string,
|
|
218
|
+
* tier: string,
|
|
219
|
+
* status: string,
|
|
220
|
+
* costUsd?: number,
|
|
221
|
+
* adaptiveMode?: 'static'|'hedge'|'full',
|
|
222
|
+
* baseDir?: string,
|
|
223
|
+
* posteriorPath?: string,
|
|
224
|
+
* }} input
|
|
225
|
+
* @returns {void}
|
|
226
|
+
*/
|
|
227
|
+
function recordOutcome(input) {
|
|
228
|
+
if (!input || typeof input !== 'object') {
|
|
229
|
+
throw new TypeError('integration.recordOutcome: input object required');
|
|
230
|
+
}
|
|
231
|
+
if (typeof input.agent !== 'string' || input.agent.length === 0) {
|
|
232
|
+
throw new TypeError('integration.recordOutcome: agent (string) required');
|
|
233
|
+
}
|
|
234
|
+
if (typeof input.bin !== 'string' || input.bin.length === 0) {
|
|
235
|
+
throw new TypeError('integration.recordOutcome: bin (string) required');
|
|
236
|
+
}
|
|
237
|
+
if (typeof input.tier !== 'string' || input.tier.length === 0) {
|
|
238
|
+
throw new TypeError('integration.recordOutcome: tier (string) required');
|
|
239
|
+
}
|
|
240
|
+
if (typeof input.status !== 'string') {
|
|
241
|
+
throw new TypeError('integration.recordOutcome: status (string) required');
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const adaptiveMode = resolveAdaptiveMode(input.adaptiveMode, input);
|
|
245
|
+
|
|
246
|
+
// D-07 + D-04: posterior is silent in static/hedge. No-op early.
|
|
247
|
+
if (adaptiveMode !== 'full') {
|
|
248
|
+
return undefined;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const delegate = resolveDelegate(input.delegate, 'recordOutcome');
|
|
252
|
+
|
|
253
|
+
// D-08: reward function unchanged. wall_time_ms always 0 per
|
|
254
|
+
// Phase 23.5 / 27.5 — the wall-time tiebreaker is not used at the
|
|
255
|
+
// recordOutcome boundary; correctness + cost are the only signals.
|
|
256
|
+
const reward = banditRouter.computeReward({
|
|
257
|
+
solidify_pass: input.status === 'completed',
|
|
258
|
+
cost_usd: typeof input.costUsd === 'number' ? input.costUsd : 0,
|
|
259
|
+
wall_time_ms: 0,
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
// D-04: best-effort write. Swallow ALL exceptions so a broken
|
|
263
|
+
// posterior file never breaks a session.
|
|
264
|
+
try {
|
|
265
|
+
if (delegate === DELEGATE_NONE) {
|
|
266
|
+
banditRouter.update({
|
|
267
|
+
agent: input.agent,
|
|
268
|
+
bin: input.bin,
|
|
269
|
+
tier: input.tier,
|
|
270
|
+
reward,
|
|
271
|
+
baseDir: input.baseDir,
|
|
272
|
+
posteriorPath: input.posteriorPath,
|
|
273
|
+
});
|
|
274
|
+
} else {
|
|
275
|
+
banditRouter.updateWithDelegate({
|
|
276
|
+
agent: input.agent,
|
|
277
|
+
bin: input.bin,
|
|
278
|
+
tier: input.tier,
|
|
279
|
+
delegate,
|
|
280
|
+
reward,
|
|
281
|
+
baseDir: input.baseDir,
|
|
282
|
+
posteriorPath: input.posteriorPath,
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
} catch (err) {
|
|
286
|
+
// Live-tail breadcrumb opt-in via env var. Inner try/catch around
|
|
287
|
+
// the stderr write itself keeps the swallow guarantee even when
|
|
288
|
+
// stderr is closed/unavailable.
|
|
289
|
+
if (process.env.GDD_BANDIT_DEBUG === '1') {
|
|
290
|
+
try {
|
|
291
|
+
process.stderr.write(
|
|
292
|
+
'[bandit-integration] recordOutcome swallowed: ' +
|
|
293
|
+
(err && err.message ? err.message : String(err)) +
|
|
294
|
+
'\n',
|
|
295
|
+
);
|
|
296
|
+
} catch {
|
|
297
|
+
/* swallow */
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return undefined;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
module.exports = {
|
|
306
|
+
consultBandit,
|
|
307
|
+
recordOutcome,
|
|
308
|
+
DELEGATE_NONE,
|
|
309
|
+
};
|
|
@@ -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
|
|