@hegemonart/get-design-done 1.21.0 → 1.23.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 +184 -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 +7 -2
- package/reference/output-contracts/planner-decision.schema.json +94 -0
- package/reference/output-contracts/verifier-decision.schema.json +66 -0
- package/scripts/cli/gdd-events.mjs +283 -0
- package/scripts/lib/audit-aggregator/index.cjs +219 -0
- package/scripts/lib/connection-probe/index.cjs +263 -0
- package/scripts/lib/design-solidify.mjs +265 -0
- package/scripts/lib/design-tokens/_js-harness.cjs +66 -0
- package/scripts/lib/design-tokens/css-vars.cjs +55 -0
- package/scripts/lib/design-tokens/figma.cjs +121 -0
- package/scripts/lib/design-tokens/index.cjs +100 -0
- package/scripts/lib/design-tokens/js-const.cjs +107 -0
- package/scripts/lib/design-tokens/tailwind.cjs +98 -0
- package/scripts/lib/domain-primitives/anti-patterns.cjs +66 -0
- package/scripts/lib/domain-primitives/nng.cjs +136 -0
- package/scripts/lib/domain-primitives/wcag.cjs +166 -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/parse-contract.cjs +168 -0
- package/scripts/lib/redact.cjs +122 -0
- package/scripts/lib/reference-resolver.cjs +184 -0
- package/scripts/lib/touches-analyzer/index.cjs +201 -0
- package/scripts/lib/touches-pattern-miner.cjs +195 -0
- package/scripts/lib/trajectory/index.cjs +126 -0
- package/scripts/lib/transports/ws.cjs +179 -0
- package/scripts/lib/visual-baseline/diff.cjs +137 -0
- package/scripts/lib/visual-baseline/index.cjs +139 -0
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* touches-analyzer/index.cjs — parse `Touches:` lines from task markdown
|
|
3
|
+
* and produce a pairwise parallelism verdict (Plan 23-03).
|
|
4
|
+
*
|
|
5
|
+
* Encodes the prompt-only heuristic from `reference/parallelism-rules.md`
|
|
6
|
+
* into auditable code. Used by /gdd:plan and /gdd:execute to decide
|
|
7
|
+
* which tasks can run concurrently in a wave.
|
|
8
|
+
*
|
|
9
|
+
* Verdict rules (first match wins):
|
|
10
|
+
* 1. empty globs → sequential, 'unknown-touches'
|
|
11
|
+
* 2. literal glob equality → sequential, 'shared-glob'
|
|
12
|
+
* 3. shared component dir → sequential, 'shared-component-dir'
|
|
13
|
+
* 4. resolved-file overlap → sequential, 'shared-file'
|
|
14
|
+
* 5. otherwise → parallel, 'disjoint'
|
|
15
|
+
*
|
|
16
|
+
* No external deps. Designed to be required from CommonJS callers.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
'use strict';
|
|
20
|
+
|
|
21
|
+
const { readFileSync } = require('node:fs');
|
|
22
|
+
const path = require('node:path');
|
|
23
|
+
|
|
24
|
+
const TOUCHES_RE = /^[ \t]{0,4}Touches:\s*(.+?)\s*$/gm;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Normalise a glob/path: convert `\\` → `/`, lowercase for case-insensitive
|
|
28
|
+
* comparison. Returned strings are used as map keys.
|
|
29
|
+
*
|
|
30
|
+
* @param {string} g
|
|
31
|
+
* @returns {string}
|
|
32
|
+
*/
|
|
33
|
+
function normalize(g) {
|
|
34
|
+
return g.replace(/\\/g, '/').toLowerCase();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Extract `Touches:` lines from markdown.
|
|
39
|
+
*
|
|
40
|
+
* @param {string} markdown
|
|
41
|
+
* @returns {string[]} globs in declaration order, deduped (case-insensitive)
|
|
42
|
+
*/
|
|
43
|
+
function parseTouches(markdown) {
|
|
44
|
+
if (typeof markdown !== 'string' || markdown.length === 0) return [];
|
|
45
|
+
const out = [];
|
|
46
|
+
const seen = new Set();
|
|
47
|
+
TOUCHES_RE.lastIndex = 0;
|
|
48
|
+
let m;
|
|
49
|
+
while ((m = TOUCHES_RE.exec(markdown)) !== null) {
|
|
50
|
+
const body = m[1];
|
|
51
|
+
for (const raw of body.split(',')) {
|
|
52
|
+
const trimmed = raw.trim();
|
|
53
|
+
if (trimmed.length === 0) continue;
|
|
54
|
+
const key = normalize(trimmed);
|
|
55
|
+
if (seen.has(key)) continue;
|
|
56
|
+
seen.add(key);
|
|
57
|
+
out.push(trimmed);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Parse a task markdown file by path.
|
|
65
|
+
*
|
|
66
|
+
* @param {string} filePath
|
|
67
|
+
* @returns {{taskId: string, globs: string[]}}
|
|
68
|
+
*/
|
|
69
|
+
function parseTouchesFile(filePath) {
|
|
70
|
+
const md = readFileSync(filePath, 'utf8');
|
|
71
|
+
const base = path.basename(filePath).replace(/\.md$/i, '');
|
|
72
|
+
return { taskId: base, globs: parseTouches(md) };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Compute the directory prefix for a glob at `componentDepth - 1` segments.
|
|
77
|
+
* Returns null when the glob's first segment is `**` or contains `..` (no
|
|
78
|
+
* meaningful prefix).
|
|
79
|
+
*
|
|
80
|
+
* @param {string} glob
|
|
81
|
+
* @param {number} componentDepth
|
|
82
|
+
* @returns {string|null}
|
|
83
|
+
*/
|
|
84
|
+
function componentDirPrefix(glob, componentDepth) {
|
|
85
|
+
const norm = glob.replace(/\\/g, '/');
|
|
86
|
+
if (norm.startsWith('..') || norm.startsWith('**')) return null;
|
|
87
|
+
// Strip leading './'.
|
|
88
|
+
const cleaned = norm.startsWith('./') ? norm.slice(2) : norm;
|
|
89
|
+
const segments = cleaned.split('/');
|
|
90
|
+
const wanted = Math.max(0, componentDepth - 1);
|
|
91
|
+
if (segments.length < wanted) return null;
|
|
92
|
+
const prefixSegs = segments.slice(0, wanted);
|
|
93
|
+
// The prefix must contain at least one *literal* (no `**`) segment.
|
|
94
|
+
const hasLiteral = prefixSegs.some((s) => s.length > 0 && s !== '**');
|
|
95
|
+
if (!hasLiteral) return null;
|
|
96
|
+
return prefixSegs.join('/').toLowerCase();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* @typedef {Object} TouchesEntry
|
|
101
|
+
* @property {string} taskId
|
|
102
|
+
* @property {string[]} globs
|
|
103
|
+
* @property {string[]} [resolved]
|
|
104
|
+
*/
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* @typedef {Object} Verdict
|
|
108
|
+
* @property {'parallel'|'sequential'} verdict
|
|
109
|
+
* @property {string} reason
|
|
110
|
+
* @property {string[]} [evidence]
|
|
111
|
+
*/
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Pairwise verdict.
|
|
115
|
+
*
|
|
116
|
+
* @param {TouchesEntry} a
|
|
117
|
+
* @param {TouchesEntry} b
|
|
118
|
+
* @param {{componentDepth?: number}} [opts]
|
|
119
|
+
* @returns {Verdict}
|
|
120
|
+
*/
|
|
121
|
+
function pairwiseVerdict(a, b, opts = {}) {
|
|
122
|
+
const componentDepth = opts.componentDepth ?? 3;
|
|
123
|
+
if (!a || !b || !Array.isArray(a.globs) || !Array.isArray(b.globs)) {
|
|
124
|
+
return { verdict: 'sequential', reason: 'unknown-touches' };
|
|
125
|
+
}
|
|
126
|
+
if (a.globs.length === 0 || b.globs.length === 0) {
|
|
127
|
+
return { verdict: 'sequential', reason: 'unknown-touches' };
|
|
128
|
+
}
|
|
129
|
+
// Rule 2: literal glob equality (case-insensitive).
|
|
130
|
+
const aSet = new Set(a.globs.map(normalize));
|
|
131
|
+
for (const bg of b.globs) {
|
|
132
|
+
if (aSet.has(normalize(bg))) {
|
|
133
|
+
return { verdict: 'sequential', reason: 'shared-glob', evidence: [bg] };
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
// Rule 3: shared component directory.
|
|
137
|
+
const aPrefixes = new Set(
|
|
138
|
+
a.globs.map((g) => componentDirPrefix(g, componentDepth)).filter((p) => p !== null),
|
|
139
|
+
);
|
|
140
|
+
const sharedPrefixes = [];
|
|
141
|
+
for (const bg of b.globs) {
|
|
142
|
+
const pfx = componentDirPrefix(bg, componentDepth);
|
|
143
|
+
if (pfx !== null && aPrefixes.has(pfx)) sharedPrefixes.push(pfx);
|
|
144
|
+
}
|
|
145
|
+
if (sharedPrefixes.length > 0) {
|
|
146
|
+
return {
|
|
147
|
+
verdict: 'sequential',
|
|
148
|
+
reason: 'shared-component-dir',
|
|
149
|
+
evidence: Array.from(new Set(sharedPrefixes)),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
// Rule 4: resolved file intersection.
|
|
153
|
+
if (Array.isArray(a.resolved) && Array.isArray(b.resolved)) {
|
|
154
|
+
const aFiles = new Set(a.resolved.map(normalize));
|
|
155
|
+
const overlap = [];
|
|
156
|
+
for (const bf of b.resolved) {
|
|
157
|
+
if (aFiles.has(normalize(bf))) overlap.push(bf);
|
|
158
|
+
}
|
|
159
|
+
if (overlap.length > 0) {
|
|
160
|
+
return { verdict: 'sequential', reason: 'shared-file', evidence: overlap };
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return { verdict: 'parallel', reason: 'disjoint' };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Build the upper-triangular N×N verdict table.
|
|
168
|
+
*
|
|
169
|
+
* @param {TouchesEntry[]} entries
|
|
170
|
+
* @param {{componentDepth?: number}} [opts]
|
|
171
|
+
* @returns {Array<{a: string, b: string, verdict: string, reason: string, evidence?: string[]}>}
|
|
172
|
+
*/
|
|
173
|
+
function verdictMatrix(entries, opts = {}) {
|
|
174
|
+
if (!Array.isArray(entries)) {
|
|
175
|
+
throw new TypeError('verdictMatrix: entries must be an array');
|
|
176
|
+
}
|
|
177
|
+
const out = [];
|
|
178
|
+
for (let i = 0; i < entries.length; i++) {
|
|
179
|
+
for (let j = i + 1; j < entries.length; j++) {
|
|
180
|
+
const v = pairwiseVerdict(entries[i], entries[j], opts);
|
|
181
|
+
const row = {
|
|
182
|
+
a: entries[i].taskId,
|
|
183
|
+
b: entries[j].taskId,
|
|
184
|
+
verdict: v.verdict,
|
|
185
|
+
reason: v.reason,
|
|
186
|
+
};
|
|
187
|
+
if (v.evidence) row.evidence = v.evidence;
|
|
188
|
+
out.push(row);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return out;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
module.exports = {
|
|
195
|
+
parseTouches,
|
|
196
|
+
parseTouchesFile,
|
|
197
|
+
pairwiseVerdict,
|
|
198
|
+
verdictMatrix,
|
|
199
|
+
componentDirPrefix,
|
|
200
|
+
normalize,
|
|
201
|
+
};
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* touches-pattern-miner.cjs — auto-crystallization PROPOSALS only
|
|
3
|
+
* (Plan 23-06).
|
|
4
|
+
*
|
|
5
|
+
* Scans archived task markdown across cycles, normalizes their
|
|
6
|
+
* `Touches:` signatures, and emits a JSON proposal file when a
|
|
7
|
+
* signature recurs in ≥ minTasks tasks across ≥ minCycles cycles.
|
|
8
|
+
*
|
|
9
|
+
* NEVER auto-applies. The reflector + `/gdd:apply-reflections`
|
|
10
|
+
* pipeline consumes the proposal JSON separately and asks the user
|
|
11
|
+
* before materializing anything.
|
|
12
|
+
*
|
|
13
|
+
* Reads: cwd/cycleDir/cycle-{slug}/tasks/{name}.md
|
|
14
|
+
* (cycleDir defaults to .design/archive)
|
|
15
|
+
* Writes: cwd/.design/learnings/touches-patterns.json
|
|
16
|
+
* (atomic via .tmp sibling + rename)
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
'use strict';
|
|
20
|
+
|
|
21
|
+
const fs = require('node:fs');
|
|
22
|
+
const path = require('node:path');
|
|
23
|
+
|
|
24
|
+
const { parseTouches } = require('./touches-analyzer/index.cjs');
|
|
25
|
+
|
|
26
|
+
const DEFAULT_CYCLE_DIR = '.design/archive';
|
|
27
|
+
const DEFAULT_OUT_PATH = '.design/learnings/touches-patterns.json';
|
|
28
|
+
const DEFAULT_MIN_TASKS = 3;
|
|
29
|
+
const DEFAULT_MIN_CYCLES = 2;
|
|
30
|
+
|
|
31
|
+
const CYCLE_DATED_RE = /^cycle-\d{4}-\d{2}-\d{2}.*/i;
|
|
32
|
+
const CYCLE_SLUG_RE = /^cycle-[a-z0-9-]+$/i;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Canonicalize a glob list into a stable signature string.
|
|
36
|
+
*
|
|
37
|
+
* @param {string[]} globs
|
|
38
|
+
* @returns {string}
|
|
39
|
+
*/
|
|
40
|
+
function canonicalize(globs) {
|
|
41
|
+
if (!Array.isArray(globs) || globs.length === 0) return '';
|
|
42
|
+
const norm = globs
|
|
43
|
+
.map((g) => stripCycleSlugs(String(g).replace(/\\/g, '/').toLowerCase()))
|
|
44
|
+
.filter((g) => g.length > 0);
|
|
45
|
+
const dedup = Array.from(new Set(norm));
|
|
46
|
+
dedup.sort();
|
|
47
|
+
return dedup.join(',');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Replace `cycle-2026-04-01` / `cycle-foo-bar` segments with `<cycle>`.
|
|
52
|
+
*
|
|
53
|
+
* @param {string} normalizedPath
|
|
54
|
+
* @returns {string}
|
|
55
|
+
*/
|
|
56
|
+
function stripCycleSlugs(normalizedPath) {
|
|
57
|
+
return normalizedPath
|
|
58
|
+
.split('/')
|
|
59
|
+
.map((seg) => (CYCLE_DATED_RE.test(seg) || CYCLE_SLUG_RE.test(seg) ? '<cycle>' : seg))
|
|
60
|
+
.join('/');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* @typedef {Object} TouchesSignature
|
|
65
|
+
* @property {string} signature
|
|
66
|
+
* @property {string[]} globs
|
|
67
|
+
* @property {Array<{cycle: string, task: string}>} occurrences
|
|
68
|
+
* @property {number} cycleCount
|
|
69
|
+
* @property {number} taskCount
|
|
70
|
+
*/
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* @typedef {Object} MinerProposal
|
|
74
|
+
* @property {string} schema_version
|
|
75
|
+
* @property {string} generated_at
|
|
76
|
+
* @property {{minTasks: number, minCycles: number}} thresholds
|
|
77
|
+
* @property {TouchesSignature[]} proposals
|
|
78
|
+
*/
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Walk archived cycles and tally signature occurrences.
|
|
82
|
+
*
|
|
83
|
+
* @param {{cwd?: string, cycleDir?: string, minTasks?: number, minCycles?: number}} [opts]
|
|
84
|
+
* @returns {Promise<MinerProposal>}
|
|
85
|
+
*/
|
|
86
|
+
async function mine(opts = {}) {
|
|
87
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
88
|
+
const cycleDir = opts.cycleDir ?? DEFAULT_CYCLE_DIR;
|
|
89
|
+
const minTasks = opts.minTasks ?? DEFAULT_MIN_TASKS;
|
|
90
|
+
const minCycles = opts.minCycles ?? DEFAULT_MIN_CYCLES;
|
|
91
|
+
|
|
92
|
+
const archiveRoot = path.isAbsolute(cycleDir) ? cycleDir : path.join(cwd, cycleDir);
|
|
93
|
+
/** @type {Map<string, {globs: string[], occurrences: Array<{cycle: string, task: string}>, cycleSet: Set<string>}>} */
|
|
94
|
+
const byKey = new Map();
|
|
95
|
+
|
|
96
|
+
let entries = [];
|
|
97
|
+
try {
|
|
98
|
+
entries = fs.readdirSync(archiveRoot, { withFileTypes: true });
|
|
99
|
+
} catch {
|
|
100
|
+
// Archive dir missing → empty proposal envelope.
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
for (const ent of entries) {
|
|
104
|
+
if (!ent.isDirectory()) continue;
|
|
105
|
+
if (!ent.name.toLowerCase().startsWith('cycle-')) continue;
|
|
106
|
+
const cycleId = ent.name;
|
|
107
|
+
const tasksDir = path.join(archiveRoot, cycleId, 'tasks');
|
|
108
|
+
let taskFiles = [];
|
|
109
|
+
try {
|
|
110
|
+
taskFiles = fs
|
|
111
|
+
.readdirSync(tasksDir, { withFileTypes: true })
|
|
112
|
+
.filter((d) => d.isFile() && d.name.toLowerCase().endsWith('.md'))
|
|
113
|
+
.map((d) => d.name);
|
|
114
|
+
} catch {
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
for (const taskName of taskFiles) {
|
|
118
|
+
const taskPath = path.join(tasksDir, taskName);
|
|
119
|
+
let md;
|
|
120
|
+
try {
|
|
121
|
+
md = fs.readFileSync(taskPath, 'utf8');
|
|
122
|
+
} catch {
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
const globs = parseTouches(md);
|
|
126
|
+
if (globs.length === 0) continue;
|
|
127
|
+
const sig = canonicalize(globs);
|
|
128
|
+
if (sig.length === 0) continue;
|
|
129
|
+
let bucket = byKey.get(sig);
|
|
130
|
+
if (!bucket) {
|
|
131
|
+
bucket = {
|
|
132
|
+
globs: sig.split(','),
|
|
133
|
+
occurrences: [],
|
|
134
|
+
cycleSet: new Set(),
|
|
135
|
+
};
|
|
136
|
+
byKey.set(sig, bucket);
|
|
137
|
+
}
|
|
138
|
+
bucket.occurrences.push({ cycle: cycleId, task: taskName });
|
|
139
|
+
bucket.cycleSet.add(cycleId);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** @type {TouchesSignature[]} */
|
|
144
|
+
const proposals = [];
|
|
145
|
+
for (const [signature, bucket] of byKey) {
|
|
146
|
+
if (bucket.occurrences.length < minTasks) continue;
|
|
147
|
+
if (bucket.cycleSet.size < minCycles) continue;
|
|
148
|
+
proposals.push({
|
|
149
|
+
signature,
|
|
150
|
+
globs: bucket.globs,
|
|
151
|
+
occurrences: bucket.occurrences.slice(),
|
|
152
|
+
cycleCount: bucket.cycleSet.size,
|
|
153
|
+
taskCount: bucket.occurrences.length,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
proposals.sort((a, b) => {
|
|
157
|
+
if (a.taskCount !== b.taskCount) return b.taskCount - a.taskCount;
|
|
158
|
+
return a.signature < b.signature ? -1 : a.signature > b.signature ? 1 : 0;
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
schema_version: '1.0.0',
|
|
163
|
+
generated_at: new Date().toISOString(),
|
|
164
|
+
thresholds: { minTasks, minCycles },
|
|
165
|
+
proposals,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Atomic write of the proposal envelope. Returns the absolute path
|
|
171
|
+
* written.
|
|
172
|
+
*
|
|
173
|
+
* @param {MinerProposal} proposal
|
|
174
|
+
* @param {{cwd?: string, outPath?: string}} [opts]
|
|
175
|
+
* @returns {string}
|
|
176
|
+
*/
|
|
177
|
+
function writeProposals(proposal, opts = {}) {
|
|
178
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
179
|
+
const outRel = opts.outPath ?? DEFAULT_OUT_PATH;
|
|
180
|
+
const out = path.isAbsolute(outRel) ? outRel : path.join(cwd, outRel);
|
|
181
|
+
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
182
|
+
const tmp = out + '.tmp';
|
|
183
|
+
fs.writeFileSync(tmp, JSON.stringify(proposal, null, 2));
|
|
184
|
+
fs.renameSync(tmp, out);
|
|
185
|
+
return out;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
module.exports = {
|
|
189
|
+
mine,
|
|
190
|
+
writeProposals,
|
|
191
|
+
canonicalize,
|
|
192
|
+
stripCycleSlugs,
|
|
193
|
+
DEFAULT_CYCLE_DIR,
|
|
194
|
+
DEFAULT_OUT_PATH,
|
|
195
|
+
};
|
|
@@ -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 };
|