@hegemonart/get-design-done 1.22.0 → 1.23.5
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 +112 -0
- package/package.json +2 -1
- package/reference/output-contracts/planner-decision.schema.json +94 -0
- package/reference/output-contracts/verifier-decision.schema.json +66 -0
- package/scripts/lib/adaptive-mode.cjs +170 -0
- package/scripts/lib/audit-aggregator/index.cjs +219 -0
- package/scripts/lib/bandit-router.cjs +368 -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/hedge-ensemble.cjs +217 -0
- package/scripts/lib/mmr-rerank.cjs +154 -0
- package/scripts/lib/parse-contract.cjs +168 -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/visual-baseline/diff.cjs +137 -0
- package/scripts/lib/visual-baseline/index.cjs +139 -0
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* reference-resolver.cjs — `type:<key>` → registry entry + excerpt
|
|
3
|
+
* (Plan 23-05).
|
|
4
|
+
*
|
|
5
|
+
* Builds on `scripts/lib/reference-registry.cjs#list` (Phase 14.5).
|
|
6
|
+
* Adds the resolution direction: given a key surfaced by an agent
|
|
7
|
+
* author, return the single matching entry plus a short excerpt
|
|
8
|
+
* suitable for inlining into prompts.
|
|
9
|
+
*
|
|
10
|
+
* Lookup order (first match wins):
|
|
11
|
+
* 1. exact `name` match
|
|
12
|
+
* 2. slug match against path basename without extension
|
|
13
|
+
* 3. singularize fuzzy match (strip trailing 's')
|
|
14
|
+
* 4. type==key AND only one entry exists at that type
|
|
15
|
+
*
|
|
16
|
+
* Ambiguous match → throws RangeError with candidate list.
|
|
17
|
+
* No match → returns null.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
'use strict';
|
|
21
|
+
|
|
22
|
+
const fs = require('node:fs');
|
|
23
|
+
const path = require('node:path');
|
|
24
|
+
|
|
25
|
+
const registry = require('./reference-registry.cjs');
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @typedef {Object} ResolverHit
|
|
29
|
+
* @property {string} name
|
|
30
|
+
* @property {string} path
|
|
31
|
+
* @property {string} type
|
|
32
|
+
* @property {string} excerpt
|
|
33
|
+
* @property {string} [tier]
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
const DEFAULT_MAX_CHARS = 200;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Pull a 200-char excerpt from a markdown file. Strips frontmatter,
|
|
40
|
+
* fences, comments, headers; collapses whitespace; truncates with `'…'`.
|
|
41
|
+
*
|
|
42
|
+
* @param {string} absolutePath
|
|
43
|
+
* @param {{maxChars?: number}} [opts]
|
|
44
|
+
* @returns {string}
|
|
45
|
+
*/
|
|
46
|
+
function excerptOf(absolutePath, opts = {}) {
|
|
47
|
+
const maxChars = typeof opts.maxChars === 'number' ? opts.maxChars : DEFAULT_MAX_CHARS;
|
|
48
|
+
let raw;
|
|
49
|
+
try {
|
|
50
|
+
raw = fs.readFileSync(absolutePath, 'utf8');
|
|
51
|
+
} catch {
|
|
52
|
+
return '';
|
|
53
|
+
}
|
|
54
|
+
// Drop YAML frontmatter.
|
|
55
|
+
raw = raw.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, '');
|
|
56
|
+
// Drop fenced code blocks.
|
|
57
|
+
raw = raw.replace(/```[\s\S]*?```/g, '');
|
|
58
|
+
// Drop HTML comments. Iterate until stable so that nested or
|
|
59
|
+
// adjacent `<!-- … -->` sequences cannot smuggle a residual `<!--`
|
|
60
|
+
// through a single regex pass (CodeQL js/incomplete-multi-character-
|
|
61
|
+
// sanitization). We're not building defense-in-depth against
|
|
62
|
+
// real markup attacks here — these excerpts are local doc files —
|
|
63
|
+
// but the loop costs nothing and silences the alert.
|
|
64
|
+
let prev;
|
|
65
|
+
do {
|
|
66
|
+
prev = raw;
|
|
67
|
+
raw = raw.replace(/<!--[\s\S]*?-->/g, '');
|
|
68
|
+
} while (raw !== prev);
|
|
69
|
+
// Drop heading lines.
|
|
70
|
+
raw = raw.replace(/^#{1,6}\s.*$/gm, '');
|
|
71
|
+
// Take first non-empty paragraph.
|
|
72
|
+
const paragraphs = raw.split(/\r?\n\s*\r?\n/).map((p) => p.trim()).filter(Boolean);
|
|
73
|
+
if (paragraphs.length === 0) return '';
|
|
74
|
+
let p = paragraphs[0].replace(/\s+/g, ' ').trim();
|
|
75
|
+
if (p.length > maxChars) {
|
|
76
|
+
p = p.slice(0, Math.max(0, maxChars - 1)) + '…';
|
|
77
|
+
}
|
|
78
|
+
return p;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Map a registry entry + cwd to a ResolverHit.
|
|
83
|
+
*/
|
|
84
|
+
function hitFor(entry, cwd) {
|
|
85
|
+
const abs = path.resolve(cwd, entry.path);
|
|
86
|
+
/** @type {ResolverHit} */
|
|
87
|
+
const hit = {
|
|
88
|
+
name: entry.name,
|
|
89
|
+
path: entry.path,
|
|
90
|
+
type: entry.type,
|
|
91
|
+
excerpt: excerptOf(abs),
|
|
92
|
+
};
|
|
93
|
+
if (entry.tier) hit.tier = entry.tier;
|
|
94
|
+
return hit;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Resolve `type:<key>` (or bare `<key>`) to a single registry hit.
|
|
99
|
+
*
|
|
100
|
+
* @param {string} typeKey
|
|
101
|
+
* @param {{cwd?: string}} [opts]
|
|
102
|
+
* @returns {ResolverHit | null}
|
|
103
|
+
*/
|
|
104
|
+
function resolve(typeKey, opts = {}) {
|
|
105
|
+
if (typeof typeKey !== 'string' || typeKey.length === 0) return null;
|
|
106
|
+
const cwd = opts.cwd ?? path.resolve(__dirname, '..', '..');
|
|
107
|
+
const key = typeKey.replace(/^type:/, '').trim().toLowerCase();
|
|
108
|
+
if (key.length === 0) return null;
|
|
109
|
+
const all = registry.list({ cwd });
|
|
110
|
+
|
|
111
|
+
// 1. Exact name match.
|
|
112
|
+
const exact = all.find((e) => e.name.toLowerCase() === key);
|
|
113
|
+
if (exact) return hitFor(exact, cwd);
|
|
114
|
+
|
|
115
|
+
// 2. Slug match against path basename (no extension).
|
|
116
|
+
const bySlug = all.filter((e) => {
|
|
117
|
+
const slug = path.posix.basename(e.path, path.posix.extname(e.path)).toLowerCase();
|
|
118
|
+
return slug === key;
|
|
119
|
+
});
|
|
120
|
+
if (bySlug.length === 1) return hitFor(bySlug[0], cwd);
|
|
121
|
+
if (bySlug.length > 1) {
|
|
122
|
+
throw new RangeError(
|
|
123
|
+
`reference-resolver: ambiguous slug match for "${typeKey}" — candidates: ${bySlug.map((e) => e.name).join(', ')}`,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// 3. Singularize: strip trailing 's' from key, then prefix-match name.
|
|
128
|
+
if (key.endsWith('s') && key.length > 1) {
|
|
129
|
+
const stem = key.slice(0, -1);
|
|
130
|
+
const stemHits = all.filter((e) => e.name.toLowerCase().startsWith(stem));
|
|
131
|
+
if (stemHits.length === 1) return hitFor(stemHits[0], cwd);
|
|
132
|
+
if (stemHits.length > 1) {
|
|
133
|
+
throw new RangeError(
|
|
134
|
+
`reference-resolver: ambiguous singularize match for "${typeKey}" — candidates: ${stemHits.map((e) => e.name).join(', ')}`,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// 4. type==key AND single entry at that type.
|
|
140
|
+
const byType = all.filter((e) => e.type.toLowerCase() === key);
|
|
141
|
+
if (byType.length === 1) return hitFor(byType[0], cwd);
|
|
142
|
+
if (byType.length > 1) {
|
|
143
|
+
throw new RangeError(
|
|
144
|
+
`reference-resolver: ambiguous type-only match for "${typeKey}" — multiple entries at type=${key}: ${byType.map((e) => e.name).join(', ')}`,
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Bulk resolver — used by the prompt-builder.
|
|
153
|
+
*
|
|
154
|
+
* @param {string[]} typeKeys
|
|
155
|
+
* @param {{cwd?: string, ignoreMissing?: boolean}} [opts]
|
|
156
|
+
* @returns {ResolverHit[]}
|
|
157
|
+
*/
|
|
158
|
+
function resolveAll(typeKeys, opts = {}) {
|
|
159
|
+
if (!Array.isArray(typeKeys)) {
|
|
160
|
+
throw new TypeError('reference-resolver: typeKeys must be an array');
|
|
161
|
+
}
|
|
162
|
+
/** @type {ResolverHit[]} */
|
|
163
|
+
const hits = [];
|
|
164
|
+
/** @type {string[]} */
|
|
165
|
+
const missing = [];
|
|
166
|
+
for (const k of typeKeys) {
|
|
167
|
+
const h = resolve(k, opts);
|
|
168
|
+
if (h) hits.push(h);
|
|
169
|
+
else missing.push(k);
|
|
170
|
+
}
|
|
171
|
+
if (missing.length > 0 && !opts.ignoreMissing) {
|
|
172
|
+
throw new Error(
|
|
173
|
+
`reference-resolver: unresolved keys: ${missing.join(', ')}. Pass {ignoreMissing: true} to skip.`,
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
return hits;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
module.exports = {
|
|
180
|
+
resolve,
|
|
181
|
+
resolveAll,
|
|
182
|
+
excerptOf,
|
|
183
|
+
DEFAULT_MAX_CHARS,
|
|
184
|
+
};
|
|
@@ -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
|
+
};
|