Compare commits

...

7 Commits

Author SHA1 Message Date
David Ibia 161d205dfd feat(bin): refresh omo skills from upstream daily on flowstate-remote
skills-update re-vendors impeccable (newest skill-v* release) and
web-design-guidelines the way the skills README describes, then commits
only .config/omo/skills and pushes when main was level with origin/main.
It also syncs the machine-local vercel-react-best-practices, docx, pdf,
pptx, xlsx and skill-creator copies from vercel-labs/agent-skills and
anthropics/skills. A failed update keeps the previous copy and exits
non-zero. skills-update --install-timer writes and enables the daily
systemd user timer.

It is Linux only, so setup-mac.sh does not link it.
2026-10-17 20:23:16 +00:00
David Ibia 721d7fbb0a feat(bin): run impeccable URL scans on Browserless on flowstate-remote
impeccable-browser stands in for Chrome via IMPECCABLE_BROWSER. Public
URLs render on Browserless with the key from the Reoclo project
flowstate-tools; localhost and private origins reach it through a
cloudflared quick tunnel that lives only for the scan, with the CDP
traffic rewritten so reports name the local URL. Anything else, or a
failed tunnel or Browserless connection, falls back to local headless
Chrome with --no-sandbox.

It is Linux only, so setup-mac.sh does not link it.
2026-10-17 20:17:54 +00:00
David Ibia 786925b583 chore(omo): vendor impeccable skill 4.6.0
Re-copy .opencode/skills/impeccable from upstream tag skill-v4.6.0
(8a0e198, engine 0.1.15), keep LICENSE, and re-apply the OmO path
rewrite and the critique routing line.

The documented rewrite missed the base-directory fallback in SKILL.md,
which ends at scripts rather than scripts/impeccable, so the upgrade
command now rewrites the scripts directory prefix. The skills README
also notes that the engine binary is downloaded by scripts/VERSION
rather than vendored.
2026-10-17 20:17:54 +00:00
David Ibia dd90b462f5 feat(omo): quit with :q, :q!, :qa or :wq
A small extension catches Enter while the editor holds :q, :q!, :qa or :wq and shuts omo down, after the running turn if there is one. It reads the key through onTerminalInput rather than the input event, so typing :q never fires UserPromptSubmit hooks, which used to leave the blue working marker behind. setup-mac.sh links it into ~/.omo/agent/extensions.
2026-10-17 16:28:20 +01:00
David Ibia cd0ed704ab feat(omo): notify plain Ghostty tabs about omo sessions on flowstate-remote
Running fs in a plain Ghostty tab left the Mac silent: no local tmux reads the title, and the server tagged its pushes attached, so the ntfy agent skipped them. The agent now skips an attached push only when a tmux pane here recorded that exact event through its title, waiting up to three seconds for it; otherwise it plays the sound and posts the banner. The banner comes from the plain tab whose mosh-client uses the port the server tags (mosh-<port>), so clicking it lands on the right tab, and sessions whose tab closed while their mosh-server lingers still notify.

The server's title now reads marker first (🟢 studio · omo:done:<time>), so a plain tab shows the state at a glance; omo-remote parses that format.
2026-10-17 16:24:26 +01:00
David Ibia 2c58626e0b docs(owt): note how owt runs on flowstate-remote
The server links ~/.local/bin/owt from a clone of this repository and carries the owt wrapper and hook. Its repositories are on GitHub, which tea cannot query, so owt done there sees merge-commit PRs as merged and needs --force after a squash merge.
2026-10-17 16:09:46 +01:00
David Ibia c9059d4867 feat(omo): notify this Mac about omo sessions on flowstate-remote
mosh drops the OSC 777 banner that omo-notify posts, so omo sessions on the server only reached the phone through ntfy. The server's omo-notify now writes the session state into its tmux client's terminal title (omo:<state>:<time>:<project>), which mosh and ssh carry. A pane-title-changed hook hands it to the new omo-remote, which runs omo-notify for the local mosh pane through the new OMO_NOTIFY_PANE override, so remote sessions get the same sound, tab marker and banner as local ones.

