@hegemonart/get-design-done 1.20.0 → 1.22.0

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 (69) hide show
  1. package/.claude-plugin/marketplace.json +9 -12
  2. package/.claude-plugin/plugin.json +8 -31
  3. package/CHANGELOG.md +200 -0
  4. package/README.md +48 -7
  5. package/bin/gdd-sdk +55 -0
  6. package/hooks/_hook-emit.js +81 -0
  7. package/hooks/gdd-bash-guard.js +8 -0
  8. package/hooks/gdd-decision-injector.js +2 -0
  9. package/hooks/gdd-protected-paths.js +8 -0
  10. package/hooks/gdd-trajectory-capture.js +64 -0
  11. package/hooks/hooks.json +9 -0
  12. package/package.json +19 -47
  13. package/reference/codex-tools.md +53 -0
  14. package/reference/gemini-tools.md +53 -0
  15. package/reference/registry.json +14 -0
  16. package/scripts/cli/gdd-events.mjs +283 -0
  17. package/scripts/e2e/run-headless.ts +514 -0
  18. package/scripts/lib/cli/commands/audit.ts +382 -0
  19. package/scripts/lib/cli/commands/init.ts +217 -0
  20. package/scripts/lib/cli/commands/query.ts +329 -0
  21. package/scripts/lib/cli/commands/run.ts +656 -0
  22. package/scripts/lib/cli/commands/stage.ts +468 -0
  23. package/scripts/lib/cli/index.ts +167 -0
  24. package/scripts/lib/cli/parse-args.ts +336 -0
  25. package/scripts/lib/connection-probe/index.cjs +263 -0
  26. package/scripts/lib/context-engine/index.ts +116 -0
  27. package/scripts/lib/context-engine/manifest.ts +69 -0
  28. package/scripts/lib/context-engine/truncate.ts +282 -0
  29. package/scripts/lib/context-engine/types.ts +59 -0
  30. package/scripts/lib/discuss-parallel-runner/aggregator.ts +448 -0
  31. package/scripts/lib/discuss-parallel-runner/discussants.ts +430 -0
  32. package/scripts/lib/discuss-parallel-runner/index.ts +223 -0
  33. package/scripts/lib/discuss-parallel-runner/types.ts +184 -0
  34. package/scripts/lib/event-chain.cjs +177 -0
  35. package/scripts/lib/event-stream/index.ts +31 -1
  36. package/scripts/lib/event-stream/reader.ts +139 -0
  37. package/scripts/lib/event-stream/types.ts +155 -1
  38. package/scripts/lib/event-stream/writer.ts +65 -8
  39. package/scripts/lib/explore-parallel-runner/index.ts +294 -0
  40. package/scripts/lib/explore-parallel-runner/mappers.ts +290 -0
  41. package/scripts/lib/explore-parallel-runner/synthesizer.ts +295 -0
  42. package/scripts/lib/explore-parallel-runner/types.ts +139 -0
  43. package/scripts/lib/harness/detect.ts +90 -0
  44. package/scripts/lib/harness/index.ts +64 -0
  45. package/scripts/lib/harness/tool-map.ts +142 -0
  46. package/scripts/lib/init-runner/index.ts +396 -0
  47. package/scripts/lib/init-runner/researchers.ts +245 -0
  48. package/scripts/lib/init-runner/scaffold.ts +224 -0
  49. package/scripts/lib/init-runner/synthesizer.ts +224 -0
  50. package/scripts/lib/init-runner/types.ts +143 -0
  51. package/scripts/lib/logger/index.ts +251 -0
  52. package/scripts/lib/logger/sinks.ts +269 -0
  53. package/scripts/lib/logger/types.ts +110 -0
  54. package/scripts/lib/pipeline-runner/human-gate.ts +134 -0
  55. package/scripts/lib/pipeline-runner/index.ts +527 -0
  56. package/scripts/lib/pipeline-runner/stage-handlers.ts +339 -0
  57. package/scripts/lib/pipeline-runner/state-machine.ts +144 -0
  58. package/scripts/lib/pipeline-runner/types.ts +183 -0
  59. package/scripts/lib/redact.cjs +122 -0
  60. package/scripts/lib/session-runner/errors.ts +406 -0
  61. package/scripts/lib/session-runner/index.ts +715 -0
  62. package/scripts/lib/session-runner/transcript.ts +189 -0
  63. package/scripts/lib/session-runner/types.ts +144 -0
  64. package/scripts/lib/tool-scoping/index.ts +219 -0
  65. package/scripts/lib/tool-scoping/parse-agent-tools.ts +207 -0
  66. package/scripts/lib/tool-scoping/stage-scopes.ts +139 -0
  67. package/scripts/lib/tool-scoping/types.ts +77 -0
  68. package/scripts/lib/trajectory/index.cjs +126 -0
  69. package/scripts/lib/transports/ws.cjs +179 -0
