@hegemonart/get-design-done 1.27.5 → 1.27.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +6 -3
  3. package/CHANGELOG.md +99 -0
  4. package/agents/perf-analyzer.md +166 -0
  5. package/hooks/gdd-precompact-snapshot.js +334 -0
  6. package/hooks/gdd-sessionstart-recap.js +281 -0
  7. package/hooks/hooks.json +18 -0
  8. package/package.json +6 -5
  9. package/reference/perf-budget.md +142 -0
  10. package/reference/registry.json +14 -0
  11. package/reference/retrieval-contract.md +16 -0
  12. package/reference/schemas/mcp-gdd-tools.schema.json +381 -0
  13. package/scripts/install.cjs +42 -0
  14. package/scripts/lib/cache/gdd-cache-manager.cjs +292 -0
  15. package/scripts/lib/discuss-parallel-runner/index.ts +5 -1
  16. package/scripts/lib/explore-parallel-runner/index.ts +5 -1
  17. package/scripts/lib/gsd-health-mirror/index.cjs +105 -0
  18. package/scripts/lib/gsd-health-mirror/index.d.cts +14 -0
  19. package/scripts/lib/install/mcp-register.cjs +235 -0
  20. package/scripts/lib/install/mcp-register.d.cts +64 -0
  21. package/scripts/lib/intel-store/index.cjs +55 -0
  22. package/scripts/lib/intel-store/index.d.cts +11 -0
  23. package/scripts/lib/mcp-tools-lint/index.cjs +216 -0
  24. package/scripts/lib/mcp-tools-lint/index.d.cts +74 -0
  25. package/scripts/lib/parallelism-engine/concurrency-tuner.cjs +259 -0
  26. package/scripts/lib/parallelism-engine/concurrency-tuner.d.cts +53 -0
  27. package/scripts/lib/perf-analyzer/cost-regression.cjs +299 -0
  28. package/scripts/lib/perf-analyzer/index.cjs +139 -0
  29. package/scripts/lib/prompt-dedup/index.cjs +161 -0
  30. package/scripts/lib/reflections-reader/index.cjs +107 -0
  31. package/scripts/lib/reflections-reader/index.d.cts +18 -0
  32. package/scripts/lib/roadmap-reader/index.cjs +81 -0
  33. package/scripts/lib/roadmap-reader/index.d.cts +13 -0
  34. package/scripts/lib/snapshot-reader/index.cjs +70 -0
  35. package/scripts/lib/snapshot-reader/index.d.cts +28 -0
  36. package/scripts/mcp-servers/gdd-mcp/README.md +66 -0
  37. package/scripts/mcp-servers/gdd-mcp/schemas/gdd_cycle_recap.schema.json +30 -0
  38. package/scripts/mcp-servers/gdd-mcp/schemas/gdd_decisions_list.schema.json +32 -0
  39. package/scripts/mcp-servers/gdd-mcp/schemas/gdd_events_tail.schema.json +22 -0
  40. package/scripts/mcp-servers/gdd-mcp/schemas/gdd_health.schema.json +30 -0
  41. package/scripts/mcp-servers/gdd-mcp/schemas/gdd_intel_get.schema.json +24 -0
  42. package/scripts/mcp-servers/gdd-mcp/schemas/gdd_learnings_digest.schema.json +22 -0
  43. package/scripts/mcp-servers/gdd-mcp/schemas/gdd_phase_current.schema.json +22 -0
  44. package/scripts/mcp-servers/gdd-mcp/schemas/gdd_phases_list.schema.json +31 -0
  45. package/scripts/mcp-servers/gdd-mcp/schemas/gdd_plans_list.schema.json +33 -0
  46. package/scripts/mcp-servers/gdd-mcp/schemas/gdd_reflections_latest.schema.json +21 -0
  47. package/scripts/mcp-servers/gdd-mcp/schemas/gdd_status.schema.json +23 -0
  48. package/scripts/mcp-servers/gdd-mcp/schemas/gdd_telemetry_query.schema.json +23 -0
  49. package/scripts/mcp-servers/gdd-mcp/server.ts +317 -0
  50. package/scripts/mcp-servers/gdd-mcp/tools/gdd_cycle_recap.ts +37 -0
  51. package/scripts/mcp-servers/gdd-mcp/tools/gdd_decisions_list.ts +33 -0
  52. package/scripts/mcp-servers/gdd-mcp/tools/gdd_events_tail.ts +26 -0
  53. package/scripts/mcp-servers/gdd-mcp/tools/gdd_health.ts +19 -0
  54. package/scripts/mcp-servers/gdd-mcp/tools/gdd_intel_get.ts +32 -0
  55. package/scripts/mcp-servers/gdd-mcp/tools/gdd_learnings_digest.ts +23 -0
  56. package/scripts/mcp-servers/gdd-mcp/tools/gdd_phase_current.ts +29 -0
  57. package/scripts/mcp-servers/gdd-mcp/tools/gdd_phases_list.ts +26 -0
  58. package/scripts/mcp-servers/gdd-mcp/tools/gdd_plans_list.ts +39 -0
  59. package/scripts/mcp-servers/gdd-mcp/tools/gdd_reflections_latest.ts +25 -0
  60. package/scripts/mcp-servers/gdd-mcp/tools/gdd_status.ts +31 -0
  61. package/scripts/mcp-servers/gdd-mcp/tools/gdd_telemetry_query.ts +27 -0
  62. package/scripts/mcp-servers/gdd-mcp/tools/index.ts +75 -0
  63. package/scripts/mcp-servers/gdd-mcp/tools/shared.ts +134 -0
  64. package/skills/health/SKILL.md +36 -0
  65. package/skills/next/SKILL.md +28 -3
  66. package/skills/progress/SKILL.md +21 -6
  67. package/skills/resume/SKILL.md +26 -1
@@ -0,0 +1,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,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
+ };