dsh-clone 0.0.0-stage → 0.2.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/lib/parse.js ADDED
@@ -0,0 +1,58 @@
1
+ /**
2
+ * `/clone` grammar. Only the shapes this command owns are parsed here; every
3
+ * remaining word is the note handed to the building agents.
4
+ *
5
+ * /clone 3 lanes, no note
6
+ * /clone 4 4 lanes, no note
7
+ * /clone 2 dark dashboard 2 lanes, note "dark dashboard"
8
+ * /clone status | pick 2 | refine 2 move the CTA up | cancel | discard | help
9
+ */
10
+
11
+ import { clampLanes } from './util.js'
12
+
13
+ export const DEFAULT_LANES = 3
14
+ export const MAX_LANES = 4
15
+
16
+ const CONTROLS = new Set(['status', 'list', 'pick', 'cancel', 'stop', 'discard', 'drop', 'help', '?', 'refine'])
17
+
18
+ /** Parse one raw command input into a discriminated action. */
19
+ export function parseCloneArgs(raw, { defaultLanes = DEFAULT_LANES, maxLanes = MAX_LANES } = {}) {
20
+ const input = String(raw ?? '').trim()
21
+ if (input.length === 0) return { kind: 'start', lanes: clampLanes(defaultLanes, { max: maxLanes }), note: '' }
22
+
23
+ const parts = input.split(/\s+/u)
24
+ const head = parts[0].toLowerCase()
25
+
26
+ if (head === 'help' || head === '?') return { kind: 'help' }
27
+ if (head === 'status' || head === 'list') return { kind: 'status' }
28
+ if (head === 'cancel' || head === 'stop') return { kind: 'cancel' }
29
+ if (head === 'discard' || head === 'drop') return { kind: 'discard' }
30
+ if (head === 'pick') {
31
+ const lane = parts[1] === undefined ? null : Number(parts[1])
32
+ return { kind: 'pick', lane: Number.isInteger(lane) && lane > 0 ? lane : null }
33
+ }
34
+ if (head === 'refine') {
35
+ // `/clone refine`, `/clone refine 2`, `/clone refine 2 move the CTA up`
36
+ const lane = /^\d{1,2}$/u.test(parts[1] ?? '') ? Number(parts[1]) : null
37
+ const change = parts.slice(lane === null ? 1 : 2).join(' ').trim()
38
+ return { kind: 'refine', lane: lane !== null && lane > 0 ? lane : null, note: change }
39
+ }
40
+ if (CONTROLS.has(head)) return { kind: 'help' }
41
+
42
+ const countToken = /^\d{1,2}$/u.test(parts[0]) ? Number(parts[0]) : null
43
+ const rest = countToken === null ? parts : parts.slice(1)
44
+ return {
45
+ kind: 'start',
46
+ lanes: clampLanes(countToken ?? defaultLanes, { max: maxLanes }),
47
+ note: rest.join(' ').trim(),
48
+ }
49
+ }
50
+
51
+ /** Extract an explicit reference-image path from the note, when present. */
52
+ export function referenceHintOf(note) {
53
+ const tokens = String(note ?? '').split(/\s+/u).filter((token) => token.length > 0)
54
+ for (const token of tokens) {
55
+ if (/\.(png|jpe?g|webp|gif)$/iu.test(token)) return token
56
+ }
57
+ return null
58
+ }
package/lib/png.js ADDED
@@ -0,0 +1,175 @@
1
+ /**
2
+ * A minimal, dependency-free PNG decoder.
3
+ *
4
+ * dsh-clone needs exactly one thing from a PNG: the pixels, so a rendered lane
5
+ * can be compared with the reference screenshot. No encoder, no palette or
6
+ * interlace support, no colour management — only the 8-bit non-interlaced
7
+ * images a browser screenshot and an ordinary screenshot produce.
8
+ *
9
+ * Node's own zlib does the inflate; everything else here is the PNG spec.
10
+ */
11
+
12
+ import { inflateSync } from 'node:zlib'
13
+
14
+ const SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]
15
+ const MAX_PIXELS = 4096 * 4096
16
+
17
+ /** Channels per pixel for the colour types this decoder accepts. */
18
+ const CHANNELS = new Map([
19
+ [0, 1], // greyscale
20
+ [2, 3], // RGB
21
+ [4, 2], // greyscale + alpha
22
+ [6, 4], // RGBA
23
+ ])
24
+
25
+ /**
26
+ * Decode one PNG into straight RGBA8 pixels.
27
+ * @param buffer - the complete PNG file bytes.
28
+ * @returns `{ width, height, data }` where data is RGBA, one byte per channel.
29
+ * @throws when the bytes are not a PNG this decoder supports.
30
+ */
31
+ export function decodePng(buffer) {
32
+ const bytes = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer)
33
+ if (bytes.length < 8) throw new Error('png: file is too short')
34
+ for (let index = 0; index < 8; index += 1) {
35
+ if (bytes[index] !== SIGNATURE[index]) throw new Error('png: bad signature')
36
+ }
37
+
38
+ let offset = 8
39
+ let header = null
40
+ const idat = []
41
+ while (offset + 8 <= bytes.length) {
42
+ const length = bytes.readUInt32BE(offset)
43
+ const type = bytes.toString('ascii', offset + 4, offset + 8)
44
+ const start = offset + 8
45
+ const end = start + length
46
+ if (end > bytes.length) throw new Error('png: truncated chunk')
47
+ if (type === 'IHDR') header = readHeader(bytes, start)
48
+ else if (type === 'IDAT') idat.push(bytes.subarray(start, end))
49
+ else if (type === 'IEND') break
50
+ offset = end + 4 // skip the CRC
51
+ }
52
+
53
+ if (header === null) throw new Error('png: missing IHDR')
54
+ if (header.interlaced) throw new Error('png: interlaced images are not supported')
55
+ if (header.bitDepth !== 8) throw new Error(`png: unsupported bit depth ${header.bitDepth}`)
56
+ const channels = CHANNELS.get(header.colourType)
57
+ if (channels === undefined) throw new Error(`png: unsupported colour type ${header.colourType}`)
58
+ if (header.width * header.height > MAX_PIXELS) throw new Error('png: image is too large')
59
+ if (idat.length === 0) throw new Error('png: no image data')
60
+
61
+ const raw = inflateSync(Buffer.concat(idat))
62
+ const scanline = header.width * channels
63
+ const expected = (scanline + 1) * header.height
64
+ if (raw.length < expected) throw new Error('png: image data is short')
65
+
66
+ const pixels = new Uint8Array(scanline * header.height)
67
+ unfilter(raw, pixels, scanline, header.height, channels)
68
+ return { width: header.width, height: header.height, data: expandToRgba(pixels, header.width, header.height, channels) }
69
+ }
70
+
71
+ /**
72
+ * Read width and height from a PNG header without inflating the image.
73
+ * @param buffer - PNG bytes, at least the first 24.
74
+ * @returns `{ width, height }`, or null when the header is not readable.
75
+ */
76
+ export function readPngSize(buffer) {
77
+ const bytes = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer)
78
+ if (bytes.length < 24) return null
79
+ for (let index = 0; index < 8; index += 1) {
80
+ if (bytes[index] !== SIGNATURE[index]) return null
81
+ }
82
+ if (bytes.toString('ascii', 12, 16) !== 'IHDR') return null
83
+ const width = bytes.readUInt32BE(16)
84
+ const height = bytes.readUInt32BE(20)
85
+ if (width <= 0 || height <= 0) return null
86
+ return { width, height }
87
+ }
88
+
89
+ /** Read the IHDR fields this decoder cares about. */
90
+ function readHeader(bytes, start) {
91
+ const width = bytes.readUInt32BE(start)
92
+ const height = bytes.readUInt32BE(start + 4)
93
+ if (width <= 0 || height <= 0) throw new Error('png: empty image')
94
+ return {
95
+ width,
96
+ height,
97
+ bitDepth: bytes[start + 8],
98
+ colourType: bytes[start + 9],
99
+ interlaced: bytes[start + 12] !== 0,
100
+ }
101
+ }
102
+
103
+ /** Undo the per-scanline filters in place (PNG spec section 9). */
104
+ function unfilter(raw, out, scanline, height, bpp) {
105
+ let source = 0
106
+ for (let y = 0; y < height; y += 1) {
107
+ const filter = raw[source]
108
+ source += 1
109
+ const rowStart = y * scanline
110
+ const previousStart = rowStart - scanline
111
+ for (let x = 0; x < scanline; x += 1) {
112
+ const value = raw[source + x]
113
+ const left = x >= bpp ? out[rowStart + x - bpp] : 0
114
+ const up = y > 0 ? out[previousStart + x] : 0
115
+ const upLeft = y > 0 && x >= bpp ? out[previousStart + x - bpp] : 0
116
+ let result
117
+ switch (filter) {
118
+ case 0: result = value; break
119
+ case 1: result = value + left; break
120
+ case 2: result = value + up; break
121
+ case 3: result = value + ((left + up) >> 1); break
122
+ case 4: result = value + paeth(left, up, upLeft); break
123
+ default: throw new Error(`png: unknown filter ${filter}`)
124
+ }
125
+ out[rowStart + x] = result & 0xff
126
+ }
127
+ source += scanline
128
+ }
129
+ }
130
+
131
+ /** The PNG Paeth predictor. */
132
+ function paeth(left, up, upLeft) {
133
+ const estimate = left + up - upLeft
134
+ const distanceLeft = Math.abs(estimate - left)
135
+ const distanceUp = Math.abs(estimate - up)
136
+ const distanceUpLeft = Math.abs(estimate - upLeft)
137
+ if (distanceLeft <= distanceUp && distanceLeft <= distanceUpLeft) return left
138
+ return distanceUp <= distanceUpLeft ? up : upLeft
139
+ }
140
+
141
+ /** Normalize any accepted colour layout into straight RGBA8. */
142
+ function expandToRgba(pixels, width, height, channels) {
143
+ const count = width * height
144
+ const out = new Uint8Array(count * 4)
145
+ for (let index = 0; index < count; index += 1) {
146
+ const source = index * channels
147
+ const target = index * 4
148
+ switch (channels) {
149
+ case 4:
150
+ out[target] = pixels[source]
151
+ out[target + 1] = pixels[source + 1]
152
+ out[target + 2] = pixels[source + 2]
153
+ out[target + 3] = pixels[source + 3]
154
+ break
155
+ case 3:
156
+ out[target] = pixels[source]
157
+ out[target + 1] = pixels[source + 1]
158
+ out[target + 2] = pixels[source + 2]
159
+ out[target + 3] = 255
160
+ break
161
+ case 2:
162
+ out[target] = pixels[source]
163
+ out[target + 1] = pixels[source]
164
+ out[target + 2] = pixels[source]
165
+ out[target + 3] = pixels[source + 1]
166
+ break
167
+ default:
168
+ out[target] = pixels[source]
169
+ out[target + 1] = pixels[source]
170
+ out[target + 2] = pixels[source]
171
+ out[target + 3] = 255
172
+ }
173
+ }
174
+ return out
175
+ }
package/lib/preview.js ADDED
@@ -0,0 +1,141 @@
1
+ /**
2
+ * The HTTP surface of the Host half: one JSON feed, one reference image, one
3
+ * action endpoint, and a static file route that serves each lane's build.
4
+ *
5
+ * Lane output is model-written HTML, so it is served into an opaque origin:
6
+ * the `sandbox` CSP directive without `allow-same-origin` means a clone can
7
+ * run its own scripts but cannot touch this page's cookies, storage, or API.
8
+ */
9
+
10
+ import { extname, join, normalize, sep } from 'node:path'
11
+
12
+ /** Content types the preview route may answer with. */
13
+ const MEDIA_TYPES = new Map(Object.entries({
14
+ '.html': 'text/html; charset=utf-8',
15
+ '.htm': 'text/html; charset=utf-8',
16
+ '.css': 'text/css; charset=utf-8',
17
+ '.js': 'text/javascript; charset=utf-8',
18
+ '.mjs': 'text/javascript; charset=utf-8',
19
+ '.json': 'application/json; charset=utf-8',
20
+ '.svg': 'image/svg+xml',
21
+ '.png': 'image/png',
22
+ '.jpg': 'image/jpeg',
23
+ '.jpeg': 'image/jpeg',
24
+ '.webp': 'image/webp',
25
+ '.gif': 'image/gif',
26
+ '.ico': 'image/x-icon',
27
+ '.woff': 'font/woff',
28
+ '.woff2': 'font/woff2',
29
+ '.txt': 'text/plain; charset=utf-8',
30
+ '.md': 'text/plain; charset=utf-8',
31
+ }))
32
+
33
+ /** Media type for a previewable file, defaulting to a binary-safe type. */
34
+ export function mediaTypeFor(filePath) {
35
+ return MEDIA_TYPES.get(extname(String(filePath ?? '')).toLowerCase()) ?? 'application/octet-stream'
36
+ }
37
+
38
+ /** Response headers shared by every previewed file. */
39
+ export const PREVIEW_HEADERS = {
40
+ 'Content-Security-Policy': "sandbox allow-scripts allow-forms allow-modals allow-popups; default-src 'self' data: blob:; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'",
41
+ 'X-Content-Type-Options': 'nosniff',
42
+ 'Cache-Control': 'no-store',
43
+ 'Referrer-Policy': 'no-referrer',
44
+ }
45
+
46
+ /** Resolve a request path inside one directory, or null when it escapes. */
47
+ export function safeJoin(root, relativePath) {
48
+ const cleaned = String(relativePath ?? '').replace(/^\/+/u, '')
49
+ const normalized = normalize(cleaned)
50
+ if (normalized.startsWith('..') || normalized.includes(`..${sep}`) || normalized.includes('\0')) return null
51
+ const absolute = join(root, normalized)
52
+ const prefix = root.endsWith(sep) ? root : `${root}${sep}`
53
+ if (absolute !== root && !absolute.startsWith(prefix)) return null
54
+ return absolute
55
+ }
56
+
57
+ /** Write one JSON response. */
58
+ export function sendJson(response, status, value) {
59
+ const body = JSON.stringify(value)
60
+ response.statusCode = status
61
+ response.setHeader('Content-Type', 'application/json; charset=utf-8')
62
+ response.setHeader('Cache-Control', 'no-store')
63
+ response.setHeader('Content-Length', Buffer.byteLength(body))
64
+ response.end(body)
65
+ }
66
+
67
+ /** Write a small plain-text rejection. */
68
+ export function sendText(response, status, text) {
69
+ response.statusCode = status
70
+ response.setHeader('Content-Type', 'text/plain; charset=utf-8')
71
+ response.end(text)
72
+ }
73
+
74
+ /** Read and parse a bounded JSON request body. */
75
+ export async function readJsonBody(request, { limit = 64 * 1024 } = {}) {
76
+ const chunks = []
77
+ let bytes = 0
78
+ for await (const chunk of request) {
79
+ bytes += chunk.length
80
+ if (bytes > limit) throw new Error('request body too large')
81
+ chunks.push(chunk)
82
+ }
83
+ if (chunks.length === 0) return {}
84
+ const text = Buffer.concat(chunks).toString('utf8')
85
+ if (text.trim().length === 0) return {}
86
+ const parsed = JSON.parse(text)
87
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('request body must be an object')
88
+ return parsed
89
+ }
90
+
91
+ /** Serve one file from a lane directory with preview-safe headers. */
92
+ export async function serveLaneFile({ fs, root, relativePath, response }) {
93
+ let relative = String(relativePath ?? '')
94
+ if (relative.length === 0 || relative.endsWith('/')) relative = `${relative}index.html`
95
+ const absolute = safeJoin(root, relative)
96
+ if (absolute === null) {
97
+ sendText(response, 403, 'forbidden')
98
+ return
99
+ }
100
+ const stats = await fs.stat(absolute).catch(() => null)
101
+ if (stats === null || !stats.isFile()) {
102
+ sendText(response, 404, 'not found')
103
+ return
104
+ }
105
+ const body = await fs.readFile(absolute)
106
+ response.statusCode = 200
107
+ response.setHeader('Content-Type', mediaTypeFor(absolute))
108
+ response.setHeader('Content-Length', body.length)
109
+ for (const [name, value] of Object.entries(PREVIEW_HEADERS)) response.setHeader(name, value)
110
+ response.end(body)
111
+ }
112
+
113
+ /** Serve one image file (the reference screenshot) with its own media type. */
114
+ export async function serveImageFile({ fs, path, mediaType, response }) {
115
+ const body = await fs.readFile(path).catch(() => null)
116
+ if (body === null) {
117
+ sendText(response, 404, 'not found')
118
+ return
119
+ }
120
+ response.statusCode = 200
121
+ response.setHeader('Content-Type', mediaType ?? mediaTypeFor(path))
122
+ response.setHeader('Content-Length', body.length)
123
+ response.setHeader('Cache-Control', 'no-store')
124
+ response.setHeader('X-Content-Type-Options', 'nosniff')
125
+ response.end(body)
126
+ }
127
+
128
+ /**
129
+ * Parse `/api/dsh-clone/preview/<cloneId>/<lane>/<rest>`.
130
+ * @returns `{ cloneId, lane, rest }` or null for a malformed path.
131
+ */
132
+ export function parsePreviewPath(pathname) {
133
+ const prefix = '/api/dsh-clone/preview/'
134
+ if (!pathname.startsWith(prefix)) return null
135
+ const parts = pathname.slice(prefix.length).split('/')
136
+ const cloneId = parts.shift() ?? ''
137
+ const lane = Number(parts.shift())
138
+ if (!/^[a-z0-9]{1,32}$/iu.test(cloneId)) return null
139
+ if (!Number.isInteger(lane) || lane < 1 || lane > 8) return null
140
+ return { cloneId, lane, rest: parts.join('/') }
141
+ }
package/lib/prompts.js ADDED
@@ -0,0 +1,118 @@
1
+ /**
2
+ * The builder prompts. Each lane gets the same reference image and the same
3
+ * hard delivery contract, but a different way of working — that difference is
4
+ * what makes a set of lanes worth comparing instead of three copies of one
5
+ * attempt.
6
+ */
7
+
8
+ /** Fixed delivery contract every lane must satisfy. */
9
+ export const DELIVERY_CONTRACT = `Delivery contract (non-negotiable):
10
+ - Work ONLY inside the current working directory. Never read or write outside it.
11
+ - Deliver a static page that opens at index.html with no build step and no server: plain HTML, CSS and vanilla JS, plus any extra local files (styles.css, app.js, assets/*).
12
+ - No network at runtime. No CDN, no Google Fonts, no remote images, no package installs. If you need an icon or a photo, draw it with inline SVG or CSS gradients/geometry.
13
+ - The page must fill the viewport edge to edge (body margin 0; a layout that uses height:100vh). Target a ${'`'}REFERENCE_VIEWPORT${'`'} viewport; make it degrade sanely when narrower.
14
+ - Never leave placeholder text like "Lorem ipsum", "TODO", "Your text here", or a grey box where the reference shows real content. Recreate the copy you can read in the reference image, in the reference's own language.
15
+ - Match the reference as a picture: structure, spacing rhythm, colour values, type scale and weights, corner radii, borders, shadows, iconography, and the alignment of every block.
16
+ - If the reference contains a chart, table, avatar or illustration you cannot copy pixel for pixel, approximate its silhouette, palette and proportions with CSS/SVG — an honest approximation, never a blank hole.
17
+ - Do not leave working files behind (crops of the reference, notes, scripts). The deliverable is index.html plus the assets it actually loads; delete your scratch before you finish.`
18
+
19
+ /**
20
+ * Budget guidance shared by every lane.
21
+ *
22
+ * A reference rebuild is a build task, not a research task: an agent that keeps
23
+ * cropping the screenshot instead of writing the page burns tokens without
24
+ * improving the result. The budget below is deliberately concrete because
25
+ * "be efficient" is not actionable for a model.
26
+ */
27
+ export const EFFORT_BUDGET = `Working budget:
28
+ - Study the reference in at most two passes: its full view, then at most two crops of regions whose detail you cannot read at full size.
29
+ - Do NOT run pixel-sampling or colour-counting scripts over the image, and do not measure exact coordinates with code: read colours and sizes by eye from the image and approximate them. Measurement scripts are the most common way this task fails — they consume the whole budget and leave no page.
30
+ - Write index.html by your tenth tool call at the latest, then refine it. An unpolished page that exists beats a perfect analysis that does not.
31
+ - Aim to be finished within roughly 20-25 tool calls. If you are past that, ship what you have and report what still differs.`
32
+
33
+ /** Lane strategies, cycled when more lanes than strategies are requested. */
34
+ export const LANE_STRATEGIES = [
35
+ {
36
+ id: 'measure-first',
37
+ label: 'Measure first',
38
+ brief: `Strategy: measure before you build. Before writing any code, study the reference image and write a short layout spec as an HTML comment at the top of index.html: the page's regions top to bottom, the column grid, the spacing scale you infer, the palette you sample (list the hex values), and the type scale. Then build strictly to that spec. Verify at the end by re-reading your spec against the image and fixing every mismatch.`,
39
+ },
40
+ {
41
+ id: 'structure-first',
42
+ label: 'Structure first',
43
+ brief: `Strategy: structure before cosmetics. First write the whole page as semantic HTML with every region, list, control and piece of copy in place, unstyled. Only then apply CSS in one pass, region by region, from the outside in. Finish by checking that the block geometry (widths, heights, alignments) matches the image before you polish colour and type.`,
44
+ },
45
+ {
46
+ id: 'visual-first',
47
+ label: 'Visual first',
48
+ brief: `Strategy: visual fidelity before features. Reproduce the image as a picture first: exact background colours, the dominant surfaces, the type, the spacing, and the overall silhouette at the target viewport. Once the page looks right as a still image, add only the interactivity that the image visibly implies. Never trade visual fidelity for extra behaviour.`,
49
+ },
50
+ {
51
+ id: 'component-first',
52
+ label: 'Component first',
53
+ brief: `Strategy: components first. Identify the 5-8 repeated or reused parts of the interface (card, row, button, tab, badge, panel) and define each once as a small CSS class or a JS factory. Compose the page from those parts, so spacing and corner treatment stay consistent across the whole screen.`,
54
+ },
55
+ ]
56
+
57
+ /**
58
+ * The task for refining an adopted lane.
59
+ *
60
+ * A refine is not a new attempt: the previous page is already in the working
61
+ * directory and the person liked it. The contract therefore forbids starting
62
+ * over and asks for the smallest edit that satisfies the change while keeping
63
+ * the reference agreement.
64
+ * @param args - reference path, the requested change, viewport.
65
+ * @returns the prompt text.
66
+ */
67
+ export function buildRefinePrompt({ referencePath, change, viewport }) {
68
+ return [
69
+ 'You are refining a page you already built. It is not a new attempt.',
70
+ '',
71
+ `The page is in this directory (index.html plus its assets). Read it first: it is your previous work and it is close to the reference already.`,
72
+ `Reference image: ${referencePath} — open it with the read_image tool. It is still the source of truth for layout, colour, type and copy.`,
73
+ '',
74
+ `The change the person wants:`,
75
+ ` ${change.length === 0 ? '(no specific change was given — improve the reference agreement where your previous version is visibly off)' : change}`,
76
+ '',
77
+ 'Rules:',
78
+ '- Keep everything that already agrees with the reference. Do not rewrite the page from scratch and do not restyle unrelated parts.',
79
+ '- Make the smallest edit that satisfies the change, then check the result against the reference again.',
80
+ `- The page must stay a self-contained static page at the target viewport \`${viewport}\`, with no build step and no network at runtime.`,
81
+ '- Delete any scratch file you create. Ship only what the page loads.',
82
+ '',
83
+ 'Reply with a short plain-text report: the files you touched, what changed, and anything in the reference you still cannot match. Do not paste the HTML.',
84
+ ].join('\n')
85
+ }
86
+
87
+ /** Pick the strategy for one lane, cycling deterministically. */
88
+ export function strategyForLane(index) {
89
+ return LANE_STRATEGIES[index % LANE_STRATEGIES.length]
90
+ }
91
+
92
+ /**
93
+ * Build the complete task for one lane.
94
+ * @param args - lane index, reference path, viewport, optional user note.
95
+ * @returns the prompt text handed to the child agent.
96
+ */
97
+ export function buildLanePrompt({ laneIndex, laneCount, strategy, referencePath, referenceLabel, viewport, note }) {
98
+ const contract = DELIVERY_CONTRACT.replace('REFERENCE_VIEWPORT', `\`${viewport}\``)
99
+ const noteLine = note === undefined || note === null || note.length === 0
100
+ ? ''
101
+ : `\nThe person asked for this specifically — honour it whenever it does not contradict the reference image:\n ${note}\n`
102
+ return [
103
+ `You are rebuilding a user interface from a reference screenshot. This is a build task, not a review task.`,
104
+ ``,
105
+ `Reference image: ${referencePath}`,
106
+ `Open it with the read_image tool and study it before you write anything. It is the only source of truth for layout, colour, type and copy.`,
107
+ referenceLabel === undefined ? '' : `Reference label: ${referenceLabel}`,
108
+ `You are lane ${laneIndex + 1} of ${laneCount}: another agent is building the same screen in parallel in a different directory, and the person will adopt whichever result they prefer. Your job is to make yours the obvious choice.`,
109
+ noteLine,
110
+ strategy.brief,
111
+ ``,
112
+ contract,
113
+ ``,
114
+ EFFORT_BUDGET,
115
+ ``,
116
+ `When you are done, reply with a short report in plain text: the files you created, the palette you sampled, and the three places where your rebuild still differs from the reference. Do not paste the HTML into the reply.`,
117
+ ].filter((line) => line !== '').join('\n')
118
+ }
package/lib/rank.js ADDED
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Ranking rules for a finished set of lanes.
3
+ *
4
+ * dsh-clone has no oracle: the reference screenshot is the ground truth, and
5
+ * only a human can say which rebuild looks closer. What the engine CAN judge
6
+ * objectively is completeness and speed, so it does exactly that and no more:
7
+ *
8
+ * 1. a lane that produced a servable page beats one that did not;
9
+ * 2. among those, the one that finished first is the suggested winner;
10
+ * 3. lanes that failed stay listed but never win automatically.
11
+ *
12
+ * The suggestion is only a default — the panel lets the reader take any lane.
13
+ */
14
+
15
+ /** Whether a lane produced something the preview route can serve. */
16
+ export function laneDelivered(lane) {
17
+ if (lane === null || lane === undefined) return false
18
+ if (lane.status !== 'settled') return false
19
+ return Array.isArray(lane.files) && lane.files.some((file) => file?.path === 'index.html')
20
+ }
21
+
22
+ /** Whether the lane carries a comparable similarity score. */
23
+ export function laneScored(lane) {
24
+ return lane !== null && lane !== undefined && typeof lane.score === 'number' && Number.isFinite(lane.score)
25
+ }
26
+
27
+ /**
28
+ * Order lanes from most to least adoptable.
29
+ *
30
+ * Delivered lanes lead. Among them, a rendered similarity score is the
31
+ * strongest signal available, so it decides; lanes without a score fall back
32
+ * to finish order. Nothing here is a correctness judgement.
33
+ */
34
+ export function rankLanes(lanes) {
35
+ const list = Array.isArray(lanes) ? lanes : []
36
+ return [...list].sort((left, right) => {
37
+ const delivered = Number(laneDelivered(right)) - Number(laneDelivered(left))
38
+ if (delivered !== 0) return delivered
39
+ if (laneScored(left) && laneScored(right) && left.score !== right.score) return right.score - left.score
40
+ if (laneScored(left) !== laneScored(right)) return laneScored(left) ? -1 : 1
41
+ const finished = (left.finishedAt ?? Number.POSITIVE_INFINITY) - (right.finishedAt ?? Number.POSITIVE_INFINITY)
42
+ if (Number.isFinite(finished) && finished !== 0) return finished
43
+ return left.index - right.index
44
+ })
45
+ }
46
+
47
+ /** Suggested winner lane, or null when nothing usable landed. */
48
+ export function suggestedWinner(lanes) {
49
+ const [first] = rankLanes(lanes).filter(laneDelivered)
50
+ return first ?? null
51
+ }
52
+
53
+ /** Whether a lane stopped working, whatever the outcome was. */
54
+ export function laneTerminal(lane) {
55
+ return lane !== null && lane !== undefined && ['settled', 'empty', 'failed', 'cancelled'].includes(lane.status)
56
+ }
57
+
58
+ /** One-line state for the composer chip. */
59
+ export function summarize(clone) {
60
+ if (clone === null || clone === undefined) return null
61
+ const lanes = Array.isArray(clone.lanes) ? clone.lanes : []
62
+ const done = lanes.filter(laneTerminal).length
63
+ return {
64
+ id: clone.id,
65
+ status: clone.status,
66
+ done,
67
+ total: lanes.length,
68
+ delivered: lanes.filter(laneDelivered).length,
69
+ winner: clone.winner ?? null,
70
+ bestScore: lanes.reduce((best, lane) => (laneScored(lane) && (best === null || lane.score > best) ? lane.score : best), null),
71
+ }
72
+ }