@@ -0,0 +1,336 @@
1
+ // scripts/lib/cli/parse-args.ts — Plan 21-09 Task 1 (SDK-21).
2
+ //
3
+ // Hand-rolled argv parser used by the `gdd-sdk` CLI. No external
4
+ // dependency (no yargs / commander / minimist). Supports the exact
5
+ // subset documented in PLAN.md:
6
+ //
7
+ // * Long flags: `--name value` or `--name=value`
8
+ // * Short flags: `-h`, `-v` only (help / version)
9
+ // * Boolean toggles: `--headless` (no value — present == true)
10
+ // * End-of-flags: `--` (everything after goes into `passthrough`)
11
+ //
12
+ // The parser itself does NOT validate that the first positional is a
13
+ // known subcommand — routing lives in `index.ts`. `coerceFlags()` is
14
+ // where type conversion + spec-driven validation happens.
15
+ //
16
+ // Contract:
17
+ // * Pure + deterministic. No I/O, no process reads. Safe to unit-test.
18
+ // * Never throws from `parseArgs()` — all failure surfaces land on the
19
+ // returned shape (unknown flags are captured as strings, not rejected).
20
+ // * `coerceFlags()` DOES throw `ValidationError` for malformed specs
21
+ // (e.g., non-numeric input for a numeric flag). Callers ideally catch
22
+ // and route to exit code 3.
23
+
24
+ import { ValidationError } from '../gdd-errors/index.ts';
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Public types.
28
+ // ---------------------------------------------------------------------------
29
+
30
+ /**
31
+ * Result of `parseArgs()`. All fields are frozen so downstream code
32
+ * cannot accidentally mutate the parser output.
33
+ *
34
+ * * `subcommand` — the first non-flag token. `null` when argv is empty
35
+ * or starts with a flag.
36
+ * * `positionals` — every non-flag token AFTER the subcommand (but
37
+ * before `--`).
38
+ * * `flags` — every `--name[=value]` / `-h` token keyed by name with
39
+ * value `true` (boolean toggle) or string (explicit value). No type
40
+ * coercion happens here.
41
+ * * `passthrough` — everything after the sentinel `--`, in order.
42
+ */
43
+ export interface ParsedArgs {
44
+ readonly subcommand: string | null;
45
+ readonly positionals: readonly string[];
46
+ readonly flags: Readonly<Record<string, string | boolean>>;
47
+ readonly passthrough: readonly string[];
48
+ }
49
+
50
+ /** Flag type a spec can declare. */
51
+ export type FlagType = 'string' | 'number' | 'boolean';
52
+
53
+ /**
54
+ * Declarative spec for one flag. Aliases let short names map to a
55
+ * canonical long name (e.g., `-h` → `help`). `default` is returned from
56
+ * `coerceFlags()` when the flag is absent.
57
+ */
58
+ export interface FlagSpec {
59
+ readonly name: string;
60
+ readonly type: FlagType;
61
+ readonly default?: unknown;
62
+ readonly aliases?: readonly string[];
63
+ }
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // parseArgs — pure tokenization pass.
67
+ // ---------------------------------------------------------------------------
68
+
69
+ /**
70
+ * Parse `argv` into typed `ParsedArgs`. See module header for grammar.
71
+ *
72
+ * The function is tolerant by design: unknown flags still appear in
73
+ * `flags` (caller may warn or error as desired via `coerceFlags`). Bad
74
+ * token order (e.g., two consecutive `--` sentinels) simply folds into
75
+ * passthrough.
76
+ *
77
+ * @param argv Argument tokens, e.g., `process.argv.slice(2)`.
78
+ * @param _specs Optional (reserved for parity with `coerceFlags`). Not
79
+ * used today — kept to match PLAN.md signature.
80
+ */
81
+ export function parseArgs(
82
+ argv: readonly string[],
83
+ _specs?: readonly FlagSpec[],
84
+ ): ParsedArgs {
85
+ const flags: Record<string, string | boolean> = {};
86
+ const positionals: string[] = [];
87
+ const passthrough: string[] = [];
88
+ let subcommand: string | null = null;
89
+
90
+ // State: have we crossed the `--` sentinel? Everything after goes to
91
+ // `passthrough` verbatim.
92
+ let afterDoubleDash = false;
93
+
94
+ for (let i = 0; i < argv.length; i++) {
95
+ const token: string | undefined = argv[i];
96
+ if (token === undefined) continue;
97
+
98
+ if (afterDoubleDash) {
99
+ passthrough.push(token);
100
+ continue;
101
+ }
102
+
103
+ if (token === '--') {
104
+ afterDoubleDash = true;
105
+ continue;
106
+ }
107
+
108
+ // Long flag: `--name` or `--name=value`.
109
+ if (token.startsWith('--')) {
110
+ const body = token.slice(2);
111
+ if (body.length === 0) {
112
+ // Bare `--` handled above; defensive fallthrough.
113
+ afterDoubleDash = true;
114
+ continue;
115
+ }
116
+ const eq = body.indexOf('=');
117
+ if (eq >= 0) {
118
+ const name = body.slice(0, eq);
119
+ const value = body.slice(eq + 1);
120
+ if (name.length > 0) {
121
+ flags[name] = value;
122
+ }
123
+ continue;
124
+ }
125
+ // No `=`. Peek the next token — if it exists and is NOT another
126
+ // flag, consume as the value. Otherwise treat as boolean toggle.
127
+ const next: string | undefined = argv[i + 1];
128
+ if (
129
+ next !== undefined &&
130
+ !next.startsWith('-') &&
131
+ !isLikelyBoolFlag(body)
132
+ ) {
133
+ flags[body] = next;
134
+ i += 1;
135
+ } else {
136
+ flags[body] = true;
137
+ }
138
+ continue;
139
+ }
140
+
141
+ // Short flag: single letter after a single dash.
142
+ if (token.startsWith('-') && token.length >= 2) {
143
+ const rest = token.slice(1);
144
+ // Accept only 1-letter short flags per PLAN.md ("Only 1-letter
145
+ // shorts: `-h`, `-v`"). Anything longer we treat as-is and let the
146
+ // consumer decide — we record it under the first letter.
147
+ if (rest.length === 1) {
148
+ flags[rest] = true;
149
+ continue;
150
+ }
151
+ // Multi-char short (e.g., `-abc`) — treat as an unknown flag
152
+ // literal. Record under the whole body so callers can detect.
153
+ flags[rest] = true;
154
+ continue;
155
+ }
156
+
157
+ // Positional. First positional is the subcommand.
158
+ if (subcommand === null) {
159
+ subcommand = token;
160
+ } else {
161
+ positionals.push(token);
162
+ }
163
+ }
164
+
165
+ return Object.freeze({
166
+ subcommand,
167
+ positionals: Object.freeze(positionals),
168
+ flags: Object.freeze(flags),
169
+ passthrough: Object.freeze(passthrough),
170
+ });
171
+ }
172
+
173
+ /**
174
+ * Known boolean-toggle flag names. When the parser encounters one of
175
+ * these WITHOUT an `=value` it should NOT consume the next token even
176
+ * if that token looks like a value — the next token is a positional
177
+ * arg. Keeps `gdd-sdk stage discuss --parallel plan` parsing correctly:
178
+ * `--parallel` is a bool, `plan` stays in positionals.
179
+ *
180
+ * The list is conservative (only flags the CLI declares as boolean in
181
+ * its specs); unknown bool flags fall through to the generic peek-value
182
+ * heuristic which is safe for the CLI's other flags because every
183
+ * value-carrying flag is numeric or string (never ambiguous).
184
+ */
185
+ const BOOL_FLAG_NAMES: ReadonlySet<string> = new Set([
186
+ 'help',
187
+ 'h',
188
+ 'version',
189
+ 'v',
190
+ 'headless',
191
+ 'interactive',
192
+ 'json',
193
+ 'text',
194
+ 'force',
195
+ 'parallel',
196
+ 'dry-run',
197
+ ]);
198
+
199
+ function isLikelyBoolFlag(name: string): boolean {
200
+ return BOOL_FLAG_NAMES.has(name);
201
+ }
202
+
203
+ // ---------------------------------------------------------------------------
204
+ // coerceFlags — spec-driven type conversion + defaults.
205
+ // ---------------------------------------------------------------------------
206
+
207
+ /**
208
+ * Apply type coercion + defaults based on `specs`. Aliases let
209
+ * `-h` (parsed as `flags.h = true`) resolve to the canonical `help`
210
+ * key in the returned map.
211
+ *
212
+ * Throws `ValidationError` when a flag was supplied with a value that
213
+ * cannot coerce to the declared type (e.g., `--budget-usd abc`). Flags
214
+ * not declared in `specs` pass through unchanged (as their raw
215
+ * string|boolean value) so callers can still see them.
216
+ */
217
+ export function coerceFlags(
218
+ parsed: ParsedArgs,
219
+ specs: readonly FlagSpec[],
220
+ ): Record<string, unknown> {
221
+ const out: Record<string, unknown> = {};
222
+
223
+ // Collect every alias → canonical map.
224
+ const canonical = new Map<string, FlagSpec>();
225
+ for (const spec of specs) {
226
+ canonical.set(spec.name, spec);
227
+ for (const alias of spec.aliases ?? []) {
228
+ canonical.set(alias, spec);
229
+ }
230
+ }
231
+
232
+ // Build a reverse-lookup of values present on `parsed.flags` keyed by
233
+ // their canonical name (so `-h` and `--help` both land on `help`).
234
+ const resolved: Record<string, string | boolean> = {};
235
+ for (const [key, value] of Object.entries(parsed.flags)) {
236
+ const spec = canonical.get(key);
237
+ const target = spec !== undefined ? spec.name : key;
238
+ // Last-write-wins — operators rarely specify a flag twice; when they
239
+ // do, the final value prevails.
240
+ resolved[target] = value;
241
+ }
242
+
243
+ // Apply defaults + coerce.
244
+ for (const spec of specs) {
245
+ const raw = Object.prototype.hasOwnProperty.call(resolved, spec.name)
246
+ ? resolved[spec.name]
247
+ : undefined;
248
+ if (raw === undefined) {
249
+ if (spec.default !== undefined) {
250
+ out[spec.name] = spec.default;
251
+ }
252
+ continue;
253
+ }
254
+ out[spec.name] = coerceValue(spec, raw);
255
+ }
256
+
257
+ // Pass-through any flags not declared in specs (so `query get --tail 5`
258
+ // keeps `tail` visible even if `tail` isn't in the common-flag spec list).
259
+ for (const [key, value] of Object.entries(resolved)) {
260
+ if (!Object.prototype.hasOwnProperty.call(out, key)) {
261
+ out[key] = value;
262
+ }
263
+ }
264
+
265
+ return out;
266
+ }
267
+
268
+ /**
269
+ * Coerce a single raw value against its spec. Throws `ValidationError`
270
+ * on malformed input so the caller can exit with code 3.
271
+ */
272
+ function coerceValue(spec: FlagSpec, raw: string | boolean): unknown {
273
+ if (spec.type === 'boolean') {
274
+ if (raw === true || raw === false) return raw;
275
+ // String values `"true"` / `"false"` (from `--flag=true`) are honored.
276
+ if (raw === 'true' || raw === '1') return true;
277
+ if (raw === 'false' || raw === '0') return false;
278
+ throw new ValidationError(
279
+ `flag --${spec.name} expects a boolean (true/false/1/0), got "${String(raw)}"`,
280
+ 'INVALID_FLAG_VALUE',
281
+ { flag: spec.name, value: raw },
282
+ );
283
+ }
284
+ if (spec.type === 'number') {
285
+ if (typeof raw === 'boolean') {
286
+ throw new ValidationError(
287
+ `flag --${spec.name} requires a numeric value`,
288
+ 'INVALID_FLAG_VALUE',
289
+ { flag: spec.name },
290
+ );
291
+ }
292
+ const n = Number(raw);
293
+ if (!Number.isFinite(n)) {
294
+ throw new ValidationError(
295
+ `flag --${spec.name} expects a number, got "${raw}"`,
296
+ 'INVALID_FLAG_VALUE',
297
+ { flag: spec.name, value: raw },
298
+ );
299
+ }
300
+ return n;
301
+ }
302
+ // string
303
+ if (typeof raw === 'boolean') {
304
+ throw new ValidationError(
305
+ `flag --${spec.name} requires a string value`,
306
+ 'INVALID_FLAG_VALUE',
307
+ { flag: spec.name },
308
+ );
309
+ }
310
+ return raw;
311
+ }
312
+
313
+ // ---------------------------------------------------------------------------
314
+ // Common-flag specs used by multiple subcommands.
315
+ // ---------------------------------------------------------------------------
316
+
317
+ /**
318
+ * Common flags shared across every subcommand. Individual commands may
319
+ * extend this list with their own flags. `default` values follow
320
+ * PLAN.md's recommendations.
321
+ */
322
+ export const COMMON_FLAGS: readonly FlagSpec[] = Object.freeze([
323
+ Object.freeze({ name: 'help', type: 'boolean', default: false, aliases: ['h'] }),
324
+ Object.freeze({ name: 'version', type: 'boolean', default: false, aliases: ['v'] }),
325
+ Object.freeze({ name: 'cwd', type: 'string' }),
326
+ Object.freeze({ name: 'log-level', type: 'string', default: 'info' }),
327
+ Object.freeze({ name: 'headless', type: 'boolean', default: false }),
328
+ Object.freeze({ name: 'interactive', type: 'boolean', default: false }),
329
+ Object.freeze({ name: 'json', type: 'boolean', default: false }),
330
+ Object.freeze({ name: 'text', type: 'boolean', default: false }),
331
+ Object.freeze({ name: 'budget-usd', type: 'number' }),
332
+ Object.freeze({ name: 'budget-input-tokens', type: 'number', default: 200_000 }),
333
+ Object.freeze({ name: 'budget-output-tokens', type: 'number', default: 50_000 }),
334
+ Object.freeze({ name: 'max-turns', type: 'number', default: 40 }),
335
+ Object.freeze({ name: 'concurrency', type: 'number', default: 4 }),
336
+ ]);
@@ -0,0 +1,263 @@
1
+ /**
2
+ * connection-probe/index.cjs — code-level connection liveness probe
3
+ * (Plan 22-08).
4
+ *
5
+ * Replaces today's per-connection ad-hoc probe bash snippets in
6
+ * `connections/` with one typed primitive. Used by Phase 21
7
+ * pipeline-runner and Phase 22 reflector.
8
+ *
9
+ * Contract:
10
+ * probe({
11
+ * name: 'figma' | 'pinterest' | … // free-form connection id
12
+ * cmd: async () => boolean | Truthy // probe action
13
+ * timeout: number ms // default 5000
14
+ * retries: number // default 3 attempts total
15
+ * fallback: async () => unknown // optional degraded path
16
+ * }) → {
17
+ * status: 'ok' | 'degraded' | 'down'
18
+ * latency_ms: number
19
+ * attempts: number
20
+ * fallback_used: boolean
21
+ * error?: string // last error message if any
22
+ * }
23
+ *
24
+ * State persistence:
25
+ * * `.design/telemetry/connection-state.json` records `{name → status}`
26
+ * across runs.
27
+ * * On every probe, if the new status differs from cached, emit a
28
+ * `connection.status_change` event via the event-stream bus and
29
+ * overwrite the cached value atomically (write to .tmp + rename).
30
+ *
31
+ * Backoff:
32
+ * * Uses `jittered-backoff.cjs` — `delayMs(attempt)` between retries.
33
+ *
34
+ * The probe `cmd` is awaited with a Promise.race against a timeout. On
35
+ * fulfilment with truthy → ok. On rejection or falsy → fail-this-attempt;
36
+ * retry until exhausted. After full-fail, if `fallback` is supplied,
37
+ * runs it and reports `degraded` + `fallback_used: true`.
38
+ */
39
+
40
+ 'use strict';
41
+
42
+ const { writeFileSync, readFileSync, existsSync, mkdirSync, renameSync } = require('node:fs');
43
+ const { dirname, isAbsolute, resolve, join } = require('node:path');
44
+
45
+ const { delayMs } = require('../jittered-backoff.cjs');
46
+
47
+ const DEFAULT_STATE_PATH = '.design/telemetry/connection-state.json';
48
+
49
+ /**
50
+ * Resolve the connection-state file path against a base dir.
51
+ * @param {{baseDir?: string, statePath?: string}} [opts]
52
+ */
53
+ function statePathFor(opts = {}) {
54
+ const raw = opts.statePath ?? DEFAULT_STATE_PATH;
55
+ if (isAbsolute(raw)) return raw;
56
+ return resolve(opts.baseDir ?? process.cwd(), raw);
57
+ }
58
+
59
+ /**
60
+ * Load + return the cached state object (or `{}` if absent / corrupt).
61
+ * @param {string} path
62
+ * @returns {Record<string, string>}
63
+ */
64
+ function loadState(path) {
65
+ if (!existsSync(path)) return {};
66
+ try {
67
+ return JSON.parse(readFileSync(path, 'utf8'));
68
+ } catch {
69
+ return {};
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Atomic state write: write to `.tmp` sibling, rename. Renames are
75
+ * atomic on POSIX and at least crash-safe on Windows for same-volume
76
+ * targets.
77
+ * @param {string} path
78
+ * @param {Record<string, string>} state
79
+ */
80
+ function saveState(path, state) {
81
+ try {
82
+ mkdirSync(dirname(path), { recursive: true });
83
+ const tmp = path + '.tmp';
84
+ writeFileSync(tmp, JSON.stringify(state, null, 2));
85
+ renameSync(tmp, path);
86
+ } catch (err) {
87
+ try {
88
+ process.stderr.write(
89
+ `[connection-probe] state write failed: ${err && err.message ? err.message : String(err)}\n`,
90
+ );
91
+ } catch {
92
+ /* swallow */
93
+ }
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Race `promise` against a timeout. Rejects with `TimeoutError` after
99
+ * `ms` if the promise hasn't settled.
100
+ *
101
+ * @template T
102
+ * @param {Promise<T>} promise
103
+ * @param {number} ms
104
+ * @returns {Promise<T>}
105
+ */
106
+ function withTimeout(promise, ms) {
107
+ return new Promise((resolve, reject) => {
108
+ const timer = setTimeout(() => {
109
+ const err = new Error(`probe timed out after ${ms}ms`);
110
+ err.code = 'PROBE_TIMEOUT';
111
+ reject(err);
112
+ }, ms);
113
+ promise.then(
114
+ (v) => {
115
+ clearTimeout(timer);
116
+ resolve(v);
117
+ },
118
+ (e) => {
119
+ clearTimeout(timer);
120
+ reject(e);
121
+ },
122
+ );
123
+ });
124
+ }
125
+
126
+ /**
127
+ * Run the probe with retries + optional fallback. Resolves to a
128
+ * structured outcome; never rejects.
129
+ *
130
+ * @param {{
131
+ * name: string,
132
+ * cmd: () => Promise<unknown>,
133
+ * timeout?: number,
134
+ * retries?: number,
135
+ * fallback?: () => Promise<unknown>,
136
+ * baseDir?: string,
137
+ * statePath?: string,
138
+ * emit?: (ev: unknown) => void,
139
+ * }} opts
140
+ * @returns {Promise<{
141
+ * status: 'ok' | 'degraded' | 'down',
142
+ * latency_ms: number,
143
+ * attempts: number,
144
+ * fallback_used: boolean,
145
+ * error?: string,
146
+ * }>}
147
+ */
148
+ async function probe(opts) {
149
+ if (!opts || typeof opts.name !== 'string' || opts.name.length === 0) {
150
+ throw new TypeError('probe: name (string) required');
151
+ }
152
+ if (typeof opts.cmd !== 'function') {
153
+ throw new TypeError('probe: cmd (async fn) required');
154
+ }
155
+ const timeout = opts.timeout ?? 5000;
156
+ const retries = Math.max(1, opts.retries ?? 3);
157
+ const path = statePathFor(opts);
158
+
159
+ const start = Date.now();
160
+ let attempts = 0;
161
+ let lastError;
162
+
163
+ for (let i = 0; i < retries; i++) {
164
+ attempts += 1;
165
+ try {
166
+ const result = await withTimeout(Promise.resolve(opts.cmd()), timeout);
167
+ if (result) {
168
+ const outcome = {
169
+ status: /** @type {'ok'} */ ('ok'),
170
+ latency_ms: Date.now() - start,
171
+ attempts,
172
+ fallback_used: false,
173
+ };
174
+ await recordTransition(opts.name, outcome.status, path, opts.emit);
175
+ return outcome;
176
+ }
177
+ // falsy = soft-fail; retry
178
+ lastError = new Error('probe returned falsy');
179
+ } catch (err) {
180
+ lastError = err;
181
+ }
182
+ if (i < retries - 1) {
183
+ await sleep(delayMs(i));
184
+ }
185
+ }
186
+
187
+ // All retries failed. Try fallback if supplied.
188
+ if (typeof opts.fallback === 'function') {
189
+ try {
190
+ await opts.fallback();
191
+ const outcome = {
192
+ status: /** @type {'degraded'} */ ('degraded'),
193
+ latency_ms: Date.now() - start,
194
+ attempts,
195
+ fallback_used: true,
196
+ error: lastError && lastError.message ? lastError.message : String(lastError),
197
+ };
198
+ await recordTransition(opts.name, outcome.status, path, opts.emit);
199
+ return outcome;
200
+ } catch {
201
+ /* fall through to down */
202
+ }
203
+ }
204
+
205
+ const outcome = {
206
+ status: /** @type {'down'} */ ('down'),
207
+ latency_ms: Date.now() - start,
208
+ attempts,
209
+ fallback_used: false,
210
+ error: lastError && lastError.message ? lastError.message : String(lastError),
211
+ };
212
+ await recordTransition(opts.name, outcome.status, path, opts.emit);
213
+ return outcome;
214
+ }
215
+
216
+ /** Sleep for `ms` milliseconds. */
217
+ function sleep(ms) {
218
+ return new Promise((r) => setTimeout(r, ms));
219
+ }
220
+
221
+ /**
222
+ * Compare against cached state. If status differs, emit a
223
+ * `connection.status_change` event (when an `emit` callback is supplied)
224
+ * and overwrite the cached value atomically.
225
+ *
226
+ * @param {string} name
227
+ * @param {string} status
228
+ * @param {string} statePath
229
+ * @param {undefined | ((ev: unknown) => void)} emit
230
+ */
231
+ async function recordTransition(name, status, statePath, emit) {
232
+ const state = loadState(statePath);
233
+ const previous = state[name];
234
+ if (previous === status) return; // no transition
235
+ state[name] = status;
236
+ saveState(statePath, state);
237
+ if (typeof emit === 'function') {
238
+ try {
239
+ emit({
240
+ type: 'connection.status_change',
241
+ timestamp: new Date().toISOString(),
242
+ sessionId: process.env.GDD_SESSION_ID || 'unknown',
243
+ payload: { name, from: previous ?? 'unknown', to: status },
244
+ });
245
+ } catch (err) {
246
+ try {
247
+ process.stderr.write(
248
+ `[connection-probe] emit failed: ${err && err.message ? err.message : String(err)}\n`,
249
+ );
250
+ } catch {
251
+ /* swallow */
252
+ }
253
+ }
254
+ }
255
+ }
256
+
257
+ module.exports = {
258
+ probe,
259
+ statePathFor,
260
+ loadState,
261
+ saveState,
262
+ DEFAULT_STATE_PATH,
263
+ };