@hegemonart/get-design-done 1.22.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.
@@ -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,137 @@
1
+ /**
2
+ * visual-baseline/diff.cjs — pixel-diff primitive (Plan 23-07).
3
+ *
4
+ * Compares two PNG buffers. With `pngjs` installed (probeOptional),
5
+ * decodes both and counts pixels whose R/G/B/A channels differ beyond
6
+ * the tolerance. Without `pngjs`, falls back to bytewise equality
7
+ * (SHA-256 hash compare) — ratio is `equal ? 0 : 1`.
8
+ *
9
+ * Dimension mismatch in pixel mode → ratio=1, drifted=true,
10
+ * reason='dimension-mismatch'. Never throws on shape mismatch.
11
+ */
12
+
13
+ 'use strict';
14
+
15
+ const { createHash } = require('node:crypto');
16
+ const { probeOptional } = require('../probe-optional.cjs');
17
+
18
+ const _pngjs = probeOptional('pngjs');
19
+
20
+ const DEFAULT_THRESHOLD = 0.005;
21
+ const DEFAULT_TOLERANCE = 4;
22
+
23
+ /**
24
+ * @typedef {Object} DiffResult
25
+ * @property {boolean} drifted
26
+ * @property {number} ratio
27
+ * @property {number} diffPixels
28
+ * @property {number} totalPixels
29
+ * @property {'pixel'|'bytewise'} mode
30
+ * @property {string} [reason]
31
+ */
32
+
33
+ function bytewiseDiff(a, b, threshold) {
34
+ const ha = createHash('sha256').update(a).digest('hex');
35
+ const hb = createHash('sha256').update(b).digest('hex');
36
+ const equal = ha === hb;
37
+ const ratio = equal ? 0 : 1;
38
+ return {
39
+ drifted: ratio > threshold,
40
+ ratio,
41
+ diffPixels: equal ? 0 : 1,
42
+ totalPixels: 1,
43
+ mode: 'bytewise',
44
+ reason: 'pngjs-not-available',
45
+ };
46
+ }
47
+
48
+ function pixelDiff(a, b, threshold, tolerance) {
49
+ const { PNG } = _pngjs;
50
+ let pa;
51
+ let pb;
52
+ try {
53
+ pa = PNG.sync.read(a);
54
+ } catch (err) {
55
+ return {
56
+ drifted: true,
57
+ ratio: 1,
58
+ diffPixels: 0,
59
+ totalPixels: 0,
60
+ mode: 'pixel',
61
+ reason: `decode-a-failed: ${err && err.message ? err.message : String(err)}`,
62
+ };
63
+ }
64
+ try {
65
+ pb = PNG.sync.read(b);
66
+ } catch (err) {
67
+ return {
68
+ drifted: true,
69
+ ratio: 1,
70
+ diffPixels: 0,
71
+ totalPixels: 0,
72
+ mode: 'pixel',
73
+ reason: `decode-b-failed: ${err && err.message ? err.message : String(err)}`,
74
+ };
75
+ }
76
+ if (pa.width !== pb.width || pa.height !== pb.height) {
77
+ return {
78
+ drifted: true,
79
+ ratio: 1,
80
+ diffPixels: 0,
81
+ totalPixels: 0,
82
+ mode: 'pixel',
83
+ reason: 'dimension-mismatch',
84
+ };
85
+ }
86
+ const total = pa.width * pa.height;
87
+ const A = pa.data;
88
+ const B = pb.data;
89
+ let diffPx = 0;
90
+ for (let i = 0; i < A.length; i += 4) {
91
+ const dr = Math.abs(A[i] - B[i]);
92
+ const dg = Math.abs(A[i + 1] - B[i + 1]);
93
+ const db = Math.abs(A[i + 2] - B[i + 2]);
94
+ const da = Math.abs(A[i + 3] - B[i + 3]);
95
+ if (dr > tolerance || dg > tolerance || db > tolerance || da > tolerance) {
96
+ diffPx += 1;
97
+ }
98
+ }
99
+ const ratio = total > 0 ? diffPx / total : 0;
100
+ return {
101
+ drifted: ratio > threshold,
102
+ ratio,
103
+ diffPixels: diffPx,
104
+ totalPixels: total,
105
+ mode: 'pixel',
106
+ };
107
+ }
108
+
109
+ /**
110
+ * Compare two PNG buffers.
111
+ *
112
+ * @param {Buffer} a
113
+ * @param {Buffer} b
114
+ * @param {{threshold?: number, tolerance?: number}} [opts]
115
+ * @returns {DiffResult}
116
+ */
117
+ function diff(a, b, opts = {}) {
118
+ if (!Buffer.isBuffer(a) || !Buffer.isBuffer(b)) {
119
+ throw new TypeError('visual-baseline/diff: both inputs must be Buffers');
120
+ }
121
+ const threshold = typeof opts.threshold === 'number' ? opts.threshold : DEFAULT_THRESHOLD;
122
+ const tolerance = typeof opts.tolerance === 'number' ? opts.tolerance : DEFAULT_TOLERANCE;
123
+ if (!_pngjs) return bytewiseDiff(a, b, threshold);
124
+ return pixelDiff(a, b, threshold, tolerance);
125
+ }
126
+
127
+ /** Test-only: report whether pngjs is available. */
128
+ function pngjsAvailable() {
129
+ return _pngjs !== null && _pngjs !== undefined;
130
+ }
131
+
132
+ module.exports = {
133
+ diff,
134
+ pngjsAvailable,
135
+ DEFAULT_THRESHOLD,
136
+ DEFAULT_TOLERANCE,
137
+ };
@@ -0,0 +1,139 @@
1
+ /**
2
+ * visual-baseline/index.cjs — baseline manager for PNG drift checks
3
+ * (Plan 23-07).
4
+ *
5
+ * Reads/writes `.design/baselines/<key>.png`. Compare delegates to
6
+ * `./diff.cjs#diff`. Atomic write via `.tmp` sibling + rename.
7
+ *
8
+ * Defers Playwright/Preview MCP screenshot capture orchestration to a
9
+ * later phase — this module only handles "given a PNG buffer, compare it
10
+ * / save it as the baseline".
11
+ */
12
+
13
+ 'use strict';
14
+
15
+ const fs = require('node:fs');
16
+ const path = require('node:path');
17
+
18
+ const { diff, DEFAULT_THRESHOLD, DEFAULT_TOLERANCE } = require('./diff.cjs');
19
+
20
+ const DEFAULT_BASELINE_DIR = '.design/baselines';
21
+ const SAFE_KEY_RE = /^[a-z0-9][a-z0-9._-]{0,127}$/i;
22
+
23
+ /**
24
+ * @typedef {Object} CompareOutcome
25
+ * @property {boolean} drifted
26
+ * @property {number} ratio
27
+ * @property {boolean} baselineExists
28
+ * @property {string} baselinePath
29
+ * @property {'pixel'|'bytewise'|'absent'} mode
30
+ * @property {number} [diffPixels]
31
+ * @property {number} [totalPixels]
32
+ * @property {string} [reason]
33
+ */
34
+
35
+ /**
36
+ * Validate a baseline key. Rejects path separators, '..', and unsafe
37
+ * characters that could traverse outside the baseline dir.
38
+ *
39
+ * @param {string} key
40
+ * @returns {string}
41
+ */
42
+ function validateKey(key) {
43
+ if (typeof key !== 'string' || key.length === 0) {
44
+ throw new TypeError('visual-baseline: key must be a non-empty string');
45
+ }
46
+ if (key.includes('..') || key.includes('/') || key.includes('\\')) {
47
+ throw new RangeError(
48
+ `visual-baseline: key "${key}" contains illegal characters (/, \\, ..)`,
49
+ );
50
+ }
51
+ if (!SAFE_KEY_RE.test(key)) {
52
+ throw new RangeError(
53
+ `visual-baseline: key "${key}" must match /^[a-z0-9][a-z0-9._-]{0,127}$/i`,
54
+ );
55
+ }
56
+ return key;
57
+ }
58
+
59
+ /**
60
+ * Resolve baseline file path.
61
+ *
62
+ * @param {string} key
63
+ * @param {{cwd?: string, baselineDir?: string}} [opts]
64
+ * @returns {string}
65
+ */
66
+ function baselinePathFor(key, opts = {}) {
67
+ validateKey(key);
68
+ const cwd = opts.cwd ?? process.cwd();
69
+ const dir = opts.baselineDir ?? DEFAULT_BASELINE_DIR;
70
+ const root = path.isAbsolute(dir) ? dir : path.join(cwd, dir);
71
+ return path.join(root, `${key}.png`);
72
+ }
73
+
74
+ /**
75
+ * Compare a PNG buffer to the on-disk baseline.
76
+ *
77
+ * @param {string} key
78
+ * @param {Buffer} pngBuffer
79
+ * @param {{cwd?: string, threshold?: number, tolerance?: number, baselineDir?: string}} [opts]
80
+ * @returns {CompareOutcome}
81
+ */
82
+ function compareToBaseline(key, pngBuffer, opts = {}) {
83
+ if (!Buffer.isBuffer(pngBuffer)) {
84
+ throw new TypeError('visual-baseline: pngBuffer must be a Buffer');
85
+ }
86
+ const baselinePath = baselinePathFor(key, opts);
87
+ if (!fs.existsSync(baselinePath)) {
88
+ return {
89
+ drifted: true,
90
+ ratio: NaN,
91
+ baselineExists: false,
92
+ baselinePath,
93
+ mode: 'absent',
94
+ reason: 'baseline-not-found',
95
+ };
96
+ }
97
+ const a = fs.readFileSync(baselinePath);
98
+ const r = diff(a, pngBuffer, opts);
99
+ return {
100
+ drifted: r.drifted,
101
+ ratio: r.ratio,
102
+ baselineExists: true,
103
+ baselinePath,
104
+ mode: r.mode,
105
+ diffPixels: r.diffPixels,
106
+ totalPixels: r.totalPixels,
107
+ reason: r.reason,
108
+ };
109
+ }
110
+
111
+ /**
112
+ * Persist a PNG buffer as the baseline. Atomic write (.tmp + rename).
113
+ *
114
+ * @param {string} key
115
+ * @param {Buffer} pngBuffer
116
+ * @param {{cwd?: string, baselineDir?: string}} [opts]
117
+ * @returns {string} absolute path written
118
+ */
119
+ function applyBaseline(key, pngBuffer, opts = {}) {
120
+ if (!Buffer.isBuffer(pngBuffer)) {
121
+ throw new TypeError('visual-baseline: pngBuffer must be a Buffer');
122
+ }
123
+ const out = baselinePathFor(key, opts);
124
+ fs.mkdirSync(path.dirname(out), { recursive: true });
125
+ const tmp = out + '.tmp';
126
+ fs.writeFileSync(tmp, pngBuffer);
127
+ fs.renameSync(tmp, out);
128
+ return out;
129
+ }
130
+
131
+ module.exports = {
132
+ compareToBaseline,
133
+ applyBaseline,
134
+ baselinePathFor,
135
+ validateKey,
136
+ DEFAULT_BASELINE_DIR,
137
+ DEFAULT_THRESHOLD,
138
+ DEFAULT_TOLERANCE,
139
+ };