@hegemonart/get-design-done 1.21.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.
@@ -0,0 +1,283 @@
1
+ #!/usr/bin/env -S node --experimental-strip-types
2
+ // scripts/cli/gdd-events.mjs — CLI transport for the event stream
3
+ // (Plan 22-06).
4
+ //
5
+ // Subcommands:
6
+ // gdd-events tail [--follow] [--path=<p>]
7
+ // - dump events.jsonl to stdout, line-by-line
8
+ // - --follow re-polls every 250ms appending new content (no native
9
+ // inotify dep; portable across platforms)
10
+ //
11
+ // gdd-events grep <filter> [--path=<p>]
12
+ // - filter language (space-separated terms, all AND'd):
13
+ // type=<exact-string> — match `type` field
14
+ // payload.<dotted.path>=<value> — drill into payload by '.'-path
15
+ // !type=<exact-string> — negate
16
+ // !payload.<path>=<value> — negate
17
+ // - prints matching events to stdout as JSONL (compact)
18
+ //
19
+ // gdd-events cat [--path=<p>]
20
+ // - alias for tail without --follow, but pretty-prints with a
21
+ // leading timestamp+type prefix per line
22
+ //
23
+ // gdd-events list-types
24
+ // - prints the runtime KNOWN_EVENT_TYPES list (from Plan 22-01)
25
+ //
26
+ // gdd-events serve [--port=<n>] [--token=<t>] [--tail=<file>]
27
+ // - WebSocket transport (Plan 22-07). Loaded lazily via
28
+ // probe-optional; helpful error if `ws` is not installed.
29
+ //
30
+ // Default --path is `.design/telemetry/events.jsonl` (relative to cwd).
31
+
32
+ import { existsSync, statSync, openSync, readSync, closeSync } from 'node:fs';
33
+ import { resolve, isAbsolute } from 'node:path';
34
+ import { pathToFileURL } from 'node:url';
35
+ import { argv, exit, stdout, stderr } from 'node:process';
36
+ import { createRequire } from 'node:module';
37
+
38
+ const require = createRequire(import.meta.url);
39
+
40
+ const DEFAULT_PATH = '.design/telemetry/events.jsonl';
41
+
42
+ function usage() {
43
+ stderr.write(
44
+ [
45
+ 'gdd-events — Phase 22 event-stream CLI',
46
+ '',
47
+ 'Usage:',
48
+ ' gdd-events tail [--follow] [--path=<p>]',
49
+ ' gdd-events grep <filter…> [--path=<p>]',
50
+ ' gdd-events cat [--path=<p>]',
51
+ ' gdd-events list-types',
52
+ ' gdd-events serve [--port=<n>] [--token=<t>] [--tail=<file>]',
53
+ '',
54
+ 'Filter language (grep): type=<s> payload.<dotted.path>=<s> !type=<s>',
55
+ '',
56
+ ].join('\n'),
57
+ );
58
+ }
59
+
60
+ function parseArgs(args) {
61
+ const out = { _: [], flags: {} };
62
+ for (const a of args) {
63
+ if (a.startsWith('--')) {
64
+ const eq = a.indexOf('=');
65
+ if (eq === -1) {
66
+ out.flags[a.slice(2)] = true;
67
+ } else {
68
+ out.flags[a.slice(2, eq)] = a.slice(eq + 1);
69
+ }
70
+ } else {
71
+ out._.push(a);
72
+ }
73
+ }
74
+ return out;
75
+ }
76
+
77
+ function resolvePath(flagPath) {
78
+ const raw = flagPath || DEFAULT_PATH;
79
+ return isAbsolute(raw) ? raw : resolve(process.cwd(), raw);
80
+ }
81
+
82
+ /** Compile filter terms like "type=foo", "!payload.x=1" into a predicate. */
83
+ export function compileFilter(terms) {
84
+ /** @type {Array<(ev: any) => boolean>} */
85
+ const checks = [];
86
+ for (const term of terms) {
87
+ let negate = false;
88
+ let body = term;
89
+ if (body.startsWith('!')) {
90
+ negate = true;
91
+ body = body.slice(1);
92
+ }
93
+ const eq = body.indexOf('=');
94
+ if (eq === -1) {
95
+ throw new Error(`gdd-events: bad filter term: ${term}`);
96
+ }
97
+ const key = body.slice(0, eq);
98
+ const want = body.slice(eq + 1);
99
+ /** @type {(ev: any) => boolean} */
100
+ let test;
101
+ if (key === 'type') {
102
+ test = (ev) => ev?.type === want;
103
+ } else if (key.startsWith('payload.')) {
104
+ const path = key.slice('payload.'.length).split('.');
105
+ test = (ev) => {
106
+ let cur = ev?.payload;
107
+ for (const part of path) {
108
+ if (cur == null || typeof cur !== 'object') return false;
109
+ cur = cur[part];
110
+ }
111
+ return String(cur) === want;
112
+ };
113
+ } else if (key === 'stage') {
114
+ test = (ev) => ev?.stage === want;
115
+ } else if (key === 'cycle') {
116
+ test = (ev) => ev?.cycle === want;
117
+ } else if (key === 'sessionId') {
118
+ test = (ev) => ev?.sessionId === want;
119
+ } else {
120
+ throw new Error(`gdd-events: unsupported filter key: ${key}`);
121
+ }
122
+ checks.push(negate ? (ev) => !test(ev) : test);
123
+ }
124
+ return (ev) => checks.every((c) => c(ev));
125
+ }
126
+
127
+ async function cmdTail(parsed) {
128
+ const path = resolvePath(parsed.flags.path);
129
+ const { readEvents } = await import('../lib/event-stream/reader.ts');
130
+ if (!parsed.flags.follow) {
131
+ for await (const ev of readEvents({ path })) {
132
+ stdout.write(JSON.stringify(ev) + '\n');
133
+ }
134
+ return 0;
135
+ }
136
+ // Follow mode: stream existing content, then poll for appends.
137
+ let offset = 0;
138
+ if (existsSync(path)) {
139
+ for await (const ev of readEvents({ path })) {
140
+ stdout.write(JSON.stringify(ev) + '\n');
141
+ }
142
+ offset = statSync(path).size;
143
+ }
144
+ // Poll loop. Reads new bytes since last offset, splits on \n, writes each.
145
+ let buf = '';
146
+ // eslint-disable-next-line no-constant-condition
147
+ while (true) {
148
+ await new Promise((r) => setTimeout(r, 250));
149
+ if (!existsSync(path)) continue;
150
+ const size = statSync(path).size;
151
+ if (size <= offset) continue;
152
+ const fd = openSync(path, 'r');
153
+ try {
154
+ const need = size - offset;
155
+ const chunk = Buffer.allocUnsafe(need);
156
+ const n = readSync(fd, chunk, 0, need, offset);
157
+ offset += n;
158
+ buf += chunk.subarray(0, n).toString('utf8');
159
+ const lines = buf.split('\n');
160
+ buf = lines.pop() || '';
161
+ for (const line of lines) {
162
+ if (line.trim() === '') continue;
163
+ stdout.write(line + '\n');
164
+ }
165
+ } finally {
166
+ closeSync(fd);
167
+ }
168
+ }
169
+ }
170
+
171
+ async function cmdGrep(parsed) {
172
+ const path = resolvePath(parsed.flags.path);
173
+ const terms = parsed._;
174
+ if (terms.length === 0) {
175
+ stderr.write('gdd-events grep: at least one filter term required\n');
176
+ return 2;
177
+ }
178
+ const predicate = compileFilter(terms);
179
+ const { readEvents } = await import('../lib/event-stream/reader.ts');
180
+ for await (const ev of readEvents({ path, predicate })) {
181
+ stdout.write(JSON.stringify(ev) + '\n');
182
+ }
183
+ return 0;
184
+ }
185
+
186
+ async function cmdCat(parsed) {
187
+ const path = resolvePath(parsed.flags.path);
188
+ const { readEvents } = await import('../lib/event-stream/reader.ts');
189
+ for await (const ev of readEvents({ path })) {
190
+ const ts = ev.timestamp ?? '?';
191
+ const tp = ev.type ?? '?';
192
+ stdout.write(`${ts} ${tp.padEnd(28)} ${JSON.stringify(ev.payload ?? {})}\n`);
193
+ }
194
+ return 0;
195
+ }
196
+
197
+ async function cmdListTypes() {
198
+ const { KNOWN_EVENT_TYPES } = await import('../lib/event-stream/types.ts');
199
+ for (const t of KNOWN_EVENT_TYPES) stdout.write(t + '\n');
200
+ return 0;
201
+ }
202
+
203
+ async function cmdServe(parsed) {
204
+ let mod;
205
+ try {
206
+ mod = require('../lib/transports/ws.cjs');
207
+ } catch (err) {
208
+ stderr.write(
209
+ 'gdd-events serve: WebSocket transport requires the optional `ws` package.\n' +
210
+ ' install: npm i -D ws\n' +
211
+ ` ${err && err.message ? err.message : String(err)}\n`,
212
+ );
213
+ return 1;
214
+ }
215
+ const port = Number(parsed.flags.port) || 9595;
216
+ const token = parsed.flags.token || process.env.GDD_EVENTS_TOKEN;
217
+ if (!token) {
218
+ stderr.write('gdd-events serve: --token=<t> or GDD_EVENTS_TOKEN env required\n');
219
+ return 2;
220
+ }
221
+ const tailFrom = parsed.flags.tail
222
+ ? resolvePath(parsed.flags.tail)
223
+ : resolvePath(undefined);
224
+ // Bridge live bus → ws transport. The transport is CommonJS and cannot
225
+ // require .ts directly, so we import the bus here and pass subscribeAll
226
+ // as a callback factory.
227
+ const { subscribeAll } = await import('../lib/event-stream/index.ts');
228
+ const subscribe = (handler) => subscribeAll(handler);
229
+ const handle = await mod.startServer({ port, token, tailFrom, subscribe });
230
+ stderr.write(`gdd-events: WebSocket listening on :${port} (auth required)\n`);
231
+ // Keep the process alive until SIGINT/SIGTERM.
232
+ await new Promise((resolve) => {
233
+ const close = () => {
234
+ handle.close();
235
+ resolve();
236
+ };
237
+ process.once('SIGINT', close);
238
+ process.once('SIGTERM', close);
239
+ });
240
+ return 0;
241
+ }
242
+
243
+ async function main() {
244
+ const parsed = parseArgs(argv.slice(2));
245
+ const sub = parsed._.shift();
246
+ try {
247
+ switch (sub) {
248
+ case 'tail':
249
+ return await cmdTail(parsed);
250
+ case 'grep':
251
+ return await cmdGrep(parsed);
252
+ case 'cat':
253
+ return await cmdCat(parsed);
254
+ case 'list-types':
255
+ return await cmdListTypes();
256
+ case 'serve':
257
+ return await cmdServe(parsed);
258
+ case '-h':
259
+ case '--help':
260
+ case 'help':
261
+ usage();
262
+ return 0;
263
+ default:
264
+ usage();
265
+ return sub === undefined ? 0 : 2;
266
+ }
267
+ } catch (err) {
268
+ stderr.write(`gdd-events: ${err && err.message ? err.message : String(err)}\n`);
269
+ return 1;
270
+ }
271
+ }
272
+
273
+ // Compare module URL via pathToFileURL — Windows paths use backslashes
274
+ // and need proper file:// URL canonicalisation; the simpler `file://${argv[1]}`
275
+ // form drops to false on Windows and the CLI silently no-ops.
276
+ const isCli = process.argv[1] !== undefined &&
277
+ import.meta.url === pathToFileURL(process.argv[1]).href;
278
+ if (isCli) {
279
+ main().then((code) => exit(code), (err) => {
280
+ stderr.write(`gdd-events fatal: ${err}\n`);
281
+ exit(1);
282
+ });
283
+ }
@@ -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
+ };