@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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +6 -3
- package/CHANGELOG.md +99 -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 +6 -5
- package/reference/perf-budget.md +142 -0
- package/reference/registry.json +14 -0
- package/reference/retrieval-contract.md +16 -0
- package/reference/schemas/mcp-gdd-tools.schema.json +381 -0
- package/scripts/install.cjs +42 -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/gsd-health-mirror/index.cjs +105 -0
- package/scripts/lib/gsd-health-mirror/index.d.cts +14 -0
- package/scripts/lib/install/mcp-register.cjs +235 -0
- package/scripts/lib/install/mcp-register.d.cts +64 -0
- package/scripts/lib/intel-store/index.cjs +55 -0
- package/scripts/lib/intel-store/index.d.cts +11 -0
- package/scripts/lib/mcp-tools-lint/index.cjs +216 -0
- package/scripts/lib/mcp-tools-lint/index.d.cts +74 -0
- 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/reflections-reader/index.cjs +107 -0
- package/scripts/lib/reflections-reader/index.d.cts +18 -0
- package/scripts/lib/roadmap-reader/index.cjs +81 -0
- package/scripts/lib/roadmap-reader/index.d.cts +13 -0
- package/scripts/lib/snapshot-reader/index.cjs +70 -0
- package/scripts/lib/snapshot-reader/index.d.cts +28 -0
- package/scripts/mcp-servers/gdd-mcp/README.md +66 -0
- package/scripts/mcp-servers/gdd-mcp/schemas/gdd_cycle_recap.schema.json +30 -0
- package/scripts/mcp-servers/gdd-mcp/schemas/gdd_decisions_list.schema.json +32 -0
- package/scripts/mcp-servers/gdd-mcp/schemas/gdd_events_tail.schema.json +22 -0
- package/scripts/mcp-servers/gdd-mcp/schemas/gdd_health.schema.json +30 -0
- package/scripts/mcp-servers/gdd-mcp/schemas/gdd_intel_get.schema.json +24 -0
- package/scripts/mcp-servers/gdd-mcp/schemas/gdd_learnings_digest.schema.json +22 -0
- package/scripts/mcp-servers/gdd-mcp/schemas/gdd_phase_current.schema.json +22 -0
- package/scripts/mcp-servers/gdd-mcp/schemas/gdd_phases_list.schema.json +31 -0
- package/scripts/mcp-servers/gdd-mcp/schemas/gdd_plans_list.schema.json +33 -0
- package/scripts/mcp-servers/gdd-mcp/schemas/gdd_reflections_latest.schema.json +21 -0
- package/scripts/mcp-servers/gdd-mcp/schemas/gdd_status.schema.json +23 -0
- package/scripts/mcp-servers/gdd-mcp/schemas/gdd_telemetry_query.schema.json +23 -0
- package/scripts/mcp-servers/gdd-mcp/server.ts +317 -0
- package/scripts/mcp-servers/gdd-mcp/tools/gdd_cycle_recap.ts +37 -0
- package/scripts/mcp-servers/gdd-mcp/tools/gdd_decisions_list.ts +33 -0
- package/scripts/mcp-servers/gdd-mcp/tools/gdd_events_tail.ts +26 -0
- package/scripts/mcp-servers/gdd-mcp/tools/gdd_health.ts +19 -0
- package/scripts/mcp-servers/gdd-mcp/tools/gdd_intel_get.ts +32 -0
- package/scripts/mcp-servers/gdd-mcp/tools/gdd_learnings_digest.ts +23 -0
- package/scripts/mcp-servers/gdd-mcp/tools/gdd_phase_current.ts +29 -0
- package/scripts/mcp-servers/gdd-mcp/tools/gdd_phases_list.ts +26 -0
- package/scripts/mcp-servers/gdd-mcp/tools/gdd_plans_list.ts +39 -0
- package/scripts/mcp-servers/gdd-mcp/tools/gdd_reflections_latest.ts +25 -0
- package/scripts/mcp-servers/gdd-mcp/tools/gdd_status.ts +31 -0
- package/scripts/mcp-servers/gdd-mcp/tools/gdd_telemetry_query.ts +27 -0
- package/scripts/mcp-servers/gdd-mcp/tools/index.ts +75 -0
- package/scripts/mcp-servers/gdd-mcp/tools/shared.ts +134 -0
- package/skills/health/SKILL.md +36 -0
- package/skills/next/SKILL.md +28 -3
- package/skills/progress/SKILL.md +21 -6
- package/skills/resume/SKILL.md +26 -1
|
@@ -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,105 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// scripts/lib/gsd-health-mirror/index.cjs — Plan 27.7-02
|
|
3
|
+
//
|
|
4
|
+
// Pure read-only mirror of skills/health/SKILL.md's check surface.
|
|
5
|
+
// NO subprocess spawn — just inspects 4 well-known files/dirs and
|
|
6
|
+
// reports status. Used by the gdd_health MCP tool.
|
|
7
|
+
//
|
|
8
|
+
// Surface:
|
|
9
|
+
// async getHealthChecks(rootDir) → { checks: HealthCheck[] }
|
|
10
|
+
//
|
|
11
|
+
// The 4 checks (in stable order) are:
|
|
12
|
+
// 1. claude_md — CLAUDE.md presence
|
|
13
|
+
// 2. planning_dir — .planning/ presence
|
|
14
|
+
// 3. design_dir — .design/ presence
|
|
15
|
+
// 4. package_json — package.json present AND parseable
|
|
16
|
+
|
|
17
|
+
const fs = require('node:fs');
|
|
18
|
+
const path = require('node:path');
|
|
19
|
+
|
|
20
|
+
function fileExists(p) {
|
|
21
|
+
try {
|
|
22
|
+
return fs.statSync(p).isFile();
|
|
23
|
+
} catch {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function dirExists(p) {
|
|
29
|
+
try {
|
|
30
|
+
return fs.statSync(p).isDirectory();
|
|
31
|
+
} catch {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function getHealthChecks(rootDir) {
|
|
37
|
+
const checks = [];
|
|
38
|
+
|
|
39
|
+
// 1. CLAUDE.md
|
|
40
|
+
{
|
|
41
|
+
const p = path.join(rootDir, 'CLAUDE.md');
|
|
42
|
+
const present = fileExists(p);
|
|
43
|
+
checks.push({
|
|
44
|
+
name: 'claude_md',
|
|
45
|
+
status: present ? 'ok' : 'warn',
|
|
46
|
+
detail: present ? p : 'CLAUDE.md not found at project root',
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// 2. .planning/
|
|
51
|
+
{
|
|
52
|
+
const p = path.join(rootDir, '.planning');
|
|
53
|
+
const present = dirExists(p);
|
|
54
|
+
checks.push({
|
|
55
|
+
name: 'planning_dir',
|
|
56
|
+
status: present ? 'ok' : 'warn',
|
|
57
|
+
detail: present ? p : '.planning/ not found at project root',
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// 3. .design/
|
|
62
|
+
{
|
|
63
|
+
const p = path.join(rootDir, '.design');
|
|
64
|
+
const present = dirExists(p);
|
|
65
|
+
checks.push({
|
|
66
|
+
name: 'design_dir',
|
|
67
|
+
status: present ? 'ok' : 'warn',
|
|
68
|
+
detail: present ? p : '.design/ not found at project root',
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// 4. package.json — present + parseable
|
|
73
|
+
{
|
|
74
|
+
const p = path.join(rootDir, 'package.json');
|
|
75
|
+
if (!fileExists(p)) {
|
|
76
|
+
checks.push({
|
|
77
|
+
name: 'package_json',
|
|
78
|
+
status: 'warn',
|
|
79
|
+
detail: 'package.json not found at project root',
|
|
80
|
+
});
|
|
81
|
+
} else {
|
|
82
|
+
try {
|
|
83
|
+
const body = await fs.promises.readFile(p, 'utf8');
|
|
84
|
+
const parsed = JSON.parse(body);
|
|
85
|
+
const name = typeof parsed.name === 'string' ? parsed.name : '(unknown)';
|
|
86
|
+
const version = typeof parsed.version === 'string' ? parsed.version : '0.0.0';
|
|
87
|
+
checks.push({
|
|
88
|
+
name: 'package_json',
|
|
89
|
+
status: 'ok',
|
|
90
|
+
detail: name + '@' + version,
|
|
91
|
+
});
|
|
92
|
+
} catch (err) {
|
|
93
|
+
checks.push({
|
|
94
|
+
name: 'package_json',
|
|
95
|
+
status: 'fail',
|
|
96
|
+
detail: 'parse error: ' + (err && err.message ? err.message : String(err)),
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return { checks };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
module.exports = { getHealthChecks };
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// scripts/lib/gsd-health-mirror/index.d.cts — TypeScript ambient declarations
|
|
2
|
+
// for the gsd-health-mirror CJS module. Plan 27.7-02.
|
|
3
|
+
|
|
4
|
+
export interface HealthCheck {
|
|
5
|
+
name: string;
|
|
6
|
+
status: 'ok' | 'warn' | 'fail';
|
|
7
|
+
detail: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface HealthChecksResult {
|
|
11
|
+
checks: HealthCheck[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function getHealthChecks(rootDir: string): Promise<HealthChecksResult>;
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// scripts/lib/install/mcp-register.cjs
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Plan 27.7-04 — registers `gdd-mcp` with the two harnesses that matter
|
|
5
|
+
// (Claude Code, Codex) and detects existing registration. Idempotent;
|
|
6
|
+
// graceful absent-CLI fallback (D-07).
|
|
7
|
+
//
|
|
8
|
+
// Pure library — no side effects on require. Invoked by:
|
|
9
|
+
// - scripts/install.cjs --register-mcp (opt-in; default off per D-07)
|
|
10
|
+
// - skills/health/SKILL.md check-mcp-registration step (read-only detect)
|
|
11
|
+
//
|
|
12
|
+
// spawnFn injection allows tests to mock child_process.spawnSync without
|
|
13
|
+
// touching real CLIs in CI.
|
|
14
|
+
//
|
|
15
|
+
// Threat model: scripts/install.cjs --register-mcp writes to harness user-
|
|
16
|
+
// level config. Command args are hardcoded in HARNESSES (no command-
|
|
17
|
+
// injection surface); `--` separator before MCP_NAME prevents flag
|
|
18
|
+
// injection (T-27.7-04-06).
|
|
19
|
+
|
|
20
|
+
const { spawnSync } = require('node:child_process');
|
|
21
|
+
|
|
22
|
+
const MCP_NAME = 'gdd-mcp';
|
|
23
|
+
|
|
24
|
+
const HARNESSES = Object.freeze({
|
|
25
|
+
claude: Object.freeze({
|
|
26
|
+
binary: 'claude',
|
|
27
|
+
addArgs: Object.freeze(['mcp', 'add', MCP_NAME, '-s', 'user', '--', MCP_NAME]),
|
|
28
|
+
listArgs: Object.freeze(['mcp', 'list']),
|
|
29
|
+
listMatchPattern: /\bgdd-mcp\b/,
|
|
30
|
+
}),
|
|
31
|
+
codex: Object.freeze({
|
|
32
|
+
binary: 'codex',
|
|
33
|
+
addArgs: Object.freeze(['mcp', 'add', MCP_NAME, '--', MCP_NAME]),
|
|
34
|
+
listArgs: Object.freeze(['mcp', 'list']),
|
|
35
|
+
listMatchPattern: /\bgdd-mcp\b/,
|
|
36
|
+
}),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Build the command tuple for a given harness + mode.
|
|
41
|
+
* Currently only 'register' (add) is supported in command-build; 'detect'
|
|
42
|
+
* uses listArgs internally, 'unregister' is reserved for future work.
|
|
43
|
+
*/
|
|
44
|
+
function buildHarnessCommand(harness, mode = 'register') {
|
|
45
|
+
const h = HARNESSES[harness];
|
|
46
|
+
if (!h) throw new Error('Unknown harness: ' + harness);
|
|
47
|
+
if (mode === 'register') {
|
|
48
|
+
return { binary: h.binary, args: Array.from(h.addArgs) };
|
|
49
|
+
}
|
|
50
|
+
if (mode === 'detect') {
|
|
51
|
+
return { binary: h.binary, args: Array.from(h.listArgs) };
|
|
52
|
+
}
|
|
53
|
+
throw new Error('Unsupported mode: ' + mode);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Detect whether the harness CLI is on PATH. Runs `<binary> --version` and
|
|
58
|
+
* returns true iff exit code is 0. Catches ENOENT (binary missing).
|
|
59
|
+
*/
|
|
60
|
+
function detectHarnessPresent(harness, spawnFn = spawnSync) {
|
|
61
|
+
const h = HARNESSES[harness];
|
|
62
|
+
if (!h) throw new Error('Unknown harness: ' + harness);
|
|
63
|
+
let result;
|
|
64
|
+
try {
|
|
65
|
+
result = spawnFn(h.binary, ['--version'], {
|
|
66
|
+
stdio: 'pipe',
|
|
67
|
+
encoding: 'utf8',
|
|
68
|
+
});
|
|
69
|
+
} catch (_e) {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
if (!result) return false;
|
|
73
|
+
if (result.error && result.error.code === 'ENOENT') return false;
|
|
74
|
+
return result.status === 0;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Detect whether gdd-mcp is already registered with the given harness.
|
|
79
|
+
* Runs `<binary> mcp list` and matches against listMatchPattern.
|
|
80
|
+
*/
|
|
81
|
+
function isAlreadyRegistered(harness, spawnFn = spawnSync) {
|
|
82
|
+
const h = HARNESSES[harness];
|
|
83
|
+
if (!h) throw new Error('Unknown harness: ' + harness);
|
|
84
|
+
let result;
|
|
85
|
+
try {
|
|
86
|
+
result = spawnFn(h.binary, Array.from(h.listArgs), {
|
|
87
|
+
stdio: 'pipe',
|
|
88
|
+
encoding: 'utf8',
|
|
89
|
+
});
|
|
90
|
+
} catch (_e) {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
if (!result || result.status !== 0) return false;
|
|
94
|
+
const stdout = (result.stdout || '').toString();
|
|
95
|
+
return h.listMatchPattern.test(stdout);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Register gdd-mcp with the given harness.
|
|
100
|
+
*
|
|
101
|
+
* @param {object} opts
|
|
102
|
+
* @param {'claude'|'codex'} opts.harness
|
|
103
|
+
* @param {'register'|'unregister'|'detect'} [opts.mode='register']
|
|
104
|
+
* @param {boolean} [opts.dryRun=false]
|
|
105
|
+
* @param {Function} [opts.spawnFn] child_process.spawnSync substitute
|
|
106
|
+
* @returns {object} {harness, action, detected, command, applied,
|
|
107
|
+
* idempotent_skip, notice?, stdout?, stderr?,
|
|
108
|
+
* exit_code?, dry_run?}
|
|
109
|
+
*/
|
|
110
|
+
function registerMcp({ harness, mode = 'register', dryRun = false, spawnFn = spawnSync } = {}) {
|
|
111
|
+
if (!HARNESSES[harness]) {
|
|
112
|
+
throw new Error('Unknown harness: ' + harness + ' (expected one of: ' + Object.keys(HARNESSES).join(', ') + ')');
|
|
113
|
+
}
|
|
114
|
+
if (mode !== 'register' && mode !== 'detect' && mode !== 'unregister') {
|
|
115
|
+
throw new Error('Unsupported mode: ' + mode);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Step 1 — detect harness CLI on PATH
|
|
119
|
+
if (!detectHarnessPresent(harness, spawnFn)) {
|
|
120
|
+
return {
|
|
121
|
+
harness,
|
|
122
|
+
action: mode,
|
|
123
|
+
detected: false,
|
|
124
|
+
command: null,
|
|
125
|
+
applied: false,
|
|
126
|
+
idempotent_skip: false,
|
|
127
|
+
notice: harness + ' CLI not on PATH — skipping ' + MCP_NAME + ' registration',
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Step 2 — idempotency check: already registered?
|
|
132
|
+
if (isAlreadyRegistered(harness, spawnFn)) {
|
|
133
|
+
return {
|
|
134
|
+
harness,
|
|
135
|
+
action: mode,
|
|
136
|
+
detected: true,
|
|
137
|
+
command: null,
|
|
138
|
+
applied: false,
|
|
139
|
+
idempotent_skip: true,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Step 3 — build + dispatch add command
|
|
144
|
+
const { binary, args } = buildHarnessCommand(harness, 'register');
|
|
145
|
+
const commandStr = binary + ' ' + args.join(' ');
|
|
146
|
+
|
|
147
|
+
if (dryRun) {
|
|
148
|
+
return {
|
|
149
|
+
harness,
|
|
150
|
+
action: mode,
|
|
151
|
+
detected: true,
|
|
152
|
+
command: commandStr,
|
|
153
|
+
applied: false,
|
|
154
|
+
idempotent_skip: false,
|
|
155
|
+
dry_run: true,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
let result;
|
|
160
|
+
try {
|
|
161
|
+
result = spawnFn(binary, args, { stdio: 'pipe', encoding: 'utf8' });
|
|
162
|
+
} catch (e) {
|
|
163
|
+
return {
|
|
164
|
+
harness,
|
|
165
|
+
action: mode,
|
|
166
|
+
detected: true,
|
|
167
|
+
command: commandStr,
|
|
168
|
+
applied: false,
|
|
169
|
+
idempotent_skip: false,
|
|
170
|
+
stderr: (e && e.message) || String(e),
|
|
171
|
+
exit_code: null,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
const stdout = (result && result.stdout) || '';
|
|
175
|
+
const stderr = (result && result.stderr) || '';
|
|
176
|
+
const exit_code = result ? result.status : null;
|
|
177
|
+
return {
|
|
178
|
+
harness,
|
|
179
|
+
action: mode,
|
|
180
|
+
detected: true,
|
|
181
|
+
command: commandStr,
|
|
182
|
+
applied: exit_code === 0,
|
|
183
|
+
idempotent_skip: false,
|
|
184
|
+
stdout: stdout.toString(),
|
|
185
|
+
stderr: stderr.toString(),
|
|
186
|
+
exit_code,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Detect overall MCP registration state across all known harnesses.
|
|
192
|
+
*
|
|
193
|
+
* @param {object} [opts]
|
|
194
|
+
* @param {Function} [opts.spawnFn]
|
|
195
|
+
* @returns {{harnesses: Array, summary: string}}
|
|
196
|
+
*/
|
|
197
|
+
function detectMcpRegistration({ spawnFn = spawnSync } = {}) {
|
|
198
|
+
const harnessIds = Object.keys(HARNESSES);
|
|
199
|
+
const results = harnessIds.map((harness) => {
|
|
200
|
+
const present = detectHarnessPresent(harness, spawnFn);
|
|
201
|
+
let registered;
|
|
202
|
+
if (present) {
|
|
203
|
+
registered = isAlreadyRegistered(harness, spawnFn);
|
|
204
|
+
} else {
|
|
205
|
+
registered = undefined;
|
|
206
|
+
}
|
|
207
|
+
return { harness, present, registered };
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
const anyPresent = results.some((r) => r.present);
|
|
211
|
+
const registeredHarnesses = results.filter((r) => r.registered === true).map((r) => r.harness);
|
|
212
|
+
|
|
213
|
+
let summary;
|
|
214
|
+
if (!anyPresent) {
|
|
215
|
+
summary = 'unknown (claude/codex CLI not found)';
|
|
216
|
+
} else if (registeredHarnesses.length === 0) {
|
|
217
|
+
summary = 'not registered';
|
|
218
|
+
} else if (registeredHarnesses.length === harnessIds.length) {
|
|
219
|
+
summary = 'registered with ' + registeredHarnesses.join('+');
|
|
220
|
+
} else {
|
|
221
|
+
summary = 'registered with ' + registeredHarnesses.join('+');
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return { harnesses: results, summary };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
module.exports = {
|
|
228
|
+
registerMcp,
|
|
229
|
+
detectMcpRegistration,
|
|
230
|
+
detectHarnessPresent,
|
|
231
|
+
isAlreadyRegistered,
|
|
232
|
+
buildHarnessCommand,
|
|
233
|
+
HARNESSES,
|
|
234
|
+
MCP_NAME,
|
|
235
|
+
};
|