@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.
@@ -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
- : 4;
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
- const concurrency: number = opts.concurrency ?? 4;
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;