diff --git a/apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/approve-gate-empty.png b/apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/approve-gate-empty.png deleted file mode 100644 index fbbb78989..000000000 Binary files a/apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/approve-gate-empty.png and /dev/null differ diff --git a/apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/approve-gate-empty@2x.png b/apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/approve-gate-empty@2x.png deleted file mode 100644 index 106244131..000000000 Binary files a/apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/approve-gate-empty@2x.png and /dev/null differ diff --git a/apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/approve-gate-pending.png b/apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/approve-gate-pending.png deleted file mode 100644 index ce1395dbe..000000000 Binary files a/apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/approve-gate-pending.png and /dev/null differ diff --git a/apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/approve-gate-pending@2x.png b/apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/approve-gate-pending@2x.png deleted file mode 100644 index 273c41b08..000000000 Binary files a/apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/approve-gate-pending@2x.png and /dev/null differ diff --git a/apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/gate-nav.png b/apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/gate-nav.png deleted file mode 100644 index 3c64c9dc6..000000000 Binary files a/apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/gate-nav.png and /dev/null differ diff --git a/apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/gate-nav@2x.png b/apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/gate-nav@2x.png deleted file mode 100644 index 4830623da..000000000 Binary files a/apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/gate-nav@2x.png and /dev/null differ diff --git a/apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/list/open-terminal.png b/apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/list/open-terminal.png new file mode 100644 index 000000000..a7358fdb0 Binary files /dev/null and b/apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/list/open-terminal.png differ diff --git a/apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/list/open-terminal@2x.png b/apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/list/open-terminal@2x.png new file mode 100644 index 000000000..d8e250ed4 Binary files /dev/null and b/apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/list/open-terminal@2x.png differ diff --git a/apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/list/send-queue.png b/apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/list/send-queue.png new file mode 100644 index 000000000..1a6800ffa Binary files /dev/null and b/apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/list/send-queue.png differ diff --git a/apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/list/send-queue@2x.png b/apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/list/send-queue@2x.png new file mode 100644 index 000000000..85edfa383 Binary files /dev/null and b/apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/list/send-queue@2x.png differ diff --git a/apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/open-terminal.png b/apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/open-terminal.png new file mode 100644 index 000000000..5e551eaa4 Binary files /dev/null and b/apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/open-terminal.png differ diff --git a/apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/open-terminal@2x.png b/apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/open-terminal@2x.png new file mode 100644 index 000000000..75a8bce77 Binary files /dev/null and b/apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/open-terminal@2x.png differ diff --git a/apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/send-queue.png b/apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/send-queue.png new file mode 100644 index 000000000..73f0b7902 Binary files /dev/null and b/apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/send-queue.png differ diff --git a/apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/send-queue@2x.png b/apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/send-queue@2x.png new file mode 100644 index 000000000..42d16f278 Binary files /dev/null and b/apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/send-queue@2x.png differ diff --git a/apps/streamdeck/com.cluesmith.codev.sdPlugin/manifest.json b/apps/streamdeck/com.cluesmith.codev.sdPlugin/manifest.json index 9b9e74915..f0a6aae8f 100644 --- a/apps/streamdeck/com.cluesmith.codev.sdPlugin/manifest.json +++ b/apps/streamdeck/com.cluesmith.codev.sdPlugin/manifest.json @@ -52,13 +52,13 @@ "Name": "Send Feedback", "UUID": "com.cluesmith.codev.send-queue", "Tooltip": "Flush the selected builder's queued review feedback. Badge shows the queued count; inert when nothing is queued (or the workspace forwards immediately).", - "Icon": "icons/list/action", + "Icon": "icons/list/send-queue", "Controllers": [ "Keypad" ], "States": [ { - "Image": "icons/action", + "Image": "icons/send-queue", "TitleAlignment": "bottom" } ] @@ -67,13 +67,13 @@ "Name": "Open Terminal", "UUID": "com.cluesmith.codev.open-terminal", "Tooltip": "Open the selected builder's terminal — the per-builder complement to Builder Action's open-artifact.", - "Icon": "icons/list/action", + "Icon": "icons/list/open-terminal", "Controllers": [ "Keypad" ], "States": [ { - "Image": "icons/action", + "Image": "icons/open-terminal", "TitleAlignment": "bottom" } ] diff --git a/apps/streamdeck/scripts/render-action-icons.mjs b/apps/streamdeck/scripts/render-action-icons.mjs new file mode 100644 index 000000000..ae839707a --- /dev/null +++ b/apps/streamdeck/scripts/render-action-icons.mjs @@ -0,0 +1,159 @@ +// Render the dedicated manifest action icons for `send-queue` and `open-terminal` (#1440). +// +// SINGLE SOURCE: the glyph vectors are NOT re-drawn here — they are parsed out of +// `src/face.ts`'s GLYPHS map, the same vectors the runtime key face draws via +// `labelFaceSvg('comment'|'terminal', …)`. So the action-picker icon and the live hardware key +// agree by construction; changing a glyph in face.ts and re-running this script keeps them aligned. +// +// FIT: the glyphs don't fill their authored 24×24 box (comment ≈ 18×17, terminal ≈ 20×16), and a +// transparent list icon needs far less padding than a rounded-key image. So we render the glyph, +// trim it to its true drawn bounding box, then scale that bbox to the SAME fill fraction the +// existing icons use (measured: list/* ≈ 95% of frame, key images ≈ 56%). Fitting the bbox — not +// the nominal box — is what keeps the new icons from reading small next to their siblings. +// +// TOOLING: system `rsvg-convert` (librsvg) rasterizes the vector; system `magick` (ImageMagick) +// trims to the glyph bbox, fits, centers, and composites over the rounded-key ground. Both are +// pre-installed dev tools, not npm dependencies — per the #1440 scope, a one-time asset build +// prefers repo-available tooling over adding a dependency just to turn SVG into PNG. Re-run after a +// glyph changes: node scripts/render-action-icons.mjs (needs: brew install librsvg imagemagick) + +import { execFileSync } from 'node:child_process'; +import { mkdirSync, readFileSync, writeFileSync, rmSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { tmpdir } from 'node:os'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const PLUGIN = join(HERE, '..', 'com.cluesmith.codev.sdPlugin'); +const FACE_TS = join(HERE, '..', 'src', 'face.ts'); + +// name → the GLYPHS key in face.ts it renders from. +export const ICONS = [ + { name: 'send-queue', glyph: 'comment' }, + { name: 'open-terminal', glyph: 'terminal' }, +]; + +const GLYPH_COLOR = '#ffffff'; +const BG = '#1C2128'; // rounded-key ground, matching icons/action.png & siblings +const CORNER_RADIUS = 12; // measured from the existing 72px key images (scales with size) +const LIST_FILL = 0.94; // glyph bbox / frame for the transparent list icon (siblings ≈ 0.95) +const KEY_FILL = 0.56; // glyph bbox / frame for the key image (siblings ≈ 0.56) +const RENDER_PX = 512; // high-res glyph raster, downscaled by magick for clean antialiasing + +/** + * Reproduce face.ts's `stroked()` wrapper — line glyphs (comment/terminal) are stored as their + * inner paths and wrapped at draw time. Kept identical to `stroked` in src/face.ts. + */ +function stroked(color, paths) { + return `${paths}`; +} + +/** + * Pull a glyph's inner SVG out of face.ts's GLYPHS map without importing it (GLYPHS is + * module-private, and face.ts is off-limits to edit while bugfix-1431 is in flight). Supports the + * two forms GLYPHS uses: `stroked(c, '<…>')` (line glyphs) and a raw `` `<… ${c} …>` `` template + * (filled glyphs), and both bare (`comment:`) and quoted (`'pull-request':`) keys. Throws loudly if + * the shape drifts, so a silent stale-icon build can't happen. + */ +export function extractGlyph(faceSrc, key, color) { + const k = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const line = faceSrc.match(new RegExp(`\\n\\s*['"]?${k}['"]?:\\s*\\(c\\)\\s*=>\\s*([^\\n]*?),?\\s*\\n`)); + if (!line) throw new Error(`glyph '${key}' not found in face.ts GLYPHS`); + const rhs = line[1].trim(); + const strokedArg = rhs.match(/^stroked\(c,\s*'(.*)'\)$/); + if (strokedArg) return stroked(color, strokedArg[1]); + const rawArg = rhs.match(/^`(.*)`$/); + if (rawArg) return rawArg[1].replace(/\$\{c\}/g, color); + throw new Error(`glyph '${key}' has an unrecognized form: ${rhs}`); +} + +/** The glyph on its own, high-res, transparent — the raster both variants trim and fit from. */ +function glyphSvg(inner) { + return `${inner}`; +} + +function tmp(tag) { + return join(tmpdir(), `sd-icon-1440-${tag}`); +} + +function ensureTool(bin, install) { + try { + execFileSync(bin, ['--version'], { stdio: 'ignore' }); + } catch { + throw new Error(`'${bin}' not found — this one-off asset build needs it (${install}).`); + } +} + +/** magick expression that trims the high-res glyph to its drawn bbox and scales it to `target` px. */ +function fittedGlyph(glyphPng, target) { + return [glyphPng, '-trim', '+repage', '-resize', `${target}x${target}`]; +} + +/** Transparent list icon: glyph fit to LIST_FILL of the frame, centered. */ +function renderList(glyphPng, out, size) { + const target = Math.round(LIST_FILL * size); + execFileSync('magick', [ + ...fittedGlyph(glyphPng, target), + '-background', 'none', '-gravity', 'center', '-extent', `${size}x${size}`, + out, + ]); +} + +/** Key image: glyph fit to KEY_FILL of the frame, centered over the rounded #1C2128 ground. */ +function renderKey(glyphPng, out, size) { + const target = Math.round(KEY_FILL * size); + const radius = Math.round((CORNER_RADIUS * size) / 72); + const bg = tmp(`bg-${size}.png`); + execFileSync('magick', [ + '-size', `${size}x${size}`, 'xc:none', '-fill', BG, + '-draw', `roundrectangle 0,0,${size - 1},${size - 1},${radius},${radius}`, + bg, + ]); + try { + execFileSync('magick', [bg, '(', ...fittedGlyph(glyphPng, target), ')', '-gravity', 'center', '-composite', out]); + } finally { + rmSync(bg, { force: true }); + } +} + +/** Guard the fix: a list icon must fill the frame like its siblings, not sit small and padded. */ +function assertListCoverage(out, size, min) { + const dims = execFileSync('magick', [out, '-trim', '+repage', '-format', '%wx%h', 'info:'], { encoding: 'utf8' }); + const [w, h] = dims.trim().split('x').map(Number); + const coverage = Math.max(w, h) / size; + if (coverage < min) { + throw new Error(`${out}: glyph fills ${(coverage * 100).toFixed(0)}% of the frame, below the ${(min * 100).toFixed(0)}% convention floor`); + } +} + +function main() { + ensureTool('rsvg-convert', 'brew install librsvg'); + ensureTool('magick', 'brew install imagemagick'); + + const faceSrc = readFileSync(FACE_TS, 'utf8'); + mkdirSync(join(PLUGIN, 'icons', 'list'), { recursive: true }); + + for (const { name, glyph } of ICONS) { + const svgFile = tmp(`${glyph}.svg`); + const glyphPng = tmp(`${glyph}.png`); + writeFileSync(svgFile, glyphSvg(extractGlyph(faceSrc, glyph, GLYPH_COLOR))); + execFileSync('rsvg-convert', ['-w', String(RENDER_PX), '-h', String(RENDER_PX), svgFile, '-o', glyphPng]); + try { + renderKey(glyphPng, join(PLUGIN, 'icons', `${name}.png`), 72); + renderKey(glyphPng, join(PLUGIN, 'icons', `${name}@2x.png`), 144); + renderList(glyphPng, join(PLUGIN, 'icons', 'list', `${name}.png`), 20); + renderList(glyphPng, join(PLUGIN, 'icons', 'list', `${name}@2x.png`), 40); + assertListCoverage(join(PLUGIN, 'icons', 'list', `${name}@2x.png`), 40, 0.8); + } finally { + rmSync(svgFile, { force: true }); + rmSync(glyphPng, { force: true }); + } + console.log(`rendered ${name} (from GLYPHS.${glyph}) → 72/144/20/40`); + } +} + +// Run only when invoked directly (`node scripts/render-action-icons.mjs`); importing the module +// for tests exercises the pure helpers above without rasterizing. +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { + main(); +} diff --git a/apps/streamdeck/src/__tests__/manifest-icons.test.ts b/apps/streamdeck/src/__tests__/manifest-icons.test.ts new file mode 100644 index 000000000..6df2abeca --- /dev/null +++ b/apps/streamdeck/src/__tests__/manifest-icons.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect } from 'vitest'; +import { existsSync, readFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +/** + * #1440: the `send-queue` and `open-terminal` actions got dedicated icons rendered from the + * face.ts glyph vectors, replacing the shared `action` asset they used to borrow. These guards + * keep the manifest and the on-disk PNGs in agreement: a manifest that points at a missing or + * deleted image reverts the key to a blank in the Stream Deck app (and fails Elgato validation), + * so pin every referenced asset to a real file — and pin the two follow-up actions to their own. + */ +const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..'); +const pluginDir = join(root, 'com.cluesmith.codev.sdPlugin'); + +interface ManifestAction { + UUID: string; + Icon: string; + States: { Image: string }[]; +} +const manifest = JSON.parse(readFileSync(join(pluginDir, 'manifest.json'), 'utf-8')) as { + Icon: string; + CategoryIcon: string; + Actions: ManifestAction[]; +}; + +/** A manifest icon reference (no extension) → the @1x and @2x PNGs it must resolve to. */ +function pngVariants(ref: string): string[] { + return [join(pluginDir, `${ref}.png`), join(pluginDir, `${ref}@2x.png`)]; +} + +/** Read a PNG's pixel dimensions from its IHDR chunk (bytes 16–24, big-endian) — no image lib. */ +function pngSize(absPath: string): { w: number; h: number } { + const buf = readFileSync(absPath); + return { w: buf.readUInt32BE(16), h: buf.readUInt32BE(20) }; +} + +describe('manifest icon assets exist on disk', () => { + const refs = new Set([manifest.Icon, manifest.CategoryIcon]); + for (const action of manifest.Actions) { + refs.add(action.Icon); + for (const state of action.States) refs.add(state.Image); + } + + for (const ref of refs) { + it(`${ref} resolves to @1x and @2x PNGs`, () => { + for (const png of pngVariants(ref)) { + expect(existsSync(png), `missing ${png}`).toBe(true); + } + }); + } +}); + +describe('#1440 dedicated action icons', () => { + function action(uuid: string): ManifestAction { + const found = manifest.Actions.find((a) => a.UUID === uuid); + if (!found) throw new Error(`action ${uuid} not in manifest`); + return found; + } + + it('send-queue points at its own icon, not the shared action asset', () => { + const a = action('com.cluesmith.codev.send-queue'); + expect(a.Icon).toBe('icons/list/send-queue'); + expect(a.States[0].Image).toBe('icons/send-queue'); + }); + + it('open-terminal points at its own icon, not the shared action asset', () => { + const a = action('com.cluesmith.codev.open-terminal'); + expect(a.Icon).toBe('icons/list/open-terminal'); + expect(a.States[0].Image).toBe('icons/open-terminal'); + }); + + it('removes the verified-dead icons (approve-gate-empty / -pending / gate-nav)', () => { + const dead = ['icons/approve-gate-empty', 'icons/approve-gate-pending', 'icons/gate-nav']; + for (const ref of dead) { + for (const png of pngVariants(ref)) { + expect(existsSync(png), `${png} should have been deleted`).toBe(false); + } + } + }); + + it('keeps the still-live approve-gate assets', () => { + for (const ref of ['icons/approve-gate', 'icons/list/approve-gate']) { + for (const png of pngVariants(ref)) { + expect(existsSync(png), `missing ${png}`).toBe(true); + } + } + }); + + // The Stream Deck convention: key Image @1x/@2x = 72/144, list Icon @1x/@2x = 20/40. A wrongly + // sized asset renders blurry or gets rejected by Elgato validation — pin the committed sizes. + it.each(['send-queue', 'open-terminal'])('%s icons ship at the convention sizes', (name) => { + expect(pngSize(join(pluginDir, `icons/${name}.png`))).toEqual({ w: 72, h: 72 }); + expect(pngSize(join(pluginDir, `icons/${name}@2x.png`))).toEqual({ w: 144, h: 144 }); + expect(pngSize(join(pluginDir, `icons/list/${name}.png`))).toEqual({ w: 20, h: 20 }); + expect(pngSize(join(pluginDir, `icons/list/${name}@2x.png`))).toEqual({ w: 40, h: 40 }); + }); +}); diff --git a/apps/streamdeck/src/__tests__/render-action-icons.test.ts b/apps/streamdeck/src/__tests__/render-action-icons.test.ts new file mode 100644 index 000000000..545ea3d20 --- /dev/null +++ b/apps/streamdeck/src/__tests__/render-action-icons.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +// @ts-expect-error — plain ESM build script, no type declarations. +import { ICONS, extractGlyph } from '../../scripts/render-action-icons.mjs'; + +/** + * #1440: the action icons are rendered FROM face.ts's GLYPHS map, not re-drawn — the render + * script parses the vector out of face.ts so the picker icon and the runtime key face share one + * source. These guards protect that contract: if GLYPHS's declaration shape drifts, the extractor + * must throw (a loud build failure) rather than silently ship a stale icon. + */ +const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..'); +const faceSrc = readFileSync(join(root, 'src', 'face.ts'), 'utf-8'); + +describe('extractGlyph pulls the glyph vector out of face.ts', () => { + for (const { name, glyph } of ICONS) { + it(`${name} extracts the '${glyph}' glyph as a colored SVG group`, () => { + const svg = extractGlyph(faceSrc, glyph, '#ffffff'); + expect(svg).toContain('#ffffff'); + // comment/terminal are line glyphs: rendered through the stroked() wrapper. + expect(svg).toMatch(/stroke="#ffffff"/); + expect(svg).not.toContain('${c}'); // the color placeholder must be substituted + }); + } + + it('throws on an unknown glyph key rather than emitting nothing', () => { + expect(() => extractGlyph(faceSrc, 'no-such-glyph', '#ffffff')).toThrow(/not found/); + }); + + it('terminal glyph carries the terminal shape (rect + prompt path)', () => { + const svg = extractGlyph(faceSrc, 'terminal', '#ffffff'); + expect(svg).toContain('.png` (72), `icons/@2x.png` (144), + `icons/list/.png` (20), `icons/list/@2x.png` (40). Repoint manifest `Icon` + + `States[].Image` off the shared `action` asset. +- Delete six verified-dead PNGs: approve-gate-empty, approve-gate-pending, gate-nav (+@2x each). + KEEP approve-gate.* and list/approve-gate.* (live). +- Profile work OUT. No key-face / press-behaviour change. +- **Do NOT edit face.ts or actions.ts** — bugfix-1431 is in flight there. Diff = manifest.json + + icon files (+ a committed render script, justified below). + +## Decisions +- **Single source honored by parsing face.ts at build time.** The render script reads + `src/face.ts`, extracts the `comment`/`terminal` GLYPHS path data + the `stroked()` wrapper, + and rasterizes — so the PNGs derive from the exact same vector the runtime key face uses, with + zero duplication and zero edit to face.ts. +- **Rasterizer: system `rsvg-convert` (librsvg), no new npm dep.** Architect asked to prefer + repo-available tooling over adding a dependency for a one-time asset build. Script committed at + `apps/streamdeck/scripts/render-action-icons.mjs` for reproducibility; it shells to + rsvg-convert (documented in PR body). +- Frame matches existing convention measured from live assets: key image = rounded rect rx=12 + fill `#1C2128` + white glyph; list icon = transparent + white glyph. + +## Status +- Implemented + PR #1443 opened (review in body). +- CMAP (AIR PR): gemini=APPROVE(HIGH), claude=REQUEST_CHANGES(HIGH), codex=unavailable (external + OpenAI billing — "no credits remaining", not our code). +- **Acted on Claude's blocking finding (verified against the PNGs first):** the first-pass list + icons filled only ~45% of the frame vs the ~95% convention, because `listSvg` reused the + key-frame padding and the glyphs don't fill their 24×24 box. Rewrote the render pipeline to + rasterize the glyph, trim to its true bbox, then fit to the convention's fill fraction + (list 0.94, key 0.56) via `magick` + `rsvg-convert`; added a self-check that fails the build if a + list icon drops below 80% coverage. Re-measured: list @2x now 38×36 / 38×30 (sibling 38×34); key + @2x 81×77 / 81×65 (sibling 80×68). Also addressed Claude's minors: friendly ENOENT for both + system tools, quoted-key-safe `extractGlyph` regex, and a zero-dep PNG-dimension test guard. +- Deferred (architect's call, noted in PR): open-terminal's glyph resembles the still-shared + `icons/action` used by catch-all "Codev Action" — re-glyphing Codev Action (to `bolt`) would + fully resolve the picker ambiguity; the terminal→open-terminal mapping itself is baked scope. +- Verified: check-types ✓, build ✓, `streamdeck validate` ✓, vitest 162 passed. +- Committed + pushed (fix commit 1d19f99f2), PR #1443 body updated with CMAP round, architect + notified. +- **PR gate open — WAITING for human approval.** `porch approve 1440 pr` is the human's to run + (via the architect). Nothing further from me until approval arrives.