@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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +122 -0
- package/hooks/_hook-emit.js +81 -0
- package/hooks/gdd-bash-guard.js +8 -0
- package/hooks/gdd-decision-injector.js +2 -0
- package/hooks/gdd-protected-paths.js +8 -0
- package/hooks/gdd-trajectory-capture.js +64 -0
- package/hooks/hooks.json +9 -0
- package/package.json +6 -2
- package/scripts/cli/gdd-events.mjs +283 -0
- package/scripts/lib/connection-probe/index.cjs +263 -0
- package/scripts/lib/event-chain.cjs +177 -0
- package/scripts/lib/event-stream/index.ts +20 -0
- package/scripts/lib/event-stream/reader.ts +139 -0
- package/scripts/lib/event-stream/types.ts +155 -1
- package/scripts/lib/event-stream/writer.ts +65 -8
- package/scripts/lib/redact.cjs +122 -0
- package/scripts/lib/trajectory/index.cjs +126 -0
- package/scripts/lib/transports/ws.cjs +179 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* redact.cjs — secret scrubbing for event-stream payloads (Plan 22-02).
|
|
3
|
+
*
|
|
4
|
+
* Deep-walks a value, replacing any string that matches a known secret
|
|
5
|
+
* pattern with a `[REDACTED:<type>]` placeholder. Non-mutating — returns
|
|
6
|
+
* a new structure; the input is not modified.
|
|
7
|
+
*
|
|
8
|
+
* Called from `event-stream/writer.ts` at serialize time so every event
|
|
9
|
+
* that hits disk (and every bus subscriber) sees the redacted form.
|
|
10
|
+
*
|
|
11
|
+
* Patterns are intentionally conservative: false-positives on redaction
|
|
12
|
+
* are low-cost (logged telemetry becomes slightly harder to read); false-
|
|
13
|
+
* negatives (real secrets leaking) are high-cost. When in doubt, match.
|
|
14
|
+
*
|
|
15
|
+
* Adding a pattern: append an entry to `PATTERNS` with a stable `type`
|
|
16
|
+
* key. The type string is the label emitted into the placeholder.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
'use strict';
|
|
20
|
+
|
|
21
|
+
/** @type {Array<{type: string, re: RegExp}>} */
|
|
22
|
+
const PATTERNS = [
|
|
23
|
+
// PEM first — must redact before generic base64 patterns would hit.
|
|
24
|
+
{
|
|
25
|
+
type: 'pem',
|
|
26
|
+
re: /-----BEGIN [A-Z ]+-----[\s\S]+?-----END [A-Z ]+-----/g,
|
|
27
|
+
},
|
|
28
|
+
// JWT — 3 dot-separated base64url segments, beginning with eyJ.
|
|
29
|
+
{
|
|
30
|
+
type: 'jwt',
|
|
31
|
+
re: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g,
|
|
32
|
+
},
|
|
33
|
+
// Anthropic API keys (sk-ant-…) — matched before generic sk- to win.
|
|
34
|
+
{
|
|
35
|
+
type: 'anthropic',
|
|
36
|
+
re: /\bsk-ant-[A-Za-z0-9_-]{20,}\b/g,
|
|
37
|
+
},
|
|
38
|
+
// Stripe live secret key.
|
|
39
|
+
{
|
|
40
|
+
type: 'stripe',
|
|
41
|
+
re: /\bsk_live_[A-Za-z0-9]{20,}\b/g,
|
|
42
|
+
},
|
|
43
|
+
// Slack tokens — xoxb/xoxp/xoxa/xoxr/xoxs.
|
|
44
|
+
{
|
|
45
|
+
type: 'slack',
|
|
46
|
+
re: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g,
|
|
47
|
+
},
|
|
48
|
+
// GitHub personal access token.
|
|
49
|
+
{
|
|
50
|
+
type: 'github_pat',
|
|
51
|
+
re: /\bghp_[A-Za-z0-9]{36,}\b/g,
|
|
52
|
+
},
|
|
53
|
+
// AWS access key ID.
|
|
54
|
+
{
|
|
55
|
+
type: 'aws',
|
|
56
|
+
re: /\bAKIA[0-9A-Z]{16}\b/g,
|
|
57
|
+
},
|
|
58
|
+
// Generic OpenAI-style sk-… (last in the sk-* family — lower priority
|
|
59
|
+
// than anthropic/stripe which start with `sk_live_`/`sk-ant-`).
|
|
60
|
+
{
|
|
61
|
+
type: 'sk',
|
|
62
|
+
re: /\bsk-[A-Za-z0-9_-]{20,}\b/g,
|
|
63
|
+
},
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Redact every secret-shaped substring in `s`, returning the scrubbed
|
|
68
|
+
* string. Patterns are applied in `PATTERNS` order — more-specific
|
|
69
|
+
* patterns (anthropic, stripe) first so they win over the generic
|
|
70
|
+
* `sk-` catch-all.
|
|
71
|
+
*
|
|
72
|
+
* @param {string} s
|
|
73
|
+
* @returns {string}
|
|
74
|
+
*/
|
|
75
|
+
function redactString(s) {
|
|
76
|
+
if (typeof s !== 'string' || s.length < 10) return s;
|
|
77
|
+
let out = s;
|
|
78
|
+
for (const { type, re } of PATTERNS) {
|
|
79
|
+
re.lastIndex = 0; // safety: `g` flag carries state across calls
|
|
80
|
+
out = out.replace(re, `[REDACTED:${type}]`);
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Deep-walk `value`, redacting every string encountered. Arrays and
|
|
87
|
+
* plain objects recurse; everything else returns unchanged.
|
|
88
|
+
*
|
|
89
|
+
* Cycle-safe via a WeakSet.
|
|
90
|
+
*
|
|
91
|
+
* @param {unknown} value
|
|
92
|
+
* @param {WeakSet<object>} [seen]
|
|
93
|
+
* @returns {unknown}
|
|
94
|
+
*/
|
|
95
|
+
function redact(value, seen) {
|
|
96
|
+
if (value === null || value === undefined) return value;
|
|
97
|
+
if (typeof value === 'string') return redactString(value);
|
|
98
|
+
if (typeof value !== 'object') return value;
|
|
99
|
+
|
|
100
|
+
const visited = seen ?? new WeakSet();
|
|
101
|
+
if (visited.has(/** @type {object} */ (value))) return value;
|
|
102
|
+
visited.add(/** @type {object} */ (value));
|
|
103
|
+
|
|
104
|
+
if (Array.isArray(value)) {
|
|
105
|
+
return value.map((v) => redact(v, visited));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Plain object. Don't try to preserve class instances — event payloads
|
|
109
|
+
// are expected to be JSON-shaped bags.
|
|
110
|
+
/** @type {Record<string, unknown>} */
|
|
111
|
+
const out = {};
|
|
112
|
+
for (const key of Object.keys(/** @type {object} */ (value))) {
|
|
113
|
+
out[key] = redact(/** @type {Record<string, unknown>} */ (value)[key], visited);
|
|
114
|
+
}
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
module.exports = {
|
|
119
|
+
redact,
|
|
120
|
+
redactString,
|
|
121
|
+
PATTERNS,
|
|
122
|
+
};
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* trajectory/index.cjs — per-tool-call trajectory stream (Plan 22-03).
|
|
3
|
+
*
|
|
4
|
+
* Records every agent tool-use as one JSONL line at
|
|
5
|
+
* `.design/telemetry/trajectories/<cycle>.jsonl`
|
|
6
|
+
*
|
|
7
|
+
* Why hash args/result instead of storing full content:
|
|
8
|
+
* * keeps line size bounded regardless of argument payload
|
|
9
|
+
* * de-identifies prompts that may contain user-private content
|
|
10
|
+
* * still allows replay via dedup-by-hash if a future analyzer wants it
|
|
11
|
+
*
|
|
12
|
+
* Schema (one JSONL line):
|
|
13
|
+
* {
|
|
14
|
+
* ts: ISO-8601 with ms,
|
|
15
|
+
* session_id: string | null,
|
|
16
|
+
* cycle: string, // 'current' if not supplied
|
|
17
|
+
* agent: string, // calling agent name
|
|
18
|
+
* tool: string, // 'Bash' / 'Edit' / 'mcp__…'
|
|
19
|
+
* args_hash: 16-char sha256 prefix of canonical-JSON args
|
|
20
|
+
* result_hash: 16-char sha256 prefix of canonical-JSON result
|
|
21
|
+
* latency_ms: number,
|
|
22
|
+
* status: 'ok' | 'error',
|
|
23
|
+
* }
|
|
24
|
+
*
|
|
25
|
+
* Side effects:
|
|
26
|
+
* * appendFileSync to the trajectory file (atomic per line on POSIX/NT)
|
|
27
|
+
* * NEVER throws — IO failure logs to stderr and returns silently
|
|
28
|
+
* * Optionally appends a `tool_call.completed` event to the
|
|
29
|
+
* event-stream so live subscribers can see the same call without
|
|
30
|
+
* scanning trajectory files. Skipped if `event_stream` arg is null.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
'use strict';
|
|
34
|
+
|
|
35
|
+
const { appendFileSync, mkdirSync } = require('node:fs');
|
|
36
|
+
const { dirname, isAbsolute, join, resolve } = require('node:path');
|
|
37
|
+
const { createHash } = require('node:crypto');
|
|
38
|
+
|
|
39
|
+
const DEFAULT_TRAJECTORY_DIR = '.design/telemetry/trajectories';
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Compute a stable 16-char sha256-hex prefix for arbitrary JSON-shaped
|
|
43
|
+
* input. Falls back to `'0'.repeat(16)` if `JSON.stringify` throws.
|
|
44
|
+
*
|
|
45
|
+
* @param {unknown} value
|
|
46
|
+
* @returns {string}
|
|
47
|
+
*/
|
|
48
|
+
function hashOf(value) {
|
|
49
|
+
let serialized;
|
|
50
|
+
try {
|
|
51
|
+
serialized = JSON.stringify(value ?? null);
|
|
52
|
+
} catch {
|
|
53
|
+
return '0'.repeat(16);
|
|
54
|
+
}
|
|
55
|
+
return createHash('sha256').update(serialized ?? '').digest('hex').slice(0, 16);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Resolve the on-disk trajectory file for `cycle` against `baseDir`.
|
|
60
|
+
*
|
|
61
|
+
* @param {{baseDir?: string, cycle?: string, dir?: string}} [opts]
|
|
62
|
+
* @returns {string}
|
|
63
|
+
*/
|
|
64
|
+
function trajectoryPath(opts = {}) {
|
|
65
|
+
const baseDir = opts.baseDir ?? process.cwd();
|
|
66
|
+
const dir = opts.dir ?? DEFAULT_TRAJECTORY_DIR;
|
|
67
|
+
const cycle = (opts.cycle ?? 'current').replace(/[^A-Za-z0-9._-]/g, '_');
|
|
68
|
+
const resolvedDir = isAbsolute(dir) ? dir : resolve(baseDir, dir);
|
|
69
|
+
return join(resolvedDir, `${cycle}.jsonl`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Append one trajectory record. Returns the recorded line for tests
|
|
74
|
+
* that want to assert on shape without re-reading the file.
|
|
75
|
+
*
|
|
76
|
+
* @param {{
|
|
77
|
+
* cycle?: string,
|
|
78
|
+
* session_id?: string | null,
|
|
79
|
+
* agent: string,
|
|
80
|
+
* tool: string,
|
|
81
|
+
* args?: unknown,
|
|
82
|
+
* result?: unknown,
|
|
83
|
+
* latency_ms?: number,
|
|
84
|
+
* status?: 'ok' | 'error',
|
|
85
|
+
* baseDir?: string,
|
|
86
|
+
* path?: string,
|
|
87
|
+
* }} call
|
|
88
|
+
* @returns {string} the JSONL line that was appended (without trailing \n)
|
|
89
|
+
*/
|
|
90
|
+
function recordCall(call) {
|
|
91
|
+
const ts = new Date().toISOString();
|
|
92
|
+
const record = {
|
|
93
|
+
ts,
|
|
94
|
+
session_id: call.session_id ?? null,
|
|
95
|
+
cycle: call.cycle ?? 'current',
|
|
96
|
+
agent: call.agent,
|
|
97
|
+
tool: call.tool,
|
|
98
|
+
args_hash: hashOf(call.args),
|
|
99
|
+
result_hash: hashOf(call.result),
|
|
100
|
+
latency_ms: typeof call.latency_ms === 'number' ? call.latency_ms : 0,
|
|
101
|
+
status: call.status ?? 'ok',
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const path = call.path ?? trajectoryPath({ baseDir: call.baseDir, cycle: record.cycle });
|
|
105
|
+
const line = JSON.stringify(record);
|
|
106
|
+
try {
|
|
107
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
108
|
+
appendFileSync(path, line + '\n', { flag: 'a' });
|
|
109
|
+
} catch (err) {
|
|
110
|
+
try {
|
|
111
|
+
process.stderr.write(
|
|
112
|
+
`[trajectory] write failed: ${err && err.message ? err.message : String(err)}\n`,
|
|
113
|
+
);
|
|
114
|
+
} catch {
|
|
115
|
+
/* swallow */
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return line;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
module.exports = {
|
|
122
|
+
recordCall,
|
|
123
|
+
trajectoryPath,
|
|
124
|
+
hashOf,
|
|
125
|
+
DEFAULT_TRAJECTORY_DIR,
|
|
126
|
+
};
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* transports/ws.cjs — WebSocket event-stream transport (Plan 22-07).
|
|
3
|
+
*
|
|
4
|
+
* Optional dep: requires `ws`. probeOptional() returns null if absent;
|
|
5
|
+
* importer renders a clear install hint.
|
|
6
|
+
*
|
|
7
|
+
* Wire format:
|
|
8
|
+
* * One event per WebSocket text frame, JSON-encoded.
|
|
9
|
+
* * If `tailFrom` is supplied at startup, replay that file's contents
|
|
10
|
+
* to each new connection BEFORE subscribing to live events.
|
|
11
|
+
* * Live events come from a caller-supplied `subscribe(handler) →
|
|
12
|
+
* unsub` — typically the event-stream bus's subscribeAll. Decoupling
|
|
13
|
+
* keeps this CommonJS module independent of the TS bus implementation.
|
|
14
|
+
*
|
|
15
|
+
* Auth:
|
|
16
|
+
* * `Authorization: Bearer <token>` header required on the upgrade.
|
|
17
|
+
* * Mismatched / missing token → HTTP 401 close on the upgrade socket.
|
|
18
|
+
*
|
|
19
|
+
* Backpressure:
|
|
20
|
+
* * Fire-and-forget. If a client's socket is not in OPEN state we drop
|
|
21
|
+
* the event for that client and log a warning. No queue.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
'use strict';
|
|
25
|
+
|
|
26
|
+
const http = require('node:http');
|
|
27
|
+
const { readFileSync, existsSync } = require('node:fs');
|
|
28
|
+
const { probeOptional } = require('../probe-optional.cjs');
|
|
29
|
+
|
|
30
|
+
const ws = probeOptional('ws');
|
|
31
|
+
if (!ws) {
|
|
32
|
+
// Importer (gdd-events.mjs) handles this throw and renders the hint.
|
|
33
|
+
throw new Error(
|
|
34
|
+
"ws module not installed (optional dep). Install via: npm i -D ws",
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
const { WebSocketServer } = ws;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Synchronously read a JSONL events file and yield parsed objects.
|
|
41
|
+
* Matches reader.ts line semantics: skip blank lines + invalid JSON.
|
|
42
|
+
*
|
|
43
|
+
* @param {string} path
|
|
44
|
+
* @returns {Generator<Record<string, unknown>>}
|
|
45
|
+
*/
|
|
46
|
+
function* readEventsSync(path) {
|
|
47
|
+
if (!existsSync(path)) return;
|
|
48
|
+
const raw = readFileSync(path, 'utf8');
|
|
49
|
+
for (const line of raw.split('\n')) {
|
|
50
|
+
if (line.trim() === '') continue;
|
|
51
|
+
try {
|
|
52
|
+
yield JSON.parse(line);
|
|
53
|
+
} catch {
|
|
54
|
+
/* skip invalid */
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Start the WebSocket server. Returns a handle with `close()`.
|
|
61
|
+
*
|
|
62
|
+
* @param {{
|
|
63
|
+
* port: number,
|
|
64
|
+
* token: string,
|
|
65
|
+
* tailFrom?: string,
|
|
66
|
+
* subscribe?: (handler: (ev: unknown) => void) => () => void,
|
|
67
|
+
* }} opts
|
|
68
|
+
* @returns {Promise<{close: () => void, port: number}>}
|
|
69
|
+
*/
|
|
70
|
+
async function startServer(opts) {
|
|
71
|
+
if (typeof opts.port !== 'number' || !Number.isFinite(opts.port)) {
|
|
72
|
+
throw new TypeError('startServer: port (number) required');
|
|
73
|
+
}
|
|
74
|
+
if (typeof opts.token !== 'string' || opts.token.length < 8) {
|
|
75
|
+
throw new TypeError('startServer: token (string, ≥8 chars) required');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const httpServer = http.createServer((_req, res) => {
|
|
79
|
+
res.statusCode = 426; // Upgrade Required
|
|
80
|
+
res.setHeader('Content-Type', 'text/plain');
|
|
81
|
+
res.end('upgrade required');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
85
|
+
|
|
86
|
+
/** @type {Set<import('ws').WebSocket>} */
|
|
87
|
+
const clients = new Set();
|
|
88
|
+
|
|
89
|
+
/** @type {() => void} */
|
|
90
|
+
let unsub = () => {};
|
|
91
|
+
if (typeof opts.subscribe === 'function') {
|
|
92
|
+
unsub = opts.subscribe((ev) => {
|
|
93
|
+
const frame = JSON.stringify(ev);
|
|
94
|
+
for (const client of clients) {
|
|
95
|
+
if (client.readyState === ws.OPEN) {
|
|
96
|
+
try {
|
|
97
|
+
client.send(frame);
|
|
98
|
+
} catch (err) {
|
|
99
|
+
try {
|
|
100
|
+
process.stderr.write(`[ws] send failed: ${err.message}\n`);
|
|
101
|
+
} catch {
|
|
102
|
+
/* swallow */
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
httpServer.on('upgrade', (req, socket, head) => {
|
|
111
|
+
const auth = req.headers['authorization'];
|
|
112
|
+
if (!auth || auth !== `Bearer ${opts.token}`) {
|
|
113
|
+
socket.write('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n');
|
|
114
|
+
socket.destroy();
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
wss.handleUpgrade(req, socket, head, (client) => {
|
|
118
|
+
clients.add(client);
|
|
119
|
+
|
|
120
|
+
if (opts.tailFrom) {
|
|
121
|
+
try {
|
|
122
|
+
for (const ev of readEventsSync(opts.tailFrom)) {
|
|
123
|
+
try {
|
|
124
|
+
client.send(JSON.stringify(ev));
|
|
125
|
+
} catch {
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
} catch (err) {
|
|
130
|
+
try {
|
|
131
|
+
process.stderr.write(`[ws] replay failed: ${err.message}\n`);
|
|
132
|
+
} catch {
|
|
133
|
+
/* swallow */
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
client.on('close', () => clients.delete(client));
|
|
139
|
+
client.on('error', () => clients.delete(client));
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
await new Promise((resolve, reject) => {
|
|
144
|
+
httpServer.once('error', reject);
|
|
145
|
+
httpServer.listen(opts.port, () => resolve(undefined));
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
const addr = httpServer.address();
|
|
149
|
+
return {
|
|
150
|
+
port: typeof addr === 'object' && addr ? addr.port : opts.port,
|
|
151
|
+
close() {
|
|
152
|
+
try {
|
|
153
|
+
unsub();
|
|
154
|
+
} catch {
|
|
155
|
+
/* swallow */
|
|
156
|
+
}
|
|
157
|
+
for (const c of clients) {
|
|
158
|
+
try {
|
|
159
|
+
c.close();
|
|
160
|
+
} catch {
|
|
161
|
+
/* swallow */
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
clients.clear();
|
|
165
|
+
try {
|
|
166
|
+
wss.close();
|
|
167
|
+
} catch {
|
|
168
|
+
/* swallow */
|
|
169
|
+
}
|
|
170
|
+
try {
|
|
171
|
+
httpServer.close();
|
|
172
|
+
} catch {
|
|
173
|
+
/* swallow */
|
|
174
|
+
}
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
module.exports = { startServer, readEventsSync };
|