Sessions that no terminal is showing come through ntfy instead: omo-remote install starts a launchd agent running ntfy subscribe, which plays the sound and posts the banner for omo pushes the server did not tag attached. Brewfile gains ntfy; the token lives in ~/.config/ntfy/omo-remote.env, outside the repository.
2026-10-17 16:09:35 +01:00
30 changed files with 1138 additions and 74 deletions
+27
View File
@@ -0,0 +1,27 @@
// Enter is caught before the editor submits, where omo handles /quit, so :q never fires prompt hooks
// such as UserPromptSubmit. ctx.shutdown() waits for the agent to go idle, so :q mid-turn quits after it.
import type { ExtensionAPI } from "@code-yeongyu/senpi";
import { Key, matchesKey } from "@earendil-works/pi-tui";
const QUIT_COMMANDS = new Set([":q", ":q!", ":qa", ":wq"]);
export default function (pi: ExtensionAPI) {
let unsubscribe: (() => void) | undefined;
pi.on("session_start", (_event, ctx) => {
unsubscribe?.();
unsubscribe = ctx.ui.onTerminalInput((data) => {
if (!matchesKey(data, Key.enter) || !QUIT_COMMANDS.has(ctx.ui.getEditorText().trim())) {
return undefined;
}
ctx.ui.setEditorText("");
ctx.shutdown();
return { consume: true };
});
});
pi.on("session_shutdown", () => {
unsubscribe?.();
unsubscribe = undefined;
});
}
+14 -4
View File
@@ -13,7 +13,7 @@ OpenCode-era evaluation notes and rollback steps are in git history at
| Skill | Upstream | Revision | License | | Skill | Upstream | Revision | License |
|---|---|---|---| |---|---|---|---|
| `impeccable` | [pbakaus/impeccable](https://github.com/pbakaus/impeccable) (`.opencode` integration, skill 4.3.1) | `f2c7051853848826aac2f4646581d62a732155ad` | Apache 2.0, included | | `impeccable` | [pbakaus/impeccable](https://github.com/pbakaus/impeccable) (`.opencode` integration, tag `skill-v4.6.0`, engine 0.1.15) | `8a0e198da34ebc5b936a9d5975fb7a3b1cf353b7` | Apache 2.0, included |
| `web-design-guidelines` | [vercel-labs/agent-skills](https://github.com/vercel-labs/agent-skills) (`skills/web-design-guidelines/SKILL.md`) | `063bee94c3f4df8453406c830b0a7df0f2860278` | per upstream | | `web-design-guidelines` | [vercel-labs/agent-skills](https://github.com/vercel-labs/agent-skills) (`skills/web-design-guidelines/SKILL.md`) | `063bee94c3f4df8453406c830b0a7df0f2860278` | per upstream |
| ten marketing and copy skills | [coreyhaines31/marketingskills](https://github.com/coreyhaines31/marketingskills) (`skills/`) | `b9ba399dd88b082b926e261e8ccfb843d20aa066` | MIT, copied into each skill | | ten marketing and copy skills | [coreyhaines31/marketingskills](https://github.com/coreyhaines31/marketingskills) (`skills/`) | `b9ba399dd88b082b926e261e8ccfb843d20aa066` | MIT, copied into each skill |
| `frontend-workflow` | local | - | - | | `frontend-workflow` | local | - | - |
@@ -30,7 +30,11 @@ The ten marketing skills are `product-marketing`, `copywriting`, `copy-editing`,
`SKILL.md`. Without this the detector and live-server verbs do not resolve under `SKILL.md`. Without this the detector and live-server verbs do not resolve under
OmO. `reference/critique.md` also carries an OmO routing line that sends OmO. `reference/critique.md` also carries an OmO routing line that sends
Assessment A to the `design-critique-a` category and Assessment B to Assessment A to the `design-critique-a` category and Assessment B to
`design-critique-b` (defined in `.config/omo/omo.jsonc`). `design-critique-b` (defined in `.config/omo/omo.jsonc`). `LICENSE` comes from the
upstream repository root, because the `.opencode` directory ships without one. The
engine binary is not vendored: on first run, `scripts/impeccable` downloads the
version named in `scripts/VERSION` into `~/.impeccable/bin/<version>/` and checks it
against the published checksum.
- **`web-design-guidelines`**: unchanged. It fetches the current - **`web-design-guidelines`**: unchanged. It fetches the current
[Web Interface Guidelines](https://raw.githubusercontent.com/vercel-labs/web-interface-guidelines/main/command.md) [Web Interface Guidelines](https://raw.githubusercontent.com/vercel-labs/web-interface-guidelines/main/command.md)
when invoked, so its rule feed is intentionally not pinned. when invoked, so its rule feed is intentionally not pinned.
@@ -47,6 +51,12 @@ and rerun `link-ai-configs.sh`. For `impeccable`, re-apply the path rewrite:
```bash ```bash
cd .config/omo/skills/impeccable cd .config/omo/skills/impeccable
rg -l -F '.opencode/skills/impeccable/scripts/impeccable' . \ rg -l -F '.opencode/skills/impeccable/scripts' . \
| xargs sed -i '' 's#\.opencode/skills/impeccable/scripts/impeccable#~/.omo/agent/skills/impeccable/scripts/impeccable#g' | xargs sed -i '' 's#\.opencode/skills/impeccable/scripts#~/.omo/agent/skills/impeccable/scripts#g'
``` ```
This pattern also covers the base-directory fallback in `SKILL.md`, which ends at
`scripts` rather than `scripts/impeccable`. Keep `LICENSE` when re-copying (for
example `rsync -a --delete --exclude LICENSE <upstream>/.opencode/skills/impeccable/ .`),
and re-add the OmO routing line after the "Delegate Assessment A and Assessment B"
paragraph in `reference/critique.md`.
+2 -1
View File
@@ -1,7 +1,7 @@
--- ---
name: impeccable name: impeccable
description: Use when the user wants to design, redesign, shape, critique, audit, polish, clarify, distill, harden, optimize, adapt, animate, colorize, extract, or otherwise improve a frontend interface. Covers websites, landing pages, dashboards, product UI, app shells, components, forms, settings, onboarding, and empty states. Handles UX review, visual hierarchy, information architecture, cognitive load, accessibility, performance, responsive behavior, theming, anti-patterns, typography, fonts, spacing, layout, alignment, color, motion, micro-interactions, UX copy, error states, edge cases, i18n, and reusable design systems or tokens. Also use for bland designs that need to become bolder or more delightful, loud designs that should become quieter, live browser iteration on UI elements, or ambitious visual effects that should feel technically extraordinary. Not for backend-only or non-UI tasks. description: Use when the user wants to design, redesign, shape, critique, audit, polish, clarify, distill, harden, optimize, adapt, animate, colorize, extract, or otherwise improve a frontend interface. Covers websites, landing pages, dashboards, product UI, app shells, components, forms, settings, onboarding, and empty states. Handles UX review, visual hierarchy, information architecture, cognitive load, accessibility, performance, responsive behavior, theming, anti-patterns, typography, fonts, spacing, layout, alignment, color, motion, micro-interactions, UX copy, error states, edge cases, i18n, and reusable design systems or tokens. Also use for bland designs that need to become bolder or more delightful, loud designs that should become quieter, live browser iteration on UI elements, or ambitious visual effects that should feel technically extraordinary. Not for backend-only or non-UI tasks.
version: 4.3.1 version: 4.6.0
user-invocable: true user-invocable: true
argument-hint: "[shape · audit|critique · animate|bolder|colorize|delight|layout|overdrive|quieter|typeset · adapt|clarify|distill · harden|onboard|optimize|polish · init|document|extract|live|generate] [target]" argument-hint: "[shape · audit|critique · animate|bolder|colorize|delight|layout|overdrive|quieter|typeset · adapt|clarify|distill · harden|onboard|optimize|polish · init|document|extract|live|generate] [target]"
license: Apache 2.0 license: Apache 2.0
@@ -29,6 +29,7 @@ Core principles:
- **The brief wins.** Honor pinned aesthetics, eras, materials, fonts, and palettes even when they conflict with a saturated-pattern warning. Redirecting a clear brief toward your taste is failure. - **The brief wins.** Honor pinned aesthetics, eras, materials, fonts, and palettes even when they conflict with a saturated-pattern warning. Redirecting a clear brief toward your taste is failure.
- **Refinement preserves; redesign replaces.** Refinement keeps the incumbent identity, behavior, copy, and everything outside scope. Ask before replacing factual copy or adding claims. Redesign keeps product truth, content, function, native affordances, and constraints, but treats the old look as evidence and anti-reference; choose a replacement world in new-work and replace DESIGN.md. Never split the difference into polish on the discarded look. - **Refinement preserves; redesign replaces.** Refinement keeps the incumbent identity, behavior, copy, and everything outside scope. Ask before replacing factual copy or adding claims. Redesign keeps product truth, content, function, native affordances, and constraints, but treats the old look as evidence and anti-reference; choose a replacement world in new-work and replace DESIGN.md. Never split the difference into polish on the discarded look.
- **Loaded symbols stay out of the decoration.** A subject's world does not license emblems tied to militarism, supremacy, or hate movements as motifs, badges, or ornament, such as the Rising Sun flag's rays, the Confederate battle flag, or Nazi-era insignia and their stylised variants; reach for that world's neutral forms instead. Content that documents such a symbol as fact stays as it is.
- **Visual authority is evidence, not a filename.** Missing DESIGN.md alone does not make a project greenfield; new-work decides whether to preserve, expand, or replace the incumbent world. - **Visual authority is evidence, not a filename.** Missing DESIGN.md alone does not make a project greenfield; new-work decides whether to preserve, expand, or replace the incumbent world.
## Modes ## Modes
@@ -69,8 +69,8 @@ Run the bundled detector and verify each finding in context. Look for repeated i
|---|-----------|-------|-------------| |---|-----------|-------|-------------|
| 1 | Accessibility | ? | [most critical a11y issue or "--"] | | 1 | Accessibility | ? | [most critical a11y issue or "--"] |
| 2 | Performance | ? | | | 2 | Performance | ? | |
| 3 | Responsive Design | ? | | | 3 | Theming | ? | |
| 4 | Theming | ? | | | 4 | Responsive Design | ? | |
| 5 | Implementation Integrity | ? | | | 5 | Implementation Integrity | ? | |
| **Total** | | **??/20** | **[Rating band]** | | **Total** | | **??/20** | **[Rating band]** |
@@ -0,0 +1,63 @@
# Plan and asset review
Use this checkpoint on comp-led builds once every raster region has its plate and the plates gate has scored them, before any page code exists. The approved comp is the reference. The user reviews two things: the plates that will ship, and the production plan for everything else, meaning which regions code draws. A painted region planned as code is the most expensive mistake a comp-led build makes, and this is where the user catches it. Text, controls and chrome are judged later, in the assembled first viewport.
## Plan, capture, serve
Run `~/.omo/agent/skills/impeccable/scripts/impeccable component-review plan`. It writes `.impeccable/review/components.json` from the measured spec, the comp and the plate files, and refuses, naming each one, while any raster region lacks its plate. Never write or edit that file for this stage: the packet is derived from the spec, so every change belongs in the regions file.
If the harness exposes `component_review`, call it with `manifest_path` set to `.impeccable/review/components.json`. The host captures, presents the review and returns the user's decisions. A suspended request is waiting for the user; it is not a failed build or an approval.
Otherwise run `~/.omo/agent/skills/impeccable/scripts/impeccable component-review capture --manifest .impeccable/review/components.json`, then start `~/.omo/agent/skills/impeccable/scripts/impeccable component-review serve --session <returned session>` in the background. Open the URL it prints in the available browser and wait for the user; `serve` exits 0 once they submit. Read the decisions with `~/.omo/agent/skills/impeccable/scripts/impeccable component-review status --session <id>`; `~/.omo/agent/skills/impeccable/scripts/impeccable component-review verify --manifest .impeccable/review/components.json` confirms approval and refuses pending, needs-work and stale input. Never submit the page or write a receipt on the user's behalf.
`serve` exits 2 when this session has no browser (the same signal as the decision page) and 4 when it closes after 30 idle minutes without a decision. Either way no one is reviewing: stop waiting, do not approve anything yourself, and do not build past this checkpoint. End the run and report the plan and asset review as pending with its session ID, so the user can resume it. A waiting review is pending work, not a completed build.
## Act on the receipt
Apply the user's decisions as given, never your own favorable verdict in their place.
- **approve**: once every item is approved and the inventory is confirmed, advance to the hero.
- **revise** (a plate): regenerate that plate at the same path with the user's feedback.
- **revise with split** (an asset): replace that region in the regions file with its layers: a frame plate with a transparent opening (kind `plate`, same box), the content as its own `image` region at the opening's box, and each moving part (a shutter, a door) as its own plate. Name each layer after the original region, as `<id>-frame`, `<id>-view` or `<id>-shutter-left`, so the next round shows it as part of the user's request. Rerun `~/.omo/agent/skills/impeccable/scripts/impeccable comp-spec --comp <comp.png> --regions <regions.json>` and produce the plates.
- **revise** (a plan item): change the regions file as the feedback says (resize or extend a raster region, split material into its own plate region, or adjust the code region), rerun `~/.omo/agent/skills/impeccable/scripts/impeccable comp-spec --comp <comp.png> --regions <regions.json>`, and produce any new plates.
- **reclassify** (a code region): in the regions file, change that region's `kind` to the one the user chose and rewrite its `note` to describe the material. Rerun `~/.omo/agent/skills/impeccable/scripts/impeccable comp-spec --comp <comp.png> --regions <regions.json>`, then produce the new plates, with the asset producer when subagents are available.
- **missing**: add the region to the regions file, rerun `~/.omo/agent/skills/impeccable/scripts/impeccable comp-spec --comp <comp.png> --regions <regions.json>`, and produce its plate if it is raster.
Then run `plan`, `capture` and `serve` again. Unchanged decisions carry over, so the user sees only what changed. Any spec change, or a plate replaced after acceptance, needs a new round; the build-phase gate stays closed until the review of the current spec is accepted.
## Assemble and review
Build the first viewport from the approved plates and plan, and run the hero gate. Human review does not waive its integrity checks. After three failed hero attempts, or three failed responsive attempts before any first viewport is accepted, stop iterating and present the first-viewport review with the current build; the user's eye settles what the readings could not.
Present a second manifest at `.impeccable/review/hero.json`. You write this one, and its shape is fixed:
```json
{
"schemaVersion": 2,
"stage": "hero",
"id": "hero",
"title": "First viewport",
"comp": {"path": ".impeccable/mocks/comp.png", "width": 1536, "height": 1024},
"components": [{
"id": "first-viewport",
"name": "First viewport",
"medium": "HTML / CSS",
"note": "Assembled first viewport",
"box": {"x": 0, "y": 0, "w": 1, "h": 1},
"preview": {"kind": "page", "path": "index.html"},
"dependencies": ["styles.css", "assets/plates/sky.png", "fonts/display.woff2"]
}]
}
```
- `schemaVersion` is 2. Version 3 is the plan review packet and requires stage `components`. `codeRegions` and `specSha256` exist only in version 3 and are refused here. `reviewGroup` is refused too: version 3 rejects it, and any other version accepts it only on a page preview in a `components`-stage manifest, never in a hero manifest.
- `comp.path` is the `comp` value in `.impeccable/build/spec.json`, and `width` and `height` are that PNG's pixel size.
- Exactly one component, with `box` exactly `{"x": 0, "y": 0, "w": 1, "h": 1}` and `name`, `medium` and `note` as strings.
- `preview.path` is the page entry the build gates (`artifact` in `.impeccable/build/state.json`).
- `dependencies` lists every other file the page loads (stylesheets, scripts, plates, images, fonts) as bare strings. Every path, here and above, is project-relative and plain: no leading `./` or `/`, no `..`, no URL, no `?`, `#`, `%`, `:` or backslash. A request to a file missing from this list, or to another host, fails the capture.
The reference stays the approved comp. Call the same host review tool, or run `capture`, `serve` and `verify` with this manifest. Needs-work feedback starts another assembly round.
Acceptance closes human review for this build: never request plan, asset or assembly approval again. While the page renders what the user accepted, the hero score, the palette check and every numeric reading are advisories; material vetoes still hold (a missing or unreferenced plate, an SVG illustration, an organic clip, a clipped plate, invented ink, failed rendered presence). A text, control or chrome region the accepted screenshot also lacks is settled by the acceptance; a plate it lacks is a question for the user that the gate's reason spells out: do not move off what they accepted on your own, but carry out their answer, including placing the plate. When the capture no longer matches the accepted screenshot, restore what the user accepted; until then the readings apply. Complete the rest of the page, responsive behavior, finish checks and documentation with the accepted first viewport as the visual direction. This is first-viewport calibration, not a claim that the user reviewed the rest of the page. Shared stylesheet edits do not reopen approval. Preserve the accepted direction; a later explicit user change is a new task.
Assembled-page capture executes inline and declared local scripts from the pinned inputs. Network APIs, frames and workers are unavailable; the initial viewport must settle before capture. Keep the real page and declare its scripts rather than removing behavior to pass review.
@@ -4,11 +4,11 @@ Resolve one stable target, run two independent assessments, synthesize a design
### Hard Invariants ### Hard Invariants
- Assessment A (design review) and Assessment B (detector/browser evidence) are both required. - Assessment A (design review) and Assessment B (detector/browser evidence) are both required, except on a native platform (see Assessment Orchestration).
- Assessment A and B MUST run as two isolated sub-agents whenever a sub-agent/Task tool is exposed. Running them inline in this context is "possible" but is NOT permitted; it is a degraded run. Inline is allowed ONLY when no sub-agent tool exists (or the user declined, on harnesses that ask). - Assessment A and B MUST run as two isolated sub-agents whenever a sub-agent/Task tool is exposed. Running them inline in this context is "possible" but is NOT permitted; it is a degraded run. Inline is allowed ONLY when no sub-agent tool exists (or the user declined, on harnesses that ask). Native platforms run A alone (see Assessment Orchestration).
- If you degrade for any reason, the report's first line MUST be a banner: `⚠️ DEGRADED: single-context (<reason>)`. A silent degraded critique is a failed critique. - If you degrade for any reason, the report's first line MUST be a banner: `⚠️ DEGRADED: single-context (<reason>)`. A silent degraded critique is a failed critique.
- Assessment A must finish before detector findings enter the parent synthesis context. Detector output is deterministic, but it still anchors judgment. - Assessment A must finish before detector findings enter the parent synthesis context. Detector output is deterministic, but it still anchors judgment.
- A skipped detector is a failed critique run unless `impeccable detect` is missing or crashes after a real attempt. - A skipped detector is a failed critique run unless `impeccable detect` is missing or crashes after a real attempt, or the platform is native (see Assessment Orchestration).
- Viewable targets require browser inspection when available. - Viewable targets require browser inspection when available.
- Any local server started only for critique visualization must run in the background, have a recorded stop method, and be stopped before final reporting unless the user asks to keep it. - Any local server started only for critique visualization must run in the background, have a recorded stop method, and be stopped before final reporting unless the user asks to keep it.
- Do not claim a user-visible overlay exists unless script injection succeeded and the detector ran in the page. - Do not claim a user-visible overlay exists unless script injection succeeded and the detector ran in the page.
@@ -30,6 +30,8 @@ Resolve one stable target, run two independent assessments, synthesize a design
### Assessment Orchestration ### Assessment Orchestration
**Native platforms** (`ios` / `android` / `adaptive`): Assessment B does not apply, because `impeccable detect` and the browser overlay read only web markup. Run Assessment A alone in this context and skip both sub-agent gates below, the Codex permission question included: with no B there is nothing to isolate A from, so this is the complete native method and carries no degraded banner. Do not run `impeccable detect` on native source; its empty result reads as clean.
Delegate Assessment A and Assessment B to separate sub-agents. They must not see each other's output. Do not show findings to the user until synthesis. Delegate Assessment A and Assessment B to separate sub-agents. They must not see each other's output. Do not show findings to the user until synthesis.
OmO routing: when the task tool accepts a category, spawn Assessment A with `category: "design-critique-a"` and Assessment B with `category: "design-critique-b"`. Never pass `model` with a category. OmO routing: when the task tool accepts a category, spawn Assessment A with `category: "design-critique-a"` and Assessment B with `category: "design-critique-b"`. Never pass `model` with a category.
@@ -57,7 +59,7 @@ Return: design-specificity verdict, heuristic scores, cognitive load, emotional
### Assessment B: Detector + Browser Evidence ### Assessment B: Detector + Browser Evidence
Run the bundled detector and browser visualization evidence. Assessment B is mandatory and must remain isolated from Assessment A until both are complete. Run the bundled detector and browser visualization evidence. On web, Assessment B is mandatory and must remain isolated from Assessment A until both are complete.
CLI scan: CLI scan:
```bash ```bash
@@ -95,6 +97,7 @@ Structure your feedback as a design director would:
The report's first line MUST declare how the assessments were run, so a degraded run is never silent: The report's first line MUST declare how the assessments were run, so a degraded run is never silent:
- Dual-agent: `Method: dual-agent (A: <agent-id> · B: <agent-id>)` - Dual-agent: `Method: dual-agent (A: <agent-id> · B: <agent-id>)`
- Degraded: `⚠️ DEGRADED: single-context (<reason, e.g. no sub-agent tool exposed>)` - Degraded: `⚠️ DEGRADED: single-context (<reason, e.g. no sub-agent tool exposed>)`
- Native: `Method: design review only (detector not applicable, native platform)`
#### Design Health Score #### Design Health Score
> *Consult the [Heuristics Scoring Guide](#heuristics-scoring-guide) section below.* > *Consult the [Heuristics Scoring Guide](#heuristics-scoring-guide) section below.*
@@ -127,7 +130,7 @@ Be honest with scores. A 4 means genuinely excellent. Most real interfaces score
**LLM assessment**: Your unanchored evaluation of design specificity. Cover overall coherence, structural sameness, category-interchangeable choices, and missed opportunities for product character. **LLM assessment**: Your unanchored evaluation of design specificity. Cover overall coherence, structural sameness, category-interchangeable choices, and missed opportunities for product character.
**Deterministic scan**: Summarize what the automated detector found, with counts and file locations. Note any additional issues the detector caught that you missed, and flag any false positives. **Deterministic scan**: Summarize what the automated detector found, with counts and file locations. Note any additional issues the detector caught that you missed, and flag any false positives. On a native platform, write `not applicable (native platform)` instead.
**Visual overlays** (if injection succeeded): Tell the user that overlays are now visible in the **[Human]** tab in their browser, highlighting the detected issues. Summarize what the console output reported. If browser visualization was attempted but injection failed, say that no reliable user-visible overlay is available and report the fallback signal instead. **Visual overlays** (if injection succeeded): Tell the user that overlays are now visible in the **[Human]** tab in their browser, highlighting the detected issues. Summarize what the console output reported. If browser visualization was attempted but injection failed, say that no reliable user-visible overlay is available and report the fallback signal instead.
@@ -11,7 +11,11 @@ Do not redesign. Preserve the reference's visual role, silhouette, palette, ligh
## Decision Comps ## Decision Comps
When the parent hands you a decision card packet instead of an approved mock, the job is one comp: one card, one file, written to the card's declared `comp` path the moment it renders. The parent runs several of you in parallel, one per card, so this card is your entire contract; generate first, plan never, because the file on disk is the deliverable and the decision page is waiting on it. Work from the card's structured fields and PRODUCT.md alone; report a card too thin to brief a comp, never pad it from imagination. Render the card's direction as a north-star comp at full fidelity: the requested surface's first viewport, prompt led by the surface's own structure (regions named in order with their scale relationships, never the world's atmosphere), fully committed in the card's own palette, type character, and material world. A native app or mobile-first surface is a portrait frame at its device viewport, never a landscape default. Every sibling renders at the same full fidelity in its own grammar, one surface, one aspect; equal commitment keeps the comparison honest. Real product name and real content only; never invent commercial claims, prices, benchmarks, or dates PRODUCT.md does not carry. Exclusions bind those claims, never a medium the card's own world has not excluded: a subject that lives in photographs keeps its photographs. Write the prompt sidecar beside the file. Return one line naming the path and any deviation, nothing more. Everything below this section is the asset-production job; none of it applies to a decision-comp run. When the parent hands you a decision card packet instead of an approved mock, the job is one comp: one card, one file, written to the card's declared `comp` path the moment it renders. The parent runs several of you in parallel, one per card, so this card is your entire contract; generate first, plan never, because the file on disk is the deliverable and the decision page is waiting on it. Work from the card's structured fields and PRODUCT.md alone; report a card too thin to brief a comp, never pad it from imagination. Render the card's direction as a north-star comp at full fidelity: the requested surface's first viewport, prompted in the short shape visualize.md gives (the product and the visitor's job with real content, what leads, the parts in reading order, the frame), with the look taken from the card's world: its card images attached as references when the packet names them, otherwise two to four sentences of concrete look. A native app or mobile-first surface is a portrait frame at its device viewport, never a landscape default. Every sibling renders at the same full fidelity in its own grammar, one surface, one aspect; equal commitment keeps the comparison honest. Real product name and real content only; never invent commercial claims, prices, benchmarks, or dates PRODUCT.md does not carry. Exclusions bind those claims, never a medium the card's own world has not excluded: a subject that lives in photographs keeps its photographs. Write the prompt sidecar beside the file. Return one line naming the path and any deviation, nothing more. Everything below this section is the asset-production job; none of it applies to a decision-comp run.
## Review handoff
Return the actual files and any unresolved drift to the parent for the user's plan and asset review in [component-review.md](../component-review.md). A parent or automatic visual check is not a substitute for that human checkpoint. The parent may call you again after it: for regions the user reclassified from code to raster (the spec now lists them with a plate path), or for plates the user asked to revise, with their feedback as the prompt notes. Produce only those, preserve unchanged assets, and hand off real files as before; never self-approve. This checkpoint does not apply to the Decision Comps job above.
## Input Contract ## Input Contract
@@ -29,6 +33,7 @@ Per region, in the spec's order:
2. Choose the background from the approved region: an isolated figure, object, or line drawing on the page ground is a **transparent cutout**; a photograph, full-frame illustration, or texture stays **opaque**. Save `~/.omo/agent/skills/impeccable/scripts/impeccable comp-spec --plate-prompt <id> --background transparent` to a UTF-8 prompt file for a cutout; use `--background opaque` otherwise. The transparent prompt preserves reference placement and clear margins, white paint, fine edges, and interior holes. 2. Choose the background from the approved region: an isolated figure, object, or line drawing on the page ground is a **transparent cutout**; a photograph, full-frame illustration, or texture stays **opaque**. Save `~/.omo/agent/skills/impeccable/scripts/impeccable comp-spec --plate-prompt <id> --background transparent` to a UTF-8 prompt file for a cutout; use `--background opaque` otherwise. The transparent prompt preserves reference placement and clear margins, white paint, fine edges, and interior holes.
3. Produce the plate at its exact spec `plate` path. Create the output directory first and choose a supported output size matching the region's aspect, at least 1.5x its pixel dimensions. Prefer the harness-native image tool with the crop as input and the saved prompt; request a transparent PNG for cutouts, then run `~/.omo/agent/skills/impeccable/scripts/impeccable embed-prompt <plate> --prompt-file <prompt.txt>` (if you refine the prompt, save and embed the exact text sent). With the API fallback, run `~/.omo/agent/skills/impeccable/scripts/impeccable generate-image --ref <crop.png> --prompt-file <prompt.txt> --out <plate.png> --size <WxH> --quality high --background transparent` for a cutout, or `--background opaque` otherwise. The API fallback embeds the prompt and records the background in the sidecar. The output must be PNG; the fallback requests native alpha and performs no chroma-keying. 3. Produce the plate at its exact spec `plate` path. Create the output directory first and choose a supported output size matching the region's aspect, at least 1.5x its pixel dimensions. Prefer the harness-native image tool with the crop as input and the saved prompt; request a transparent PNG for cutouts, then run `~/.omo/agent/skills/impeccable/scripts/impeccable embed-prompt <plate> --prompt-file <prompt.txt>` (if you refine the prompt, save and embed the exact text sent). With the API fallback, run `~/.omo/agent/skills/impeccable/scripts/impeccable generate-image --ref <crop.png> --prompt-file <prompt.txt> --out <plate.png> --size <WxH> --quality high --background transparent` for a cutout, or `--background opaque` otherwise. The API fallback embeds the prompt and records the background in the sidecar. The output must be PNG; the fallback requests native alpha and performs no chroma-keying.
4. Open the plate beside the crop and compare subject, placement, scale, palette, and style. For cutouts, verify a real alpha channel and inspect composites on light and dark grounds: white paint must stay solid, interior holes must clear, and fine edges must avoid halos. Inspect glass and soft shadows carefully; partial alpha alone does not ensure convincing translucency. Never chroma-key native transparent output or flatten it before saving. If a native tool returns opaque pixels or a painted checkerboard, retry with the API fallback when available; otherwise report the transparency blocker. On a visual miss, tighten the prompt and regenerate once. Two misses on one region: keep the better plate, mark it `needs_parent_review`, and name the drift. The parent runs the plates gate after all assets exist; report `unscored` until a gate score is available. 4. Open the plate beside the crop and compare subject, placement, scale, palette, and style. For cutouts, verify a real alpha channel and inspect composites on light and dark grounds: white paint must stay solid, interior holes must clear, and fine edges must avoid halos. Inspect glass and soft shadows carefully; partial alpha alone does not ensure convincing translucency. Never chroma-key native transparent output or flatten it before saving. If a native tool returns opaque pixels or a painted checkerboard, retry with the API fallback when available; otherwise report the transparency blocker. On a visual miss, tighten the prompt and regenerate once. Two misses on one region: keep the better plate, mark it `needs_parent_review`, and name the drift. The parent runs the plates gate after all assets exist; report `unscored` until a gate score is available.
5. A frame plate (a window, a doorway, an arch) is a transparent cutout with an opening: generate it with a transparent background and keep the opening fully transparent. Verify the alpha inside the opening before saving: no baked view, no glow or vignette halo across it. The content seen through the opening is its own image region; produce it separately at the opening's size.
Do not redesign. Do not add objects, restyle, or reinterpret; the comp was approved as it is. Do not touch the page code, the spec, or the comp. Do not produce anything the spec does not list; a region the parent forgot goes back as a one-line note, not a plate. Do not redesign. Do not add objects, restyle, or reinterpret; the comp was approved as it is. Do not touch the page code, the spec, or the comp. Do not produce anything the spec does not list; a region the parent forgot goes back as a one-line note, not a plate.
@@ -16,10 +16,10 @@ Expect: the original request; the confirmed user answers; the artifact path(s);
## Checks, in order ## Checks, in order
0. **Evidence.** Before any other check, verify the required captures exist and every capture is valid. Required: the platform's full viewport set (web: `desktop.png` and `mobile.png`; native: one capture per shipped device class), plus every capture the calling brief names as required, a reported user viewport (`user-<width>.png`) included. Valid: no black or blank regions, content matching what the filename claims (a visit capture showing the About section is invalid), the document top visible where the file claims a full page, dimensions that make sense for the named viewport. A required capture that is absent fails exactly like one that is malformed: a viewport nobody captured is a viewport nobody inspected, and it cannot ship. When any capture fails, the whole review changes shape: return `disposition: recapture` as the first line, then one section, `recapture`, listing each missing or invalid file and what a valid capture of it shows, and stop. Never build a matrix on malformed evidence; a verdict derived from a broken capture launders the breakage into an approval, and the parent owes you a full re-review on valid captures, not a scoring round. 0. **Evidence.** Before any other check, verify the required captures exist and every capture is valid. Required: the platform's full viewport set (web: `desktop.png` and `mobile.png`; native: one capture per shipped device class), plus every capture the calling brief names as required, a reported user viewport (`user-<width>.png`) included. Valid: no black or blank regions, content matching what the filename claims (a visit capture showing the About section is invalid), the document top visible where the file claims a full page, dimensions that make sense for the named viewport. A required capture that is absent fails exactly like one that is malformed: a viewport nobody captured is a viewport nobody inspected, and it cannot ship. When any capture fails, the whole review changes shape: return `disposition: recapture` as the first line, then one section, `recapture`, listing each missing or invalid file and what a valid capture of it shows, and stop. Never build a matrix on malformed evidence; a verdict derived from a broken capture launders the breakage into an approval, and the parent owes you a full re-review on valid captures, not a scoring round.
1. **Persistence.** PRODUCT.md exists. On a comp-led build, `.impeccable/build/state.json` exists and its `comps` (or `skipped` when a surface round locked the comp), `spec`, `plates`, and `hero` phases are `closed`; a comp-led config with no state file, or a state whose `comps` phase never closed, means the comp round was skipped and the build ran from a world description alone, a material finding that outranks craft; a phase closed with a `forced` record is disclosed as a material finding unless the user downgraded the comp in words the packet quotes; a state file whose `hero.gate.score` sits under 0.72, or a missing state file, means the reproduction ran unproven, a material finding, and `.impeccable/review/hero-repro.png` must exist either way. When DESIGN.md predates this build (an extension or redesign), it matches the built world; on a new world it is written after this review by the documenter, so its absence here is not a finding. When comp-round comps exist under `.impeccable/mocks/`, an approval record exists too: the surface brief naming the approved comp, or an `approved` flag in its sidecar. Comp-round comps with no recorded pick mean the approval point was skipped, a material finding. Files under `.impeccable/mocks/decision/` are exempt: they are the direction round's dealt hand, produced before any comp round, and imply no approval whatever the build path; a code-led build has no comp round at all. 1. **Persistence.** PRODUCT.md exists. On a comp-led build, `.impeccable/build/state.json` exists and every phase before `review` is `closed` or explicitly `skipped`; an open or failed phase is a material finding. A comp-led config with no state file, or a state whose `comps` phase is neither `closed` nor `skipped`, means the comp round was skipped and the build ran from a world description alone, a material finding that outranks craft; a phase closed with a `forced` record is disclosed as a material finding unless the user downgraded the comp in words the packet quotes; a state file whose `hero.gate.score` sits under 0.72, or a missing state file, means the reproduction ran unproven, a material finding, and `.impeccable/review/hero-repro.png` must exist either way. When DESIGN.md predates this build (an extension or redesign), it matches the built world; on a new world it is written after this review by the documenter, so its absence here is not a finding. When comp-round comps exist under `.impeccable/mocks/`, an approval record exists too: the surface brief naming the approved comp, or an `approved` flag in its sidecar. Comp-round comps with no recorded pick mean the approval point was skipped, a material finding. Files under `.impeccable/mocks/decision/` are exempt: they are the direction round's dealt hand, produced before any comp round, and imply no approval whatever the build path; a code-led build has no comp round at all.
2. **Fidelity.** Start from the measurement, then judge what it cannot: read `.impeccable/review/diff/final/report.json` (and hero) first; every region scored `missing` or `contradicted` is a matrix row in that state unless the paired crop under `regions/` shows the score is wrong, and you say why; a region scored `match` still gets your eye for lettering character and material, which the numbers do not measure. Then, against your own element inventory of the approved comp, never against the contract's summary of it: topology, reading order, focal scale, overlaps and z-order, density, signature geometry, the primary action's treatment (a CTA the comp physically works, dissolves, or stamps is a signature element; its plain-rectangle rendition is contradicted), navigation items and icons, headline levels and scale relationships. Classify every salient element: match, acceptable adaptation, missing, contradicted, or added without approval. Three rows are mandatory in every matrix. TYPE: the display lettering's character, compression, width, weight, contrast, terminals, against the comp's; a face of a different character is contradicted however the layout matches. MATERIAL: an element rendered as flat CSS or clean vector where the comp shows painted, textured, dimensional, or photographic material is contradicted regardless of placement; medium is part of the promise. GROUND: the page field's value and temperature against the comp's, sampled from pixels on both sides when tooling allows rather than judged from memory, and read as the net on-screen result where a texture or tile paints over the base color; a ground warmer or cooler than the comp's is contradicted however faithfully the layout matches, and drift toward the rendition prior (warm cream on light grounds, blue-black slate on dark) is the direction to hunt. With no approved comp, TYPE and MATERIAL do not lapse: judge them against the contract's OWN-WORLD and the world's real materials, and treat faked physicality (CSS bevels, embossing, stamped-metal or chalk effects imitating a material the page never renders) as contradicted on its face; imitation material is the single most reliable mark of machine-made design. GROUND narrows rather than lapses: with no comp to sample, a color OWN-WORLD names is the target and the same warmer-or-cooler judgment applies; when OWN-WORLD names none, there is no GROUND authority, and the review says so in place of a verdict, because a target the reviewer invents turns the check into taste. A critique-reference comp on such a build is provocation, not spec: no element matrix, no adaptation citations, no asset obligations; its one contribution is what the image dared that the build did not, and dares worth adopting enter material_fixes as ordinary ordered fixes. An adaptation counts as intentional only when it cites the user answer, surface brief, accessibility need, or product truth that forced it; an uncited deviation is a defect. A missing signature element, a changed topology, or content added without approval fails fidelity and outranks every craft point in material_fixes. When MATERIAL is contradicted on the focal element, or contradiction is the page rather than the exception, stop ordering repairs: make the first material fix a rebuild directive naming the comp regions to re-derive and the assets to produce; a list of patches against a rejected page launders the rejection into an approval. A fix that requires producing an asset says so explicitly ("produce: <region> as a raster asset"), never phrased as a style adjustment the parent will answer with CSS. The comp is the spec for composition, topology, element inventory, density, lettering character, and material; it is not a pixel spec for semantics, accessibility, or responsive reflow, and that allowance covers translation, never replacement. 2. **Fidelity.** Start from the measurement, then judge what it cannot: read `.impeccable/review/diff/final/report.json` (and hero) first; every region scored `missing` or `contradicted` is a matrix row in that state unless the paired crop under `regions/` shows the score is wrong, and you say why; a region scored `match` still gets your eye for lettering character and material, which the numbers do not measure. Then, against your own element inventory of the approved comp, never against the contract's summary of it: topology, reading order, focal scale, overlaps and z-order, density, signature geometry, the primary action's treatment (a CTA the comp physically works, dissolves, or stamps is a signature element; its plain-rectangle rendition is contradicted), navigation items and icons, headline levels and scale relationships. Classify every salient element: match, acceptable adaptation, missing, contradicted, or added without approval. Three rows are mandatory in every matrix. TYPE: the display lettering's character, compression, width, weight, contrast, terminals, against the comp's; a face of a different character is contradicted however the layout matches. MATERIAL: an element rendered as flat CSS or clean vector where the comp shows painted, textured, dimensional, or photographic material is contradicted regardless of placement; medium is part of the promise. GROUND: the page field's value and temperature against the comp's, sampled from pixels on both sides when tooling allows rather than judged from memory, and read as the net on-screen result where a texture or tile paints over the base color; a ground warmer or cooler than the comp's is contradicted however faithfully the layout matches, and drift toward the rendition prior (warm cream on light grounds, blue-black slate on dark) is the direction to hunt. With no approved comp, TYPE and MATERIAL do not lapse: judge them against the contract's OWN-WORLD and the world's real materials, and treat faked physicality (CSS bevels, embossing, stamped-metal or chalk effects imitating a material the page never renders) as contradicted on its face; imitation material is the single most reliable mark of machine-made design. GROUND narrows rather than lapses: with no comp to sample, a color OWN-WORLD names is the target and the same warmer-or-cooler judgment applies; when OWN-WORLD names none, there is no GROUND authority, and the review says so in place of a verdict, because a target the reviewer invents turns the check into taste. A critique-reference comp on such a build is provocation, not spec: no element matrix, no adaptation citations, no asset obligations; its one contribution is what the image dared that the build did not, and dares worth adopting enter material_fixes as ordinary ordered fixes. An adaptation counts as intentional only when it cites the user answer, surface brief, accessibility need, or product truth that forced it; an uncited deviation is a defect. A missing signature element, a changed topology, or content added without approval fails fidelity and outranks every craft point in material_fixes. When MATERIAL is contradicted on the focal element, or contradiction is the page rather than the exception, stop ordering repairs: make the first material fix a rebuild directive naming the comp regions to re-derive and the assets to produce; a list of patches against a rejected page launders the rejection into an approval. A fix that requires producing an asset says so explicitly ("produce: <region> as a raster asset"), never phrased as a style adjustment the parent will answer with CSS. The comp is the spec for composition, topology, element inventory, density, lettering character, and material; it is not a pixel spec for semantics, accessibility, or responsive reflow, and that allowance covers translation, never replacement.
3. **Ceiling.** Against the QUALITY BAR card: name the world's native devices the build left unused, frame, depth, lettering treatment, ornament density, motion. The card governs commitment and finish, never composition. 3. **Ceiling.** Against the QUALITY BAR card: name the world's native devices the build left unused, frame, depth, lettering treatment, ornament density, motion. The card governs commitment and finish, never composition.
4. **Contract, promise by promise.** First verify FORM carries the seed key the concept roll printed; a contract with no seed key, or one the parent cannot corroborate, means the roll was skipped, a material fix ahead of any craft point. Then, for each of the five blocks: does the render keep the promise? Apply the memory test to the first viewport. 4. **Contract, promise by promise.** First verify FORM carries the seed reference the concept roll printed (`seed:` and 16 hex), corroborated against the roll output the parent saw or with `impeccable concept-seed --check-seed <reference>` (exit 0 means this machine rolled it). A raw seed key in FORM is itself a material fix: the brief is committed, and the key must not be. `seed: none (unavailable)` stands only when the roll printed SEED REFERENCE unavailable, which `--check-seed none` confirms with exit 0. Any other contract with no reference, or one neither source corroborates, means the roll was skipped, a material fix ahead of any craft point. Then, for each of the five blocks: does the render keep the promise? Apply the memory test to the first viewport.
5. **Truth.** Demonstration data authored and labeled synthetic; no invented commercial claims; unanswered claims present as marked placeholders, not omissions. Every raster region of the spec shipped as its plate (the spec names the file; the page references it; the region's diff row is not `missing`), not a gradient, an inline SVG, or a many-vertex `clip-path` standing in for it, and every produced asset visibly present in the screenshots; an asset applied at near-zero opacity or buried behind a wash is a compliance token, not a shipped material, and the detector's `buried-raster` and `organic-clip-path` findings in the packet are material fixes. 5. **Truth.** Demonstration data authored and labeled synthetic; no invented commercial claims; unanswered claims present as marked placeholders, not omissions. Every raster region of the spec shipped as its plate (the spec names the file; the page references it; the region's diff row is not `missing`), not a gradient, an inline SVG, or a many-vertex `clip-path` standing in for it, and every produced asset visibly present in the screenshots; an asset applied at near-zero opacity or buried behind a wash is a compliance token, not a shipped material, and the detector's `buried-raster` and `organic-clip-path` findings in the packet are material fixes.
6. **Floor.** Read the craft floor's Refuse list and hold the screenshots against it: kickers and eyebrows, hard offset shadows outside a neobrutalist world, glyph icons, system display faces, gradient text, side stripes, and the rest. A banned element is a material fix even when it matches nothing in the comp: the builder loaded the same ban before writing it, and fidelity to a comp cannot authorize what the floor refuses. The parent's hook findings cover this mechanically where hooks run; this check exists because hookless harnesses reach you with none, and the last two live sessions shipped five kickers past a reviewer that never looked. 6. **Floor.** Read the craft floor's Refuse list and hold the screenshots against it: kickers and eyebrows, hard offset shadows outside a neobrutalist world, glyph icons, system display faces, gradient text, side stripes, and the rest. A banned element is a material fix even when it matches nothing in the comp: the builder loaded the same ban before writing it, and fidelity to a comp cannot authorize what the floor refuses. The parent's hook findings cover this mechanically where hooks run; this check exists because hookless harnesses reach you with none, and the last two live sessions shipped five kickers past a reviewer that never looked.
@@ -46,7 +46,7 @@ The same restraint applies to `workspace-context-inherited`. Inheritance is a de
- `workspace-platform-native-evidence` is the finding that matters most here: a workspace carrying native build files while inheriting a root record that resolves to web gets web guidance for its whole life and never loads [ios.md](ios.md) or [android.md](android.md). The repair is a child PRODUCT.md in that workspace, because one inherited record cannot hold two platforms. - `workspace-platform-native-evidence` is the finding that matters most here: a workspace carrying native build files while inheriting a root record that resolves to web gets web guidance for its whole life and never loads [ios.md](ios.md) or [android.md](android.md). The repair is a child PRODUCT.md in that workspace, because one inherited record cannot hold two platforms.
- `config-project-roots-match-nothing` means every `projectRoots` glob missed, so the repo root is silently standing in as the active project. A renamed workspace directory is the usual cause. Report the patterns and ask which directories they should name. - `config-project-roots-match-nothing` means every `projectRoots` glob missed, so the repo root is silently standing in as the active project. A renamed workspace directory is the usual cause. Report the patterns and ask which directories they should name.
- `config-invalid-build-path` and `config-build-path-unset` both concern one key, `buildPath` in `.impeccable/config.json` (or the gitignored `.impeccable/config.local.json`, which wins for that developer). It holds `comp` or `code` and sets whether new surfaces are built from a generated comp or straight in code. An unread value does not fall back to the opposite path, so a project meaning `code` has been building comp-led; report the exact value. The unset finding fires only where a project has done direction work and never recorded a preference, and the offer belongs in it only when image generation exists in your tool surface. Without image generation there is nothing to choose and nothing to say. - `config-invalid-build-path` and `config-build-path-unset` both concern one key, `buildPath` in `.impeccable/config.json` (or the gitignored `.impeccable/config.local.json`, which wins for that developer). It holds `comp` or `code` and sets whether new Persuade and Experience surfaces are built from a generated comp or straight in code; Operate and Read surfaces are code-led whatever it holds. An unread value does not fall back to the opposite path, so a project meaning `code` has been building comp-led; report the exact value. The unset finding fires only where a project has done direction work and never recorded a preference, and the offer belongs in it only when image generation exists in your tool surface. Without image generation there is nothing to choose and nothing to say.
- Use the `workspaces` table to show the user which apps carry their own context, which inherit, and which have none, before proposing any change. - Use the `workspaces` table to show the user which apps carry their own context, which inherit, and which have none, before proposing any change.
## Opting out of the boot check ## Opting out of the boot check
@@ -4,7 +4,7 @@ Manage the **design detector hook** for the current project.
The hook runs the impeccable design detector on direct file edits to design-relevant files (`.tsx`, `.jsx`, `.html`, `.vue`, `.svelte`, `.astro`, `.css`, `.scss`, `.sass`, `.less`, `.ts`, `.js`). Claude Code, Codex, and GitHub Copilot use a post-tool-use hook and push a short system reminder into the agent's context after the edit; findings get a correction prompt, pending issues get a re-nudge, and clean UI-ish files get a short ack unless quiet mode is on (`hook.quiet` in config). Plain `.ts` and `.js` files are still scanned, but stay quiet unless the detector finds something. Cursor uses `preToolUse` to block bad proposed writes before they land and stays silent when it allows a clean write. Grok Build fires the same PostToolUse scan to mark touched files, then surfaces findings on Stop `additionalContext`. Do not expect a Grok per-edit reminder: Grok discards that stdout. The hook runs the impeccable design detector on direct file edits to design-relevant files (`.tsx`, `.jsx`, `.html`, `.vue`, `.svelte`, `.astro`, `.css`, `.scss`, `.sass`, `.less`, `.ts`, `.js`). Claude Code, Codex, and GitHub Copilot use a post-tool-use hook and push a short system reminder into the agent's context after the edit; findings get a correction prompt, pending issues get a re-nudge, and clean UI-ish files get a short ack unless quiet mode is on (`hook.quiet` in config). Plain `.ts` and `.js` files are still scanned, but stay quiet unless the detector finds something. Cursor uses `preToolUse` to block bad proposed writes before they land and stays silent when it allows a clean write. Grok Build fires the same PostToolUse scan to mark touched files, then surfaces findings on Stop `additionalContext`. Do not expect a Grok per-edit reminder: Grok discards that stdout.
The detector rules run in two tiers. The per-edit hook surfaces only the immediate tier: mechanical, unambiguous problems worth interrupting an edit for, such as broken images, overflowing or clipped content, contrast and legibility failures, gradient text, glow shadows, and design-system drift. Everything else (copy cadence, palette and typography taste, layout rhythm) is deferred to a deep pass on the `Stop` hook event, which runs the full rule set over every UI file touched in the session and surfaces the remaining findings once, deduplicated against what the per-edit pass already reported. A session with nothing left to report stops silently. Set `hook.perEditRules` to `"all"` in `.impeccable/config.json` to restore the full rule set on every edit. The Stop deep pass is wired for Claude Code, Codex, and Grok Build, which dispatch a native `Stop` hook event. Cursor does not get one (its stop hook is not consistently dispatched; the pre-write gate covers it), and GitHub Copilot's stop-style events do not feed context back to the model, so they keep the full detector per edit. Grok also fires an observe-only Stop with `reason: "shutdown"` after `end_turn`; skip that one, scan only `end_turn`. The detector rules run in two tiers. The per-edit hook surfaces only the immediate tier: mechanical, unambiguous problems worth interrupting an edit for, such as broken images, overflowing content, contrast and legibility failures, gradient text, glow shadows, and design-system drift. Everything else (copy cadence, palette and typography taste, layout rhythm) is deferred to a deep pass on the `Stop` hook event, which runs the full rule set over every UI file touched in the session and surfaces the remaining findings once, deduplicated against what the per-edit pass already reported. A session with nothing left to report stops silently. Set `hook.perEditRules` to `"all"` in `.impeccable/config.json` to restore the full rule set on every edit. The Stop deep pass is wired for Claude Code, Codex, and Grok Build, which dispatch a native `Stop` hook event. Cursor does not get one (its stop hook is not consistently dispatched; the pre-write gate covers it), and GitHub Copilot's stop-style events do not feed context back to the model, so they keep the full detector per edit. Grok also fires an observe-only Stop with `reason: "shutdown"` after `end_turn`; skip that one, scan only `end_turn`.
Every hook is a mechanical pass. The reflexes no scanner catches live in [craft-floor.md](craft-floor.md), which the skill loads before it edits UI, so they apply whether or not a hook is wired. A session with no automatic hook gets one `MANUAL_DETECTOR_REQUIRED` directive from `impeccable context` asking for a single detector run at the end. Every hook is a mechanical pass. The reflexes no scanner catches live in [craft-floor.md](craft-floor.md), which the skill loads before it edits UI, so they apply whether or not a hook is wired. A session with no automatic hook gets one `MANUAL_DETECTOR_REQUIRED` directive from `impeccable context` asking for a single detector run at the end.
@@ -14,10 +14,12 @@ Declare server-side template extensions under **`detector.extensions`** when the
Manual `npx impeccable detect` scans use the same project filter config by default: `detector.ignoreRules`, `detector.ignoreFiles`, `detector.ignoreValues`, and `detector.designSystem.enabled`. `hook.enabled` only controls automatic hook execution, not manual CLI scans. Use `npx impeccable detect --no-config ...` for a raw detector run that ignores project config/context. Use `npx impeccable ignores ...` for direct CLI CRUD on the same detector ignores. Manual `npx impeccable detect` scans use the same project filter config by default: `detector.ignoreRules`, `detector.ignoreFiles`, `detector.ignoreValues`, and `detector.designSystem.enabled`. `hook.enabled` only controls automatic hook execution, not manual CLI scans. Use `npx impeccable detect --no-config ...` for a raw detector run that ignores project config/context. Use `npx impeccable ignores ...` for direct CLI CRUD on the same detector ignores.
Supported harnesses: Claude Code (`.claude/settings.local.json` in the project, which is gitignored so the hook stays machine-local; a hook you move into the shared `settings.json` is honored in place too), Codex (`.codex/hooks.json` in the project), Cursor (`.cursor/hooks.json` in the project), Grok Build (`.grok/hooks/impeccable.json` in the project; requires `/hooks-trust` or `--trust`), and GitHub Copilot (`.github/hooks/impeccable.json` in the project, a team-shared committed file that both the Copilot CLI and the cloud agent read). For the Copilot CLI, repo-level hooks fire once `.github/hooks/impeccable.json` is committed to the repository's default branch. Supported harnesses: Claude Code (`.claude/settings.local.json` for a project-scope install; `~/.claude/settings.json` for a user-scope install; a project hook moved into shared `.claude/settings.json` is honored in place too), Codex (`.codex/hooks.json` in the project), Cursor (`.cursor/hooks.json` in the project), Grok Build (`.grok/hooks/impeccable.json` in the project; requires `/hooks-trust` or `--trust`), and GitHub Copilot (`.github/hooks/impeccable.json` in the project, a team-shared committed file that both the Copilot CLI and the cloud agent read). For the Copilot CLI, repo-level hooks fire once `.github/hooks/impeccable.json` is committed to the repository's default branch.
On **Cursor**, `preToolUse` checks proposed Write/Edit/Shell write content and denies only when the real detector finds an issue. The denial message is visible to the agent as the tool error, so the agent can reconsider before the bad write lands. On **Cursor**, `preToolUse` checks proposed Write/Edit/Shell write content and denies only when the real detector finds an issue. The denial message is visible to the agent as the tool error, so the agent can reconsider before the bad write lands.
Gemini installs session and completion hooks in `.gemini/settings.json`, merged into the settings already there (comments are tolerated; a commented file is backed up to `settings.json.bak` before the rewrite, and a file that is not valid JSON is left alone). It does not install a per-edit detector hook. The `BeforeTool` hook only rewrites shell commands that run `build-phase`, and only on macOS and Linux; on Windows (where Gemini runs hooks through PowerShell) no session id reaches the shell, so a comp build is not tied to the session and the completion reminder stays silent.
## Routing ## Routing
The first argument is the action. Defaults to `status`. The first argument is the action. Defaults to `status`.
@@ -103,7 +105,7 @@ Example whole-file exception, for a file that is out of scope entirely:
- Never modify `.impeccable/config.json` or `.impeccable/config.local.json` by hand from this command. Always go through `impeccable hooks` so writes stay validated and the file shape stays consistent. One exception: `detector.extensions` has no admin action, so when the user asks to cover a template stack, edit that one field in `.impeccable/config.json` directly and leave the rest of the file untouched. - Never modify `.impeccable/config.json` or `.impeccable/config.local.json` by hand from this command. Always go through `impeccable hooks` so writes stay validated and the file shape stays consistent. One exception: `detector.extensions` has no admin action, so when the user asks to cover a template stack, edit that one field in `.impeccable/config.json` directly and leave the rest of the file untouched.
- Do not edit the launcher or the binary behind `impeccable hook` and `impeccable hook-before-edit` from this flow. Those are skill plumbing. - Do not edit the launcher or the binary behind `impeccable hook` and `impeccable hook-before-edit` from this flow. Those are skill plumbing.
- Cursor can block a proposed write when the detector finds a real issue. Claude Code, Codex, and GitHub Copilot do not block the edit; they emit a post-edit reminder instead. Disabling stops both blocking and reminders. - Cursor can block a proposed write when the detector finds a real issue. Claude Code, Codex, and GitHub Copilot do not block the edit; they emit a post-edit reminder instead. Disabling stops both blocking and reminders.
- The hook is bundled with the Impeccable skill and installed through project-local manifests: `.claude/settings.local.json`, `.codex/hooks.json`, `.cursor/hooks.json`, and `.github/hooks/impeccable.json`. On Codex, the user must approve the hook via `/hooks` the first time. On Cursor, confirm hooks are enabled under Settings -> Hooks. On GitHub Copilot, the CLI loads `.github/hooks/impeccable.json` once it is committed to the repository's default branch, and the cloud agent reads it from the repo directly. - The hook is bundled with the Impeccable skill. Project installs use `.claude/settings.local.json`, `.codex/hooks.json`, `.cursor/hooks.json`, `.github/hooks/impeccable.json`, and `.gemini/settings.json`; a user-scope Claude install uses `~/.claude/settings.json`. On Codex, the user must approve the hook via `/hooks` the first time. On Cursor, confirm hooks are enabled under Settings -> Hooks. On GitHub Copilot, the CLI loads `.github/hooks/impeccable.json` once it is committed to the repository's default branch, and the cloud agent reads it from the repo directly.
## Failure modes ## Failure modes
@@ -109,9 +109,9 @@ Before loading new-work or resuming shape/build, verify that PRODUCT.md exists a
## Step 5: Record workflow defaults ## Step 5: Record workflow defaults
When image generation is available and no `buildPath` is recorded yet, ask once how new surfaces should be built. Availability means a harness-native image tool or the API fallback that `impeccable context` reports as `IMAGE_GEN_AVAILABLE`, and the first of those leaves no trace in the boot output: `impeccable context` only sees the key, so a silent boot on a harness that generates images is not evidence there is nothing to ask about. This is its own question, never a clause riding inside another one. The stack round asks what to build with; this asks how the building starts, and an answer to the first carries no consent about the second. State the trade in the question the user actually reads, because the two names mean nothing to someone meeting them for the first time: **comp-first** (an image sets the bar before any code; bolder composition, slower, and the build must match the image) or **code-first** (build directly; the ambition is written into the direction contract and audited at the finish; leaner, faster). When image generation is available and no `buildPath` is recorded yet, ask once how new surfaces should be built. Availability means a harness-native image tool or the API fallback that `impeccable context` reports as `IMAGE_GEN_AVAILABLE`, and the first of those leaves no trace in the boot output: `impeccable context` only sees the key, so a silent boot on a harness that generates images is not evidence there is nothing to ask about. This is its own question, never a clause riding inside another one. The stack round asks what to build with; this asks how the building starts, and an answer to the first carries no consent about the second. State the trade in the question the user actually reads, because the two names mean nothing to someone meeting them for the first time: **comp-first** (an image sets the bar before any code; bolder composition, slower, and the build must match the image) or **code-first** (build directly; the ambition is written into the direction contract and audited at the finish; leaner, faster). Say in the same question what the choice covers: landing pages and showcase surfaces. App screens and documents are built code-first either way.
Write the answer to `.impeccable/config.json` as `"buildPath": "comp"` or `"buildPath": "code"`, merging with the keys already there. Write only the value the user chose. A recommendation you made is not an answer you received, and a value taken from silence is a standing default nobody set: it then rides every future round in the project, which is the opposite of asking once. When the question goes unanswered, record nothing and say in one line which path this session is taking and that it is not stored. That path is comp-first, the default new-work applies wherever image generation exists and nothing is recorded; name it rather than choosing a quieter one, because a silent default invented here is the same failure as a value written without an answer. Unset is a working state, not a gap: the decision page's toggle governs each session, and new-work's one-time offer records the answer the first time the user flips it. The config is the only place this lives. It is a workflow setting, not product truth, so it never joins `## Stack` or any other PRODUCT.md section, where a second copy would outlive the setting and steer rounds nobody could trace back to it. Write the answer to `.impeccable/config.json` as `"buildPath": "comp"` or `"buildPath": "code"`, merging with the keys already there. Write only the value the user chose. A recommendation you made is not an answer you received, and a value taken from silence is a standing default nobody set: it then rides every future round in the project, which is the opposite of asking once. When the question goes unanswered, record nothing and say in one line which path this session is taking and that it is not stored. That path is comp-first on a landing page or showcase surface, the default new-work applies wherever image generation exists and nothing is recorded, and code-first on an app screen or document; name it rather than choosing a quieter one, because a silent default invented here is the same failure as a value written without an answer. Unset is a working state, not a gap: the decision page's toggle governs each session, and new-work's one-time offer records the answer the first time the user flips it. The config is the only place this lives. It is a workflow setting, not product truth, so it never joins `## Stack` or any other PRODUCT.md section, where a second copy would outlive the setting and steer rounds nobody could trace back to it.
A value already recorded in `.impeccable/config.json` or the gitignored `.impeccable/config.local.json` is a confirmed answer: on a re-run, honor it in silence rather than asking again. This is a default, not a lock: the decision page renders a toggle whose flip binds a single session and is never written back. Without image generation there is no choice to record; code-first is the only path. A value already recorded in `.impeccable/config.json` or the gitignored `.impeccable/config.local.json` is a confirmed answer: on a re-run, honor it in silence rather than asking again. This is a default, not a lock: the decision page renders a toggle whose flip binds a single session and is never written back. Without image generation there is no choice to record; code-first is the only path.
@@ -0,0 +1,21 @@
# Operate
`impeccable concept-seed --mode operate` prints the two sections below as its MODE RULES block. On an Operate surface the visitor came to get a job done, so the page is first a working product of its category and the world serves it.
## Directions
Someone who uses tools like this one should know where everything is before they notice the world. The world lends four things and only these: type, palette, density, and one signature move. It never supplies the layout, the navigation model, or the controls. Colour is part of the world's contribution, and it does real jobs: a committed palette for the shell (a coloured rail or header, a tinted ground), state colours, data series, selection, and the signature move. A grey screen with one accent colour is the category default without the category's polish, not restraint.
Read every candidate, the assigned direction, and every challenger through that limit before you fuse or judge it. A world taken from a tool or instrument the audience operates (a terminal, a scope, a control panel, a cockpit) or from a fictional interface comes back as a costume of that tool on a working screen: take from it only what transfers as type, palette, density, and a signature move, or replace it from your list. When a catalog world's source is itself an interface language, the same limit holds: it lends type, density, palette, and one signature move, while navigation and controls stay the standard ones for the platform, web components on the web and native controls in an app.
Write each direction's first viewport as the product's shell first: the navigation, the work areas and what each is for, the data the visitor reads first and its states, and then where the signature move lives. Expression may never obscure the task, state, or familiar affordance. Name the signature move concretely, so the comp and the build can find it: service health drawn as a transit map, a schedule set in a timetable's column rhythm. A direction whose world shows up only as a colour scheme has not found its move yet. When you weigh fused challengers, product clarity includes reading as a working product of this category.
## Comps
The comp is the product screen, in the direction's committed palette: its shell and navigation with a distinct area for each job and, where there is data, filters over what they scope and charts and tables with clear states. The world enters only through type, palette, density, and the one signature move; it never supplies the page's organisation, so a working screen never reads as a newspaper, a poster, or any other artifact of the world. Every control is a standard one the visitor already knows how to use, a web component on the web and the platform's native control in an app: buttons, tabs, inputs, selects, filters, tables with sortable headers, navigation that reads as navigation. Never a costume of the world's own interface: a terminal's function-key bar or command prompt, an instrument's bezel, ruled ledger paper standing in for a table. A comp an engineer could not build from the platform's standard components is a picture of the tool, not the tool. The finish bar is the best product in the category: an aligned grid, consistent spacing, a real type scale, and clear hierarchy. The signature move adds to a screen that is already that clean; it never stands in for it.
Prompt with the screen's real content: the product name, realistic data with plausible values and units, real labels, and one state worth seeing (an alert, a degraded service, an empty filter result), never placeholder tiles. State colours (red for critical, amber for degraded) sit on top of the world's palette only where they carry meaning, and a headline number that moves over time shows its recent history, with the onset marked when something changed.
Comps two and three vary the product's organisation, never its world coverage: the navigation model (side rail, top bar, command-first), how the work areas divide the screen, which data leads, the density, and where the signature move sits. Each must be recognisably different from the first at thumbnail size; a variant only its prompt can tell apart adds no choice. A variant that adds world material, texture, or ornament goes further in the wrong direction; on a working screen, more world is a costume.
Self-check every render: with the name covered, does it read as a product of its category, could the visitor do the task from it, and is it at least as clean as the category standard and more distinctive than it? If it reads as an artifact of the world first, regenerate with the shell and the work areas named region by region and the world held to its four contributions.
@@ -0,0 +1,19 @@
# Persuade and Experience
`impeccable concept-seed --mode persuade` (or `experience`) prints the two sections below as its MODE RULES block. On these surfaces the design is what the visitor judges, so the world may own and structure the page.
## Directions
When a catalog world's source is itself an interface language, the surface may carry its native grammar across navigation, content, controls, and states, within the platform conventions a native app keeps.
For **Persuade**, the opening must make the offer intelligible and desirable, expose a clear action, and demonstrate something only this product can prove. The action is the one the category's visitors came to take, in its working form: an airline's route-and-dates search, a clinic's open appointment slots, a store's add to cart. A decorative link pointing at it elsewhere does not count. Conversion lives inside the form's own vocabulary: a hook that lands in one line, a visible primary action, a legible reading order. A committed form that hides the offer or the action has not finished translating. For **Experience**, the work itself leads from the first viewport.
## Comps
The world may be the carrier of the page, never a picture of it: a facade, a shutter or a book can be what the page is built on, while a viewport painted whole as a facade, a book cover or a travel poster, with no working parts in it, is a picture of the world, not its website. Material coverage is wanted: the world's material is the ground and the major surfaces, its type sets the headlines, and a world reduced to an accent on a neutral template is the category default with a sticker on it. Coverage is not performance: how many regions act out the concept is limited by the depth rule in visualize.md, and the rest stay in the world's material without performing it. Both ends erase the direction: the painted poster, and the generic product page (a headline over subcopy and a button, a row of equal cards below). The dealt composition sets the skeleton, and the compositions you write for comps two and three answer to the same test: a split hero (copy on one side, the product on the other, a badge row under it) or a centred headline over a row of cards is the template whatever world paints it.
The world carries the page's parts and never absorbs them, so wherever it puts one, the page still works as a page: navigation set into a facade reads as links, copy on a shutter as set text a visitor reads, an action on a door plaque as plainly clickable, text reads against whatever sits behind it, the sections keep a steady vertical rhythm, the primary action is unmistakable, and the section below starts as a section rather than more painting. Fix a poster by making its parts work where the world put them, never by moving them into a standard layout on the world's colour: a cover that becomes copy beside a device on marigold has lost the cover.
When the world is an object, the page is that one object seen once, at one scale and from one point of view, and the object keeps its real form: a book opens to two pages on one gutter, never a third panel, and a spine that carries the navigation belongs to the book on the page, never a second book beside it. More content goes on the object's own surfaces or on the next spread below, never on extra copies or impossible folds.
Comps two and three each go further than the first, never cleaner: a bolder use of the same world, where the world's material carries more of the page's structure (still one dominant move, per the depth rule), a larger or stranger focal move, or a composition the category would not attempt.
@@ -0,0 +1,21 @@
# Read
`impeccable concept-seed --mode read` prints the two sections below as its MODE RULES block. On a Read surface the visitor came to understand something, so the page is first a document of its kind, framed by the world.
## Directions
The reader's question leads, and comprehension and wayfinding stay intact at every step. Documentation reads as documentation, a guide as a guide, an article as an article, a changelog as a changelog. The world owns the frame and serves the column. The frame is the masthead, the navigation rail, the ground, the title and section openers, and the way code, tables, and callouts are set: there the world's palette, type, and signature move commit fully, so the page could belong to no other product. The reading column inside it stays calm: a reading face, real contrast, a real measure, nothing performing behind the text. The world never supplies the reading order or replaces the navigation with something a reader has to learn. Documentation still reads as documentation, a guide as a guide, an article as an article.
Candidates from the world's own publishing traditions transfer best: reference books, type specimens, timetables, field guides, technical manuals, scholarly apparatus, a newspaper's wayfinding. A tool, instrument, or fictional interface from the audience's world comes back as a costume on a page meant for reading: take from it only what transfers as type, palette, density, and a signature move, or replace it from your list. When a catalog world's source is itself an interface language, the same limit holds: it lends type, density, palette, and one signature move, while navigation and controls stay the standard ones for the platform.
Write each direction's first viewport as the document's shell first: the navigation and where the reader stands in it, the title and the opening that answers or frames the reader's question, the reading column, and the way forward (contents, section headings, next and previous). Then name where the world owns the frame and where the signature move lives: a masthead, a coloured rail, a running head, a margin system, a numbering scheme, a way of setting code or tables. When you weigh fused challengers, product clarity includes how fast a reader finds and understands the answer.
## Comps
The comp is the document as the reader meets it: a frame the world owns, in the direction's committed palette and type, around a calm reading column at a real measure (on the web about 60 to 75 characters with body text near 16px; in a native app, the platform's body text style and its readable width), with headings in a clear hierarchy and the wayfinding that carries the reader through. Body text is set in a reading face with real contrast against its ground; a monospace face is for code, parameters, and small labels, never the running text or the headings by default. Code, tables, notes, and figures sit in the standard forms readers already know, set in the world's type and palette. The signature move is one system used where it earns its place, never a frame repeated around every element. The world never supplies the page's reading order, so a document never reads as a poster, a magazine cover, or a book photographed open.
Prompt with the real material: the actual title, a real opening paragraph, real section names, and one example of the hardest content the surface holds (a code sample, a reference table, a diagram), never filler text.
Comps two and three vary the reading structure and how the frame carries the world, never the column: the navigation model (sidebar tree, top tabs, inline contents), the column and margin system, how densely the reference material sits, and where the signature move appears. Each must be recognisably different from the first at thumbnail size; a variant only its prompt can tell apart adds no choice. A variant that pushes world material into the reading column makes reading harder and goes the wrong way.
Self-check every render: can a reader tell where they are and where to go next, and read the body comfortably? Then cover the name: could this be any other product's documentation? If so, the frame has not taken the world; push the masthead, rail, ground, and openers further into it. If the render reads as an artifact of the world before it reads as a document, the world has reached the column; pull it back to the frame.
File diff suppressed because one or more lines are too long
@@ -0,0 +1,26 @@
# Region map
A region map names what is actually visible in the approved comp before asset production. It is not a page build or an asset approval.
1. Run `~/.omo/agent/skills/impeccable/scripts/impeccable comp-spec --comp <comp.png> --grid` and open the original and gridded images.
2. Run `~/.omo/agent/skills/impeccable/scripts/impeccable comp-spec --schema` for the JSON fields. Write `regions.json` with a `regions` array. Each region needs a stable `id`, `kind`, `note`, and exactly one of `pixelBox`, normalized `box`, or `grid`. Use the original comp’s dimensions.
3. Run `~/.omo/agent/skills/impeccable/scripts/impeccable comp-spec --comp <comp.png> --regions regions.json --inspect-map`. The output points to a report, overlay, exact crops and `COMPARE` sheets of masked references. The default prints findings; `--json` prints the entire report, with sheet paths in `comparisonSheets`.
4. Open the comparison sheets first to inspect affected crops together, then open individual crops where more detail is needed. Compare their bounds with the original. Inspect excluded foreground pixels as well as geometry errors. Every overlapping non-container code box is masked in full, including empty space inside it. Bound separate text elements separately so artwork in the gaps stays visible; a container describes their layout extent and never replaces its children. Correct the map and inspect again; use a new output directory each time. Zero errors does not certify crop accuracy. Coverage warnings are hints, not proof of completeness.
If the request ends at mapping, stop with the map, inspection report and unresolved findings. To continue a build, measure the inspected map with `comp-spec --comp <comp.png> --regions regions.json` and follow [new-work.md](new-work.md).
`--auto` produces horizontal band scaffolding, not element identification. It is optional and does not replace authoring a map.
## Containment
`parentId` identifies an enclosing `container: true` region. Parent and children keep separate IDs and crops. Containment never transfers approval.
## What varies independently
Split regions by what varies independently: content the site swaps (room photos, products, people), moving parts (anything a hover or the signature interaction moves), and structure (frames, surrounds, ornament). A window with open shutters onto a room is three kinds of region: the surround as a plate with a transparent opening, the room as an image region beneath it, and each shutter as its own plate. Overlapping regions are composited in the page. `comp-spec` flags a raster region whose note names a frame and the view it opens onto (`baked-composite`), and the plan and asset review shows it to the user first.
## Painted material
When measuring, `comp-spec` flags a `text`, `control` or `chrome` region, containers included, whose crop looks painted (`painted-pixels`: many colours, soft gradients) and lists it in its summary. The plan and asset review shows flagged regions, and those marked `codeDrawn` (painted material you chose to draw in code), to the user first. Do not leave the catch to them: if a region is painted material (a figure, a photograph, a metal or paper surface), classify it `plate`, `image` or `texture` now.
Comp crops are reference evidence only, never production assets. The map inspector marks its PNGs as comp-derived.
@@ -1,21 +1,22 @@
# Visualize: Direction Comps & Asset Production # Visualize: Direction Comps & Asset Production
Load this from [new-work.md](new-work.md) on a comp-led build, when image generation is available (a harness-native tool or the API fallback `impeccable context` reports). A code-led contract skips this file by design, not by drift; do not load it then. PRODUCT.md and DESIGN.md are preconditions. New-work has already resolved the visual world; this file must not reopen it. A surface-scope structure round that already put three visualized cards before the user (new-work.md, established world) has discharged this round: the locked card's comp is the approved comp, so record the approval and continue at After approval; generate nothing new. Load this from [new-work.md](new-work.md) on a comp-led build, when image generation is available (a harness-native tool or the API fallback `impeccable context` reports). A code-led contract skips this file by design, not by drift; do not load it then. PRODUCT.md is a precondition; DESIGN.md is one only on an established world, since a new or replacement world writes it at the finish. The MODE RULES block concept-seed printed for this surface (COMPS) governs these comps together with this file and wins where they conflict; with no roll this session, read the mode's file named in new-work.md. New-work has already resolved the visual world; this file must not reopen it. A surface-scope structure round that already put three visualized cards before the user (new-work.md, established world) has discharged this round: the locked card's comp is the approved comp, so record the approval and continue at After approval; generate nothing new.
A probe tests composition, narrative, hierarchy, density, focal moment, signature use, and image requirements. It is not a second identity workshop. Keep DESIGN.md's palette, typography direction, material language, component character, imagery stance, and motion grammar fixed. A probe tests composition, narrative, hierarchy, density, focal moment, signature use, and image requirements. It is not a second identity workshop. Keep the world's palette, typography direction, material language, component character, imagery stance, and motion grammar fixed: DESIGN.md's on an established world, the chosen direction's on a new one.
## Generate three compositional options ## Generate three compositional options
The comp round runs inside the build's phase state: `impeccable build-phase start --direction <seed key> --kind <...>` has already run (the roll's output names the command) and its `comps` phase is open before the first comp is generated; a comp rendered before that sits outside the state, and a session resumed from that point has no phases to follow. `impeccable generate-image` refuses to write under `.impeccable/mocks/` until start has run; a harness-native image tool is bound by the same order. The comp round runs inside the build's phase state: `impeccable build-phase start --direction <seed reference>` has already run (with `--decision-comp <path>` when the chosen card has a decision comp) and its `comps` phase is open before the first comp is generated; a comp rendered before that sits outside the state, and a session resumed from that point has no phases to follow. `impeccable generate-image` refuses to write under `.impeccable/mocks/` until start has run; a harness-native image tool is bound by the same order.
Render three distinct high-fidelity north-star comps of the requested surface, saved under `.impeccable/mocks/` so they survive the session. Comp at the surface's own viewport: portrait at device size for a native app or mobile-first surface, desktop landscape otherwise; a phone screen comped landscape misstates the composition before anything is built against it. Comps are the build thread's own work, never delegated: the thread that writes the prompts holds the direction's full context and has seen every comp when the build starts. Open every image by its workspace-relative path; sandboxed viewers reject absolute paths, and everything under the project root has a relative one. Base the comps on real content and the surface concepts already developed with the user. On an established world, anchor every comp on the real identity: capture a screenshot of a representative existing page and pass it as a reference image (the harness image tool's input image, or `impeccable generate-image --ref`); the prompt leads with the new surface's structure while the reference carries palette, type, and component character, because DESIGN.md words alone drift where a pixel reference does not. Name what the reference contributes and what it must not: chrome, palette, type, and component character carry over; the reference page's own content does not, and a banner, hero, or card lifted verbatim is the reference leaking, not fidelity. Three is the number: one comp invites rubber-stamping; the spread between three surfaces the composition worth building. The chosen card's decision comp is the first of the three: it already renders this direction at full fidelity under this discipline, so generate two more that vary what the first held fixed, and send all three to the approval point together. Only a round arriving with no decision comp (a degraded roll, an identity-mode page, a direction pinned without the decision round) renders all three here. Render three distinct high-fidelity north-star comps of the requested surface, saved under `.impeccable/mocks/` so they survive the session. When the chosen card has a decision comp, it is option one: it stays at its path under `.impeccable/mocks/decision/`, where the gate counts it through `--decision-comp`, and is never regenerated; render the other two. Comp at the surface's own viewport: portrait at device size for a native app or mobile-first surface, desktop landscape otherwise; a phone screen comped landscape misstates the composition before anything is built against it. Comps are the build thread's own work, never delegated: the thread that writes the prompts holds the direction's full context and has seen every comp when the build starts. Open every image by its workspace-relative path; sandboxed viewers reject absolute paths, and everything under the project root has a relative one. Base the comps on real content and the surface concepts already developed with the user. On an established world, anchor every comp on the real identity: capture a screenshot of a representative existing page and pass it as a reference image (the harness image tool's input image, or `impeccable generate-image --ref`); the prompt leads with the new surface's structure while the reference carries palette, type, and component character, because DESIGN.md words alone drift where a pixel reference does not. Name what the reference contributes and what it must not: chrome, palette, type, and component character carry over; the reference page's own content does not, and a banner, hero, or card lifted verbatim is the reference leaking, not fidelity. Three is the number: one comp invites rubber-stamping; the spread between three surfaces the composition worth building. The chosen card's decision comp is the first of the three: it already renders this direction at full fidelity under this discipline, so generate two more that vary what the first held fixed, from the direction in words rather than with the first comp as an input image (a reference image anchors the layout, and three comps collapse into one composition). How comps two and three vary from the first is the mode's call, in its COMPS rules. A variant that simplifies the first into a tidier version of itself adds no choice to the round. Send all three to the approval point together. Only a round arriving with no decision comp (a degraded roll, an identity-mode page, a direction pinned without the decision round) renders all three here.
- A comp is a designed surface, not a picture of the subject. Lead the prompt with the surface's own structure: the regions this design has, named in order with their scale relationships; a page with no navigation says so instead of inventing one, and an unconventional surface states its unconventional skeleton. A prompt that leads with atmosphere gets a vignette back: the model paints the fish market instead of the fish market's website. Self-check every render: if it could hang as a poster, or reads as a photograph with some text on it, it is not a comp; regenerate with the layout scaffold stated more literally. - A comp prompt is short, 150 to 300 words, and carries only what the image model cannot know. The model draws what the prompt names and stops deciding whatever the prompt pins down, so a prompt that specifies everything gets back a template. Open with the surface: the kind of screen it is (a desktop web app, a documentation page, a landing page, a native app screen at its device viewport) for the product, by name, rendered edge to edge with crisp readable text and no browser window, device, or desk around it. Then the product and the visitor's job in plain words, with the real content that answers it: names, numbers with units, the offer or the state, any claim at its honest certainty, and the next action by name. Then what leads this comp, its product concept (summary-led, timeline-led, offer-led, reference-led), and the parts the screen holds in reading order, a phrase each. Then the frame in one line: the product's mark and name, its navigation, its utility controls, and the far edge, so the comp is a finished screen, never a fragment; a page with no navigation says so instead of inventing one. Leave sizes, positions, colours, and component styling out, except a colour, typeface, or brand element the user pinned: that is content, so state it. Self-check every render: if it could hang as a poster, or reads as a photograph with some text on it, it is not a comp; regenerate.
- The inverse is also a failure: a surface with none of its subject in it. The subject appears as the content the regions hold; the world dresses the frame and never displaces what the frame shows. The deletion usually rides in on the prompt's exclusion list, so exclusions bind invented claims, and a medium ban belongs to the committed imagery stance, never to caution. Before accepting a render, point at the subject; a render that depicts everything about the world and nothing of the subject fails however faithful its atmosphere. Regenerate with the subject's content named region by region. - The look comes from the world, and from a picture wherever one exists. An established world has the screenshot reference above: it carries the look, so the prompt adds one sentence on what the reference contributes and nothing more about style. A catalog world has card images: attach its board and hero as reference images (the NEXT line names them; `impeccable generate-image --ref <board> --ref <hero>`, or the harness image tool's input images) and write one sentence, that the references are style inspiration adapted into a real working product, their layout and content not to be copied. Describing the style as well overrides the picture. A direction with no card images carries its look in two to four sentences of concrete visual law: the ground and main colours with what each does, the type voices, and the one signature that makes it recognisable. No path lists colours past the main few or describes components one by one.
- The subject must be in it. Before accepting a render, point at the subject: a render that depicts the world and nothing of the subject fails however faithful its atmosphere, and the deletion usually rides in on an exclusion list. Regenerate with the subject's content named.
- Judge a comp as the shipped screen: the visitor's job must be readable from the image alone. Name the surface's mode from the render with no caption; a render whose mode cannot be read back is art direction without a surface. Regenerate with the visitor's job as the prompt's spine. - Judge a comp as the shipped screen: the visitor's job must be readable from the image alone. Name the surface's mode from the render with no caption; a render whose mode cannot be read back is art direction without a surface. Regenerate with the visitor's job as the prompt's spine.
- Commitment is depth, not coverage. The world enters through one dominant move plus the material, type, and spacing that support it; the remaining regions hold still so that move can be read. A region that simply does its job in the world's grammar carries the direction further than a region performing the concept. The check cuts competition, never content: a quieted region keeps its information and stops performing. A second element competing with the named focal moment at the same scale means the comp is shouting; with no named focal moment, several regions performing the concept at once is the same shout. Regenerate keeping the strongest move and quieting the rest. Busy is louder, not bolder. - Commitment is depth, not performance spread across regions. The world enters through one dominant move plus the material, type, and spacing that support it; the remaining regions hold still in that material and keep their content at real reading size. A second element competing with the lead at the same scale is the comp shouting: regenerate keeping the strongest move and quieting the rest. Busy is louder, not bolder.
- When the user shortlisted multiple concepts, spread the three across them. - When the user shortlisted multiple concepts, spread the three across them.
- When one direction is committed, vary the structural uncertainty an image can resolve: topology, sequence, density, hierarchy, focal composition, or interaction framing. - When one direction is committed, vary the structural uncertainty an image can resolve: what leads the screen, topology, sequence, density, hierarchy, focal composition, or interaction framing.
- Show enough beyond the opening moment to prove the concept can govern the whole surface. - Show enough beyond the opening moment to prove the concept can govern the whole surface.
- Do not generate a palette artifact, ask new atmosphere questions, introduce a different type voice, or invent a new motif. If the committed world cannot support the concept, return to the concept shortlist rather than changing the world. - Do not generate a palette artifact, ask new atmosphere questions, introduce a different type voice, or invent a new motif. If the committed world cannot support the concept, return to the concept shortlist rather than changing the world.
@@ -29,7 +30,7 @@ Do not begin code until the user approves a direction or explicitly delegates th
This approval point has no substitute and no skip condition. When the structured question tool errors, fall back to the decision page; only after both fail may you treat the choice as delegated, and a delegated pick is recorded exactly as an approval is and disclosed in your first reply, not your last. The finish reviewer treats comp-round comps with no recorded approval as a material finding; decision comps under `.impeccable/mocks/decision/` are the direction round's hand, not comp-round output, and imply no approval on their own. This approval point has no substitute and no skip condition. When the structured question tool errors, fall back to the decision page; only after both fail may you treat the choice as delegated, and a delegated pick is recorded exactly as an approval is and disclosed in your first reply, not your last. The finish reviewer treats comp-round comps with no recorded approval as a material finding; decision comps under `.impeccable/mocks/decision/` are the direction round's hand, not comp-round output, and imply no approval on their own.
After approval, record the choice where tools can find it: the approved comp's path goes in the surface brief, and its `.json` prompt sidecar gains `"approved": true` (every comp generated through `impeccable generate-image` has one; create it if a native tool didn't). The sidecar travels with the mocks folder, so the approval survives sessions and machines that never see the brief, and it is what `impeccable build-phase advance` reads to close the comps phase. Summarize the composition and the parts of the comp that must not be literalized, return to new-work.md, record the direction contract from the approved concept, and build. After approval, record the choice where tools can find it: the approved comp's path goes in the surface brief, and its prompt sidecar (the image's full file name plus `.json`: `a.png` gets `a.png.json`) gains `"approved": true` (every comp generated through `impeccable generate-image` has one; create it if a native tool didn't). The sidecar travels with the mocks folder, so the approval survives sessions and machines that never see the brief, and it is what `impeccable build-phase advance` reads to close the comps phase. Summarize the composition and the parts of the comp that must not be literalized, return to new-work.md, record the direction contract from the approved concept, and build.
## After approval: the comp becomes a spec ## After approval: the comp becomes a spec
@@ -1 +1 @@
0.1.5 0.1.15
@@ -59,7 +59,7 @@ if errorlevel 1 goto download
call :probe impeccable call :probe impeccable
if not "%probe_ok%"=="1" goto download if not "%probe_ok%"=="1" goto download
impeccable %* impeccable %*
exit /b exit /b %errorlevel%
:download :download
rem Last resort: fetch this version's binary from the release channel into rem Last resort: fetch this version's binary from the release channel into
@@ -166,7 +166,7 @@ exit /b 127
:run :run
"%run%" %* "%run%" %*
exit /b exit /b %errorlevel%
:probe :probe
rem Sets probe_ok=1 when %1 answers the engine handshake: prints rem Sets probe_ok=1 when %1 answers the engine handshake: prints
@@ -100,8 +100,108 @@
return doc.body; return doc.body;
} }
// A modal <dialog> (showModal) paints in the top layer and makes every
// node outside its subtree inert, so chrome on <body> can be neither seen
// nor clicked while one is open, and no z-index reaches it (issue #879).
// While a modal is open the chrome parks inside the topmost one, in one
// popover: inside the dialog it is not inert, and a popover shown after
// the dialog paints above it. One popover for all of it, because
// top-layer entries stack by show order, which would drop the z-index
// order between the roots. The detect overlay (browser-bundle/
// 40-overlay.js) mounts its outlines here too: it finds the host by this
// id and places its outlines again on the event of the same name, fired
// on every move.
const topLayerHost = doc.createElement('div');
topLayerHost.id = prefix + '-top-layer';
// Same box as the SvelteKit shadow host: 0x0, fixed, chrome overflows it.
// `all: initial` also clears the popover UA box.
for (const [name, value] of Object.entries({
all: 'initial', position: 'fixed', top: '0', left: '0', width: '0', height: '0', overflow: 'visible',
})) topLayerHost.style.setProperty(name, value, 'important');
const pageRoots = new WeakSet(); // chrome nodes mounted on the page itself
const openModals = []; // in the order they opened; last is topmost
// Modals that were open before watching began have no readable top-layer
// order, but the topmost one's backdrop covers the viewport, so a hit
// test lands in it. Raise that one to the top of the stack.
function raiseHitModal() {
const i = openModals.indexOf(doc.elementFromPoint(0, 0)?.closest('dialog:modal'));
if (i !== -1) openModals.push(...openModals.splice(i, 1));
}
function syncTopLayerHost(records = []) {
for (const { type, target, oldValue } of records) {
if (type !== 'attributes' || oldValue !== null || !target.matches('dialog:modal')) continue;
// Opened, or closed and reopened in one task (the observer sees only
// the end state): either way it is back on top of the top layer, so
// a host shown inside it earlier has to be shown again. A write to an
// `open` that was already set (oldValue not null) moved nothing.
const i = openModals.indexOf(target);
if (i !== -1) openModals.splice(i, 1);
openModals.push(target);
if (topLayerHost.parentNode === target) topLayerHost.remove();
}
// A closed dialog, or one removed from the document while open, stops
// matching :modal. Whichever is now on top may predate the watch.
const before = openModals.length;
for (let i = openModals.length - 1; i >= 0; i--) {
if (!openModals[i].matches('dialog:modal')) openModals.splice(i, 1);
}
if (openModals.length < before && openModals.length > 1) raiseHitModal();
const modal = openModals[openModals.length - 1] || null;
if (topLayerHost.parentNode === modal) return;
if (modal) {
modal.appendChild(topLayerHost);
for (const el of [...doc.body.children]) if (pageRoots.has(el)) topLayerHost.appendChild(el);
topLayerHost.showPopover();
} else {
doc.body.append(...topLayerHost.childNodes);
topLayerHost.remove();
}
doc.dispatchEvent(new Event(topLayerHost.id));
}
// Returns the function that stops watching and puts the chrome back.
function watchModalDialogs() {
if (typeof topLayerHost.showPopover !== 'function') return () => {};
topLayerHost.popover = 'manual';
// Parked in the page's dialog, chrome clicks would bubble into its own
// handlers, such as a click-outside-the-box close. Live listens in capture.
topLayerHost.addEventListener('click', (e) => e.stopPropagation());
openModals.push(...doc.querySelectorAll('dialog:modal'));
raiseHitModal();
const observer = new MutationObserver(syncTopLayerHost);
observer.observe(doc, { subtree: true, childList: true, attributes: true, attributeOldValue: true, attributeFilter: ['open'] });
syncTopLayerHost();
return () => {
observer.disconnect();
openModals.length = 0;
syncTopLayerHost();
};
}
// A copy of a page element without the chrome parked inside it, which a
// picked modal dialog contains.
function cloneWithoutChrome(el) {
const clone = el.cloneNode(true);
if (el.contains(topLayerHost)) clone.querySelector('#' + cssId(topLayerHost.id)).remove();
return clone;
}
// Mount a chrome node on the page itself: <body>, or the top-layer host
// while the chrome is parked in a modal.
function uiAppendToPage(el) {
pageRoots.add(el);
(topLayerHost.parentNode ? topLayerHost : doc.body).appendChild(el);
return el;
}
function uiAppend(el) { function uiAppend(el) {
liveUiRoot().appendChild(el); const uiRoot = liveUiRoot();
if (uiRoot === doc.body) return uiAppendToPage(el);
// An adapter shadow root: its host is the node that sits on the page.
if (uiRoot.host) pageRoots.add(uiRoot.host);
uiRoot.appendChild(el);
return el; return el;
} }
@@ -142,6 +242,15 @@
rootEl.addEventListener('focusin', stop); rootEl.addEventListener('focusin', stop);
} }
// The half of the defang that cannot sit on the chrome: a focus trap (Radix /
// Reka FocusScope) also hears the page's own focusout as focus leaves its
// modal for our chrome, and pulls it back. Register on the document in
// capture, ahead of the trap. The page's own focusout handlers miss that
// one transition too; blur still fires.
function stopFocusOutIntoChrome(e) {
if (own(e.relatedTarget)) e.stopPropagation();
}
return { return {
own, own,
pickable, pickable,
@@ -153,10 +262,15 @@
cssId, cssId,
liveUiRoot, liveUiRoot,
uiAppend, uiAppend,
uiAppendToPage,
topLayerHost,
watchModalDialogs,
cloneWithoutChrome,
uiAppendStyle, uiAppendStyle,
uiGetById, uiGetById,
activeElementDeep, activeElementDeep,
defangOutsideHandlers, defangOutsideHandlers,
stopFocusOutIntoChrome,
}; };
} }
@@ -44,6 +44,10 @@
function saveSession(session) { function saveSession(session) {
if (!session || !session.id) return; if (!session || !session.id) return;
// Adopt a revision another tab stored so this write does not lower it.
// localStorage has no compare-and-set, so a save from another tab that
// lands between this read and the write below can still be overwritten.
loadSession();
const payload = { const payload = {
...session, ...session,
checkpointRevision, checkpointRevision,
@@ -56,8 +60,8 @@
} }
function nextCheckpointRevision() { function nextCheckpointRevision() {
checkpointRevision += 1;
const existing = loadSession(); const existing = loadSession();
checkpointRevision += 1;
if (existing?.id) saveSession(existing); if (existing?.id) saveSession(existing);
return checkpointRevision; return checkpointRevision;
} }
@@ -265,6 +265,7 @@
let editBadgeEl = null; let editBadgeEl = null;
let editBadgeProxyRoot = null; let editBadgeProxyRoot = null;
let editBadgeProxyByTarget = new Map(); let editBadgeProxyByTarget = new Map();
let stopModalWatch = null;
// //
// Helpers // Helpers
@@ -291,10 +292,15 @@
cssId, cssId,
liveUiRoot, liveUiRoot,
uiAppend, uiAppend,
uiAppendToPage,
topLayerHost,
watchModalDialogs,
cloneWithoutChrome,
uiAppendStyle, uiAppendStyle,
uiGetById, uiGetById,
activeElementDeep, activeElementDeep,
defangOutsideHandlers, defangOutsideHandlers,
stopFocusOutIntoChrome,
} = domHelpers; } = domHelpers;
window.__IMPECCABLE_LIVE_CHROME_CORE__ = { window.__IMPECCABLE_LIVE_CHROME_CORE__ = {
@@ -863,8 +869,15 @@
setTimeout(() => input.focus(), 0); setTimeout(() => input.focus(), 0);
} }
// The Enter that commits an IME candidate (Zhuyin, Pinyin, kana, Hangul) is
// not a submit. Safari fires that keydown after compositionend, so
// isComposing is already false there and keyCode 229 is the only signal.
function isImeKeydown(e) {
return e.isComposing || e.keyCode === 229;
}
function onAnnotInputKey(e) { function onAnnotInputKey(e) {
if (e.key === 'Enter') { if (e.key === 'Enter' && !isImeKeydown(e)) {
e.preventDefault(); e.stopPropagation(); e.preventDefault(); e.stopPropagation();
finalizeEditingPin(); finalizeEditingPin();
} else if (e.key === 'Escape') { } else if (e.key === 'Escape') {
@@ -967,11 +980,12 @@
} }
} }
function sanitizedContextOuterHTML(el, maxLength) { // The element as the agent reads it, text and markup alike: no parked
if (!el || !el.cloneNode) return ''; // chrome (a picked modal dialog contains it) and no edit runtime state.
const clone = el.cloneNode(true); function sanitizedContextClone(el) {
const clone = cloneWithoutChrome(el);
stripManualEditRuntimeState(clone); stripManualEditRuntimeState(clone);
return clone.outerHTML ? clone.outerHTML.slice(0, maxLength) : ''; return clone;
} }
function extractContext(el) { function extractContext(el) {
@@ -1001,12 +1015,13 @@
: (anchorClasses.length ? el.tagName.toLowerCase() + '.' + anchorClasses.join('.') : null); : (anchorClasses.length ? el.tagName.toLowerCase() + '.' + anchorClasses.join('.') : null);
let anchorMatches = null; let anchorMatches = null;
if (anchor) { try { anchorMatches = document.querySelectorAll(anchor).length; } catch { anchorMatches = null; } } if (anchor) { try { anchorMatches = document.querySelectorAll(anchor).length; } catch { anchorMatches = null; } }
const clone = sanitizedContextClone(el);
return { return {
tagName: el.tagName.toLowerCase(), id: el.id || null, tagName: el.tagName.toLowerCase(), id: el.id || null,
classes: [...el.classList], classes: [...el.classList],
anchor, anchorMatches, anchor, anchorMatches,
textContent: (el.textContent || '').slice(0, 500), textContent: (clone.textContent || '').slice(0, 500),
outerHTML: sanitizedContextOuterHTML(el, 10000), outerHTML: (clone.outerHTML || '').slice(0, 10000),
computedStyles: { computedStyles: {
'font-family': cs.fontFamily, 'font-size': cs.fontSize, 'font-family': cs.fontFamily, 'font-size': cs.fontSize,
'font-weight': cs.fontWeight, 'line-height': cs.lineHeight, 'font-weight': cs.fontWeight, 'line-height': cs.lineHeight,
@@ -2456,7 +2471,7 @@
input.addEventListener('focus', () => syncConfigureInputChrome()); input.addEventListener('focus', () => syncConfigureInputChrome());
input.addEventListener('blur', () => syncConfigureInputChrome()); input.addEventListener('blur', () => syncConfigureInputChrome());
input.addEventListener('keydown', (e) => { input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.stopPropagation(); e.preventDefault(); handleGo(); return; } if (e.key === 'Enter' && !isImeKeydown(e)) { e.stopPropagation(); e.preventDefault(); handleGo(); return; }
if (e.key === 'Escape') { if (e.key === 'Escape') {
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
@@ -2542,7 +2557,7 @@
try { input.focus({ preventScroll: true }); } catch { input.focus(); } try { input.focus({ preventScroll: true }); } catch { input.focus(); }
}); });
input.addEventListener('keydown', (e) => { input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { if (e.key === 'Enter' && !isImeKeydown(e)) {
e.stopPropagation(); e.preventDefault(); e.stopPropagation(); e.preventDefault();
if (isInsertCreateEnabled()) handleInsertCreate(); if (isInsertCreateEnabled()) handleInsertCreate();
return; return;
@@ -3710,6 +3725,7 @@
function copyEditLeafContext(el, originalText, newText) { function copyEditLeafContext(el, originalText, newText) {
if (!el) return null; if (!el) return null;
const clone = sanitizedContextClone(el);
return { return {
ref: documentRefForElement(el), ref: documentRefForElement(el),
tagName: el.tagName ? el.tagName.toLowerCase() : null, tagName: el.tagName ? el.tagName.toLowerCase() : null,
@@ -3717,8 +3733,8 @@
classes: el.classList ? [...el.classList].filter((cls) => cls.indexOf('impeccable-') !== 0) : [], classes: el.classList ? [...el.classList].filter((cls) => cls.indexOf('impeccable-') !== 0) : [],
originalText, originalText,
newText, newText,
textContent: (el.textContent || '').slice(0, 500), textContent: (clone.textContent || '').slice(0, 500),
outerHTML: sanitizedContextOuterHTML(el, 3000) || null, outerHTML: (clone.outerHTML || '').slice(0, 3000) || null,
}; };
} }
@@ -3744,13 +3760,14 @@
function copyEditContainerContext(el) { function copyEditContainerContext(el) {
if (!el) return null; if (!el) return null;
const clone = sanitizedContextClone(el);
return { return {
ref: documentRefForElement(el), ref: documentRefForElement(el),
tagName: el.tagName ? el.tagName.toLowerCase() : null, tagName: el.tagName ? el.tagName.toLowerCase() : null,
id: el.id || null, id: el.id || null,
classes: el.classList ? [...el.classList].filter((cls) => cls.indexOf('impeccable-') !== 0) : [], classes: el.classList ? [...el.classList].filter((cls) => cls.indexOf('impeccable-') !== 0) : [],
textContent: (el.textContent || '').slice(0, 1000), textContent: (clone.textContent || '').slice(0, 1000),
outerHTML: sanitizedContextOuterHTML(el, 10000) || null, outerHTML: (clone.outerHTML || '').slice(0, 10000) || null,
}; };
} }
@@ -4540,7 +4557,7 @@
for (const [name, value] of Object.entries(styles)) { for (const [name, value] of Object.entries(styles)) {
setImportantStyle(editBadgeProxyRoot, name.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase()), value); setImportantStyle(editBadgeProxyRoot, name.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase()), value);
} }
document.body.appendChild(editBadgeProxyRoot); uiAppendToPage(editBadgeProxyRoot);
} }
function styleEditBadgeProxy(proxy, target) { function styleEditBadgeProxy(proxy, target) {
@@ -8032,8 +8049,8 @@
let depth = 0; let depth = 0;
while (node && depth < 12) { while (node && depth < 12) {
// 1. Active dialog / modal // 1. Active dialog / modal
if (node.getAttribute && node.getAttribute('role') === 'dialog' if (node.tagName === 'DIALOG' || (node.getAttribute && node.getAttribute('role') === 'dialog'
&& node.getAttribute('aria-modal') === 'true') { && node.getAttribute('aria-modal') === 'true')) {
showToast('Heads up: this element lives inside a dialog. If state resets during generation, you may need to re-open it.', 6000); showToast('Heads up: this element lives inside a dialog. If state resets during generation, you may need to re-open it.', 6000);
return; return;
} }
@@ -8645,6 +8662,7 @@
const opts = { const opts = {
scale: Math.min(window.devicePixelRatio || 1, 2), scale: Math.min(window.devicePixelRatio || 1, 2),
font: fontCssText ? { cssText: fontCssText } : undefined, font: fontCssText ? { cssText: fontCssText } : undefined,
filter: (node) => node !== topLayerHost,
}; };
if (shouldUseAncestorCropShaderProxy(el)) { if (shouldUseAncestorCropShaderProxy(el)) {
try { try {
@@ -11431,7 +11449,7 @@ void main() {
} }
return; return;
} }
if (e.key === 'Enter') { if (e.key === 'Enter' && !isImeKeydown(e)) {
e.preventDefault(); e.preventDefault();
submitSteerMessage(); submitSteerMessage();
} }
@@ -12249,6 +12267,7 @@ void main() {
pagePickSkipClick = false; pagePickSkipClick = false;
cleanup(); cleanup();
hideBar(); hideBar();
if (stopModalWatch) { stopModalWatch(); stopModalWatch = null; }
if (pendingDockResizeObserver) { pendingDockResizeObserver.disconnect(); pendingDockResizeObserver = null; } if (pendingDockResizeObserver) { pendingDockResizeObserver.disconnect(); pendingDockResizeObserver = null; }
window.removeEventListener('resize', positionPendingDock); window.removeEventListener('resize', positionPendingDock);
if (pendingIntroAnimation) { pendingIntroAnimation.cancel(); pendingIntroAnimation = null; } if (pendingIntroAnimation) { pendingIntroAnimation.cancel(); pendingIntroAnimation = null; }
@@ -12286,6 +12305,7 @@ void main() {
document.removeEventListener('mousemove', handleMouseMove, true); document.removeEventListener('mousemove', handleMouseMove, true);
document.removeEventListener('click', handleClick, true); document.removeEventListener('click', handleClick, true);
document.removeEventListener('keydown', handleKeyDown, true); document.removeEventListener('keydown', handleKeyDown, true);
document.removeEventListener('focusout', stopFocusOutIntoChrome, true);
window.removeEventListener('message', onDetectMessage); window.removeEventListener('message', onDetectMessage);
// Remove detection overlays // Remove detection overlays
window.postMessage({ source: 'impeccable-command', action: 'remove' }, '*'); window.postMessage({ source: 'impeccable-command', action: 'remove' }, '*');
@@ -13466,10 +13486,12 @@ void main() {
attachSteerFocusDebug(); attachSteerFocusDebug();
attachSteerFocusGuard(); attachSteerFocusGuard();
initDesignPanel(); initDesignPanel();
stopModalWatch = watchModalDialogs();
fetchPendingCount(); fetchPendingCount();
document.addEventListener('mousemove', handleMouseMove, true); document.addEventListener('mousemove', handleMouseMove, true);
document.addEventListener('click', handleClick, true); document.addEventListener('click', handleClick, true);
document.addEventListener('keydown', handleKeyDown, true); document.addEventListener('keydown', handleKeyDown, true);
document.addEventListener('focusout', stopFocusOutIntoChrome, true);
connectSSE(); connectSSE();
// Check for an active session to resume (variant wrapper already in DOM after HMR) // Check for an active session to resume (variant wrapper already in DOM after HMR)
+287
View File
@@ -0,0 +1,287 @@
#!/bin/sh
// 2>/dev/null; b="$HOME/.bun/bin/bun"; [ -x "$b" ] || b=bun; exec "$b" "$0" "$@"
// (sh runs the line above and execs Bun on this file; Bun reads it as a comment.)
//
// Chrome stand-in for the impeccable engine, wired in via IMPECCABLE_BROWSER. Linux only: it reads
// the engine's argv from /proc and finds Chrome in the Linux Puppeteer and Playwright caches, so
// setup-mac.sh does not link it. On flowstate-remote, ~/.zshenv exports
// IMPECCABLE_BROWSER="$HOME/.local/bin/impeccable-browser".
//
// The engine launches its browser the way Puppeteer does (--remote-debugging-port=0, then reads
// "DevTools listening on ws://..." from stderr). Here, URL scans run on Browserless (key
// BROWSERLESS_API_KEY in the Reoclo project flowstate-tools) through a local CDP proxy. Local and
// private origins (localhost, LAN, Tailscale) are exposed to Browserless through a cloudflared
// quick tunnel for the length of the scan; the proxy swaps the local origin for the tunnel origin
// on the way out and back, so the engine and its report only ever see the local URL.
// Anything else (non-detect launches, or Browserless or the tunnel failing) runs on local Chrome.
//
// Env overrides:
// IMPECCABLE_BROWSER_MODE auto (default) | remote | local
// IMPECCABLE_TUNNEL 1 (default) tunnels private origins; 0 sends them to local Chrome
// BROWSERLESS_TOKEN use this key instead of the Reoclo secret
// BROWSERLESS_URL default wss://production-sfo.browserless.io
// BROWSERLESS_TIMEOUT_MS Browserless session cap, default 600000
// IMPECCABLE_LOCAL_CHROME local Chrome binary, default newest cached headless shell
// Decisions are appended to ~/.impeccable/browser.log.
import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
import type { Subprocess } from "bun";
const HOME = process.env.HOME ?? "/home/ubuntu";
const STATE_DIR = `${HOME}/.impeccable`;
const TOKEN_CACHE = `${STATE_DIR}/browserless.token`;
const TOKEN_TTL_MS = 12 * 60 * 60 * 1000;
const args = process.argv.slice(2);
mkdirSync(STATE_DIR, { recursive: true });
const note = (msg: string) =>
appendFileSync(`${STATE_DIR}/browser.log`, `${new Date().toISOString()} pid=${process.pid} ${msg}\n`);
const bin = (name: string) => (existsSync(`${HOME}/.local/bin/${name}`) ? `${HOME}/.local/bin/${name}` : name);
function isPrivateHost(host: string): boolean {
const h = host.replace(/^\[|\]$/g, "").toLowerCase();
return (
h === "localhost" ||
h === "0.0.0.0" ||
h === "::1" ||
/\.(localhost|local|internal|lan)$/.test(h) ||
/^127\./.test(h) ||
/^10\./.test(h) ||
/^192\.168\./.test(h) ||
/^169\.254\./.test(h) ||
/^172\.(1[6-9]|2\d|3[01])\./.test(h) ||
/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(h) ||
/^f[cd][0-9a-f]{2}:/.test(h)
);
}
type Plan = { mode: "remote" | "local"; why: string; privateOrigins: string[] };
// The engine binary is our parent; its argv says what is being scanned.
function plan(): Plan {
const forced = process.env.IMPECCABLE_BROWSER_MODE;
if (forced === "local") return { mode: "local", why: "IMPECCABLE_BROWSER_MODE", privateOrigins: [] };
let argv: string[];
try {
argv = readFileSync(`/proc/${process.ppid}/cmdline`, "utf8").split("\0").filter(Boolean);
} catch {
return { mode: "local", why: "parent argv unreadable", privateOrigins: [] };
}
const at = argv.indexOf("detect");
if (at < 0) return { mode: "local", why: `not a detect run (${argv.slice(1, 3).join(" ")})`, privateOrigins: [] };
const valueFlags = new Set(["--scope", "--viewport", "--target"]);
const targets: string[] = [];
for (let i = at + 1; i < argv.length; i++) {
if (valueFlags.has(argv[i])) i++;
else if (!argv[i].startsWith("-")) targets.push(argv[i]);
}
if (targets.length === 0) return { mode: "local", why: "no targets", privateOrigins: [] };
const privateOrigins = new Set<string>();
for (const t of targets) {
if (!/^https?:\/\//i.test(t)) return { mode: "local", why: `file target ${t}`, privateOrigins: [] };
const url = new URL(t);
if (isPrivateHost(url.hostname)) {
if (process.env.IMPECCABLE_TUNNEL === "0") return { mode: "local", why: `private host ${t}, tunnels off`, privateOrigins: [] };
privateOrigins.add(url.origin);
}
}
return { mode: "remote", why: targets.join(" "), privateOrigins: [...privateOrigins] };
}
function localChrome(): string {
const pinned = process.env.IMPECCABLE_LOCAL_CHROME;
if (pinned) return pinned;
const scan = (pattern: string, cwd: string) =>
existsSync(cwd) ? [...new Bun.Glob(pattern).scanSync({ cwd, absolute: true, onlyFiles: true })] : [];
const newestFirst = (paths: string[]) => paths.sort((a, b) => b.localeCompare(a, undefined, { numeric: true }));
const found = [
...newestFirst(scan("*/chrome-headless-shell-linux64/chrome-headless-shell", `${HOME}/.cache/puppeteer/chrome-headless-shell`)),
...newestFirst(scan("chromium_headless_shell-*/chrome-headless-shell-linux64/chrome-headless-shell", `${HOME}/.cache/ms-playwright`)),
];
if (found.length === 0) throw new Error("no local Chrome found; set IMPECCABLE_LOCAL_CHROME");
return found[0];
}
type Tunnel = { local: string; remote: string; proc: Subprocess };
const tunnels: Tunnel[] = [];
process.on("exit", () => tunnels.forEach((t) => t.proc.kill()));
async function runLocal(why: string): Promise<never> {
tunnels.forEach((t) => t.proc.kill());
const chrome = localChrome();
note(`local ${chrome} (${why})`);
// This host's AppArmor blocks unprivileged user namespaces, so Chrome's sandbox cannot start.
const flags = args.includes("--no-sandbox") ? args : [...args, "--no-sandbox"];
const child = Bun.spawn([chrome, ...flags], { stdin: "inherit", stdout: "inherit", stderr: "inherit" });
for (const sig of ["SIGTERM", "SIGINT", "SIGHUP"] as const) process.on(sig, () => child.kill(sig));
process.exit(await child.exited);
}
function withTimeout<T>(p: Promise<T>, ms: number, what: string): Promise<T> {
return Promise.race([p, new Promise<never>((_, reject) => setTimeout(() => reject(new Error(`${what} timed out after ${ms / 1000}s`)), ms))]);
}
// A cloudflared quick tunnel to one local origin, alive until this shim exits. The sh wrapper is a
// watchdog: it stops cloudflared when this process dies, even by SIGKILL, so no tunnel outlives a scan.
async function openTunnel(local: string): Promise<Tunnel> {
const watchdog =
'trap \'kill $c 2>/dev/null; exit 0\' TERM; "$0" tunnel --no-autoupdate --url "$1" --http-host-header "$2" & c=$!; ' +
'while kill -0 "$3" 2>/dev/null && kill -0 $c 2>/dev/null; do sleep 1; done; kill $c 2>/dev/null';
const proc = Bun.spawn(["sh", "-c", watchdog, bin("cloudflared"), local, new URL(local).host, String(process.pid)], {
stdin: "ignore",
stdout: "ignore",
stderr: "pipe",
});
const tunnel: Tunnel = { local, remote: "", proc };
tunnels.push(tunnel);
const reader = proc.stderr.getReader();
const decoder = new TextDecoder();
let log = "";
await withTimeout(
(async () => {
while (!(tunnel.remote && /Registered tunnel connection/.test(log))) {
const { value, done } = await reader.read();
if (done) throw new Error(`cloudflared exited: ${log.trim().split("\n").slice(-2).join(" | ")}`);
log += decoder.decode(value);
tunnel.remote ||= log.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/)?.[0] ?? "";
}
})(),
30_000,
"cloudflared quick tunnel",
);
// Keep draining cloudflared's log so a full pipe never stalls it.
(async () => {
while (!(await reader.read()).done);
})();
// The edge can answer 530 for a moment after registration; wait until it routes to the origin.
await withTimeout(
(async () => {
for (;;) {
const status = await fetch(tunnel.remote, { redirect: "manual" }).then((r) => r.status, () => 0);
if (status && status !== 530 && status !== 502) return;
await Bun.sleep(500);
}
})(),
20_000,
`tunnel ${tunnel.remote} readiness`,
);
return tunnel;
}
async function browserlessKey(fresh: boolean): Promise<string> {
const fromEnv = process.env.BROWSERLESS_TOKEN;
if (fromEnv && !fresh) return fromEnv;
if (!fresh && existsSync(TOKEN_CACHE) && Date.now() - statSync(TOKEN_CACHE).mtimeMs < TOKEN_TTL_MS) {
return readFileSync(TOKEN_CACHE, "utf8").trim();
}
const proc = Bun.spawn([bin("reoclo"), "secrets", "get", "BROWSERLESS_API_KEY", "--project", "flowstate-tools"], {
stdin: "ignore",
stdout: "pipe",
stderr: "pipe",
});
const [out, err, code] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited]);
const key = out.trim();
if (code !== 0 || !key) throw new Error(`reoclo secrets get exited ${code}: ${err.trim().slice(0, 200)}`);
writeFileSync(TOKEN_CACHE, key, { mode: 0o600 });
return key;
}
function openUpstream(key: string): Promise<WebSocket> {
const url = new URL(process.env.BROWSERLESS_URL ?? "wss://production-sfo.browserless.io");
url.searchParams.set("token", key);
url.searchParams.set("timeout", process.env.BROWSERLESS_TIMEOUT_MS ?? "600000");
return new Promise((resolve, reject) => {
const ws = new WebSocket(url.toString());
ws.binaryType = "arraybuffer";
const timer = setTimeout(() => {
ws.close();
reject(new Error("connect timed out after 20s"));
}, 20_000);
ws.onopen = () => {
clearTimeout(timer);
resolve(ws);
};
ws.onerror = () => {
clearTimeout(timer);
reject(new Error("connect refused (bad key, quota, or network)"));
};
});
}
async function connectBrowserless(): Promise<WebSocket> {
try {
return await openUpstream(await browserlessKey(false));
} catch (first) {
try {
return await openUpstream(await browserlessKey(true));
} catch (second) {
throw new Error(`browserless unavailable: ${(first as Error).message}; retry: ${(second as Error).message}`);
}
}
}
const decided = plan();
if (decided.mode === "local") await runLocal(decided.why);
let upstream: WebSocket;
try {
[upstream] = await Promise.all([connectBrowserless(), ...decided.privateOrigins.map(openTunnel)]);
} catch (error) {
await runLocal((error as Error).message);
}
const routes = tunnels.map((t) => `${t.local} via ${t.remote}`).join(", ");
note(`browserless (${decided.why})${routes ? ` tunnels: ${routes}` : ""}`);
// Longest origin first, so http://127.0.0.1:80 never rewrites inside http://127.0.0.1:8080.
const byLength = [...tunnels].sort((a, b) => b.local.length - a.local.length);
const outbound = (msg: string) => byLength.reduce((text, t) => text.replaceAll(t.local, t.remote), msg);
const inbound = (msg: string) => byLength.reduce((text, t) => text.replaceAll(t.remote, t.local), msg);
const exit = () => {
try {
upstream.close();
} catch {}
process.exit(0);
};
for (const sig of ["SIGTERM", "SIGINT", "SIGHUP"] as const) process.on(sig, exit);
const browserId = crypto.randomUUID();
let client: import("bun").ServerWebSocket<unknown> | null = null;
const backlog: (string | ArrayBuffer)[] = [];
upstream.onmessage = (event) => {
const data = typeof event.data === "string" ? inbound(event.data) : event.data;
if (client) client.send(data);
else backlog.push(data);
};
upstream.onclose = () => {
client?.close();
process.exit(0);
};
const server = Bun.serve({
hostname: "127.0.0.1",
port: 0,
fetch(req, srv) {
if (new URL(req.url).pathname === "/json/version") {
return Response.json({
Browser: "Browserless",
webSocketDebuggerUrl: `ws://127.0.0.1:${srv.port}/devtools/browser/${browserId}`,
});
}
if (srv.upgrade(req)) return;
return new Response("not found", { status: 404 });
},
websocket: {
maxPayloadLength: 512 * 1024 * 1024,
open(ws) {
client = ws;
for (const msg of backlog.splice(0)) ws.send(msg);
},
message(_ws, msg) {
upstream.send(typeof msg === "string" ? outbound(msg) : msg);
},
close: exit,
},
});
process.stderr.write(`\nDevTools listening on ws://127.0.0.1:${server.port}/devtools/browser/${browserId}\n`);
+3 -1
View File
@@ -56,7 +56,9 @@ esac
# Find the tmux pane omo runs in: hooks run detached without TMUX_PANE, so walk # Find the tmux pane omo runs in: hooks run detached without TMUX_PANE, so walk
# up the process tree to the first ancestor whose tty is a tmux pane. # up the process tree to the first ancestor whose tty is a tmux pane.
# Outside tmux, the first tty found is the Ghostty tab's own terminal. # Outside tmux, the first tty found is the Ghostty tab's own terminal.
pane="" # omo-remote names the pane itself (OMO_NOTIFY_PANE): for a session on a remote
# server it is the local pane running mosh or ssh.
pane="${OMO_NOTIFY_PANE:-}"
own_tty="" own_tty=""
panes=$(tmux list-panes -a -F '#{pane_tty} #{pane_id}' 2>/dev/null) panes=$(tmux list-panes -a -F '#{pane_tty} #{pane_id}' 2>/dev/null)
pid=$PPID pid=$PPID
+194
View File
@@ -0,0 +1,194 @@
#!/usr/bin/env bash
# Ding, tab marker and notification for omo sessions on a remote server
# (flowstate-remote), the same as omo-notify gives local sessions. Two paths,
# so each event arrives once:
#
# Title: the server's omo-notify sets the terminal title of every tmux client
# attached to the session to "<marker> <project> · omo:<state>:<unix time>",
# plus ":<question>" when it asks you something, e.g.
# "🟢 studio · omo:done:1792249251". mosh and ssh carry titles to this Mac
# (mosh drops the OSC 777 banner). In a tmux pane running mosh or ssh, tmux's
# pane-title-changed hook (~/.tmux.conf) runs `omo-remote title <pane>`, which
# hands the event to omo-notify for that pane. A plain Ghostty tab just shows
# the title, marker first.
#
# ntfy: the server also posts each finish, error and question to ntfy, tagged
# "omo", plus "attached" when the title reached a terminal. The launchd agent
# runs `omo-remote subscribe`, which dings and posts the banner unless a tmux
# pane here already handled that event through its title. Sessions no terminal
# shows, and sessions in plain tabs, still notify, and only once. A
# "mosh-<port>" tag names the mosh connection, so the banner comes from the
# plain tab running that mosh-client.
#
# Setup: put NTFY_URL, NTFY_TOPIC and NTFY_TOKEN in ~/.config/ntfy/omo-remote.env
# (chmod 600), then run `omo-remote install` once.
#
# Usage: omo-remote title <pane> (run by the tmux pane-title-changed hook)
# omo-remote subscribe (run by the launchd agent)
# omo-remote message (run by ntfy for each push; reads $NTFY_*)
# omo-remote install (install and start the launchd agent)
# Hooks and launchd run with a minimal PATH; make Homebrew tools reachable.
export PATH="$PATH:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"
label="local.omo-remote"
conf="${OMO_REMOTE_CONF:-$HOME/.config/ntfy/omo-remote.env}"
log="${OMO_REMOTE_LOG:-$HOME/.local/state/omo-remote.log}"
# An older event happened while you were away (the Mac slept or mosh was
# reconnecting): mark the tab, but skip the ding and the banner.
fresh=120
logline() {
mkdir -p "$(dirname "$log")"
printf '%s %s\n' "$(date '+%F %T')" "$*" >>"$log"
}
# Whether a tmux pane here running mosh or ssh got this event through its
# title: the same state and project, stamped within a few seconds of the push.
# The title usually lands first, so give it up to three seconds.
title_got_through() {
local state=$1 project=$2 sent=$3 i panes
for ((i = 0; i < 12; i++)); do
panes=$(tmux list-panes -a -F '#{pane_current_command} #{@omo_remote_event}' 2>/dev/null |
awk '$1 == "mosh-client" || $1 == "ssh"')
[[ -n $panes ]] || return 1 # no tmux pane here talks to a server
awk -v s="$state" -v p="$project" -v t="$sent" '
{ e = substr($0, index($0, " ") + 1)
i = index(e, ":"); st = substr(e, 1, i - 1); e = substr(e, i + 1)
i = index(e, ":"); w = substr(e, 1, i - 1) + 0; pr = substr(e, i + 1)
if (st == s && pr == p && w - t <= 5 && t - w <= 5) found = 1 }
END { exit !found }' <<<"$panes" && return 0
sleep 0.25
done
return 1
}
# The terminal of a Ghostty tab running mosh or ssh outside tmux: the one on
# the mosh port the push names (tags ",mosh-<port>," in $1), else the newest.
plain_tab_tty() {
local panes
panes=$(tmux list-panes -a -F '#{pane_tty}' 2>/dev/null)
ps -A -o tty=,args= | PANES=$panes TAGS=$1 awk '
BEGIN { n = split(ENVIRON["PANES"], list, "\n"); for (i = 1; i <= n; i++) pane[list[i]] = 1 }
{ cmd = $2; sub(/.*\//, "", cmd) }
$1 == "??" || (("/dev/" $1) in pane) || (cmd != "mosh-client" && cmd != "ssh") { next }
{ tty = "/dev/" $1; if (cmd == "mosh-client" && index(ENVIRON["TAGS"], ",mosh-" $NF ",")) named = tty }
END { if (named != "") print named; else if (tty != "") print tty }'
}
case ${1:-} in
title)
pane=${2:?pane id required}
title=$(tmux display-message -p -t "$pane" '#{pane_title}' 2>/dev/null) || exit 0
[[ $title == *" · omo:"* ]] || exit 0
IFS=: read -r state when question <<<"${title#*" · omo:"}"
[[ $when =~ ^[0-9]+$ ]] || exit 0
project=${title%%" · omo:"*}
project=${project#"[mosh] "} # mosh puts "[mosh] " in front
case $project in "🔵 "* | "🟡 "* | "🟢 "* | "🔴 "*) project=${project#* } ;; esac
# Act once per event, however often the title is set again. The ntfy
# handler reads this to tell whether the title already got through.
event="$state:$when:$project"
[[ $(tmux show-option -pqv -t "$pane" @omo_remote_event) == "$event" ]] && exit 0
tmux set-option -p -t "$pane" @omo_remote_event "$event"
case $state in
working) payload='{"event":"UserPromptSubmit"}' ;;
done) payload='{"hook_event_name":"Stop","stopReason":"stop"}' ;;
error) payload='{"hook_event_name":"Stop","stopReason":"error"}' ;;
clear) payload='{"hook_event_name":"Stop","stopReason":"aborted"}' ;;
input) payload=$(jq -cn --arg q "$question" '{hook_event_name:"Notification",kind:"ask-user-asked",title:$q}') ;;
*) exit 0 ;;
esac
if (($(date +%s) - when > fresh)); then
exec "$HOME/.local/bin/omo-status" set "$pane" "$state"
fi
payload=$(jq -c --arg cwd "${project:-remote}" '. + {cwd: $cwd}' <<<"$payload")
OMO_NOTIFY_PANE=$pane exec "$HOME/.local/bin/omo-notify" <<<"$payload"
;;
subscribe)
if [[ ! -r $conf ]]; then
echo "omo-remote: $conf is missing" >&2
exit 1
fi
set -a
# shellcheck disable=SC1090
. "$conf"
set +a
: "${NTFY_URL:?NTFY_URL is not set in $conf}" "${NTFY_TOPIC:?NTFY_TOPIC is not set in $conf}"
# ntfy reads the token from $NTFY_TOKEN, which keeps it out of `ps`.
exec ntfy subscribe "${NTFY_URL%/}/$NTFY_TOPIC" "$(printf '%q' "$0") message"
;;
message)
tags=",${NTFY_TAGS:-},"
[[ $tags == *,omo,* ]] || exit 0 # Claude Code posts to the same topic
(($(date +%s) - ${NTFY_TIME:-0} <= fresh)) || exit 0
case $tags in
*,question,*) state=input sound=Ping ;;
*,x,*) state=error sound=Glass ;;
*) state="done" sound=Glass ;;
esac
# "attached" only says some terminal shows the session: a tmux pane here
# (the title path has it), a plain tab, or a mosh-server left behind.
if [[ $tags == *,attached,* ]] &&
title_got_through "$state" "${NTFY_TITLE##* · }" "${NTFY_TIME:-0}"; then
logline "ntfy $state ${NTFY_TITLE:-} skipped: a tmux pane got it through the title"
exit 0
fi
afplay "/System/Library/Sounds/${sound}.aiff" >/dev/null 2>&1 &
# Ghostty posts the banner from a plain tab connected with mosh or ssh, so
# clicking it lands there, or else from the tmux tab you used last.
target=$(plain_tab_tty "$tags")
[[ -n $target ]] ||
target=$(tmux list-clients -F '#{client_activity} #{client_tty}' 2>/dev/null | sort -rn | awk 'NR == 1 { print $2 }')
clean() { tr -d '\000-\037\177' <<<"$1" | tr ';' ','; }
if [[ -n $target && -w $target ]]; then
printf '\033]777;notify;%s;%s\a' "$(clean "omo: ${NTFY_TITLE:-remote session}")" \
"$(clean "$(head -n 1 <<<"${NTFY_MESSAGE:-}")")" >"$target"
fi
logline "ntfy $state ${NTFY_TITLE:-} target=${target:-none}"
;;
install)
if [[ ! -r $conf ]]; then
echo "omo-remote: put NTFY_URL, NTFY_TOPIC and NTFY_TOKEN in $conf first" >&2
exit 1
fi
command -v ntfy >/dev/null || {
echo "omo-remote: install the ntfy CLI first (brew install ntfy)" >&2
exit 1
}
plist="$HOME/Library/LaunchAgents/$label.plist"
mkdir -p "$(dirname "$log")" "$(dirname "$plist")"
cat >"$plist" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>$label</string>
<key>ProgramArguments</key>
<array>
<string>$HOME/.local/bin/omo-remote</string>
<string>subscribe</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>ThrottleInterval</key>
<integer>30</integer>
<key>StandardOutPath</key>
<string>$log</string>
<key>StandardErrorPath</key>
<string>$log</string>
</dict>
</plist>
PLIST
launchctl bootout "gui/$(id -u)/$label" 2>/dev/null
launchctl bootstrap "gui/$(id -u)" "$plist" && echo "omo-remote: listening for ntfy pushes ($plist)"
;;
*)
sed -n '2,/^$/s/^# \{0,1\}//p' "$0" >&2
exit 2
;;
esac
+223
View File
@@ -0,0 +1,223 @@
#!/usr/bin/env bash
# skills-update: refresh omo's upstream-tracked skills. On flowstate-remote it runs daily from the
# skills-update.timer systemd user unit; `skills-update --install-timer` writes and enables it.
#
# Vendored in this repo (.config/omo/skills, linked into ~/.omo/agent/skills), committed and pushed:
# impeccable newest pbakaus/impeccable skill-v* release, re-vendored the way
# .config/omo/skills/README.md describes (path rewrite, OmO routing line)
# web-design-guidelines vercel-labs/agent-skills main, when its content changes
# Local to this machine (~/.omo/agent/skills, not in git):
# vercel-react-best-practices vercel-labs/agent-skills skills/react-best-practices
# docx pdf pptx xlsx skill-creator anthropics/skills skills/<name>
# deep-research came from Claude's skill sync and has no public upstream, so it is not updated.
#
# Only the skill paths are committed, and the push happens only when main was level with
# origin/main, so unrelated local edits and unpushed commits are never published. A skill whose
# update fails is restored to its previous copy, and the run exits non-zero.
#
# Env overrides (for testing): DOTFILES, SKILLS, XDG_STATE_HOME.
set -uo pipefail
DOTFILES=${DOTFILES:-$HOME/dotfiles}
VENDORED=$DOTFILES/.config/omo/skills
SKILLS=${SKILLS:-$HOME/.omo/agent/skills}
STATE=${XDG_STATE_HOME:-$HOME/.local/state}/skills-update
work=$(mktemp -d)
trap 'rm -rf "$work"' EXIT
failures=0
msg=()
log() { printf '%s\n' "$*"; }
fail() {
log "FAIL: $*"
failures=$((failures + 1))
}
install_timer() {
local dir=${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user
mkdir -p "$dir"
cat >"$dir/skills-update.service" <<EOF
[Unit]
Description=Refresh omo skills from their upstream repositories
After=network-online.target
[Service]
Type=oneshot
Environment=HOME=$HOME
Environment=PATH=$HOME/.local/bin:$HOME/.bun/bin:/usr/local/bin:/usr/bin:/bin
ExecStart=$HOME/.local/bin/skills-update
TimeoutStartSec=15min
EOF
cat >"$dir/skills-update.timer" <<EOF
[Unit]
Description=Daily omo skills refresh
[Timer]
OnCalendar=*-*-* 05:00:00
RandomizedDelaySec=20min
Persistent=true
[Install]
WantedBy=timers.target
EOF
systemctl --user daemon-reload && systemctl --user enable --now skills-update.timer
}
if [ "${1:-}" = --install-timer ]; then
install_timer
exit
fi
# Download one revision of a GitHub repository into its own directory. Nothing in it is executed.
fetch() {
local dir="$work/${1//\//_}@$2"
if [ ! -d "$dir" ]; then
mkdir -p "$dir"
if ! gh api "repos/$1/tarball/$2" | tar xz -C "$dir" --strip-components=1; then
rm -rf "$dir"
return 1
fi
fi
printf '%s\n' "$dir"
}
head_sha() { gh api "repos/$1/commits/$2" --jq .sha; }
# The revision recorded for a vendored skill in the provenance table of the skills README.
readme_rev() { grep -E "^\| \`$1\` \|" "$VENDORED/README.md" | grep -o -E '`[0-9a-f]{40}`' | tr -d '`'; }
# Each vendored updater returns 0 when it changed the skill, 1 when it is current, and 2 or more
# when it failed and the skill must be restored.
update_impeccable() {
local tag sha src engine dst=$VENDORED/impeccable
tag=$(gh api repos/pbakaus/impeccable/releases --jq '[.[] | select(.tag_name | startswith("skill-v"))][0].tag_name') || return 2
sha=$(head_sha pbakaus/impeccable "$tag") || return 2
if [ "$sha" = "$(readme_rev impeccable)" ]; then
log "impeccable: $tag already vendored"
return 1
fi
src=$(fetch pbakaus/impeccable "$sha") || return 2
src=$src/.opencode/skills/impeccable
[ -f "$src/SKILL.md" ] || { log "impeccable: $tag ships no .opencode skill"; return 2; }
rsync -a --delete --exclude LICENSE "$src/" "$dst/"
grep -rlF '.opencode/skills/impeccable/scripts' "$dst" |
xargs -r sed -i 's#\.opencode/skills/impeccable/scripts#~/.omo/agent/skills/impeccable/scripts#g'
if grep -rqF '.opencode/' "$dst"; then
log "impeccable: $tag has .opencode paths the rewrite does not cover"
return 3
fi
if ! grep -q '^OmO routing:' "$dst/reference/critique.md"; then
python3 - "$dst/reference/critique.md" <<'EOF' || return 3
import sys
path = sys.argv[1]
anchor = "Delegate Assessment A and Assessment B to separate sub-agents."
routing = ('OmO routing: when the task tool accepts a category, spawn Assessment A with '
'`category: "design-critique-a"` and Assessment B with `category: "design-critique-b"`. '
'Never pass `model` with a category.')
lines = open(path).read().split("\n")
for i, line in enumerate(lines):
if line.startswith(anchor):
lines[i + 1:i + 1] = ["", routing]
open(path, "w").write("\n".join(lines))
sys.exit(0)
sys.exit("impeccable: critique.md no longer has the delegation paragraph the routing line follows")
EOF
fi
engine=$(tr -d '[:space:]' <"$dst/scripts/VERSION")
sed -i -E "/^\| \`impeccable\` \|/{s/tag \`skill-v[^\`]*\`, engine [0-9.]+/tag \`$tag\`, engine $engine/; s/\`[0-9a-f]{40}\`/\`$sha\`/}" "$VENDORED/README.md"
"$dst/scripts/impeccable" engine-probe >/dev/null 2>&1 || log "impeccable: engine $engine download failed; the launcher retries on first use"
msg+=("impeccable: $tag, engine $engine ($sha)")
log "impeccable: vendored $tag (engine $engine)"
}
update_web_design_guidelines() {
local sha src dst=$VENDORED/web-design-guidelines
sha=$(head_sha vercel-labs/agent-skills main) || return 2
if [ "$sha" = "$(readme_rev web-design-guidelines)" ]; then
log "web-design-guidelines: up to date"
return 1
fi
src=$(fetch vercel-labs/agent-skills "$sha") || return 2
src=$src/skills/web-design-guidelines
[ -f "$src/SKILL.md" ] || return 2
rsync -a --delete "$src/" "$dst/"
if ! git -C "$DOTFILES" status --porcelain -- "$dst" | grep -q .; then
log "web-design-guidelines: content unchanged at ${sha:0:7}"
return 1
fi
sed -i -E "/^\| \`web-design-guidelines\` \|/s/\`[0-9a-f]{40}\`/\`$sha\`/" "$VENDORED/README.md"
msg+=("web-design-guidelines: vercel-labs/agent-skills $sha")
log "web-design-guidelines: updated to ${sha:0:7}"
}
update_vendored() {
cd "$DOTFILES" || { fail "no dotfiles checkout at $DOTFILES"; return; }
[ "$(git branch --show-current)" = main ] || { fail "dotfiles is not on main; vendored skills skipped"; return; }
if git status --porcelain -- .config/omo/skills | grep -q .; then
fail "uncommitted edits under .config/omo/skills; vendored skills skipped"
return
fi
git fetch -q origin || { fail "git fetch failed; vendored skills skipped"; return; }
local ahead behind name rc
read -r ahead behind < <(git rev-list --left-right --count main...origin/main)
if [ "$behind" -gt 0 ] && ! git merge -q --ff-only origin/main; then
fail "dotfiles cannot fast-forward to origin/main; vendored skills skipped"
return
fi
for name in impeccable web-design-guidelines; do
cp "$VENDORED/README.md" "$work/README.md.before"
"update_${name//-/_}"
rc=$?
if [ "$rc" -ge 2 ]; then
git checkout -q -- ".config/omo/skills/$name"
git clean -fdq -- ".config/omo/skills/$name"
cp "$work/README.md.before" "$VENDORED/README.md"
fail "$name: update failed (exit $rc); kept the vendored copy"
fi
done
[ "${#msg[@]}" -gt 0 ] || return
git add -- .config/omo/skills
git commit -q -m "chore(omo): update vendored skills" -m "$(printf '%s\n' "${msg[@]}")" -- .config/omo/skills ||
{ fail "dotfiles commit failed"; return; }
if [ "$ahead" -gt 0 ]; then
log "dotfiles: main had $ahead unpushed commits, so $(git rev-parse --short HEAD) stays local"
elif git push -q origin main; then
log "dotfiles: pushed $(git rev-parse --short HEAD)"
else
fail "dotfiles push failed; $(git rev-parse --short HEAD) is committed locally"
fi
}
update_local() {
local name=$1 repo=$2 path=$3 dst=$SKILLS/$1 sha src
if [ -L "$dst" ]; then
log "$name: links into the dotfiles, skipped"
return
fi
sha=$(head_sha "$repo" main) || { fail "$name: cannot read $repo"; return; }
if [ "$sha" = "$(cat "$STATE/$name" 2>/dev/null)" ]; then
log "$name: up to date"
return
fi
src=$(fetch "$repo" "$sha") || { fail "$name: download of $repo failed"; return; }
src=$src/$path
[ -f "$src/SKILL.md" ] || { fail "$name: $repo has no $path/SKILL.md"; return; }
mkdir -p "$dst"
if diff -rq "$src" "$dst" >/dev/null 2>&1; then
log "$name: content unchanged at ${sha:0:7}"
else
rsync -a --delete "$src/" "$dst/"
log "$name: updated to $repo@${sha:0:7}"
fi
printf '%s\n' "$sha" >"$STATE/$name"
}
mkdir -p "$STATE"
update_vendored
update_local vercel-react-best-practices vercel-labs/agent-skills skills/react-best-practices
for name in docx pdf pptx xlsx skill-creator; do
update_local "$name" anthropics/skills "skills/$name"
done
log "deep-research: no public upstream, not updated"
[ "$failures" -eq 0 ]
+3
View File
@@ -68,6 +68,9 @@ set -g set-titles on
# focusing the tab clears the attention markers. # focusing the tab clears the attention markers.
set -g set-titles-string "#{?@omo_status,#{@omo_status} ,}#{session_name}" set -g set-titles-string "#{?@omo_status,#{@omo_status} ,}#{session_name}"
set-hook -g client-focus-in 'run-shell -b "~/.local/bin/omo-status focus #{q:hook_client}"' set-hook -g client-focus-in 'run-shell -b "~/.local/bin/omo-status focus #{q:hook_client}"'
# omo on a remote server reports its state in the title of a mosh or ssh pane
# ("omo:<state>:..."); omo-remote turns that into the same ding, marker and banner.
set-hook -g pane-title-changed 'if -F "#{&&:#{m:*omo:*,#{pane_title}},#{||:#{==:#{pane_current_command},mosh-client},#{==:#{pane_current_command},ssh}}}" "run-shell -b \"~/.local/bin/omo-remote title #{hook_pane}\""'
# Set plugin manager path before loading plugins # Set plugin manager path before loading plugins
set-environment -g TMUX_PLUGIN_MANAGER_PATH "$HOME/.tmux/plugins" set-environment -g TMUX_PLUGIN_MANAGER_PATH "$HOME/.tmux/plugins"
+1
View File
@@ -25,6 +25,7 @@ brew "tmux"
brew "zellij" brew "zellij"
brew "yazi" brew "yazi"
brew "mosh" brew "mosh"
brew "ntfy"
brew "openssh" brew "openssh"
brew "gu1llaum-3/sshm/sshm", trusted: true brew "gu1llaum-3/sshm/sshm", trusted: true
+7 -1
View File
@@ -206,6 +206,8 @@ owt done # remove the workspace you are in once its work is merge
`owt done` refuses to discard work unless you pass `--force`. Work counts as merged when the branch is part of the default branch or its Gitea PR is merged (checked with `tea`). It then deletes the remote and local branch, removes the worktree and its memory link, and closes tmux windows open in it. `OWT_ROOT` (default `~/worktrees`) and `OWT_AGENT` (default `omo`) override the defaults; `owt --help` lists every option. `owt done` refuses to discard work unless you pass `--force`. Work counts as merged when the branch is part of the default branch or its Gitea PR is merged (checked with `tea`). It then deletes the remote and local branch, removes the worktree and its memory link, and closes tmux windows open in it. `OWT_ROOT` (default `~/worktrees`) and `OWT_AGENT` (default `omo`) override the defaults; `owt --help` lists every option.
`owt` also runs on Linux servers. On flowstate-remote, `~/.local/bin/owt` links to a clone of this repository, the server's own `~/.zshrc` carries the `owt` wrapper, and its `~/.omo/agent/hooks.json` runs `owt hook` like the Mac's. Its repositories live on GitHub, which `tea` cannot query, so `owt done` there counts a branch as merged only once it is part of the default branch: merge-commit PRs pass, squash-merged ones need `--force`.
## Configuration ## Configuration
### Environment Variables ### Environment Variables
@@ -229,9 +231,13 @@ Custom scripts and binaries are stored in `.local/bin/`:
- `tmux-open-path`: Backs tmux's Ctrl+click. Takes the path under the mouse, strips `:line:col`, `#L10`, `@` and `file://`, resolves it against the pane's directory and then its git root, and routes it by type: markdown to Markdown Preview (`cask "markdown-preview"` in `Brewfile.apps`), images, PDFs and directories to yazi, the rest to nvim - `tmux-open-path`: Backs tmux's Ctrl+click. Takes the path under the mouse, strips `:line:col`, `#L10`, `@` and `file://`, resolves it against the pane's directory and then its git root, and routes it by type: markdown to Markdown Preview (`cask "markdown-preview"` in `Brewfile.apps`), images, PDFs and directories to yazi, the rest to nvim
- `omo-notify`: Sound, tab marker and notification for OmO's hooks. Ghostty posts the notification, so clicking it opens the session's tab and pane - `omo-notify`: Sound, tab marker and notification for OmO's hooks. Ghostty posts the notification, so clicking it opens the session's tab and pane
- `omo-status`: Coloured marker in front of the Ghostty tab title for each tmux session running omo: 🔴 stopped with an error, 🟡 waiting for your answer, 🟢 finished, 🔵 working. A tab with several agents shows the most urgent one; focusing the tab clears red, amber and green, and undoes any title you gave the tab by hand so it always shows its session name - `omo-status`: Coloured marker in front of the Ghostty tab title for each tmux session running omo: 🔴 stopped with an error, 🟡 waiting for your answer, 🟢 finished, 🔵 working. A tab with several agents shows the most urgent one; focusing the tab clears red, amber and green, and undoes any title you gave the tab by hand so it always shows its session name
- `omo-remote`: The same sound, tab marker and notification for omo sessions on a remote server (flowstate-remote). The server's `omo-notify` (kept on the server in `~/.local/bin`, not in this repository) writes the session's state into its terminal title, marker first (`🟢 studio · omo:done:...`). mosh drops notifications but carries titles, and so does ssh, so tmux's `pane-title-changed` hook passes the event to `omo-notify` for the pane running mosh or ssh. Everything else reaches you through ntfy: `omo-remote install` starts a launchd agent that subscribes to the topic and plays the sound and posts the notification for omo pushes, unless a tmux pane here already handled that event through its title, so nothing arrives twice. That covers sessions no terminal is showing and `fs` in a plain Ghostty tab, whose title shows the marker and which posts the notification itself (the coloured tab marker needs tmux). The agent reads `NTFY_URL`, `NTFY_TOPIC` and `NTFY_TOKEN` from `~/.config/ntfy/omo-remote.env` (chmod 600, not in this repository) and needs `brew "ntfy"`
- `docker-nuke`: Force-restart a wedged Docker Desktop, capturing diagnostics to `~/.local/state/docker-nuke/` first (`--dry-run` to preview, `--no-restart` to leave it down, `--deep` for root helpers) - `docker-nuke`: Force-restart a wedged Docker Desktop, capturing diagnostics to `~/.local/state/docker-nuke/` first (`--dry-run` to preview, `--no-restart` to leave it down, `--deep` for root helpers)
The `links` step of `setup-mac.sh` links each script into `~/.local/bin`, which `.zshrc` puts on your PATH. `pbcopy` and `pbpaste` are OSC 52 clipboard shims for headless servers, so they are not linked on macOS. - `impeccable-browser`: The browser for Impeccable's URL scans (`impeccable detect <url>`) on flowstate-remote, set as `IMPECCABLE_BROWSER` in the server's `~/.zshenv`. Public URLs render on Browserless. Its key, `BROWSERLESS_API_KEY` from the Reoclo project `flowstate-tools`, is cached for 12 hours in `~/.impeccable/browserless.token`. Localhost and private addresses reach Browserless through a cloudflared quick tunnel that lasts only for the scan, and reports still name the local URL. Other launches, and any scan whose tunnel or Browserless connection fails, run on the headless Chrome in the Puppeteer cache with `--no-sandbox`, because the server's AppArmor blocks Chrome's sandbox. `IMPECCABLE_TUNNEL=0` keeps private addresses on local Chrome, which pages that call a second local port (a separate API, say) need. Every decision is logged to `~/.impeccable/browser.log`
- `skills-update`: Refreshes omo's skills from upstream every day on flowstate-remote, from a systemd user timer that `skills-update --install-timer` writes and enables (05:00 UTC). The vendored `impeccable` (newest `skill-v*` release) and `web-design-guidelines` are re-vendored as [the skills README](.config/omo/skills/README.md) describes, then committed and pushed. Only the skill paths are committed, and the push happens only when `main` was level with `origin/main`. The machine-local `vercel-react-best-practices`, `docx`, `pdf`, `pptx`, `xlsx` and `skill-creator` copies in `~/.omo/agent/skills` are synced from `vercel-labs/agent-skills` and `anthropics/skills`. A failed update keeps the previous copy and marks the run failed (`journalctl --user -u skills-update`)
The `links` step of `setup-mac.sh` links each script into `~/.local/bin`, which `.zshrc` puts on your PATH. `pbcopy` and `pbpaste` (OSC 52 clipboard shims for headless servers), `impeccable-browser` and `skills-update` (Linux only) are not linked on macOS.
### Local Servers ### Local Servers
+2
View File
@@ -48,6 +48,7 @@ local_servers
.config/zed/settings.json .config/zed/settings.json
.config/omo/hooks.json:.omo/agent/hooks.json .config/omo/hooks.json:.omo/agent/hooks.json
.config/omo/keybindings.json:.omo/agent/keybindings.json .config/omo/keybindings.json:.omo/agent/keybindings.json
.config/omo/extensions/vim-quit.ts:.omo/agent/extensions/vim-quit.ts
.config/omo/prompts:.omo/agent/prompts .config/omo/prompts:.omo/agent/prompts
" "
@@ -197,6 +198,7 @@ step_links() {
name=${script##*/} name=${script##*/}
case $name in case $name in
pbcopy | pbpaste) continue ;; # OSC 52 clipboard shims for headless servers pbcopy | pbpaste) continue ;; # OSC 52 clipboard shims for headless servers
impeccable-browser | skills-update) continue ;; # Linux-only tools for flowstate-remote
esac esac
if [[ -x $script ]]; then link ".local/bin/$name" ".local/bin/$name"; fi if [[ -x $script ]]; then link ".local/bin/$name" ".local/bin/$name"; fi
done done