Compare commits

...
Author SHA1 Message Date
copilot-swe-agent[bot]andGitHub b130f911ef Initial plan 2026-07-18 20:34:28 +00:00
Paul Bakaus 61f37ccfb8 Drop the progressive benchmark, remove dead wrap scaffolding
Review fallout from removing progressive publication.

The Live benchmark existed to compare atomic against progressive delivery:
compareModelBackedReports measures goToFirstVariantMs improvement of one
over the other. With progressive gone it measures nothing against nothing.
Worse, benchmark-live.mjs still passed `progressive` to bootFixtureSession,
which no longer accepts it, so `--delivery progressive` was silently
ignored and would have emitted reports labeled progressive that actually
ran atomic. Silent wrong data is worse than a crash. It was built for
progressive, so it goes with progressive: benchmark-live.mjs, its lib, its
test, and the bench:live script. If an atomic latency baseline is wanted
later, that is a smaller thing built on purpose.

live-wrap.mjs: sourceOriginalLines was assigned and never read.

Both found by review bots on #381 (Copilot).

Assisted-by: Claude (claude-halva-eap) via Claude Code
2026-07-18 13:27:48 -07:00
Paul Bakaus 55b297d7fc Live: polling rework, source locks, preflight scaffolding, Vue previews
Carved out of #371, minus progressive publication. Everything here works
against real project source the way main's Live already does: the agent
writes variants into the file the browser loaded, HMR fires, Accept
promotes and carbonizes. Nothing is staged anywhere.

Poll lanes. Events now carry an explicit priority: accept/discard/exit
ahead of manual_edit_apply/steer/carbonize_cleanup ahead of generate. A
long generate can no longer sit in front of the Accept the user just
clicked. leaseEvent claims its lease before awaiting, so a slow prepare
cannot hand the same event to two pollers.

Source locks. A per-file mutex around every accept and discard path, keyed
on a digest of the absolute path. Staleness is decided by owner-pid
liveness rather than mtime, so a wedged lock clears when its owner dies
instead of after an arbitrary timeout, and a slow-but-live accept is never
stolen from. Only the owning process can release a lock.

Preflight scaffolding. The server runs live-wrap (or live-insert) before
the poll returns and hands the result back as event.scaffold. That walk is
measured at ~7.6s on a large repo; moving it off the agent's critical path
removes a deterministic tool round trip without touching the generated
design. Falls back cleanly to the agent running the helper itself.

Vue previews. previewMode: "vue-component" for Nuxt/Vue targets, matching
the existing Svelte component path: variants compile as real SFCs from a
dev-only directory so the route is never rewritten during generation, and
Vite mounts them without invalidating page state. Accept is the only route
write. Includes a Vue attr tokenizer that normalizes shorthand bindings
(@x, :x, #x) to their canonical forms.

Accept hardening. Every thrown failure now returns mode: 'error' rather
than an ambiguous unhandled result, so a real failure is never classified
as a deliberate manual handoff and silently dropped. The marker search
skips node_modules/.git/dist/build/.impeccable.

Shared CLI arg parsing extracted to scripts/lib/cli-args.mjs.

Assisted-by: Claude (claude-halva-eap) via Claude Code
2026-07-18 13:18:07 -07:00
Paul BakausandClaude 97dbaad4a4 Flag inset stripes written with the two-length box-shadow form
box-shadow takes <length>{2,4}: only the two offsets are required, so
`inset 4px 0 red` is valid and paints the same single-edge stripe as
`inset 4px 0 0 red`. The scan demanded a third length, so the short form was
silently missed.

Blur and spread now default to 0 when omitted, which is exactly the stripe shape
the rule looks for. The neutral-color and blur/spread exclusions still hold:
`inset 4px 0 #000` and `inset 4px 0 5px var(--brand-accent)` both pass. Fixture
covers both orders of the short form plus those two exclusions, and fails against
the previous regex.

Third false negative found in this rule (after trailing `inset` and literal
neutral colors), all from the same cause: the scan was written against one
spelling of the syntax rather than the grammar.

Prepared with AI assistance under maintainer direction.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-18 12:24:16 -07:00
Paul BakausandClaude c654acb005 Rip out the dead isolated-preview mode and the private repo's job
Comparing this branch's live against main's turned up two whole features that
never made sense here. -2,466 lines.

1. The isolated source-artifact preview was never switched on.

`scaffoldSourceArtifactSession` is only reachable via live-wrap's `--isolated`,
and nothing passes it: not the server's preflight, not live.md, nothing. Proved
it end-to-end — the default wrap writes markers straight into real source and
creates no previews/ session. So the mode was wired through three modules,
carried its own accept/discard branches, browser branches, server metadata
resolution, preview-mode classifier entry, and test suites, and none of it could
run.

Worse, live.md documented it as the active path and told the agent "The true
source is only the publisher's hash fence and must remain byte-identical until
Accept." That is false: the wrapper lands in source at scaffold time and each
revision rewrites it. An agent following that sentence believes source is
protected when it isn't, and the leftover artifacts are what made accept resolve
the wrong file in the first real run. live.md now describes what actually
happens, including that markers are visible in source until Accept or Discard.

Removed: source-artifact.mjs, --isolated, the preflight's isolated option, the
accept/discard branches, four dead browser branches, the server's previews/
resolution, the classifier entry, and their tests. Kept the previews/ gitignore
pattern: an ignore line for a directory that cannot exist is free, and a test
pins it.

2. Quality judging belongs to the private evals repo, which says so.

runner/live/README.md there is explicit: the public repo owns protocol
correctness, framework coverage, timing, source commit, recovery, and a
rubric-free evidence bundle; the private repo owns the task corpus, baselines,
comparative judges, and release-quality decisions — "Do not add quality rubrics,
competitor comparisons, or broad fixture corpora to the public Live benchmark."

This branch added exactly those: an LLM judge scoring 1-10 on "off-brand,
generic-AI" (live-rendered-quality.mjs, judge-live-rendered.mjs), a
cross-provider comparison with a BRAND_CONTRACT rubric (live-provider-benchmark
.mjs, benchmark-live-providers.mjs), and a brand-fidelity fixture corpus. All
removed, with bench:live:providers and their suite entries.

Also removed tests/framework-fixtures/README.md's "External quality-eval
fixtures" section: it documented a bench:live workflow using --fixture-dir,
--agent=codex, --action and --evidence-bundle, none of which benchmark-live.mjs
implements, plus an evidenceCapture block nothing reads.

Kept: timing benchmarks (the public repo's half of that boundary), progressive
publication, the source lock, poll lanes, and Nuxt/Vue component previews.

Coverage note: deleting the isolated suites took the only tests for
`source_locked` classification with them, so the plain wrapper path — now the
only non-component preview — gets equivalent accept and discard coverage. Both
new tests fail if mode:'error' is removed.

Prepared with AI assistance under maintainer direction.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-17 19:17:38 -07:00
Paul BakausandClaude c7b67b3832 Drop the live generator subagent; fix the artifact decoy that broke accept
The first real Claude Code Live run failed, and the subagent was not the cause.

Root cause: progressive publication stages each revision as
`.impeccable/live/artifacts/<id>-r<n>.<source-ext>`, nothing ever deleted them,
and findSessionFile's walker skipped only node_modules/.git/dist/build. It
searches src, app, pages, ... then `.`; a project whose source is not under one of
those (this repo's own site lives in site/pages/) falls through to the `.` walk,
where dot-directories sort before letters. So accept found the artifact instead of
the real file. Two outcomes, both reproduced: where isGeneratedFile returns true
it declines with mode: 'fallback' (what the run hit, after which the agent
hand-carbonized several hundred lines across three stylesheets, including
unrequested drive-by edits); where it returns false, accept writes the variant
into the throwaway artifact and reports handled: true while real source never
changes.

The E2E suite could not have caught this. Every fixture puts source under `src/`,
which is searched before the `.` walk can reach `.impeccable`. Five framework
fixtures and three progressive scenarios pass because of fixture layout, not
because the path works. I read that as evidence and shouldn't have.

- Never search `.impeccable`: it is Impeccable's own state, never project source.
- Retire a session's staged artifacts on accept/discard, so they cannot outlive
  the session and become a decoy for anything else that walks the tree.
- Regression tests use a site/pages layout with artifacts present. All three fail
  against the previous code.

Generator subagent removed, on both harnesses:
The parent must hand-compress the design system into the handoff, and compression
is lossy. Measured on the real run: a 6,826-char handoff carrying exactly one
token reference, after the parent had itself read kinpaku-tokens.css. The subagent
then spent 3 of its first 9 turns hunting DESIGN.md, gave up, and emitted 0
var(--token) uses and 22 raw oklch literals — violating its own spec's "Never
invent raw colors when tokens exist" — including a 1:1 gold-on-gold contrast bug.
Isolation is not a benefit here; knowing the design system is the job. Generation
stays in the main thread, which already holds the tokens and writes them from the
first byte, so carbonize is a move rather than a translation.

Copy edits keep their subagent: applying a known set of ops to a named file is
self-contained, so an isolated context costs nothing. That is the line.

Progressive delivery stays for Codex and Claude Code, main-thread driven. Claude
Code keeps the full benefit because its poll is a background task. Codex's poll
blocks the foreground, so with no subagent the user sees variant 1 early via HMR
but cannot accept it until the trio finishes; that is the cost of the
simplification and it is worth naming.

Prepared with AI assistance under maintainer direction.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-17 18:56:23 -07:00
Paul BakausandClaude 1b194d9751 Stop the progressive benchmark agent inventing a second variant on count:1
`Math.max(1, event.count - 1)` floored the tail request at one variant, so a
one-variant request fetched a second direction and assembled two. Ask for
`count - 1` and return the first variant untouched when there is no tail.

Latent rather than live: the only caller hardcodes `count: 3`. The reason it is
worth fixing is the caller inconsistency it exposed. tests/live-e2e/agent.mjs
gates its split-progressive path on `event.count > 1`; benchmark-live-providers.mjs
had no such guard, so it would have run the tail for a one-variant request, and
the parallel strategy would have assembled its three fixed lanes regardless of
what was asked for. Guard the caller the same way.

Prepared with AI assistance under maintainer direction.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-17 17:39:54 -07:00
Paul BakausandClaude e2ef633b95 Finish the failed-accept classification my last commit only half did
All three new findings are the same root cause, and it is my incomplete fix:
operationFailure only covered results built from a *thrown* error. Two paths it
missed:

- Two catches wrote the failure result as a multi-line literal, so the
  single-line replace skipped them. The Vue accept catch was still bare, exactly
  as reported; the Svelte one too, though its failures happened to be caught by
  completion.mjs's Svelte-only special case.
- The accept implementations also *return* `{handled: false, error}` for their own
  checks (variant missing, template empty, original text ambiguous). Those never
  throw, so no catch ran and no `mode` was set.

Both layers now agree, because each is reachable on its own:

- live-accept marks any unhandled preview-path result via markPreviewFailure,
  keyed on `previewMode` — a clean discriminator, since only the preview branches
  set it and a plain wrapper never does. This is what the agent reads:
  reference/live.md routes on `mode`, so without it the agent was told "read
  file, find markers, edit" for a preview that has no markers in source.
- completion.mjs replaces its arbitrary svelte-component special case with the
  set of preview modes whose variants live outside the user's source. That case
  existed for precisely this reason; Vue and source-artifact were simply never
  added, so the identical failure on those paths acknowledged as success.

The plain wrapper keeps its manual handoff, which is the one shape with editable
markers in source. Both deliberate handoffs (mode: 'fallback' and markers not
found) still classify as agent_done, now pinned by a test so the generalization
cannot swallow them.

Prepared with AI assistance under maintainer direction.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-17 17:26:55 -07:00
Paul BakausandClaude 6cbb7ce8d1 Classify failed accepts as errors, and fix parallel lane race/all misuse
Two of the three new findings, plus the bug that chasing them exposed in my own
earlier fix. The third is mitigated rather than broken; details below.

Failed accepts reported success:
live/completion.mjs only classifies a result as `error` when it carries
`mode: 'error'`. Everything else unhandled falls through to `agent_done` with an
ok ack, which is deliberate for the documented fallback paths (two tests pin it)
but wrong for a real failure. So `accept_receipt_conflict` reported success, and
reference/live.md's `handled: false` without `mode` bullet told the agent to
"read file, find markers, edit" — hand-applying a second accept on top of the one
the receipt already recorded.

The same hole swallowed `source_locked`, which is mine: the earlier commit made
lock contention return clean JSON so the agent could retry, but the classifier
turned that failure into agent_done/ok, so the accept was dequeued and silently
lost. Mark genuine failures with `mode: 'error'` through one `operationFailure`
helper, and give live.md a `mode: "error"` bullet with per-error guidance: retry
the same command on `source_locked`, never hand-edit, and on a receipt conflict
report what the session actually resolved to. The deliberate fallback and
markers-not-found handoffs stay untouched.

parallel-compact lane orchestration:
`Promise.race` settles on the first *settlement*, so one lane failing fast
rejected the whole first-variant step while two lanes were still on their way to
succeeding. `Promise.any` now takes the first success and only a total wipeout is
fatal, reporting every lane's reason. The tail step's `Promise.all` surfaced a
raw lane error non-deterministically; `Promise.allSettled` now reports how many
lanes failed and why. Added a `requestImpl` seam so lane orchestration is
testable without a provider key.

Not a defect: the browser releasing Accept before the source write. That is the
intended optimistic design, and it is safe because poll-lanes ranks accept at
priority 0 against generate at 2, so a queued accept is always leased before a
generate the user queues afterwards, even if the generate arrived first. Its
source write lands inside the poll script before the next generate preflights.
That invariant is load-bearing and had no tests at all; poll-lanes.mjs now has a
suite covering it plus lease and type filtering.

Prepared with AI assistance under maintainer direction.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-17 16:10:57 -07:00
Paul BakausandClaude 529184bbe4 Fix inset-order detection, the unlocked artifact discard, and stray boolean flags
Three of the four open review findings. The fourth is declined below.

- The inset-stripe scan only matched layers starting with `inset`, but the keyword
  is order-independent: `box-shadow: 4px 0 0 var(--brand-accent) inset` paints the
  same stripe and was silently missed. Strip the keyword wherever it sits, but
  only as a standalone token, so a color like var(--inset-accent) is not mangled
  into `var(-- -accent)` and quietly reclassified as neutral. The fixture now
  covers both orders plus that token, and a trailing-inset neutral still passes.
- The source-artifact discard deleted the preview without the source lock, unlike
  every other discard path. Take the lock. Narrower than reported, though: the
  server journals `discard_requested` as a fenced phase before live-accept runs
  and the publisher checks it three times, so a publish could never land on a
  discarded session. What this actually prevents is deleting the artifact under a
  publisher mid-critical-section, turning a clean stale_generation_epoch into an
  ENOENT crash.
- benchmark-live-providers.mjs still compared `--headed` and `--skip-cleanup-control`
  against a boolean sentinel, so the `=true` spelling silently did nothing. My
  gap: I introduced boolFlag and converted benchmark-live.mjs but not this one.
  skipCleanupControl is now read once rather than twice, so the two call sites
  cannot drift.

Declined: tightening the selector guard that skips `active` / `current` /
`selected` tokens. It does cause false negatives on names like `.selected-feature`,
but the rule's contract makes selection and focus indicators its one exception,
and `.active-tab` / `.current-step` / `.selected-row` are syntactically identical
to `.selected-feature`. No regex separates them, so tightening the guard trades
missed stripes for false positives on exactly the case the rule exempts. The
conservative skip is the intended behavior.

Prepared with AI assistance under maintainer direction.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-17 15:58:27 -07:00
Paul BakausandClaude fc620b9620 Bring Live progressive delivery and the generator subagent to Claude Code
Almost none of this branch's Live work was actually Codex-specific. The publisher,
the fences, the source locks and the browser's partial-arrival UI are plain node
and DOM with zero provider references, and the progressive E2E already passes on
five frameworks driven by a non-Codex agent. The Codex-only part was policy prose
and one frontmatter line, so Claude Code shipped the progressive browser UI it
could never trigger.

Progressive delivery, Codex and Claude Code:
- Add a `live-progressive` capability tag and opt codex, agents, and claude-code
  in. A provider block takes one tag, so naming harnesses would have meant
  duplicating the recipe per tag; a capability reads better than a provider list
  anyway. Cursor and everyone else keep the atomic path until their poll loop is
  known not to stall on the extra publish calls.
- Claude Code publishes variant 1 as soon as it validates rather than waiting to
  write the whole trio in one edit. Nothing about the arrival path needed
  changing: the publisher writes, framework HMR pushes, and the browser's
  MutationObserver counts variants. The parent conversation was never in that
  path, which is why Claude Code's lack of subagent progress streaming does not
  matter here.

Generator subagent:
- Drop `providers: codex` from impeccable-live-generator. The build already maps
  its frontmatter correctly for Claude Code, and impeccable-manual-edit-applier
  has shipped to .claude/agents/ this way all along.
- The reason differs per harness, so the reference says so: Codex delegates to
  unblock a foreground poll, Claude Code delegates to keep a long session's
  screenshots and variant CSS out of the main context. Follows the existing
  manual-edit-applier convention: both agent names, and an inline fallback when
  native subagents are unavailable.

Fixes found on the way:
- The two publish commands hardcoded `.agents/skills/impeccable/scripts/` while
  the other thirteen commands in live.md use {{scripts_path}}. Correct only for
  the Codex repo-skills bundle; it would have pointed Claude Code at a directory
  its install never creates. The shipped .codex variant was already internally
  inconsistent. Now covered by a test.
- `--agent=codex` resolved to the canned fake agent, because the flag parsed as
  `x === 'llm' ? 'llm' : 'fake'`. The private evals Live runner passes exactly
  that, so a real-harness run would have scored deterministic stub variants and
  reported them as Codex output. Unknown values for --agent, --scenario and
  --delivery now fail loudly.
- live-reference tests now compile with each provider's real providerTags instead
  of hand-written lists, so a providers.js misconfiguration fails in tests rather
  than shipping.

Verified: progressive E2E green on vite8-react-plain against a real Vite server
and Chromium; every provider variant's publish and poll paths now agree; Cursor
and Gemini still compile to atomic only.

Prepared with AI assistance under maintainer direction.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-17 15:43:34 -07:00
Paul BakausandClaude 79656d1ce8 Correct the prose-gate docs and write down the no-bump-in-a-PR rule
CLAUDE.md said the prose validator "deliberately skips skill/", which is only
half true and cost a build failure this week: validateProse skips it, but
validateSkillProse then scans skill/**/*.md and fails the build on em dashes plus
the phrases with no technical reading. Document both gates, which files each one
reads, and the line that actually matters in practice: an em dash in
skill/reference/*.md fails the build, one in a skill/scripts/*.mjs comment does
not. Each claim was checked against a real `bun run build`.

Also record that feature PRs do not bump manifest versions or add changelog
entries. It was not written down anywhere: not CLAUDE.md, not AGENTS.md, not the
PR template. CLAUDE.md's "Bump when: CLI code changes" reads as an instruction to
bump inside the PR that touches cli/, so say plainly that it names which
component a change belongs to rather than when to edit the manifest.

Put the rule in AGENTS.md too. That is the guide the agents opening PRs here
actually read, so a rule about PR hygiene living only in CLAUDE.md would not
reach them.

Prepared with AI assistance under maintainer direction.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-17 15:17:25 -07:00
Paul BakausandClaude f148496f67 Let hooks ignore-value scope a rule to files, and stop churning the config
Fallout from suppressing the overlay's font-size findings: the narrowest
exception detector.ignoreValues supports was unreachable from the path the hook
tells the model to use, so the guidance steered to the blunt instrument instead.

- hook-admin's ignore-value now takes --file / --files / --file= / --files=,
  matching `impeccable ignores add-value`, which already had them. Without it the
  only file-scoped option was ignore-file, which silences every rule for a path
  permanently, including rules not yet written.
- A bare wildcard value is now refused with a message pointing at either --file
  or ignore-rule. Previously `ignore-value <rule> "*"` quietly wrote a
  project-wide suppression from a single file's finding.
- ignore-value keyed entries on rule+value only, so a second scope for the same
  rule overwrote the first instead of coexisting. Key on the file scope too.
- An unknown flag folded into the value: `ignore-value overused-font Inter
  --shard` stored "inter --shard", matched nothing, and reported success. Reject
  it, as the sibling command does.

Config churn: normalizeIgnoreValueEntries runs on every write and emitted keys as
rule, value, files, reason, createdAt while the config on disk uses createdAt
before reason. Any edit therefore rewrote every untouched entry (35 churned lines
for a one-line change). Pin the canonical order in both copies of the normalizer
and in ignores.mjs, and add a test that the two copies cannot drift apart.

Also point the hook's own footer and reference/hooks.md at the file-scoped form
first, and say plainly what ignore-file costs.

Prepared with AI assistance under maintainer direction.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-17 15:06:08 -07:00
Paul BakausandClaude 331c2f2696 Scope design-system-font-size off the injected live overlay
live-browser.js builds a self-contained UI that renders over arbitrary host
pages, so its inline type scale is deliberately independent of DESIGN.md, which
documents the impeccable website's ramp. The rule fired 32 times there and is
the only rule that fires on that file.

Suppress it as a file-scoped value wildcard rather than via ignoreFiles: an
ignoreFiles glob would silence every rule for the file, and the overlay is real
user-facing chrome where a future contrast or side-tab finding should still be
heard. Scoped to this one file, so the rule keeps working everywhere else.

Written by hand because hook-admin's ignore-value cannot emit the `files` array
that detector.ignoreValues supports and existing entries already use.

Prepared with AI assistance under maintainer direction.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-17 14:51:26 -07:00
Paul BakausandClaude 60c4fa25db Stop telling users a busy agent is disconnected
The agent-poll indicator tracks whether a poll is parked, which is the right
signal for "can steering reach the agent right now" and is why the flag itself
is left alone. But it goes quiet for two different reasons, and both got the
same copy: "Agent disconnected - run live-poll.mjs to connect".

Under the one-shot foreground polling that live.md calls the primary contract,
no poll is parked while the agent works, so the second reason is every normal
generation. For its whole duration the bar told the user a healthy session was
broken and advised them to start a poll loop that was already running.

Pick the copy from the live state, which the browser already tracks: GENERATING
and SAVING mean the agent holds work it was handed, so say it is working. Every
other state with no parked poll keeps the original, actionable wording. The
aria-label carries the same distinction, since the tooltip is mouse-only.

The text is derived at read time rather than cached, because the live state moves
between the 5s status polls and a finished generation would otherwise keep
reading "Agent is working" until the next one landed. Deriving it also keeps the
read out of setLiveState, which runs long before agentPollingConnected's
declaration and would hit its temporal dead zone.

Prepared with AI assistance under maintainer direction.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-17 14:23:34 -07:00
Paul BakausandClaude 917d3afcf2 Route the last two benchmark scripts through the shared argv parser
Follow-up on review feedback. The previous commit consolidated four of the six
Live benchmark parsers and left these two on their own hand-rolled `arg()`,
which was the inconsistency the first pass was meant to remove.

- benchmark-live-control.mjs and benchmark-live-init.mjs parsed --iterations
  with Number(), so a non-numeric value became NaN and `index < NaN` ran the
  benchmark zero times before failing on the metrics file. They also accepted
  only the space-separated form, so --iterations=20 silently measured the
  default. Both now use parseArgs + positiveIntFlag, which throws on a value
  that was clearly meant as a number.
- benchmark-live-control.mjs read the metrics file with no handling for the case
  where the run produced nothing: a missing file surfaced as a raw ENOENT stack
  and a malformed line as a bare SyntaxError. Report both with a diagnostic
  naming the file and the env var that populates it.
- summarize() now reports a `samples` count and nulls instead of letting
  percentile() read past an empty array, where the NaN serialized to null and a
  report of nothing measured looked like a real measurement.

Prepared with AI assistance under maintainer direction.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-17 14:13:32 -07:00
Paul BakausandClaude 4e381305e1 Fix source-safety, detector, and lock defects in Live polling work
Addresses the review findings on #371, plus several the bots did not catch.
All fixes have regression coverage that fails on the prior code.

Source corruption:
- Vue accept dropped valueless root attrs (disabled, v-cloak) and, worse,
  rewrote @click="x" as a literal click="x" DOM attribute, because the attr
  parser was name-anchored and skipped the sigil. Tokenize the whole Vue attr
  grammar and normalize shorthands so accept round-trips directives.
- --variant was interpolated unescaped into a RegExp, so --variant '.*' matched
  the original block first and reported a successful accept while silently
  restoring the original. Validate against the digits pattern the browser and
  the /events schema already enforce.
- --id reached path.join unvalidated, so --id ../../../../etc/evil wrote and
  read receipts outside the project. Hoist the existing safeSessionId check
  into impeccable-paths and apply it at every id-to-path sink.

Accept/lock correctness:
- Plain HTML/JSX accept and discard did not catch SOURCE_LOCKED, so contention
  exited non-zero with empty stdout and the agent got no JSON to retry on.
- Lock staleness was mtime-only and never read the pid it records: a holder
  whose critical section outran 60s had its live lock swept, admitting a second
  writer to the same file, while a crashed holder blocked accepts for a full
  60s. Decide staleness by owner liveness, and release only our own lock.

Detector:
- isNeutralColor only parses computed color forms, so routing authored CSS
  through it reported inset 4px 0 0 #000 / black / #e5e7eb as chromatic
  side-tab stripes. Add an authored-color neutrality test covering hex and
  named neutrals; the fixture had no literal-color cases at all.
- Rule line numbers were off by one for every rule after the first, and
  commented-out CSS was scanned as live rules.

Server:
- An error reply carries no sourceEventType, and inferSourceEventType returned
  undefined, which acknowledgePendingEvent treats as a wildcard: a stale
  generate worker's failure consumed the user's queued Accept, which then
  reached no agent and left the browser in SAVING forever.
- The generate preflight spawned live-wrap.mjs synchronously inside the request
  handler, freezing the single-threaded server for the whole scaffold (~7.6s
  measured on this repo, 15s ceiling) and stalling Accept/Discard/SSE. Make it
  async, claiming the lease before the first await so no event double-delivers.
- Every browser checkpoint was echoed back as variant_progress, so a Tune
  slider drag remounted the preview under the user's cursor and latched the
  *_reviewable phases from the wrong trigger. Gate on the reason.

Cleanup:
- Collapse four divergent benchmark argv parsers into scripts/lib/cli-args.mjs.
  Three silently misread flags: --iterations 20 benchmarked 5, --agent llm ran
  the fake agent, --median-target=0.4 used the default threshold.
- Drop a snapshot cache this branch made write-only (it grew per session for
  the server's lifetime and was never read), a dead exported reconcile helper,
  and the unused deferReply branch.

Prepared with AI assistance under maintainer direction.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-17 13:48:44 -07:00
Paul Bakaus c6ac34b929 Improve Live polling responsiveness and reliability
Restore foreground/background polling as the primary harness architecture, add progressive publication and framework-safe previews, and harden quality and regression coverage. The experimental app-server runtime is intentionally excluded.\n\nPrepared with AI assistance under maintainer direction.
2026-07-15 17:05:30 -07:00
50 changed files with 4403 additions and 367 deletions
+87
View File
@@ -0,0 +1,87 @@
/**
* One argv parser for the Live benchmark / judging scripts.
*
* These scripts had four subtly different hand-rolled parsers, and the gaps
* failed silently rather than loudly: a parser without the `argv[i + 1]`
* lookahead turned `--iterations 20` into `iterations: true` and benchmarked
* the default 5 runs; a parser without kebab→camel mapping turned
* `--median-target=0.4` into a key nothing read, so the comparison ran against
* the default threshold. Both produce a clean-looking report of the wrong thing.
*
* Supported forms, per flag:
* --flag → true
* --flag=value → 'value'
* --flag value → 'value' (unless `value` itself starts with `--`)
*
* Keys are camel-cased, so `--simulated-tail-ms` and `--simulatedTailMs` both
* land on `simulatedTailMs`.
*/
export function parseArgs(argv) {
const out = {};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (!arg.startsWith('--')) continue;
const body = arg.slice(2);
if (!body) continue;
const equals = body.indexOf('=');
if (equals !== -1) {
out[toCamel(body.slice(0, equals))] = body.slice(equals + 1);
continue;
}
const next = argv[index + 1];
if (next !== undefined && !next.startsWith('--')) {
out[toCamel(body)] = next;
index += 1;
} else {
out[toCamel(body)] = true;
}
}
return out;
}
export function toCamel(value) {
return String(value).replace(/-([a-z0-9])/gi, (_, char) => char.toUpperCase());
}
/**
* Read a boolean flag. `--headed` and `--headed=true` must mean the same thing;
* comparing the raw value against `true` silently ignores the second form.
*/
export function boolFlag(value, fallback = false) {
if (value === undefined) return fallback;
if (typeof value === 'boolean') return value;
const normalized = String(value).trim().toLowerCase();
if (['', 'true', '1', 'yes', 'on'].includes(normalized)) return true;
if (['false', '0', 'no', 'off'].includes(normalized)) return false;
return fallback;
}
/**
* Parse a positive integer flag, falling back when absent. Throws on a value
* that was clearly meant as a number but isn't one, so `--iterations abc`
* fails instead of quietly benchmarking the default.
*/
export function positiveIntFlag(value, fallback) {
if (value === undefined || value === true) return fallback;
const parsed = Number.parseInt(String(value), 10);
if (!Number.isFinite(parsed) || parsed <= 0 || String(parsed) !== String(value).trim()) {
throw new Error(`expected a positive integer, got: ${value}`);
}
return parsed;
}
/**
* Resolve a flag that must be one of a fixed set.
*
* A silent `x === 'known' ? 'known' : fallback` is the trap this replaces: the
* private evals Live runner passes `--agent=codex`, which fell through to the
* canned fake agent and produced a clean-looking report of a deterministic stub
* labelled as a real harness run. An unrecognized value is a mistake, not a
* request for the default.
*/
export function resolveEnum(value, allowed, fallback, flagName) {
if (value === undefined || value === true) return fallback;
const normalized = String(value).trim().toLowerCase();
if (allowed.includes(normalized)) return normalized;
throw new Error(`${flagName} must be one of ${allowed.join(', ')}; got: ${value}`);
}
+5
View File
@@ -54,6 +54,7 @@ export const SUITES = {
runner: 'node',
files: [
'tests/ci-test-plan.test.mjs',
'tests/cli-args.test.mjs',
'tests/context.test.mjs',
'tests/context-signals.test.mjs',
'tests/critique-storage.test.mjs',
@@ -134,17 +135,21 @@ export const SUITES = {
'tests/live-e2e-steer-agent.test.mjs',
'tests/live-e2e/agent-insert.test.mjs',
'tests/live-event-validation.test.mjs',
'tests/live-generation-preflight.test.mjs',
'tests/live-inject.test.mjs',
'tests/live-insert.test.mjs',
'tests/live-insert-ui.test.mjs',
'tests/live-manual-edits-buffer.test.mjs',
'tests/live-poll.test.mjs',
'tests/live-poll-lanes.test.mjs',
'tests/live-poll-stream.test.mjs',
'tests/live-recovery-commands.test.mjs',
'tests/live-reference.test.mjs',
'tests/live-server.test.mjs',
'tests/live-session-store.test.mjs',
'tests/live-source-lock.test.mjs',
'tests/live-target-context.test.mjs',
'tests/live-vue-component.test.mjs',
'tests/live-wrap.test.mjs',
'tests/live-wrap-buffer-aware.test.mjs',
],
+29 -18
View File
@@ -14,21 +14,24 @@ Execute in order. No step skipped, no step reordered.
1. `live.mjs`: boot. If the request names or implies a file, route, or app inside a monorepo, infer the concrete path and run `node {{scripts_path}}/live.mjs --target <path>` instead; then run the rest of this live session from the returned `projectRoot`.
2. Open the app URL that serves `pageFile` (infer from `package.json`, docs, terminal output, or an open tab). Never use `serverPort`; it's the helper, not the app. **Cursor:** `browser_navigate` to that URL before polling; do not skip. **Other harnesses:** use the available browser tool; if the URL is uncertain, ask the user once.
3. Poll loop with the default long timeout (600000 ms). After every event or `--reply`, run `live-poll.mjs` again immediately. Never pass a short `--timeout=`.
3. Poll loop with the default long timeout (600000 ms). Run `live-poll.mjs` again immediately after every event or `--reply`; Codex runs this one-shot poll in the foreground. Never pass a short `--timeout=`.
The global bar **Impeccable mark** dims and shows a pulsing amber dot when no agent is long-polling `/poll`. Hover the mark for the hint; restart `live-poll.mjs` to reconnect.
4. On `generate`: read screenshot if present; load the action's reference; plan three distinct directions; write all variants in one edit; `--reply done`; poll again.
4. On `generate`: reuse `event.scaffold` when present; read the screenshot if present; load the action's reference; deliver variants using the delivery policy below; `--reply done`; poll again. Generate in this thread. You already hold the project's tokens, conventions, and file layout; that context is the job, not overhead.
5. On `steer`: read the message and `pageUrl`; do the work (page edits, navigation help, or a short reply in the `--reply` message); `--reply steer_done`; poll again. No pickup ack. The Steer bar unlocks when `steer_done` arrives over SSE.
6. On `accept` / `discard`: the poll script runs `live-accept.mjs`, acknowledges the delivered event, and prints `_completionAck`. Plain accepts/discards are terminal immediately; carbonize accepts remain recoverable until you finish cleanup, run `live-complete.mjs --id EVENT_ID`, and only then poll again.
6. On `accept` / `discard`: the poll script runs `live-accept.mjs`, acknowledges the delivered event, and prints `_completionAck`. Plain accepts/discards are terminal immediately. Carbonize accepts remain recoverable until the foreground task runs `live-complete.mjs --id EVENT_ID`; finish that cleanup before polling again.
7. If interrupted, run `live-status.mjs` or `live-resume.mjs` before guessing. The durable journal replays unacknowledged work after helper restart.
8. On `exit`: run the cleanup at the bottom.
Harness policy:
- **Claude Code**: run the poll as a **background task** (no short timeout). The harness notifies you when it completes, so the main conversation stays free. Do not block the shell.
- **Claude Code**: run the poll as a **background task** (no short timeout). The harness notifies you when it completes, so the main conversation stays free while you generate and publish in it. Do not block the shell.
- **Cursor**: run **one-shot** poll in a **background terminal** with notify on `"type":"(steer|generate|accept|discard|exit)"`. After each event the poll exits; handle it, `--reply`, then start `live-poll.mjs` again. Do **not** use `--stream` on Cursor: incremental stdout notify is slower in practice than exit-based notify (~5s vs sub-second in testing).
- **Codex**: run the poll in the **foreground** (blocking shell; not a background task, not a subagent). Codex background exec sessions do not reliably surface poll stdout back into the conversation at the moment events arrive, so a "fire-and-forget" background poll will stall live mode.
- **Codex**: run the default one-shot poll in a **yielded foreground exec session**. Do not suffix it with `&`, use `--stream`, or leave Live without an active foreground poll. Handle every event in the main task; after each handler/reply, restart the foreground poll.
- **Other harnesses**: one-shot foreground unless you know stdout reliably returns to this session when a shell exits.
Generation delivery policy:
- **Default (Cursor and other harnesses):** keep the established atomic single-edit delivery. Do not switch a harness to progressive until its poll loop is known not to block on the extra publish calls. This avoids trading model latency for extra tool-call latency on harnesses with different streaming behavior.
Chat is overhead. No recap, no tutorial output, no pasting PRODUCT / DESIGN bodies. Spend tokens on tools and edits; on failure, one or two short sentences.
## Start
@@ -96,14 +99,14 @@ Server restart rule: start `live-server.mjs` again, then poll. Startup requeues
**Insert mode** (`event.mode === "insert"`): `{id, mode: "insert", count, pageUrl, insert: { position, anchor }, placeholder: { width, height }, freeformPrompt?, screenshotPath?, comments?, strokes?}`. No `action`. Requires a non-empty `freeformPrompt` **or** annotations. Screenshot is sent only when annotations exist (same rule as replace). Use `placeholder` dimensions as a soft size hint for net-new content.
Speed matters; the user is watching a spinner. Minimize tool calls by using the wrap/insert helper and writing all variants in a single edit.
Speed matters; the user is watching the selected element. Reuse server preflight metadata when available, minimize discovery calls, and follow the harness-specific delivery policy above.
### Insert mode branch
When `event.mode === "insert"`:
1. Read the screenshot if `event.screenshotPath` is present (annotations only).
2. Run the insert helper instead of wrap:
2. If `event.scaffold` is present, use it as the insert-helper result and do **not** run the helper again. Otherwise run the insert helper instead of wrap:
```bash
node {{scripts_path}}/live-insert.mjs --id EVENT_ID --count EVENT_COUNT --position after \
@@ -113,7 +116,7 @@ node {{scripts_path}}/live-insert.mjs --id EVENT_ID --count EVENT_COUNT --positi
- `--position``event.insert.position` (`before` | `after`)
- Anchor flags ← `event.insert.anchor` (same mapping as wrap: id, classes, tag, text)
The scaffold has **no** `data-impeccable-variant="original"`. Variants are net-new HTML+CSS inserted at `insertLine`. Load `brand.md` or `product.md` (freeform only, no action sub-command). Write all variants in one edit, then `--reply done`.
The scaffold has **no** `data-impeccable-variant="original"`. Variants are net-new HTML+CSS inserted at `insertLine`. Load `brand.md` or `product.md` (freeform only, no action sub-command). Deliver using the harness policy, then `--reply done`.
For Svelte/SvelteKit targets, `live-insert.mjs` returns `previewMode: "svelte-component"` with `mode: "insert"`, `file` pointing at a temporary `node_modules/.impeccable-live/<id>/manifest.json`, `componentDir` pointing at the variant component files, and `sourceFile` pointing at the real `.svelte` route. Write each inserted variant as a real Svelte component (`v1.svelte`, `v2.svelte`, …) under `componentDir`. Insert variants must be non-empty net-new content with a single top-level root, no `data-impeccable-*` attributes, and CSS in each component's `<style>` block. Do **not** edit the route source during generation; the browser mounts the temporary component before/after the live anchor while the user cycles variants. On Accept, `live-accept.mjs` inserts the selected component markup into `sourceFile` immediately and deletes the temp session after the source write succeeds.
@@ -138,6 +141,8 @@ Reading annotations precisely:
### 2. Wrap the element
When `event.scaffold` is present, the local helper already found and wrapped the source before the poll returned. Treat `event.scaffold` as the successful helper output and skip this command entirely. `event.scaffoldAttempted` with `scaffoldError` means local preflight could not finish; use the command/fallback path below. This optimization removes a deterministic tool round trip without changing the generated design.
```bash
node {{scripts_path}}/live-wrap.mjs --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div" --text "TEXT_SNIPPET"
```
@@ -157,7 +162,9 @@ Output on success: `{ file, insertLine, commentSyntax, styleMode, styleTag, cssS
For Svelte/SvelteKit targets, `live-wrap.mjs` returns `previewMode: "svelte-component"` with `file` pointing at a temporary `node_modules/.impeccable-live/<id>/manifest.json`, `componentDir` pointing at the variant component files, and `sourceFile` pointing at the real `.svelte` route. Write each variant as a real Svelte component (`v1.svelte`, `v2.svelte`, …) under `componentDir`; use the `propContract` prop names for dynamic text (`{propName}`), not literal snapshot strings. Put variant CSS in each component's `<style>` block with semantic class selectors (no `@scope`, no `data-impeccable-*`). Reply with `--file` set to the manifest path; the browser dynamically imports and mounts the compiled components so Svelte HMR does not reset page state while the user cycles variants. On Accept, `live-accept.mjs` inlines the accepted component back into `sourceFile` immediately after source promotion succeeds.
**Params on the Svelte component path go in a sidecar, never as an attribute.** Svelte parses `{` inside an attribute value as the start of an expression, so a `data-impeccable-params='[{…}]'` attribute on a component element fails to compile (`Expected token }`). Declare params for this path in `componentDir/params.json`, keyed by variant number, using the exact param schema from section 7:
For Nuxt/Vue targets, `live-wrap.mjs` returns `previewMode: "vue-component"` with `file` pointing at an app-local generated manifest under `<appDir>/.impeccable-live/<id>/manifest.json`, `componentDir` pointing at real Vue SFC variants, and `sourceFile` pointing at the untouched `.vue` route. Write `v1.vue`, `v2.vue`, … with one root inside `<template>` and variant CSS in `<style scoped>`; keep dynamic text on the `propContract` bindings as `{{ propName }}`. Do **not** rewrite `sourceFile` during generation: Nuxt/Vite compiles and mounts these dev-only modules without invalidating the route. Accept is the only route write and inlines the selected template/CSS under the source lock; Discard deletes the generated session.
**Params on component-preview paths go in a sidecar, never as an attribute.** Svelte parses `{` inside an attribute value as the start of an expression, and both Svelte/Vue previews mount without an HTML variant wrapper. Declare params in `componentDir/params.json`, keyed by variant number, using the exact param schema from section 7:
```json
{
@@ -295,11 +302,13 @@ In **departure mode**, the prompt narrows the lanes you draw from, not the famil
When the prompt and PRODUCT.md anti-references conflict (the prompt asks for X, the anti-references ban X), the anti-references win; they describe the brand's standing position, the prompt is one moment.
### 6. Write all variants in a single edit
### 6. Deliver variants
Complete HTML replacement of the original element for each variant, not a CSS-only patch. Consider the element's context (computed styles, parent structure, CSS variables from `event.element`).
Write CSS + all variants in ONE edit at the `insertLine` reported by `wrap`. Colocate CSS as a `<style>` tag inside the variant wrapper; `<style>` works anywhere in modern browsers and this ensures CSS and HTML arrive atomically (no FOUC).
Colocate preview CSS as a `<style>` tag inside the variant wrapper; `<style>` works anywhere in modern browsers and keeps each delivered state internally complete (no FOUC).
**Atomic default:** write CSS + all variants + parameter manifests in one edit at `insertLine`, preserving the established behavior.
Use the `cssAuthoring` object returned by `live-wrap.mjs` to author the temporary preview CSS. The style opening tag shown below is the common case; replace it with `cssAuthoring.styleTag` when the tool returns a different one. The variant markup shape is otherwise stable:
@@ -323,7 +332,7 @@ Use the `cssAuthoring` object returned by `live-wrap.mjs` to author the temporar
The first variant has no `display: none` (visible by default). All others do. If variants use only inline styles and no preview CSS, omit the `<style>` tag entirely.
One edit, all variants; the browser's MutationObserver picks everything up in one pass.
The browser's MutationObserver accepts either delivery shape. On the transactional progressive path it shows arrived variants and pending dots immediately; Accept and Discard are available as soon as one variant exists. Accepting an arrived variant fences the worker before the browser releases the picker, so later publications are rejected.
For `styleMode: "scoped"`, author every `:scope` rule with a descendant combinator. The `@scope` boundary is the **variant wrapper `<div data-impeccable-variant="N">`**, not the element you're designing. A bare `:scope { background: cream; }` styles the wrapper, not the inner replacement, so the cream lands on a `display: contents` shell while the actual element keeps page defaults. Always step in: `:scope > .card`, `:scope > section`, `:scope .hero-title`, etc. The fake test agent's CSS in `tests/live-e2e/agent.mjs` is a faithful template; every scoped rule starts `:scope > ...`.
@@ -365,7 +374,7 @@ Each variant can expose **coarse** knobs alongside the full HTML/CSS replacement
**Hard cap per variant**: at most **four** parameters so the panel stays legible; rare fifth only if the reference explicitly allows it.
**How to declare.** Put a JSON manifest on the variant wrapper (HTML/JSX path). **On the Svelte `svelte-component` path, do not use this attribute** (Svelte can't compile `{` inside an attribute value). Declare params in `componentDir/params.json` keyed by variant number instead (see the Svelte component paragraph in the wrap section). The param schema below is identical for both paths.
**How to declare.** Put a JSON manifest on the variant wrapper (HTML/JSX path). **On `svelte-component` and `vue-component` paths, do not use this attribute.** Declare params in `componentDir/params.json` keyed by variant number instead (see the component-preview paragraphs in the wrap section). The param schema below is identical for every path.
```html
<div data-impeccable-variant="1" data-impeccable-params='[
@@ -466,15 +475,19 @@ Event: `{id, variantId, _acceptResult, _completionAck}`. The poll script already
- The accept event includes `pageUrl`; the poll script must forward it to `live-accept.mjs --page-url PAGE_URL` so accept-time cleanup only scrubs staged copy edits for the current page.
- `_completionAck.ok !== true`: do not poll yet. Run `live-status.mjs` / `live-resume.mjs`, complete the cleanup manually if needed, then run `live-complete.mjs --id EVENT_ID`.
- `_acceptResult.handled: true` and `carbonize: false`: nothing to do. Poll again.
- `_acceptResult.handled: true` and `carbonize: true`: **post-accept cleanup is required before the next poll.** See the "Required after accept (carbonize)" section below. The `event._acceptResult.todo` field, `_completionAck.requiresComplete`, and a stderr banner all point at this required follow-up; none are decorative. After cleanup, run `live-complete.mjs --id EVENT_ID`, then poll again.
- `_acceptResult.handled: true` and `carbonize: true`: post-accept cleanup is required, but it must not stall Codex's control lane. See "Required after accept (carbonize)" below. The `event._acceptResult.todo` field, `_completionAck.requiresComplete`, and stderr banner all point at this required follow-up; none are decorative.
- `_acceptResult.handled: false, mode: "fallback"`: the session lived in a generated file and the script refused to persist there. You've already written the accepted variant into true source during Handle fallback Step 3; just clean up the temporary wrapper in the served file if any, and poll again.
- `_acceptResult.handled: false, mode: "error"`: the operation genuinely failed. **Do not hand-edit the file**; the source was not touched and editing it yourself would either double-apply or race whoever holds it.
- `error: "source_locked"`: a generation publish holds the file. Run the same `live-accept.mjs` command again; it is idempotent and will succeed once the publisher releases. Do not poll past it.
- `error: "accept_receipt_conflict"`: this session already resolved as `priorOperation` (on `priorVariantId` for an accept), so the request contradicts durable truth. Do not edit. Run `live-status.mjs` and tell the user what the session actually resolved to.
- anything else: report the error briefly and run `live-status.mjs` before continuing.
- `_acceptResult.handled: false` without `mode`: manual cleanup: read file, find markers, edit.
### Required after accept (carbonize)
When `_acceptResult.carbonize === true`, the accepted variant was stitched into source with helper markers and inline CSS so the browser can render it immediately with no visual gap. That stitch-in is **temporary**. The agent must rewrite it into permanent form before doing anything else. Skipping this leaves dead `@scope` rules for unaccepted variants, a pointless `data-impeccable-variant` wrapper, and `impeccable-carbonize-start/end` comment noise in the source file; all of which accumulate across sessions.
Do these five steps in the current thread, synchronously, before the next poll. Do not poll again until the file is clean.
Do these five steps synchronously before the next poll. The source lock, generation epoch, and expected-source hash remain the final safety gates against a generator finishing concurrently with Accept.
1. **Locate the carbonize block** in the source file (`_acceptResult.file`). It's bracketed by `<!-- impeccable-carbonize-start SESSION_ID -->` and `<!-- impeccable-carbonize-end SESSION_ID -->` and contains a `<style data-impeccable-css="SESSION_ID">` element. If the variant declared parameters, an `<!-- impeccable-param-values SESSION_ID: {...} -->` comment sits alongside the style tag with the user's chosen values; read it first; it drives steps 3 and 4 below.
2. **Move the CSS rules** into the project's real stylesheet. Which stylesheet depends on the project (e.g. `site/styles/workflow.css` for an Astro project, or the component's co-located CSS file for a Vite/Next project; pick whichever already owns styling for the surrounding element).
@@ -482,9 +495,7 @@ Do these five steps in the current thread, synchronously, before the next poll.
4. **Unwrap the accepted content.** Delete the inner `<div data-impeccable-variant="N" style="display: contents">` that wraps it. On JSX/TSX, also delete the outer `<div data-impeccable-carbonize="SESSION_ID" style={{ display: 'contents' }}>` wrapper if present (accept adds it so ternary/`return` slots keep a single root). Drop `data-impeccable-params` and any `data-p-*` attributes; those are live-mode plumbing, not source.
5. **Delete the inline `<style>` block, the `<!-- impeccable-param-values -->` comment if present, and both `<!-- impeccable-carbonize-start/end -->` markers.** Also drop any `@scope` rules for variants other than the accepted one; those are dead code now.
After the file is clean, run `live-complete.mjs --id SESSION_ID`, verify it reports `phase: "completed"`, then poll again.
A background agent may be used for the rewrite, but the current thread is responsible for verifying the five steps are complete before issuing the next poll. In practice, inline is usually faster and less error-prone.
After the file is clean, the cleanup owner runs `live-complete.mjs --id SESSION_ID` and verifies `phase: "completed"`. Poll again only after that verification.
## Handle `discard`
+14
View File
@@ -104,6 +104,20 @@ export function removeLiveServerInfo(cwd = process.cwd(), options = {}) {
}
}
/**
* Session IDs become path segments (journals, snapshots, accept receipts,
* preview manifests, generated component dirs). They arrive from CLI `--id`
* arguments and HTTP payloads, so anything containing a separator or `..` must
* be rejected before it reaches path.join, which would happily escape
* `.impeccable/live/`. Real IDs are 8 hex chars; the tests use short slugs.
*/
export function safeSessionId(id) {
if (typeof id !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/.test(id)) {
throw new Error('invalid session id: ' + id);
}
return id;
}
export function getLiveSessionsDir(cwd = process.cwd(), options = {}) {
return path.join(getLiveDir(cwd, options), 'sessions');
}
+264 -29
View File
@@ -16,15 +16,62 @@
import fs from 'node:fs';
import path from 'node:path';
import { isGeneratedFile } from './lib/is-generated.mjs';
import { IMPECCABLE_DIR, getLiveDir, safeSessionId } from './lib/impeccable-paths.mjs';
import { readBuffer as readManualEditsBuffer, writeBuffer as writeManualEditsBuffer } from './live/manual-edits-buffer.mjs';
import { withSourceLockSync } from './live/source-lock.mjs';
import {
applyDeferredSvelteComponentAccepts,
findSvelteComponentManifest,
inlineSvelteComponentAccept,
removeSvelteComponentSession,
} from './live/svelte-component.mjs';
import {
findVueComponentManifest,
inlineVueComponentAccept,
retireVueComponentSession,
} from './live/vue-component.mjs';
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
const ACCEPT_LOCK_WAIT_MS = 1_000;
// Mirrors VARIANT_ID_PATTERN in live/event-validation.mjs, which gates the same
// value arriving over HTTP.
const VARIANT_NUM_PATTERN = /^[0-9]{1,3}$/;
/**
* A thrown accept/discard is a real failure, not a manual handoff.
*
* live/completion.mjs only classifies a result as `error` when it carries
* `mode: 'error'`; anything else unhandled falls through to `agent_done` with a
* successful ack, and reference/live.md then tells the agent to finish the edit
* by hand. That is right for the documented fallback paths and wrong here: a
* `source_locked` contention needs a retry (hand-editing races the publisher
* holding the lock), and a crash needs surfacing, not a hand-applied guess.
*/
function operationFailure(err, extra = {}) {
return { handled: false, mode: 'error', error: err.message, ...extra };
}
/**
* Mark an unhandled preview-path result as a real failure.
*
* operationFailure only covers results built from a *thrown* error. The accept
* implementations also return `{handled: false, error}` for their own checks
* (variant missing, template empty, original text ambiguous), and those arrived
* without `mode`, so completion.mjs classified them as agent_done and
* reference/live.md routed the agent to "read file, find markers, edit".
*
* That handoff only makes sense for a plain wrapper session, which is the one
* shape with markers in the user's source to edit. Component and isolated
* artifact previews keep the source clean until Accept, so there is nothing to
* hand-edit and an unhandled result is always a failure. `previewMode` is
* exactly that discriminator: only the preview branches set it.
*/
function markPreviewFailure(result) {
if (result?.handled === false && !result.mode && result.previewMode) {
return { ...result, mode: 'error' };
}
return result;
}
// ---------------------------------------------------------------------------
// CLI
@@ -63,7 +110,52 @@ Output (JSON):
const isDiscard = args.includes('--discard');
if (!id) { console.error('Missing --id'); process.exit(1); }
// `id` becomes a path segment (accept receipts, preview manifests, generated
// component dirs). Reject separators and traversal here so one check covers
// every downstream sink.
try { safeSessionId(id); } catch { console.error('Invalid --id'); process.exit(1); }
if (!isDiscard && !variantNum) { console.error('Need --discard or --variant N'); process.exit(1); }
// `variantNum` is interpolated into a RegExp and into the markup written back
// to source. The browser and the /events schema both constrain it to digits;
// enforce the same here, or `--variant '.*'` matches the `original` block
// first and silently accepts the original while reporting success.
if (!isDiscard && !VARIANT_NUM_PATTERN.test(variantNum)) {
console.error('Invalid --variant');
process.exit(1);
}
const requestedOperation = isDiscard ? 'discard' : 'accept';
const priorReceipt = readAcceptReceipt(process.cwd(), id);
if (priorReceipt) {
const sameOperation = priorReceipt.operation === requestedOperation
&& (isDiscard || String(priorReceipt.variantId) === String(variantNum));
console.log(JSON.stringify(sameOperation
? { ...priorReceipt.result, handled: true, alreadyApplied: true }
: {
// mode: 'error' is what marks this a real failure rather than a manual
// handoff. Without it, live/completion.mjs classifies the reply as
// agent_done and reference/live.md tells the agent to "read file, find
// markers, edit" by hand — which would apply a second, conflicting
// accept on top of the one the receipt already recorded.
handled: false,
mode: 'error',
error: 'accept_receipt_conflict',
priorOperation: priorReceipt.operation,
priorVariantId: priorReceipt.variantId ?? null,
}));
return;
}
const emitResult = (rawResult) => {
const result = markPreviewFailure(rawResult);
if (result?.handled !== false) {
writeAcceptReceipt(process.cwd(), id, {
operation: requestedOperation,
variantId: isDiscard ? null : String(variantNum),
result,
});
}
console.log(JSON.stringify(result));
};
let paramValues = null;
if (paramValuesRaw) {
@@ -74,47 +166,111 @@ Output (JSON):
// Find the file containing this session's markers
const found = findSessionFile(id, process.cwd());
const svelteComponentManifest = found ? null : findSvelteComponentManifest(id, process.cwd());
const vueComponentManifest = found || svelteComponentManifest ? null : findVueComponentManifest(id, process.cwd());
if (!found && !svelteComponentManifest) {
if (!found && !svelteComponentManifest && !vueComponentManifest) {
console.log(JSON.stringify({ handled: false, error: 'Session markers not found for id: ' + id }));
process.exit(0);
}
if (svelteComponentManifest) {
if (vueComponentManifest) {
if (isDiscard) {
removeSvelteComponentSession(id, process.cwd());
console.log(JSON.stringify({
handled: true,
file: svelteComponentManifest.sourceFile,
let result;
try {
result = withSourceLockSync(
path.resolve(process.cwd(), vueComponentManifest.sourceFile),
'discard:' + id,
() => {
retireVueComponentSession(id, process.cwd());
return { handled: true };
},
{ waitMs: ACCEPT_LOCK_WAIT_MS },
);
} catch (err) {
result = operationFailure(err);
}
emitResult({
...result,
file: vueComponentManifest.sourceFile,
carbonize: false,
previewMode: 'svelte-component',
componentDir: svelteComponentManifest.componentDir,
}));
previewMode: 'vue-component',
componentDir: vueComponentManifest.componentDir,
});
return;
}
let result;
try {
result = inlineSvelteComponentAccept(
svelteComponentManifest,
variantNum,
paramValues,
process.cwd(),
result = withSourceLockSync(
path.resolve(process.cwd(), vueComponentManifest.sourceFile),
'accept:' + id,
() => inlineVueComponentAccept(vueComponentManifest, variantNum, process.cwd()),
{ waitMs: ACCEPT_LOCK_WAIT_MS },
);
} catch (err) {
result = {
handled: false,
error: err.message,
result = operationFailure(err, {
file: vueComponentManifest.sourceFile,
sourceFile: vueComponentManifest.sourceFile,
previewMode: 'vue-component',
componentDir: vueComponentManifest.componentDir,
carbonize: false,
});
}
emitResult(result);
return;
}
if (svelteComponentManifest) {
if (isDiscard) {
let result;
try {
result = withSourceLockSync(
path.resolve(process.cwd(), svelteComponentManifest.sourceFile),
'discard:' + id,
() => {
removeSvelteComponentSession(id, process.cwd());
return { handled: true };
},
{ waitMs: ACCEPT_LOCK_WAIT_MS },
);
} catch (err) {
result = operationFailure(err);
}
emitResult({
...result,
file: svelteComponentManifest.sourceFile,
carbonize: false,
previewMode: 'svelte-component',
componentDir: svelteComponentManifest.componentDir,
});
return;
}
let result;
try {
result = withSourceLockSync(
path.resolve(process.cwd(), svelteComponentManifest.sourceFile),
'accept:' + id,
() => inlineSvelteComponentAccept(
svelteComponentManifest,
variantNum,
paramValues,
process.cwd(),
),
{ waitMs: ACCEPT_LOCK_WAIT_MS },
);
} catch (err) {
result = operationFailure(err, {
file: svelteComponentManifest.sourceFile,
sourceFile: svelteComponentManifest.sourceFile,
previewMode: 'svelte-component',
componentDir: svelteComponentManifest.componentDir,
};
});
}
if (result.carbonize) {
result.todo = 'REQUIRED before next poll: carbonize cleanup in ' + result.file + '. See reference/live.md "Required after accept".';
}
console.log(JSON.stringify({ handled: result.handled !== false, ...result }));
emitResult({ handled: result.handled !== false, ...result });
return;
}
@@ -145,10 +301,25 @@ Output (JSON):
}
if (isDiscard) {
const result = handleDiscard(id, lines, targetFile);
console.log(JSON.stringify({ handled: true, file: relFile, carbonize: false, ...result }));
let result;
// handleDiscard takes the source lock, which throws SOURCE_LOCKED under
// contention. Without this catch the CLI exits non-zero with empty stdout
// and the agent gets no JSON to act on.
try {
result = handleDiscard(id, lines, targetFile);
} catch (err) {
emitResult(operationFailure(err, { file: relFile }));
return;
}
emitResult({ handled: true, file: relFile, carbonize: false, ...result });
} else {
const result = handleAccept(id, variantNum, lines, targetFile, paramValues);
let result;
try {
result = handleAccept(id, variantNum, lines, targetFile, paramValues);
} catch (err) {
emitResult(operationFailure(err, { file: relFile }));
return;
}
const acceptedOriginalText = result.acceptedOriginalText || '';
delete result.acceptedOriginalText;
// Single-line attention-grabber when cleanup is required. The full
@@ -167,7 +338,7 @@ Output (JSON):
// Non-fatal; the buffer stays as-is and the user can discard later.
}
}
console.log(JSON.stringify({ handled: true, file: relFile, ...result }));
emitResult({ handled: true, file: relFile, ...result });
}
}
@@ -235,7 +406,14 @@ function scrubManualEditsAgainstFile(_targetFile, cwd = process.cwd(), originalB
// Discard
// ---------------------------------------------------------------------------
function handleDiscard(id, lines, targetFile) {
function handleDiscard(id, _lines, targetFile) {
return withSourceLockSync(targetFile, 'discard:' + id, () => {
const lines = fs.readFileSync(targetFile, 'utf-8').split('\n');
return handleDiscardUnlocked(id, lines, targetFile);
}, { waitMs: ACCEPT_LOCK_WAIT_MS });
}
function handleDiscardUnlocked(id, lines, targetFile) {
const block = findMarkerBlock(id, lines);
if (!block) return { handled: false, error: 'Markers not found' };
@@ -330,7 +508,24 @@ function reindentContent(contentLines, fromIndent, toIndent) {
});
}
function handleAccept(id, variantNum, lines, targetFile, paramValues) {
function handleAccept(id, variantNum, _lines, targetFile, paramValues) {
return withSourceLockSync(targetFile, 'accept:' + id, () => {
const lines = fs.readFileSync(targetFile, 'utf-8').split('\n');
return handleAcceptUnlocked(id, variantNum, lines, targetFile, paramValues);
}, { waitMs: ACCEPT_LOCK_WAIT_MS });
}
function handleAcceptUnlocked(id, variantNum, lines, targetFile, paramValues) {
const built = buildAcceptedWrappedSource(id, variantNum, lines, targetFile, paramValues);
if (built.handled === false) return built;
fs.writeFileSync(targetFile, built.content, 'utf-8');
return {
carbonize: built.carbonize,
acceptedOriginalText: built.acceptedOriginalText,
};
}
function buildAcceptedWrappedSource(id, variantNum, lines, targetFile, paramValues) {
const block = findMarkerBlock(id, lines);
if (!block) return { handled: false, error: 'Markers not found' };
@@ -375,11 +570,14 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) {
...replacement,
...lines.slice(replaceRange.end + 1),
];
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
return { carbonize: needsCarbonize, acceptedOriginalText: originalContent.join('\n') };
return {
content: newLines.join('\n'),
carbonize: needsCarbonize,
acceptedOriginalText: originalContent.join('\n'),
};
}
function readSourceShadowPreviewMeta(content, id) {
const escaped = escapeRegExp(id);
const wrapperRe = new RegExp('<[^>]+data-impeccable-variants=(["\'])' + escaped + '\\1[^>]*>');
@@ -746,6 +944,21 @@ function detectCommentSyntax(filePath) {
// File search (find the file containing session markers)
// ---------------------------------------------------------------------------
/**
* `.impeccable` is the critical entry, and it is not cosmetic.
*
* Progressive publication stages each revision as `.impeccable/live/artifacts/
* <id>-r<n>.<source-ext>`, and those artifacts carry the very marker this search
* looks for. The walk reaches `.` for any project whose source is not under one
* of the privileged dirs above (this repo's own site lives in `site/pages/`), and
* dot-directories sort before letters, so the artifact was found *before* the
* real file. isGeneratedFile then declined the accept, and the agent fell back to
* carbonizing several hundred lines of stylesheet by hand.
*
* Impeccable's own state directory is never project source. Never search it.
*/
const SEARCH_SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', IMPECCABLE_DIR]);
function findSessionFile(id, cwd) {
const marker = 'impeccable-variants-start ' + id;
const searchDirs = ['src', 'app', 'pages', 'components', 'public', 'views', 'templates', '.'];
@@ -786,7 +999,7 @@ function searchDir(dir, query, seen, depth) {
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (['node_modules', '.git', 'dist', 'build'].includes(entry.name)) continue;
if (SEARCH_SKIP_DIRS.has(entry.name)) continue;
const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1);
if (result) return result;
}
@@ -798,6 +1011,28 @@ function searchDir(dir, query, seen, depth) {
// Utilities
// ---------------------------------------------------------------------------
function acceptReceiptPath(cwd, id) {
return path.join(getLiveDir(cwd), 'accept-receipts', `${safeSessionId(id)}.json`);
}
function readAcceptReceipt(cwd, id) {
try { return JSON.parse(fs.readFileSync(acceptReceiptPath(cwd, id), 'utf-8')); } catch { return null; }
}
function writeAcceptReceipt(cwd, id, receipt) {
const file = acceptReceiptPath(cwd, id);
fs.mkdirSync(path.dirname(file), { recursive: true });
const value = {
id,
...receipt,
completedAt: new Date().toISOString(),
};
const temporary = `${file}.${process.pid}.${Date.now()}.tmp`;
fs.writeFileSync(temporary, JSON.stringify(value, null, 2) + '\n', 'utf-8');
fs.renameSync(temporary, file);
return value;
}
function argVal(args, flag) {
const idx = args.indexOf(flag);
return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : null;
File diff suppressed because it is too large Load Diff
+145 -4
View File
@@ -27,6 +27,8 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
const CONFIG_PATH = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname });
const MARKER_OPEN_TEXT = 'impeccable-live-start';
const MARKER_CLOSE_TEXT = 'impeccable-live-end';
const NUXT_PLUGIN_MARKER = 'impeccable-live-nuxt-plugin';
const NUXT_PLUGIN_NAME = 'impeccable-live.client.ts';
const IGNORE_MARKER_OPEN = '# impeccable-live-ignore-start';
const IGNORE_MARKER_CLOSE = '# impeccable-live-ignore-end';
@@ -38,6 +40,9 @@ export const LIVE_IGNORE_PATTERNS = Object.freeze([
'.impeccable/live/sessions/',
'.impeccable/live/previews/',
'.impeccable/live/annotations/',
'.impeccable/live/artifacts/',
'.impeccable/live/accept-receipts/',
'.impeccable/live/locks/',
'.impeccable/live/cache/',
'.impeccable/live/manual-edit-apply-transaction.json',
'.impeccable/live/manual-edit-events.jsonl',
@@ -46,10 +51,15 @@ export const LIVE_IGNORE_PATTERNS = Object.freeze([
'.impeccable/live/deferred-svelte-component-accepts.json',
'.impeccable-live.json',
'.impeccable-live/',
'app/.impeccable-live/',
'src/.impeccable-live/',
'node_modules/.impeccable-live/',
'src/lib/impeccable/ImpeccableLiveRoot.svelte',
'src/lib/impeccable/__runtime.js',
'src/lib/impeccable/[0-9a-f]*/',
'plugins/impeccable-live.client.ts',
'app/plugins/impeccable-live.client.ts',
'src/plugins/impeccable-live.client.ts',
]);
/**
@@ -113,6 +123,7 @@ Output (JSON):
const resolvedFiles = resolveFiles(process.cwd(), config);
const svelteKit = detectSvelteKitProject(process.cwd(), config);
const nuxt = detectNuxtProject(process.cwd());
if (args.includes('--remove')) {
if (svelteKit) {
@@ -120,6 +131,12 @@ Output (JSON):
console.log(JSON.stringify({ ok: true, adapter: 'sveltekit', results: [adapterResult] }));
return;
}
if (nuxt) {
const adapterResult = removeNuxtLiveAdapter({ cwd: process.cwd(), project: nuxt });
console.log(JSON.stringify({ ok: !adapterResult.error, adapter: 'nuxt', results: [adapterResult] }));
if (adapterResult.error) process.exitCode = 1;
return;
}
const results = resolvedFiles.map((relFile) => {
const absFile = path.resolve(process.cwd(), relFile);
if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
@@ -145,13 +162,28 @@ Output (JSON):
console.error(JSON.stringify({ ok: false, error: 'missing_port' }));
process.exit(1);
}
const gitIgnore = ensureLiveGitIgnores(process.cwd());
const gitIgnore = ensureLiveGitIgnores(
process.cwd(),
nuxt ? [nuxt.pluginFile] : [],
);
if (svelteKit) {
const adapterResult = applySvelteKitLiveAdapter({ cwd: process.cwd(), port, config });
console.log(JSON.stringify({ ok: true, port, adapter: 'sveltekit', gitIgnore, results: [adapterResult] }));
return;
}
if (nuxt) {
const adapterResult = applyNuxtLiveAdapter({ cwd: process.cwd(), port, project: nuxt });
console.log(JSON.stringify({
ok: !adapterResult.error,
port,
adapter: 'nuxt',
gitIgnore,
results: [adapterResult],
}));
if (adapterResult.error) process.exitCode = 1;
return;
}
const results = resolvedFiles.map((relFile) => {
const absFile = path.resolve(process.cwd(), relFile);
@@ -175,12 +207,12 @@ Output (JSON):
if (!anyInserted) process.exit(1);
}
export function ensureLiveGitIgnores(cwd = process.cwd()) {
export function ensureLiveGitIgnores(cwd = process.cwd(), extraPatterns = []) {
const target = resolveIgnoreTarget(cwd);
const existing = fs.existsSync(target.path) ? fs.readFileSync(target.path, 'utf-8') : '';
const block = [
IGNORE_MARKER_OPEN,
...LIVE_IGNORE_PATTERNS,
...new Set([...LIVE_IGNORE_PATTERNS, ...extraPatterns]),
IGNORE_MARKER_CLOSE,
].join('\n');
const markerRe = new RegExp(`${escapeRegExp(IGNORE_MARKER_OPEN)}[\\s\\S]*?${escapeRegExp(IGNORE_MARKER_CLOSE)}`);
@@ -202,10 +234,119 @@ export function ensureLiveGitIgnores(cwd = process.cwd()) {
file: path.relative(cwd, target.path).split(path.sep).join('/'),
mode: target.mode,
changed: updated !== existing,
patterns: [...LIVE_IGNORE_PATTERNS],
patterns: [...new Set([...LIVE_IGNORE_PATTERNS, ...extraPatterns])],
};
}
// ---------------------------------------------------------------------------
// Nuxt adapter
//
// A script element placed in app.vue is compiled as Vue-rendered DOM and is
// not executed. Nuxt instead auto-discovers client plugins. Keep the adapter
// generated, dev-only, and outside user-authored source: Live creates one
// marked .client.ts plugin on start and removes it on stop.
// ---------------------------------------------------------------------------
export function detectNuxtProject(cwd = process.cwd()) {
const configFile = fs.readdirSync(cwd, { withFileTypes: true })
.find((entry) => entry.isFile() && /^nuxt\.config\.(?:js|mjs|cjs|ts|mts|cts)$/.test(entry.name))
?.name;
if (!configFile) return null;
const config = fs.readFileSync(path.join(cwd, configFile), 'utf-8');
const literalSrcDir = config.match(/\bsrcDir\s*:\s*(['"])([^'"]+)\1/);
let appDir = '';
if (literalSrcDir) {
const candidate = literalSrcDir[2]
.replace(/\\/g, '/')
.replace(/^\.\//, '')
.replace(/\/+$/, '');
const normalized = path.posix.normalize(candidate);
if (normalized !== '..' && !normalized.startsWith('../') && !path.isAbsolute(normalized)) {
appDir = normalized === '.' ? '' : normalized;
}
} else if (
fs.existsSync(path.join(cwd, 'app', 'app.vue'))
|| fs.existsSync(path.join(cwd, 'app', 'pages'))
) {
appDir = 'app';
}
const pluginFile = [appDir, 'plugins', NUXT_PLUGIN_NAME].filter(Boolean).join('/');
return { configFile, appDir, pluginFile };
}
export function buildNuxtPlugin(port) {
return `/* ${NUXT_PLUGIN_MARKER} */
const liveSrc = 'http://localhost:${port}/live.js';
const liveSelector = 'script[data-impeccable-live-nuxt]';
export default defineNuxtPlugin(() => {
if (!import.meta.dev || typeof document === 'undefined') return;
const expectedSrc = new URL(liveSrc, window.location.href).href;
let script = document.querySelector(liveSelector);
if (script?.src === expectedSrc) return;
script?.remove();
script = document.createElement('script');
script.src = liveSrc;
script.async = true;
script.dataset.impeccableLiveNuxt = '';
document.head.appendChild(script);
import.meta.hot?.dispose(() => {
if (script?.isConnected) script.remove();
});
});
/* /${NUXT_PLUGIN_MARKER} */
`;
}
export function applyNuxtLiveAdapter({ cwd = process.cwd(), port, project = detectNuxtProject(cwd) }) {
if (!project) return { error: 'nuxt_not_detected' };
const absFile = path.join(cwd, project.pluginFile);
const existing = fs.existsSync(absFile) ? fs.readFileSync(absFile, 'utf-8') : null;
if (existing !== null && !existing.includes(NUXT_PLUGIN_MARKER)) {
return {
file: project.pluginFile,
error: 'nuxt_plugin_conflict',
hint: `${project.pluginFile} already exists and is not managed by Impeccable Live`,
};
}
const content = buildNuxtPlugin(port);
fs.mkdirSync(path.dirname(absFile), { recursive: true });
if (content !== existing) fs.writeFileSync(absFile, content, 'utf-8');
return {
file: project.pluginFile,
inserted: true,
changed: content !== existing,
devOnly: true,
};
}
export function removeNuxtLiveAdapter({ cwd = process.cwd(), project = detectNuxtProject(cwd) }) {
if (!project) return { error: 'nuxt_not_detected' };
const absFile = path.join(cwd, project.pluginFile);
if (!fs.existsSync(absFile)) {
return { file: project.pluginFile, removed: false, note: 'no adapter present' };
}
const content = fs.readFileSync(absFile, 'utf-8');
if (!content.includes(NUXT_PLUGIN_MARKER)) {
return {
file: project.pluginFile,
removed: false,
error: 'nuxt_plugin_conflict',
hint: `${project.pluginFile} is not managed by Impeccable Live`,
};
}
fs.unlinkSync(absFile);
const pluginDir = path.dirname(absFile);
if (fs.readdirSync(pluginDir).length === 0) fs.rmdirSync(pluginDir);
return { file: project.pluginFile, removed: true };
}
function resolveIgnoreTarget(cwd) {
const gitExcludePath = resolveGitInfoExcludePath(cwd);
if (gitExcludePath) {
+46 -14
View File
@@ -27,7 +27,7 @@ const scriptCmd = (name) => `node "${path.join(SELF_DIR, name)}"`;
export const PER_REQUEST_TIMEOUT_MS = 270_000;
export const DEFAULT_EVENT_LEASE_MS = 600_000;
const EVENT_TYPES_NEEDING_AGENT_REPLY = new Set(['generate', 'steer', 'manual_edit_apply']);
const EVENT_TYPES_NEEDING_AGENT_REPLY = new Set(['generate', 'steer', 'manual_edit_apply', 'carbonize_cleanup']);
function readServerInfo() {
const record = readLiveServerInfo(process.cwd());
@@ -38,8 +38,8 @@ function readServerInfo() {
return record.info;
}
export function buildPollReplyPayload(token, { id, type, message, file, data }) {
return { token, id, type, message, file, data };
export function buildPollReplyPayload(token, { id, type, message, file, data, sourceEventType }) {
return { token, id, type, message, file, data, sourceEventType };
}
export function manualApplyPollBanner(event = {}) {
@@ -152,7 +152,14 @@ export async function waitForEventAck(base, token, eventId, {
return false;
}
export async function fetchNextEvent(base, token, { totalDeadline } = {}) {
export async function fetchNextEvent(base, token, {
totalDeadline,
types,
resolveTypes,
perRequestTimeoutMs = PER_REQUEST_TIMEOUT_MS,
leaseMs = DEFAULT_EVENT_LEASE_MS,
signal,
} = {}) {
while (true) {
if (totalDeadline && Date.now() >= totalDeadline) {
return { type: 'timeout' };
@@ -161,8 +168,15 @@ export async function fetchNextEvent(base, token, { totalDeadline } = {}) {
const remaining = totalDeadline
? totalDeadline - Date.now()
: PER_REQUEST_TIMEOUT_MS;
const slice = Math.min(Math.max(remaining, 1000), PER_REQUEST_TIMEOUT_MS);
const res = await fetch(`${base}/poll?token=${token}&timeout=${slice}&leaseMs=${DEFAULT_EVENT_LEASE_MS}`);
const slice = Math.min(Math.max(remaining, 1000), perRequestTimeoutMs);
const query = new URLSearchParams({
token,
timeout: String(slice),
leaseMs: String(leaseMs),
});
const normalizedTypes = normalizePollTypes(resolveTypes ? await resolveTypes() : types);
if (normalizedTypes.length > 0) query.set('types', normalizedTypes.join(','));
const res = await fetch(`${base}/poll?${query}`, { signal });
if (res.status === 401) {
const err = new Error('Authentication failed. The server token may have changed.');
@@ -202,11 +216,17 @@ export async function augmentEventWithAcceptHandling(event, base, token) {
event._acceptResult = { handled: false, mode: 'error', error: err.message };
}
await completeAcceptHandling(event, base, token);
return event;
}
export async function completeAcceptHandling(event, base, token) {
const completionType = completionTypeForAcceptResult(event.type, event._acceptResult);
try {
await postReply(base, token, {
id: event.id,
type: completionType,
sourceEventType: event.type,
message: event._acceptResult?.error,
file: event._acceptResult?.file,
data: event._acceptResult?.carbonize === true ? { carbonize: true } : undefined,
@@ -217,7 +237,6 @@ export async function augmentEventWithAcceptHandling(event, base, token) {
if (!event._completionAck) {
event._completionAck = completionAckForAcceptResult(event.id, completionType, event._acceptResult);
}
return event;
}
@@ -245,9 +264,9 @@ export function printPollEvent(event) {
console.log(JSON.stringify(event));
}
export async function runPollOnce(base, token, { totalTimeout = 600_000 } = {}) {
export async function runPollOnce(base, token, { totalTimeout = 600_000, types, resolveTypes, perRequestTimeoutMs } = {}) {
const deadline = Date.now() + totalTimeout;
const event = await fetchNextEvent(base, token, { totalDeadline: deadline });
const event = await fetchNextEvent(base, token, { totalDeadline: deadline, types, resolveTypes, perRequestTimeoutMs });
await augmentEventWithAcceptHandling(event, base, token);
writeCarbonizeBanner(event);
printPollEvent(event);
@@ -258,11 +277,14 @@ export async function runPollStream(base, token, {
ackTimeoutMs = 600_000,
ackPollIntervalMs = 400,
shouldContinue = () => true,
types,
resolveTypes,
perRequestTimeoutMs,
} = {}) {
process.stderr.write('[impeccable-poll] stream mode: one JSON object per line on stdout; use --reply while this process stays running\n');
while (shouldContinue()) {
const event = await fetchNextEvent(base, token);
const event = await fetchNextEvent(base, token, { types, resolveTypes, perRequestTimeoutMs });
await augmentEventWithAcceptHandling(event, base, token);
writeCarbonizeBanner(event);
printPollEvent(event);
@@ -322,14 +344,17 @@ Modes:
Options:
--timeout=MS One-shot poll timeout in ms (default: 600000). Ignored in --stream mode
--types=A,B Lease only these event types
--ack-timeout=MS Stream mode: max wait for --reply after generate/steer (default: 600000)
--file PATH Attach a source file path to the reply (generate/steer flow)
--data JSON Attach a JSON result object to the reply (manual_edit_apply flow). Must be valid JSON
--help Show this help message
Harness note:
Default one-shot mode is the portable contract for Claude Code, Codex, and Cursor.
--stream is experimental for harnesses with fast incremental stdout; do not use on Cursor.`);
Default one-shot mode is the primary contract, including Codex foreground polling.
Claude Code may run it as a background task; Cursor uses a background terminal with exit notification.
--stream is retained for harnesses with measured, reliable incremental stdout.
Do not use --stream on Cursor.`);
process.exit(0);
}
@@ -360,23 +385,30 @@ Harness note:
}
const streamMode = args.includes('--stream');
const typesArg = args.find((a) => a.startsWith('--types='));
const types = normalizePollTypes(typesArg ? typesArg.slice('--types='.length) : null);
const ackTimeoutArg = args.find((a) => a.startsWith('--ack-timeout='));
const ackTimeoutMs = ackTimeoutArg ? parseInt(ackTimeoutArg.split('=')[1], 10) : 600_000;
try {
if (streamMode) {
await runPollStream(base, info.token, { ackTimeoutMs });
await runPollStream(base, info.token, { ackTimeoutMs, types });
return;
}
const timeoutArg = args.find((a) => a.startsWith('--timeout='));
const totalTimeout = timeoutArg ? parseInt(timeoutArg.split('=')[1], 10) : 600_000;
await runPollOnce(base, info.token, { totalTimeout });
await runPollOnce(base, info.token, { totalTimeout, types });
} catch (err) {
handlePollError(err);
}
}
export function normalizePollTypes(value) {
const values = Array.isArray(value) ? value : String(value || '').split(',');
return [...new Set(values.map((type) => String(type).trim()).filter(Boolean))];
}
// Auto-execute when run directly
const _running = process.argv[1];
if (_running?.endsWith('live-poll.mjs') || _running?.endsWith('live-poll.mjs/')) {
+300 -30
View File
@@ -29,7 +29,9 @@ import {
resolveLiveBrowserScriptParts,
} from './live/browser-script-parts.mjs';
import { createLiveSessionStore } from './live/session-store.mjs';
import { runGenerationPreflight } from './live/generation-preflight.mjs';
import { validateEvent } from './live/event-validation.mjs';
import { selectAvailablePendingEvent } from './live/poll-lanes.mjs';
import { createManualEditRoutes } from './live/manual-edit-routes.mjs';
import { LIVE_COMMANDS } from './live/vocabulary.mjs';
import {
@@ -51,6 +53,7 @@ import {
applyDeferredSvelteComponentAccepts,
removeAllSvelteComponentSessions,
} from './live/svelte-component.mjs';
import { removeAllVueComponentSessions } from './live/vue-component.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// PRODUCT.md / DESIGN.md live wherever context.mjs resolves. The generated
@@ -63,6 +66,10 @@ const DESIGN_MD_PATH = PROJECT_CONTEXT.designPath
: null;
const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway
const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s
// The browser checkpoints for several unrelated reasons (see checkpointPayload
// in live-browser.js). Only these two report that variant availability changed,
// and only they may drive variant_progress / the *_reviewable phases.
const VARIANT_PROGRESS_CHECKPOINT_REASONS = new Set(['variants_progress', 'variants_ready']);
// ---------------------------------------------------------------------------
// Port detection
@@ -156,29 +163,148 @@ function restorePendingEventsFromStore() {
}
}
function findAvailablePendingEvent(now = Date.now()) {
for (const entry of state.pendingEvents) {
if (entry.leaseUntil && entry.leaseUntil > now) continue;
return entry;
}
return null;
function findAvailablePendingEvent(now = Date.now(), types = null) {
return selectAvailablePendingEvent(state.pendingEvents, { now, types });
}
function leaseEvent(entry, leaseMs) {
async function leaseEvent(entry, leaseMs) {
// Claim the entry before awaiting anything. prepareGenerateEventForLease
// yields to the event loop, and selectAvailablePendingEvent only skips
// entries whose lease is in the future — an unclaimed entry would be handed
// to a second poll in that window and generated twice.
entry.leaseUntil = Date.now() + leaseMs;
await prepareGenerateEventForLease(entry);
if (!entry.event?.id) {
const idx = state.pendingEvents.indexOf(entry);
if (idx !== -1) state.pendingEvents.splice(idx, 1);
return entry.event;
}
// Re-stamp so the lease window starts when the agent actually receives the
// work, not when scaffolding began.
entry.leaseUntil = Date.now() + leaseMs;
recordGenerateDelivery(entry);
scheduleLeaseFlush();
broadcastAgentPollingIfChanged();
return entry.event;
}
function acknowledgePendingEvent(id) {
function recordGenerateDelivery(entry) {
const event = entry?.event;
if (!event || event.type !== 'generate' || event.generationReadyAt) return;
const at = Date.now();
entry.event = { ...event, generationReadyAt: at };
state.sessionStore?.appendEvent(entry.event);
recordAgentPhase(event.id, 'generation_ready', { at });
}
async function prepareGenerateEventForLease(entry) {
const event = entry?.event;
if (!event || event.type !== 'generate' || event.scaffoldAttempted) return;
recordAgentPhase(event.id, 'picked_up');
recordAgentPhase(event.id, 'scaffolding');
const result = await runGenerationPreflight(event, {
cwd: process.cwd(),
scriptsDir: __dirname,
});
entry.event = {
...event,
scaffoldAttempted: true,
scaffoldDurationMs: result.durationMs ?? null,
...(result.ok ? { scaffold: result.scaffold } : { scaffoldError: result.error || result.reason }),
};
state.sessionStore?.appendEvent(entry.event);
recordAgentPhase(event.id, result.ok ? 'source_ready' : 'scaffold_fallback', {
durationMs: result.durationMs ?? null,
previewMode: result.scaffold?.previewMode || 'source',
});
}
function recordAgentPhase(id, phase, details = {}) {
if (!id) return;
const event = {
type: 'agent_phase',
id,
phase,
at: Date.now(),
...details,
};
state.sessionStore?.appendEvent(event);
broadcast(event);
}
function recordGenerationCheckpoint(event) {
if (!event?.id || event.type !== 'checkpoint') return;
if (generationIsFenced(event.id)) return;
// Only checkpoints that report a change in variant availability are
// generation progress. The browser also checkpoints for durability on Tune
// slider drags, resumes, and anchor recovery; treating those as progress
// echoed `variant_progress` straight back to the browser that sent it, which
// remounts the component preview mid-drag (reverting the user's live param
// edit and detaching the popover's element), and permanently latched the
// *_reviewable phases from the wrong trigger, corrupting generation timings.
if (!VARIANT_PROGRESS_CHECKPOINT_REASONS.has(event.reason)) return;
const arrived = Number(event.arrivedVariants) || 0;
const expected = Number(event.expectedVariants) || 0;
if (arrived <= 0 || expected <= 0) return;
const previewMode = event.previewMode || 'source';
const previewFile = event.previewFile || event.file;
if (previewFile) {
broadcast({
type: 'variant_progress',
id: event.id,
file: previewFile,
sourceFile: event.sourceFile || (previewMode === 'source' ? previewFile : undefined),
previewFile,
previewMode,
arrivedVariants: arrived,
expectedVariants: expected,
publicationKind: event.publicationKind || 'variants',
});
}
const details = {
arrivedVariants: arrived,
expectedVariants: expected,
checkpointReason: event.reason || null,
};
const at = Date.now();
if (!generationPhaseAlreadyRecorded(event.id, 'first_reviewable')) {
recordAgentPhase(event.id, 'first_reviewable', { ...details, at });
}
if (arrived >= 2 && expected >= 3 && !generationPhaseAlreadyRecorded(event.id, 'second_reviewable')) {
recordAgentPhase(event.id, 'second_reviewable', { ...details, at });
}
if (arrived >= expected && !generationPhaseAlreadyRecorded(event.id, 'all_variants_ready')) {
recordAgentPhase(event.id, 'all_variants_ready', { ...details, at });
}
}
function generationIsFenced(id) {
if (!state.sessionStore || !id) return false;
try {
const snapshot = state.sessionStore.getSnapshot(id, { includeCompleted: true });
return snapshot?.generationCanceled === true;
} catch {
return false;
}
}
function generationPhaseAlreadyRecorded(id, phase) {
if (!state.sessionStore) return false;
try {
const snapshot = state.sessionStore.getSnapshot(id, { includeCompleted: true });
return !!snapshot?.generationTimings?.[phase];
} catch {
return false;
}
}
function acknowledgePendingEvent(id, sourceEventType) {
if (!id) return false;
const idx = state.pendingEvents.findIndex((entry) => entry.event?.id === id);
const idx = state.pendingEvents.findIndex((entry) => (
entry.event?.id === id
&& (!sourceEventType || entry.event?.type === sourceEventType)
));
if (idx === -1) return false;
const acknowledged = state.pendingEvents[idx].event;
state.pendingEvents.splice(idx, 1);
@@ -187,9 +313,39 @@ function acknowledgePendingEvent(id) {
return acknowledged;
}
function findPendingEventById(id) {
function releasePendingEvent(id, sourceEventType) {
const entry = state.pendingEvents.find((item) => (
item.event?.id === id
&& (!sourceEventType || item.event?.type === sourceEventType)
));
if (!entry) return null;
entry.leaseUntil = 0;
scheduleLeaseFlush();
return entry.event;
}
function retirePendingGeneration(id) {
if (!id) return 0;
let retired = 0;
for (let index = state.pendingEvents.length - 1; index >= 0; index -= 1) {
const event = state.pendingEvents[index]?.event;
if (event?.id !== id || event.type !== 'generate') continue;
state.pendingEvents.splice(index, 1);
retired += 1;
}
if (retired > 0) {
scheduleLeaseFlush();
broadcastAgentPollingIfChanged();
}
return retired;
}
function findPendingEventById(id, sourceEventType) {
if (!id) return null;
const entry = state.pendingEvents.find((item) => item.event?.id === id);
const entry = state.pendingEvents.find((item) => (
item.event?.id === id
&& (!sourceEventType || item.event?.type === sourceEventType)
));
return entry?.event || null;
}
@@ -198,7 +354,7 @@ function summarizePendingEventForStatus(entry) {
const summary = {
id: event.id,
type: event.type,
leased: !!(entry.leaseUntil && entry.leaseUntil > Date.now()),
leased: isLeased(entry),
leaseUntil: entry.leaseUntil || null,
};
if (event.type === 'manual_edit_apply') {
@@ -224,7 +380,12 @@ function summarizeActiveSessionForClient(snapshot = {}) {
arrivedVariants: snapshot.arrivedVariants ?? 0,
visibleVariant: snapshot.visibleVariant ?? null,
checkpointRevision: snapshot.checkpointRevision ?? 0,
browserCheckpointRevision: snapshot.browserCheckpointRevision ?? snapshot.checkpointRevision ?? 0,
publicationCheckpointRevision: snapshot.publicationCheckpointRevision ?? 0,
paramValues: snapshot.paramValues || {},
generationPhase: snapshot.generationPhase ?? null,
generationCanceled: snapshot.generationCanceled === true,
cancelReason: snapshot.cancelReason ?? null,
};
}
@@ -269,24 +430,46 @@ function scheduleLeaseFlush() {
function flushPendingPolls() {
let changed = false;
while (state.pendingPolls.length > 0) {
const entry = findAvailablePendingEvent();
let pollIndex = -1;
let entry = null;
for (let index = 0; index < state.pendingPolls.length; index += 1) {
const candidate = findAvailablePendingEvent(Date.now(), state.pendingPolls[index].types);
if (!candidate) continue;
pollIndex = index;
entry = candidate;
break;
}
if (!entry) {
scheduleLeaseFlush();
broadcastAgentPollingIfChanged();
return;
}
const poll = state.pendingPolls.shift();
poll.resolve(leaseEvent(entry, poll.leaseMs));
const [poll] = state.pendingPolls.splice(pollIndex, 1);
// leaseEvent is async (it may scaffold source), but it claims the entry
// synchronously, so the next loop iteration will not re-select it. Resolve
// the poll when the lease settles rather than awaiting here, so one slow
// scaffold never delays the other parked polls. On the exceptional failure
// path, answer `timeout` so the agent re-polls; the claim stays until the
// lease expires, which keeps a deterministic failure from hot-looping.
leaseEvent(entry, poll.leaseMs).then(poll.resolve, (error) => {
console.error('[live] lease failed for ' + (entry.event?.id || 'unknown') + ': ' + (error?.message || error));
poll.resolve({ type: 'timeout' });
});
changed = true;
}
scheduleLeaseFlush();
if (changed) broadcastAgentPollingIfChanged();
}
function isLeased(entry) {
return !!(entry?.leaseUntil && entry.leaseUntil > Date.now());
}
function agentPollingConnected() {
const now = Date.now();
return state.pendingPolls.length > 0
|| state.pendingEvents.some((entry) => entry.leaseUntil && entry.leaseUntil > now);
// A leased event only proves that a poll returned once. The foreground task
// may have ended immediately afterward, so only an actively waiting poll is
// evidence that steering can wake the task right now.
return state.pendingPolls.length > 0;
}
function broadcastAgentPollingIfChanged() {
@@ -689,6 +872,15 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
res.end(JSON.stringify({ error }));
return;
}
if (msg.type === 'agent_phase') {
recordAgentPhase(msg.id, msg.phase, {
...(Number.isFinite(msg.durationMs) ? { durationMs: msg.durationMs } : {}),
owner: typeof msg.owner === 'string' ? msg.owner : undefined,
});
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true }));
return;
}
if (state.sessionStore && msg.id) {
try {
state.sessionStore.appendEvent(msg);
@@ -698,6 +890,10 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
return;
}
}
if (msg.type === 'accept' || msg.type === 'discard') {
retirePendingGeneration(msg.id);
}
recordGenerationCheckpoint(msg);
if (msg.type === 'exit') {
cleanupSvelteComponentSessionsBeforeExit();
}
@@ -738,6 +934,12 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
// Agent poll endpoints (unchanged from WS version)
// ---------------------------------------------------------------------------
function parsePollTypes(value) {
if (!value) return null;
const types = String(value).split(',').map((type) => type.trim()).filter(Boolean);
return types.length > 0 ? new Set(types) : null;
}
function handlePollGet(req, res, url) {
const token = url.searchParams.get('token');
if (token !== state.token) {
@@ -748,13 +950,25 @@ function handlePollGet(req, res, url) {
state.lastPollAt = Date.now();
const timeout = parseInt(url.searchParams.get('timeout') || DEFAULT_POLL_TIMEOUT, 10);
const leaseMs = parseInt(url.searchParams.get('leaseMs') || '30000', 10);
const available = findAvailablePendingEvent();
const types = parsePollTypes(url.searchParams.get('types'));
const available = findAvailablePendingEvent(Date.now(), types);
if (available) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(leaseEvent(available, leaseMs)));
// Do not await inline: leaseEvent may scaffold source, and this handler runs
// on the server's only thread. The client can disconnect during that window,
// so check the socket before replying.
leaseEvent(available, leaseMs).then((event) => {
if (res.writableEnded || res.destroyed) return;
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(event));
}, (error) => {
console.error('[live] lease failed for ' + (available.event?.id || 'unknown') + ': ' + (error?.message || error));
if (res.writableEnded || res.destroyed) return;
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ type: 'timeout' }));
});
return;
}
const poll = { resolve, leaseMs };
const poll = { resolve, leaseMs, types };
const timer = setTimeout(() => {
const idx = state.pendingPolls.indexOf(poll);
if (idx !== -1) state.pendingPolls.splice(idx, 1);
@@ -783,12 +997,15 @@ function sessionFileMetadataFromPollReply(file) {
if (!file || typeof file !== 'string') return { file };
const normalized = file.split(path.sep).join('/');
const base = { file: normalized };
if (!normalized.endsWith('/manifest.json') && normalized !== 'manifest.json') return base;
if (!normalized.includes('node_modules/.impeccable-live/') && !normalized.includes('src/lib/impeccable/')) return base;
const metadataFile = normalized;
if (!metadataFile.endsWith('/manifest.json') && metadataFile !== 'manifest.json') return base;
if (!metadataFile.includes('node_modules/.impeccable-live/')
&& !metadataFile.includes('src/lib/impeccable/')
&& !metadataFile.includes('/.impeccable-live/')) return base;
let full;
try {
full = path.resolve(process.cwd(), normalized);
full = path.resolve(process.cwd(), metadataFile);
const rel = path.relative(process.cwd(), full);
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return base;
} catch {
@@ -797,18 +1014,47 @@ function sessionFileMetadataFromPollReply(file) {
try {
const manifest = JSON.parse(fs.readFileSync(full, 'utf-8'));
if (manifest?.previewMode !== 'svelte-component' || !manifest.sourceFile) return base;
if (!['svelte-component', 'vue-component'].includes(manifest?.previewMode)
|| !manifest.sourceFile) return base;
return {
file: String(manifest.sourceFile).split(path.sep).join('/'),
sourceFile: String(manifest.sourceFile).split(path.sep).join('/'),
previewFile: normalized,
previewMode: 'svelte-component',
previewMode: manifest.previewMode,
};
} catch {
return base;
}
}
function inferSourceEventType(msg = {}, pendingEvents = state.pendingEvents) {
const entriesForId = pendingEvents.filter((entry) => entry.event?.id === msg.id);
const pendingTypes = new Set(entriesForId.map((entry) => entry.event?.type));
if (msg.type === 'discarded' || msg.type === 'discard') return 'discard';
if (msg.type === 'complete') {
if (pendingTypes.has('carbonize_cleanup')) return 'carbonize_cleanup';
return pendingTypes.has('accept') ? 'accept' : (pendingTypes.has('generate') ? 'generate' : undefined);
}
if (msg.type === 'steer_done') return 'steer';
// `agent_done` can be the automatic acknowledgement for a carbonize Accept.
// New pollers send sourceEventType explicitly; default to generate only for
// older callers so a late worker cannot acknowledge a queued Accept.
if (msg.type === 'agent_done' || msg.type === 'done') return 'generate';
// `error` is reference/live.md's documented failure reply, and parseReplyArgs
// never sets sourceEventType on it (the poller is a fresh process that cannot
// know what it leased). Returning undefined here makes acknowledgePendingEvent
// match *any* event for this id: a stale generate worker's failure silently
// consumed the user's queued Accept, which was then never delivered to any
// agent and left the browser in SAVING forever. Attribute the failure to the
// event this agent actually holds a lease on, and otherwise to `generate` —
// never to a wildcard. If that generate was already retired by an Accept, the
// ack simply finds no match, which is the correct outcome for a stale reply.
if (msg.type === 'error') {
return entriesForId.find(isLeased)?.event?.type || 'generate';
}
return undefined;
}
function handlePollPost(req, res) {
let body = '';
req.on('data', (c) => { body += c; });
@@ -869,7 +1115,23 @@ function handlePollPost(req, res) {
res.end(JSON.stringify({ error: 'stale_manual_edit_apply_reply', ...rollback }));
return;
}
const pendingEventBeforeAck = findPendingEventById(msg.id);
const sourceEventType = msg.sourceEventType || inferSourceEventType(msg);
if (msg.type === 'retry') {
const releasedEvent = releasePendingEvent(msg.id, sourceEventType);
if (!releasedEvent) {
res.writeHead(msg.id ? 404 : 400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
error: msg.id ? 'unknown_poll_retry_id' : 'missing_poll_retry_id',
id: msg.id,
}));
return;
}
flushPendingPolls();
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true, released: true }));
return;
}
const pendingEventBeforeAck = findPendingEventById(msg.id, sourceEventType);
if (pendingEventBeforeAck?.type === 'steer' && msg.type === 'steer_done'
&& !msg.file && !(typeof msg.message === 'string' && msg.message.trim())) {
res.writeHead(400, { 'Content-Type': 'application/json' });
@@ -879,7 +1141,7 @@ function handlePollPost(req, res) {
}));
return;
}
const acknowledgedEvent = acknowledgePendingEvent(msg.id);
const acknowledgedEvent = acknowledgePendingEvent(msg.id, sourceEventType);
let skipJournalReply = false;
let existingSession = null;
if (!acknowledgedEvent && state.sessionStore && msg.id) {
@@ -971,6 +1233,11 @@ function cleanupSvelteComponentSessionsBeforeExit() {
} catch (err) {
console.warn('[impeccable] Svelte component session cleanup failed:', err.message);
}
try {
removeAllVueComponentSessions(process.cwd());
} catch (err) {
console.warn('[impeccable] Vue component session cleanup failed:', err.message);
}
}
function applyLegacyDeferredAcceptsOnStartup() {
@@ -1083,7 +1350,10 @@ if (args.includes('--background')) {
process.exit(0);
}
} catch { /* not ready yet */ }
await new Promise(r => setTimeout(r, 200));
// The detached child is typically listening in 35-45ms. A 200ms polling
// floor dominated configured cold Live startup; poll cheaply and return
// as soon as the child has written its ready record.
await new Promise(r => setTimeout(r, 5));
}
console.error('Timed out waiting for live server to start.');
process.exit(1);
+9 -5
View File
@@ -37,15 +37,19 @@ export async function statusCli() {
pendingEvents: server.pendingEvents,
} : null,
activeSessions: server?.activeSessions || activeSessions,
recoveryHint: manualApply
? manualApplyResumeHint(manualApply)
: server
? 'Run live-poll.mjs to continue pending work, or live-complete.mjs --id <session> after manual cleanup.'
: 'Start live-server.mjs to requeue pending durable events, then run live-poll.mjs.',
recoveryHint: recoveryHint({ server, manualApply }),
};
console.log(JSON.stringify(payload, null, 2));
}
function recoveryHint({ server, manualApply }) {
if (manualApply) return manualApplyResumeHint(manualApply);
if (server) {
return 'Run live-poll.mjs to continue pending work, or live-complete.mjs --id <session> after manual cleanup.';
}
return 'Start live-server.mjs to requeue pending durable events, then run live-poll.mjs.';
}
function findPendingManualApply(server, activeSessions) {
const fromServer = server?.pendingEvents?.find((event) => event?.type === 'manual_edit_apply');
if (fromServer) return fromServer;
+64 -17
View File
@@ -20,6 +20,11 @@ import {
scaffoldSvelteComponentSession,
shouldUseSvelteComponentInjection,
} from './live/svelte-component.mjs';
import {
buildVueComponentCssAuthoring,
scaffoldVueComponentSession,
shouldUseVueComponentInjection,
} from './live/vue-component.mjs';
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
@@ -160,11 +165,29 @@ The agent should insert variant HTML at insertLine.`);
if (filtered.length === 1) {
match = filtered[0];
} else if (filtered.length === 0) {
// Source uses dynamic content (`<h1>{title}</h1>` etc.) so the
// browser-side textContent doesn't appear literally in source. Fall
// back to first-match rather than refusing — this is the same
// behavior unmodified callers see, just preserved.
match = candidates[0];
const normalizedText = String(text).replace(/\s+/g, ' ').trim();
if (normalizedText.length < 8) {
// Very short labels cannot disambiguate siblings reliably. Preserve
// the legacy behavior for these low-information picker events.
match = candidates[0];
} else {
// Rendered text that is absent from every candidate usually means
// the source uses expressions or component props. Picking the first
// same-class sibling silently edits the wrong instance (observed on
// Astro result cards), so stop and surface every candidate instead.
console.error(JSON.stringify({
error: 'element_ambiguous',
fallback: 'agent-driven',
reason: 'rendered_text_not_in_source',
file: path.relative(process.cwd(), targetFile),
candidates: candidates.map((c) => ({
startLine: c.startLine + 1,
endLine: c.endLine + 1,
})),
hint: 'Rendered text does not occur in any matching source branch. The element may use dynamic props or expressions; inspect the candidates and wrap the intended instance manually.',
}));
process.exit(1);
}
} else {
// Multiple candidates ALSO match the text. Truly ambiguous — refuse
// rather than pick wrong, and hand the agent the candidate locations
@@ -269,6 +292,8 @@ The agent should insert variant HTML at insertLine.`);
const originalIndented = reindentOriginal(' ');
const relTargetFile = path.relative(process.cwd(), targetFile).split(path.sep).join('/');
const useSvelteComponent = shouldUseSvelteComponentInjection(targetFile);
const useVueComponent = !useSvelteComponent && shouldUseVueComponentInjection(targetFile);
const useFrameworkComponent = useSvelteComponent || useVueComponent;
// Wrapper attributes differ by syntax. HTML allows plain string attrs;
// JSX requires object-literal style and parses string attrs as HTML (which
@@ -288,7 +313,7 @@ The agent should insert variant HTML at insertLine.`);
// replacement range to include the wrapper's `<div>` open / close lines
// so the entire scaffold gets removed cleanly.
const wrapperLines = isJsx ? [
indent + '<div data-impeccable-variants="' + id + '" data-impeccable-variant-count="' + count + '" ' + styleContents + '>',
indent + '<div data-impeccable-variants="' + id + '" data-impeccable-variant-count="' + count + '"' + ' ' + styleContents + '>',
indent + ' ' + commentSyntax.open + ' impeccable-variants-start ' + id + ' ' + commentSyntax.close,
indent + ' ' + commentSyntax.open + ' Original ' + commentSyntax.close,
indent + ' <div data-impeccable-variant="original">',
@@ -299,7 +324,7 @@ The agent should insert variant HTML at insertLine.`);
indent + '</div>',
] : [
indent + commentSyntax.open + ' impeccable-variants-start ' + id + ' ' + commentSyntax.close,
indent + '<div data-impeccable-variants="' + id + '" data-impeccable-variant-count="' + count + '" ' + styleContents + '>',
indent + '<div data-impeccable-variants="' + id + '" data-impeccable-variant-count="' + count + '"' + ' ' + styleContents + '>',
indent + ' ' + commentSyntax.open + ' Original ' + commentSyntax.close,
indent + ' <div data-impeccable-variant="original">',
originalIndented,
@@ -315,6 +340,7 @@ The agent should insert variant HTML at insertLine.`);
let outputEndLine = startLine + wrapperLines.length + (originalLines.length - 1);
let insertLine;
let svelteSession = null;
let vueSession = null;
if (useSvelteComponent) {
// Svelte/SvelteKit resets component-local state on markup HMR updates.
@@ -334,6 +360,23 @@ The agent should insert variant HTML at insertLine.`);
outputStartLine = 1;
outputEndLine = 1;
insertLine = 1;
} else if (useVueComponent) {
// Nuxt route-module HMR can invalidate the active page while a generated
// wrapper is only partially written. Stage real Vue SFCs in an app-local
// dev module tree and leave the route untouched until Accept.
vueSession = scaffoldVueComponentSession({
id,
count,
sourceFile: relTargetFile,
sourceStartLine: startLine + 1,
sourceEndLine: endLine + 1,
originalLines,
cwd: process.cwd(),
});
outputFile = path.resolve(process.cwd(), vueSession.manifestFile);
outputStartLine = 1;
outputEndLine = 1;
insertLine = 1;
} else {
// Replace the original element with the wrapper
const newLines = [
@@ -356,15 +399,19 @@ The agent should insert variant HTML at insertLine.`);
const outputRelFile = path.relative(process.cwd(), outputFile).split(path.sep).join('/');
const svelteComponentAuthoring = useSvelteComponent ? buildSvelteComponentCssAuthoring(count) : null;
const vueComponentAuthoring = useVueComponent ? buildVueComponentCssAuthoring(count) : null;
const componentSession = svelteSession || vueSession;
const componentPreviewMode = useSvelteComponent ? 'svelte-component' : useVueComponent ? 'vue-component' : undefined;
const previewMode = componentPreviewMode;
console.log(JSON.stringify({
file: outputRelFile,
sourceFile: useSvelteComponent ? relTargetFile : undefined,
previewMode: useSvelteComponent ? 'svelte-component' : undefined,
componentDir: svelteSession?.componentDir,
propContract: svelteSession?.propContract,
sourceStartLine: useSvelteComponent ? startLine + 1 : undefined,
sourceEndLine: useSvelteComponent ? endLine + 1 : undefined,
sourceFile: useFrameworkComponent ? relTargetFile : undefined,
previewMode,
componentDir: componentSession?.componentDir,
propContract: componentSession?.propContract,
sourceStartLine: useFrameworkComponent ? startLine + 1 : undefined,
sourceEndLine: useFrameworkComponent ? endLine + 1 : undefined,
startLine: outputStartLine, // 1-indexed for the agent
// wrapperLines is an array but one element (the original-content slot)
// is a `\n`-joined multi-line string, so the actual file-row count is
@@ -374,10 +421,10 @@ The agent should insert variant HTML at insertLine.`);
endLine: outputEndLine, // 1-indexed
insertLine, // 1-indexed: where variants go
commentSyntax: commentSyntax,
styleMode: useSvelteComponent ? 'svelte-component' : styleMode.mode,
styleTag: useSvelteComponent ? null : styleMode.styleTag,
cssSelectorPrefixExamples: useSvelteComponent ? [] : buildCssSelectorPrefixExamples(styleMode.mode, count),
cssAuthoring: useSvelteComponent ? svelteComponentAuthoring : buildCssAuthoring(styleMode, count),
styleMode: componentPreviewMode || styleMode.mode,
styleTag: useFrameworkComponent ? null : styleMode.styleTag,
cssSelectorPrefixExamples: useFrameworkComponent ? [] : buildCssSelectorPrefixExamples(styleMode.mode, count),
cssAuthoring: svelteComponentAuthoring || vueComponentAuthoring || buildCssAuthoring(styleMode, count),
originalLineCount: originalLines.length,
}));
}
+2 -1
View File
@@ -10,7 +10,7 @@
*
* After this, the agent's only remaining steps are:
* - Open the project's live dev/preview URL in the browser (optional, if browser automation exists)—not `serverPort`; that port is the Impeccable helper for /live.js and /poll
* - Enter the poll loop: `node live-poll.mjs`
* - Enter the harness-native poll loop: `node live-poll.mjs`
*
* Usage:
* node live.mjs # Prepare everything, print JSON, exit
@@ -40,6 +40,7 @@ Prepare everything for live variant mode in a single command:
- Starts (or reuses) the live server in the background
- Injects the browser script tag
- Reads PRODUCT.md / DESIGN.md for project context
- Prepares the harness-native foreground/background poll loop
- In monorepos, choose a child app first; --target <path> is the fallback/manual path
On success, prints a JSON blob with:
+11 -1
View File
@@ -1,9 +1,19 @@
// A preview whose variants live in component modules rather than in the user's
// source. These leave no markers in the real file, so a failed accept gives the
// agent nothing to hand-edit and must be reported as a failure rather than
// reference/live.md's manual-cleanup handoff. Previously only `svelte-component`
// was special-cased, so the same failure on a Vue preview read as success.
const PREVIEW_MODES_WITHOUT_SOURCE_MARKERS = new Set([
'svelte-component',
'vue-component',
]);
export function completionTypeForAcceptResult(eventType, acceptResult) {
if (eventType === 'discard') return acceptResult?.handled === true ? 'discarded' : 'error';
if (acceptResult?.handled === true && acceptResult?.carbonize === true) return 'agent_done';
if (acceptResult?.handled === true) return 'complete';
if (acceptResult?.mode === 'error') return 'error';
if (eventType === 'accept' && acceptResult?.previewMode === 'svelte-component') return 'error';
if (eventType === 'accept' && PREVIEW_MODES_WITHOUT_SOURCE_MARKERS.has(acceptResult?.previewMode)) return 'error';
return 'agent_done';
}
+15
View File
@@ -118,6 +118,15 @@ export function validateEvent(msg) {
return 'checkpoint: paramValues must be an object';
}
return null;
case 'agent_phase':
if (!isValidId(msg.id)) return 'agent_phase: missing or malformed id';
if (typeof msg.phase !== 'string' || !/^[a-z][a-z0-9_]{1,63}$/.test(msg.phase)) {
return 'agent_phase: missing or malformed phase';
}
if (msg.durationMs !== undefined && (!Number.isFinite(msg.durationMs) || msg.durationMs < 0)) {
return 'agent_phase: durationMs must be a non-negative number';
}
return null;
case 'exit':
return null;
case 'prefetch':
@@ -131,6 +140,12 @@ export function validateEvent(msg) {
if (msg.message.length > 4000) return 'steer: message too long';
if (msg.pageUrl !== undefined && typeof msg.pageUrl !== 'string') return 'steer: pageUrl must be string';
return null;
case 'carbonize_cleanup':
if (!isValidId(msg.id)) return 'carbonize_cleanup: missing or malformed id';
if (!isValidId(msg.sessionId)) return 'carbonize_cleanup: missing or malformed sessionId';
if (!msg.file || typeof msg.file !== 'string') return 'carbonize_cleanup: missing file';
if (!isValidVariantId(String(msg.variantId))) return 'carbonize_cleanup: missing or malformed variantId';
return null;
default:
return 'Unknown event type: ' + msg.type;
}
+102
View File
@@ -0,0 +1,102 @@
import { execFile } from 'node:child_process';
import path from 'node:path';
import { promisify } from 'node:util';
const execFileAsync = promisify(execFile);
const PREFLIGHT_TIMEOUT_MS = 15_000;
export function buildGenerationPreflight(event, scriptsDir) {
if (!event || event.type !== 'generate' || !event.id) return null;
const isInsert = event.mode === 'insert';
const target = isInsert ? insertTarget(event) : replaceTarget(event);
if (!target.elementId && !target.classes) return null;
const script = path.join(scriptsDir, isInsert ? 'live-insert.mjs' : 'live-wrap.mjs');
const args = [script, '--id', event.id, '--count', String(event.count || 3)];
if (isInsert) args.push('--position', target.position);
if (target.elementId) args.push('--element-id', target.elementId);
if (target.classes) args.push('--classes', target.classes);
if (target.tag) args.push('--tag', target.tag);
if (target.text) args.push('--text', target.text);
if (!isInsert && event.pageUrl) args.push('--page-url', event.pageUrl);
return { script, args, mode: isInsert ? 'insert' : 'replace' };
}
/**
* Scaffold the source for a generate event before handing it to an agent.
*
* Async on purpose. This spawns `live-wrap.mjs`, which walks the project's
* source tree and can take seconds (measured at ~7.6s on a large repo when the
* element is not found, with a 15s ceiling). The live server is single-threaded
* and calls this while leasing a poll, so a synchronous spawn froze the whole
* server for that entire window: Accept and Discard POSTs, SSE progress
* broadcasts, and every other poll stalled behind it.
*/
export async function runGenerationPreflight(event, {
cwd = process.cwd(),
scriptsDir,
execFileImpl = execFileAsync,
timeoutMs = PREFLIGHT_TIMEOUT_MS,
} = {}) {
const command = buildGenerationPreflight(event, scriptsDir);
if (!command) {
return { ok: false, skipped: true, reason: 'insufficient_locator' };
}
const startedAt = performance.now();
try {
const { stdout } = await execFileImpl(process.execPath, command.args, {
cwd,
encoding: 'utf-8',
timeout: timeoutMs,
});
const line = String(stdout).trim().split('\n').filter(Boolean).pop();
if (!line) throw new Error('preflight returned no scaffold metadata');
return {
ok: true,
mode: command.mode,
durationMs: performance.now() - startedAt,
scaffold: JSON.parse(line),
};
} catch (error) {
return {
ok: false,
mode: command.mode,
durationMs: performance.now() - startedAt,
error: compactError(error),
};
}
}
function replaceTarget(event) {
return normalizeTarget(event.element || {});
}
function insertTarget(event) {
return {
...normalizeTarget(event.insert?.anchor || {}),
position: event.insert?.position === 'before' ? 'before' : 'after',
};
}
function normalizeTarget(target) {
const classes = Array.isArray(target.classes)
? target.classes.join(' ')
: String(target.classes || '').trim();
const text = typeof target.textContent === 'string'
? target.textContent.trim().slice(0, 80)
: '';
return {
elementId: target.id || target.elementId || undefined,
classes: classes || undefined,
tag: target.tagName || target.tag || undefined,
text: text || undefined,
};
}
function compactError(error) {
const stderr = error?.stderr ? String(error.stderr).trim() : '';
const message = stderr.split('\n').filter(Boolean).pop() || error?.message || 'preflight failed';
return String(message).slice(0, 500);
}
+14
View File
@@ -0,0 +1,14 @@
export function eventPriority(event = {}) {
if (event.type === 'accept' || event.type === 'discard' || event.type === 'exit') return 0;
if (event.type === 'manual_edit_apply' || event.type === 'steer' || event.type === 'carbonize_cleanup') return 1;
if (event.type === 'generate') return 2;
return 3;
}
export function selectAvailablePendingEvent(entries, { now = Date.now(), types = null } = {}) {
const allowed = types instanceof Set ? types : (Array.isArray(types) ? new Set(types) : null);
return entries
.filter((entry) => !(entry.leaseUntil && entry.leaseUntil > now))
.filter((entry) => !allowed || allowed.has(entry.event?.type))
.sort((a, b) => eventPriority(a.event) - eventPriority(b.event) || a.seq - b.seq)[0] || null;
}
+103 -32
View File
@@ -1,24 +1,26 @@
import fs from 'node:fs';
import path from 'node:path';
import { getLegacyLiveSessionsDir, getLiveSessionsDir } from '../lib/impeccable-paths.mjs';
import { getLegacyLiveSessionsDir, getLiveSessionsDir, safeSessionId } from '../lib/impeccable-paths.mjs';
const COMPLETED_PHASES = new Set(['completed', 'discarded']);
const GENERATION_FENCED_PHASES = new Set([
'accept_requested',
'discard_requested',
'carbonize_required',
'completed',
'discarded',
]);
export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {}) {
const rootDir = getLiveSessionsDir(cwd);
const legacyRootDir = getLegacyLiveSessionsDir(cwd);
fs.mkdirSync(rootDir, { recursive: true });
const snapshotCache = new Map();
function loadCachedOrRebuild(id) {
const cached = snapshotCache.get(id);
if (cached) return cached;
const journalPath = getReadableJournalPath(id);
const rebuilt = rebuildSnapshotFromJournal(journalPath, id);
snapshotCache.set(id, rebuilt);
return rebuilt;
}
// No snapshot cache on purpose: appendEvent and getSnapshot both rebuild from
// the journal so sequence numbers and phase fences never come from a stale
// in-memory copy when the publisher/complete helpers append from another
// process. A cache written but never read would grow per session for the
// lifetime of the server without ever saving a rebuild.
function getReadableJournalPath(id) {
const primary = getJournalPath(rootDir, id);
if (fs.existsSync(primary)) return primary;
@@ -38,7 +40,10 @@ export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {})
if (!fs.existsSync(journalPath) && fs.existsSync(legacyJournalPath)) {
fs.copyFileSync(legacyJournalPath, journalPath);
}
const prior = loadCachedOrRebuild(normalized.id);
// Publisher/complete helpers can append from a separate process while
// the server is alive. Rebuild here so sequence numbers and phase
// fences never come from a stale in-memory cache.
const prior = rebuildSnapshotFromJournal(getReadableJournalPath(normalized.id), normalized.id);
const seq = prior.nextSeq;
const entry = {
seq,
@@ -49,7 +54,6 @@ export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {})
};
fs.appendFileSync(journalPath, JSON.stringify(entry) + '\n');
const next = applyEvent(prior.snapshot, entry, prior.diagnostics);
snapshotCache.set(normalized.id, { snapshot: next, diagnostics: next.diagnostics || [], nextSeq: seq + 1 });
writeSnapshot(snapshotPath, next);
return next;
},
@@ -58,7 +62,6 @@ export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {})
const journalPath = getReadableJournalPath(id);
const snapshotPath = getSnapshotPath(rootDir, id);
const rebuilt = rebuildSnapshotFromJournal(journalPath, id);
snapshotCache.set(id, rebuilt);
writeSnapshot(snapshotPath, rebuilt.snapshot);
if (!opts.includeCompleted && COMPLETED_PHASES.has(rebuilt.snapshot.phase)) return null;
return rebuilt.snapshot;
@@ -95,11 +98,6 @@ function getSnapshotPath(rootDir, id) {
return path.join(rootDir, safeSessionId(id) + '.snapshot.json');
}
function safeSessionId(id) {
if (!/^[A-Za-z0-9_-]{1,128}$/.test(id)) throw new Error('invalid session id: ' + id);
return id;
}
function baseSnapshot(id) {
return {
id,
@@ -116,9 +114,17 @@ function baseSnapshot(id) {
pendingEvent: null,
deliveryLease: null,
checkpointRevision: 0,
browserCheckpointRevision: 0,
publicationCheckpointRevision: 0,
activeOwner: null,
sourceMarkers: {},
fallbackMode: null,
generationPhase: null,
generationTimings: {},
variantPlan: null,
generationCanceled: false,
generationCanceledAt: null,
cancelReason: null,
annotationArtifacts: [],
diagnostics: [],
updatedAt: null,
@@ -158,6 +164,8 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
...snapshot,
paramValues: { ...(snapshot.paramValues || {}) },
sourceMarkers: { ...(snapshot.sourceMarkers || {}) },
generationTimings: { ...(snapshot.generationTimings || {}) },
variantPlan: snapshot.variantPlan || null,
annotationArtifacts: [...(snapshot.annotationArtifacts || [])],
diagnostics: [...(snapshot.diagnostics || [])],
updatedAt: entry.ts || new Date().toISOString(),
@@ -174,10 +182,42 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
next.expectedVariants = event.count ?? next.expectedVariants;
next.pendingEventSeq = entry.seq ?? next.pendingEventSeq;
next.pendingEvent = toPendingEvent(event);
next.variantPlan = null;
if (event.screenshotPath) upsertArtifact(next.annotationArtifacts, { type: 'screenshot', path: event.screenshotPath });
break;
case 'variant_plan':
if (!next.generationCanceled && !GENERATION_FENCED_PHASES.has(next.phase)) {
next.variantPlan = event.plan ?? next.variantPlan;
}
break;
case 'detector_waivers':
if (!next.generationCanceled && !GENERATION_FENCED_PHASES.has(next.phase)) {
next.detectorWaivers = [
...(next.detectorWaivers || []),
...(Array.isArray(event.waivers) ? event.waivers : []),
];
}
break;
case 'agent_phase':
next.generationPhase = event.phase ?? next.generationPhase;
if (event.phase) {
next.generationTimings[event.phase] = {
at: event.at ?? (Date.parse(entry.ts || '') || null),
durationMs: event.durationMs ?? null,
};
}
break;
case 'variants_ready':
case 'agent_done':
if ((next.generationCanceled || GENERATION_FENCED_PHASES.has(next.phase))
&& !(event.type === 'agent_done' && event.carbonize === true && next.phase === 'accept_requested')) {
next.diagnostics.push({
error: 'late_generation_event_ignored',
type: event.type,
phase: next.phase,
});
break;
}
next.phase = event.carbonize === true ? 'carbonize_required' : 'variants_ready';
next.sourceFile = event.sourceFile ?? event.file ?? next.sourceFile;
next.previewFile = event.previewFile ?? next.previewFile;
@@ -194,27 +234,45 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
}
break;
case 'checkpoint':
if (COMPLETED_PHASES.has(next.phase)) {
if (next.generationCanceled || GENERATION_FENCED_PHASES.has(next.phase)) {
next.diagnostics.push({ error: 'checkpoint_after_terminal_ignored', phase: event.phase ?? null, revision: event.revision ?? null });
break;
}
if ((event.revision ?? 0) >= (next.checkpointRevision ?? 0)) {
next.phase = event.phase ?? next.phase;
next.checkpointRevision = event.revision ?? next.checkpointRevision;
next.activeOwner = event.owner ?? next.activeOwner;
next.arrivedVariants = event.arrivedVariants ?? next.arrivedVariants;
next.visibleVariant = event.visibleVariant ?? next.visibleVariant;
next.sourceFile = event.sourceFile ?? next.sourceFile;
next.previewFile = event.previewFile ?? next.previewFile;
next.previewMode = event.previewMode ?? next.previewMode;
if (event.paramValues) next.paramValues = { ...event.paramValues };
} else {
next.diagnostics.push({ error: 'stale_checkpoint_ignored', revision: event.revision });
{
const revisionDomain = event.revisionDomain === 'publication'
|| (event.reason === 'variants_progress' && !event.owner)
? 'publication'
: 'browser';
const revisionField = revisionDomain === 'publication'
? 'publicationCheckpointRevision'
: 'browserCheckpointRevision';
const currentRevision = next[revisionField]
?? (revisionDomain === 'browser' ? next.checkpointRevision : 0)
?? 0;
if ((event.revision ?? 0) >= currentRevision) {
next.phase = event.phase ?? next.phase;
next[revisionField] = event.revision ?? currentRevision;
if (revisionDomain === 'browser') {
next.checkpointRevision = event.revision ?? next.checkpointRevision;
next.activeOwner = event.owner ?? next.activeOwner;
}
next.arrivedVariants = event.arrivedVariants ?? next.arrivedVariants;
if (revisionDomain === 'browser') next.visibleVariant = event.visibleVariant ?? next.visibleVariant;
next.sourceFile = event.sourceFile ?? next.sourceFile;
next.previewFile = event.previewFile ?? next.previewFile;
next.previewMode = event.previewMode ?? next.previewMode;
if (revisionDomain === 'browser' && event.paramValues) next.paramValues = { ...event.paramValues };
} else {
next.diagnostics.push({ error: 'stale_checkpoint_ignored', revision: event.revision, revisionDomain });
}
}
break;
case 'accept':
case 'accept_intent':
next.phase = 'accept_requested';
next.generationCanceled = true;
next.generationCanceledAt = event.at ?? (Date.parse(entry.ts || '') || Date.now());
next.cancelReason = 'accept';
next.visibleVariant = Number(event.variantId ?? next.visibleVariant);
if (event.paramValues) next.paramValues = { ...event.paramValues };
next.pendingEventSeq = entry.seq ?? next.pendingEventSeq;
@@ -232,6 +290,12 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
next.pendingEventSeq = entry.seq ?? next.pendingEventSeq;
next.pendingEvent = toPendingEvent(event);
break;
case 'carbonize_cleanup':
next.phase = 'carbonize_cleanup_requested';
next.sourceFile = event.file ?? next.sourceFile;
next.pendingEventSeq = entry.seq ?? next.pendingEventSeq;
next.pendingEvent = toPendingEvent(event);
break;
case 'steer_done':
next.phase = 'steer_done';
next.sourceFile = event.sourceFile ?? event.file ?? next.sourceFile;
@@ -243,6 +307,9 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
break;
case 'discard':
next.phase = 'discard_requested';
next.generationCanceled = true;
next.generationCanceledAt = event.at ?? (Date.parse(entry.ts || '') || Date.now());
next.cancelReason = 'discard';
next.pendingEventSeq = entry.seq ?? next.pendingEventSeq;
next.pendingEvent = toPendingEvent(event);
break;
@@ -260,6 +327,10 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
next.pendingEvent = null;
break;
case 'agent_error':
if (next.generationCanceled && event.sourceEventType === 'generate') {
next.diagnostics.push({ error: 'late_generation_event_ignored', type: event.type, phase: next.phase });
break;
}
next.phase = 'agent_error';
next.pendingEventSeq = null;
next.pendingEvent = null;
+105
View File
@@ -0,0 +1,105 @@
import fs from 'node:fs';
import path from 'node:path';
import { createHash, randomUUID } from 'node:crypto';
import { getLiveDir, isLiveServerPidReachable } from '../lib/impeccable-paths.mjs';
// Only used to retire a lock whose contents we cannot read (empty or truncated
// by a crash mid-write). A readable lock's fate is decided by its owner's
// liveness instead, so a slow critical section is never swept.
const UNREADABLE_LOCK_STALE_MS = 60_000;
export function sourceLockPath(file, cwd = process.cwd()) {
const digest = createHash('sha256').update(path.resolve(cwd, file)).digest('hex').slice(0, 24);
return path.join(getLiveDir(cwd), 'locks', digest + '.lock');
}
export function withSourceLockSync(file, owner, fn, {
cwd = process.cwd(),
waitMs = 0,
retryMs = 5,
} = {}) {
const lockPath = sourceLockPath(file, cwd);
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
const deadline = Date.now() + Math.max(0, Number(waitMs) || 0);
// Identifies this acquisition specifically, so release can tell our own lock
// from a replacement that some other writer created.
const token = randomUUID();
let acquired = false;
while (!acquired) {
clearStaleLock(lockPath);
let fd;
try {
fd = fs.openSync(lockPath, 'wx');
fs.writeFileSync(fd, JSON.stringify({
owner,
token,
pid: process.pid,
at: Date.now(),
file: path.resolve(cwd, file),
}) + '\n');
acquired = true;
} catch (error) {
if (error?.code !== 'EEXIST') throw error;
if (Date.now() >= deadline) {
const locked = new Error('source_locked');
locked.code = 'SOURCE_LOCKED';
locked.lockPath = lockPath;
throw locked;
}
sleepSync(Math.max(1, Math.min(Number(retryMs) || 5, deadline - Date.now())));
} finally {
try { if (fd !== undefined) fs.closeSync(fd); } catch {}
}
}
try {
return fn();
} finally {
releaseOwnLock(lockPath, token);
}
}
function sleepSync(ms) {
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
}
function readLock(lockPath) {
try { return JSON.parse(fs.readFileSync(lockPath, 'utf-8')); } catch { return null; }
}
/**
* Remove the lock only if it is still the one this call created. If a sweeper
* judged our lock stale and another writer replaced it, unlinking here would
* end *their* critical section and admit a third writer to the same file.
*/
function releaseOwnLock(lockPath, token) {
const held = readLock(lockPath);
if (held && held.token !== token) return;
try { fs.unlinkSync(lockPath); } catch {}
}
/**
* A lock is stale when its owner is gone, not when it is old.
*
* Age alone cuts both ways: it sweeps a live holder whose critical section
* outran the timeout (a suspended laptop, a stopped process), letting two
* writers into the same source file, while still making every accept on a
* crashed holder's file wait out the full timeout. Asking the OS whether the
* recorded pid is alive answers both correctly: a dead owner releases at once,
* and a live owner keeps its lock however long it needs.
*/
function clearStaleLock(lockPath) {
const held = readLock(lockPath);
if (!held) {
// Unreadable: either a crash truncated it, or we caught the brief window
// between create and write in a live acquisition. mtime distinguishes them.
try {
const stat = fs.statSync(lockPath);
if (Date.now() - stat.mtimeMs > UNREADABLE_LOCK_STALE_MS) fs.unlinkSync(lockPath);
} catch { /* gone already */ }
return;
}
if (typeof held.pid === 'number' && isLiveServerPidReachable(held.pid)) return;
try { fs.unlinkSync(lockPath); } catch {}
}
+374
View File
@@ -0,0 +1,374 @@
/**
* Nuxt/Vue live-mode component previews.
*
* Generation writes real Vue SFCs into a generated app-local module tree.
* Nuxt/Vite compiles those modules without touching the active route; Accept
* is the only operation that writes the user's .vue source.
*/
import fs from 'node:fs';
import path from 'node:path';
import { safeSessionId } from '../lib/impeccable-paths.mjs';
const NUXT_CONFIG_RE = /^nuxt\.config\.(?:js|mjs|cjs|ts|mts|cts)$/;
export function detectNuxtVueProject(cwd = process.cwd()) {
const configFile = fs.readdirSync(cwd, { withFileTypes: true })
.find((entry) => entry.isFile() && NUXT_CONFIG_RE.test(entry.name))?.name;
if (!configFile) return null;
const config = fs.readFileSync(path.join(cwd, configFile), 'utf-8');
const srcDirMatch = config.match(/\bsrcDir\s*:\s*(['"])([^'"]+)\1/);
let appDir = fs.existsSync(path.join(cwd, 'app')) ? 'app' : '';
if (srcDirMatch) {
const candidate = path.posix.normalize(srcDirMatch[2].replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, ''));
if (candidate !== '..' && !candidate.startsWith('../') && !path.isAbsolute(candidate)) {
appDir = candidate === '.' ? '' : candidate;
}
}
const componentRoot = [appDir, '.impeccable-live'].filter(Boolean).join('/');
return { configFile, appDir, componentRoot };
}
export function shouldUseVueComponentInjection(filePath, cwd = process.cwd()) {
if (/^(0|false|no)$/i.test(process.env.IMPECCABLE_LIVE_VUE_COMPONENT || '')) return false;
return path.extname(filePath).toLowerCase() === '.vue' && !!detectNuxtVueProject(cwd);
}
export function vueComponentSessionDir(id, cwd = process.cwd()) {
const project = detectNuxtVueProject(cwd);
if (!project) throw new Error('Nuxt project not found');
return path.join(cwd, project.componentRoot, safeSessionId(id));
}
export function vueManifestPathForSession(id, cwd = process.cwd()) {
return path.join(vueComponentSessionDir(id, cwd), 'manifest.json');
}
function ensureVueRuntime(cwd = process.cwd()) {
const project = detectNuxtVueProject(cwd);
if (!project) throw new Error('Nuxt project not found');
const rel = `${project.componentRoot}/__runtime.js`;
const file = path.join(cwd, rel);
fs.mkdirSync(path.dirname(file), { recursive: true });
const source = `import { createApp } from 'vue';\n\nexport function mount(Component, options = {}) {\n const app = createApp(Component, options.props || {});\n app.mount(options.target);\n return app;\n}\n\nexport async function unmount(app) {\n app?.unmount?.();\n}\n`;
if (!fs.existsSync(file) || fs.readFileSync(file, 'utf-8') !== source) fs.writeFileSync(file, source, 'utf-8');
return nuxtViteFsModulePath(file, cwd);
}
/**
* Nuxt mounts Vite beneath its build-assets base (normally `/_nuxt/`).
* Keep the manifest path base-agnostic and let the browser prepend the
* runtime's actual buildAssetsDir. A page-route URL such as
* `/app/.impeccable-live/x.vue` is handled by Nitro and returns HTML.
*/
export function nuxtViteFsModulePath(file, cwd = process.cwd()) {
const absolute = path.resolve(cwd, file).split(path.sep).join('/');
const relative = path.relative(cwd, absolute);
if (relative.startsWith('..') || path.isAbsolute(relative)) {
throw new Error('Nuxt live module must stay inside the project root');
}
return '/@fs/' + absolute.replace(/^\/+/, '');
}
export function extractVueExpressions(markup) {
const out = [];
const seen = new Set();
const re = /\{\{\s*([^{}]+?)\s*\}\}/g;
let match;
while ((match = re.exec(String(markup || '')))) {
const expr = match[1].trim();
if (!expr || seen.has(expr)) continue;
seen.add(expr);
out.push({ expr, token: match[0] });
}
return out;
}
function buildVuePropContract(expressions) {
return expressions.map(({ expr, token }, index) => ({
prop: derivePropName(expr, index),
expr,
placeholder: token,
// DOMParser sees Vue interpolation `{{ user.name }}` as text containing
// the inner `{ user.name }` token; preserve its whitespace for the
// browser's source-text → rendered-text map.
previewToken: token.slice(1, -1),
}));
}
function derivePropName(expr, index) {
const tail = expr.match(/(?:^|\.|\[)([A-Za-z_$][\w$]*)\s*\]?$/);
return tail?.[1] || `prop${index}`;
}
function substituteVueExpressions(markup, contract) {
let out = String(markup || '');
for (const entry of contract) out = out.split(entry.placeholder).join(`{{ ${entry.prop} }}`);
return out;
}
function buildVueVariantStub(variant, markup, contract) {
const props = contract.length > 0
? `<script setup>\ndefineProps({\n${contract.map((entry) => ` ${entry.prop}: { default: '' },`).join('\n')}\n});\n</script>\n\n`
: '';
return `${props}<template>\n${markup.trim()}\n</template>\n\n<style scoped>\n/* Variant ${variant}: add scoped CSS here */\n</style>\n`;
}
export function scaffoldVueComponentSession({
id,
count,
sourceFile,
sourceStartLine,
sourceEndLine,
originalLines,
cwd = process.cwd(),
}) {
const runtimeModule = ensureVueRuntime(cwd);
const dir = vueComponentSessionDir(id, cwd);
fs.mkdirSync(dir, { recursive: true });
const originalMarkup = originalLines.join('\n');
const propContract = buildVuePropContract(extractVueExpressions(originalMarkup));
const previewMarkup = substituteVueExpressions(originalMarkup, propContract);
const manifest = {
id,
previewMode: 'vue-component',
framework: 'vue',
componentExtension: 'vue',
sourceFile: sourceFile.split(path.sep).join('/'),
sourceStartLine,
sourceEndLine,
count,
propContract,
originalMarkup,
componentDir: path.relative(cwd, dir).split(path.sep).join('/'),
componentModuleBase: nuxtViteFsModulePath(dir, cwd),
runtimeModule,
};
fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf-8');
for (let variant = 1; variant <= count; variant++) {
const file = path.join(dir, `v${variant}.vue`);
if (!fs.existsSync(file)) fs.writeFileSync(file, buildVueVariantStub(variant, previewMarkup, propContract), 'utf-8');
}
return {
manifest,
manifestFile: path.relative(cwd, path.join(dir, 'manifest.json')).split(path.sep).join('/'),
componentDir: manifest.componentDir,
propContract,
};
}
export function findVueComponentManifest(id, cwd = process.cwd()) {
let direct;
try { direct = vueManifestPathForSession(id, cwd); } catch { return null; }
if (!fs.existsSync(direct)) return null;
try {
const manifest = JSON.parse(fs.readFileSync(direct, 'utf-8'));
return manifest?.id === id && manifest?.previewMode === 'vue-component'
? { ...manifest, manifestPath: direct }
: null;
} catch {
return null;
}
}
function parseVueSfc(source) {
const text = String(source || '');
const template = text.match(/<template\b[^>]*>([\s\S]*?)<\/template\s*>/i)?.[1]?.trim() || '';
const style = text.match(/<style\b[^>]*>([\s\S]*?)<\/style\s*>/i)?.[1]?.trim() || '';
return { template, cssLines: style ? style.split('\n').map((line) => line.trimEnd()) : [] };
}
function restoreVueExpressions(markup, contract) {
let out = String(markup || '');
for (const entry of contract || []) {
out = out.replace(new RegExp(`\\{\\{\\s*${escapeRegExp(entry.prop)}\\s*\\}\\}`, 'g'), entry.placeholder);
}
return out;
}
export function inlineVueComponentAccept(manifest, variantNum, cwd = process.cwd()) {
const sourcePath = resolveInside(cwd, manifest.sourceFile);
const componentDir = resolveInside(cwd, manifest.componentDir);
const variantPath = componentDir && path.join(componentDir, `v${variantNum}.vue`);
const resultBase = {
file: manifest.sourceFile,
sourceFile: manifest.sourceFile,
previewMode: 'vue-component',
componentDir: manifest.componentDir,
carbonize: false,
};
if (!sourcePath || !componentDir || !variantPath || !fs.existsSync(sourcePath) || !fs.existsSync(variantPath)) {
return { handled: false, error: `Variant ${variantNum} not found`, ...resultBase };
}
const { template, cssLines } = parseVueSfc(fs.readFileSync(variantPath, 'utf-8'));
if (!template) return { handled: false, error: 'Accepted Vue variant has no template', ...resultBase };
if (/\bdata-impeccable-[\w-]*\s*=/.test(template)) {
return { handled: false, error: 'Accepted Vue variant contains preview-only attributes', ...resultBase };
}
const sourceLines = fs.readFileSync(sourcePath, 'utf-8').split('\n');
const start = Number(manifest.sourceStartLine) - 1;
const end = Number(manifest.sourceEndLine) - 1;
if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end < start || end >= sourceLines.length) {
return { handled: false, error: 'Invalid source line range for ' + manifest.sourceFile, ...resultBase };
}
const indent = sourceLines[start].match(/^(\s*)/)?.[1] || '';
const mergedTemplate = mergeOriginalVueAttrs(template, manifest.originalMarkup || '');
const markupLines = restoreVueExpressions(mergedTemplate, manifest.propContract)
.split('\n')
.map((line) => line.trim() ? indent + line.trimStart() : '');
let next = [...sourceLines.slice(0, start), ...markupLines, ...sourceLines.slice(end + 1)];
const meaningfulCss = cssLines.filter((line) => line.trim() && !/^\/\*\s*Variant \d+:/.test(line.trim()));
if (meaningfulCss.length > 0) next = appendVueStyle(next, meaningfulCss);
fs.writeFileSync(sourcePath, next.join('\n'), 'utf-8');
retireVueComponentSession(manifest.id, cwd);
return { handled: true, ...resultBase };
}
function appendVueStyle(lines, cssLines) {
let close = -1;
for (let index = lines.length - 1; index >= 0; index--) {
if (/<\/style\s*>/.test(lines[index])) { close = index; break; }
}
const block = ['', ...cssLines.map((line) => line.trim() ? ' ' + line.trimStart() : '')];
if (close < 0) return [...lines, '', '<style scoped>', ...block.slice(1), '</style>'];
return [...lines.slice(0, close), ...block, ...lines.slice(close)];
}
function mergeOriginalVueAttrs(markup, originalMarkup) {
const variant = matchOpeningTag(markup);
const original = matchOpeningTag(originalMarkup);
if (!variant || !original || variant.tag.toLowerCase() !== original.tag.toLowerCase()) return markup;
const variantAttrs = parseStaticAttrs(variant.attrs);
const originalAttrs = parseStaticAttrs(original.attrs);
const additions = [];
let attrs = variant.attrs;
const originalClass = originalAttrs.get('class');
const variantClass = variantAttrs.get('class');
if (originalClass && variantClass) {
const classes = [
...variantClass.value.split(/\s+/),
...originalClass.value.split(/\s+/),
].filter(Boolean);
const replacement = `class=${variantClass.quote}${[...new Set(classes)].join(' ')}${variantClass.quote}`;
attrs = attrs.slice(0, variantClass.start) + replacement + attrs.slice(variantClass.end);
} else if (originalClass) {
additions.push(originalClass.raw);
}
for (const [name, attr] of originalAttrs) {
if (name === 'class' || variantAttrs.has(name)) continue;
additions.push(attr.raw);
}
const open = `<${variant.tag}${attrs}${additions.map((attr) => ' ' + attr.trim()).join('')}${variant.close}`;
return markup.slice(0, variant.index) + open + markup.slice(variant.index + variant.raw.length);
}
function matchOpeningTag(markup) {
const match = String(markup || '').match(/<([A-Za-z][\w:-]*)([^>]*?)(\/?>)/);
return match ? {
raw: match[0],
tag: match[1],
attrs: match[2] || '',
close: match[3],
index: match.index || 0,
} : null;
}
/**
* Tokenize the attributes of a Vue opening tag.
*
* The name pattern is deliberately permissive so directive shorthands survive
* a round trip: `@click.prevent`, `:aria-label`, `:[dynamicKey]`, `#default`,
* and `v-cloak` are all one attribute each. A name-anchored pattern such as
* `[A-Za-z_:][\w:.-]*` skips the `@`/`#` sigil and re-matches from the bare
* name, which turns `@click="submit"` into a literal `click="submit"` DOM
* attribute on Accept. Values are optional so valueless attributes
* (`disabled`, `v-cloak`) are recorded rather than dropped.
*/
function parseStaticAttrs(attrs) {
const out = new Map();
const re = /([^\s"'=<>/]+)(?:\s*=\s*(?:(["'])([\s\S]*?)\2|([^\s"'=<>`]+)))?/g;
let match;
while ((match = re.exec(attrs))) {
const quoted = match[2] !== undefined;
const valueless = !quoted && match[4] === undefined;
out.set(normalizeVueAttrName(match[1]), {
raw: match[0],
value: valueless ? '' : (quoted ? match[3] : match[4]),
quote: quoted ? match[2] : '"',
valueless,
start: match.index,
end: match.index + match[0].length,
});
}
return out;
}
/**
* Collapse Vue's directive shorthands to their canonical form for identity
* comparison only (the raw text is what gets written back). Without this, an
* original `:aria-label` and a variant `v-bind:aria-label` read as two
* different attributes and Accept emits both, which is a Vue compile error.
*/
function normalizeVueAttrName(name) {
const raw = String(name);
if (raw.startsWith('@')) return `v-on:${raw.slice(1)}`;
if (raw.startsWith(':')) return `v-bind:${raw.slice(1)}`;
if (raw.startsWith('#')) return `v-slot:${raw.slice(1)}`;
if (raw.startsWith('.')) return `v-bind:${raw.slice(1)}.prop`;
return raw;
}
export function removeVueComponentSession(id, cwd = process.cwd()) {
try { fs.rmSync(vueComponentSessionDir(id, cwd), { recursive: true, force: true }); } catch { /* best effort */ }
}
/**
* Make an accepted/discarded session undiscoverable immediately while keeping
* Vue modules that Vite has in its graph alive until Live shuts down. Deleting
* an imported SFC mid-session makes Nuxt's HMR client attempt to reload a
* missing module and emit a console error. The generated directory remains
* ignored and removeAllVueComponentSessions removes it on server shutdown.
*/
export function retireVueComponentSession(id, cwd = process.cwd()) {
let dir;
try { dir = vueComponentSessionDir(id, cwd); } catch { return; }
for (const name of ['manifest.json', 'params.json']) {
try { fs.rmSync(path.join(dir, name), { force: true }); } catch { /* best effort */ }
}
}
export function removeAllVueComponentSessions(cwd = process.cwd()) {
const project = detectNuxtVueProject(cwd);
if (!project) return;
const root = path.join(cwd, project.componentRoot);
if (!fs.existsSync(root)) return;
fs.rmSync(root, { recursive: true, force: true });
}
export function buildVueComponentCssAuthoring(count) {
return {
mode: 'vue-component',
count,
requirements: [
'Write each variant as a real Vue SFC in componentDir/vN.vue.',
'Keep one root element inside <template> and put variant CSS in <style scoped>.',
'Keep propContract bindings as {{ propName }} instead of snapshot text.',
'Do not add data-impeccable-* attributes.',
],
forbidden: ['Rewriting sourceFile during preview', 'data-impeccable-* attributes', 'Off-brand replacement content'],
};
}
function resolveInside(cwd, value) {
if (!value || path.isAbsolute(value)) return null;
const full = path.resolve(cwd, value);
const rel = path.relative(cwd, full);
return !rel || rel.startsWith('..') || path.isAbsolute(rel) ? null : full;
}
function escapeRegExp(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
+127
View File
@@ -0,0 +1,127 @@
/**
* Tests for scripts/lib/cli-args.mjs the shared argv parser for the Live
* benchmark / judging scripts.
* Run with: node --test tests/cli-args.test.mjs
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { boolFlag, parseArgs, positiveIntFlag, resolveEnum, toCamel } from '../scripts/lib/cli-args.mjs';
describe('parseArgs', () => {
it('reads space-separated values', () => {
// The regression: without the argv[i+1] lookahead this yielded
// {fixture: true, iterations: true}, silently benchmarking the defaults.
assert.deepEqual(
parseArgs(['--fixture', 'vite8-react-modal', '--iterations', '20']),
{ fixture: 'vite8-react-modal', iterations: '20' },
);
});
it('reads --flag=value values', () => {
assert.deepEqual(parseArgs(['--fixture=vite8-react-plain']), { fixture: 'vite8-react-plain' });
});
it('treats a flag followed by another flag as boolean', () => {
assert.deepEqual(parseArgs(['--headed', '--quiet']), { headed: true, quiet: true });
});
it('treats a trailing flag as boolean', () => {
assert.deepEqual(parseArgs(['--append']), { append: true });
});
it('camel-cases kebab keys so both spellings land on one key', () => {
assert.deepEqual(parseArgs(['--simulated-tail-ms=250']), { simulatedTailMs: '250' });
assert.deepEqual(parseArgs(['--simulatedTailMs=250']), { simulatedTailMs: '250' });
assert.deepEqual(parseArgs(['--median-target', '0.4']), { medianTarget: '0.4' });
});
it('keeps a value that contains an equals sign intact', () => {
assert.deepEqual(parseArgs(['--model=claude-sonnet-4-6=x']), { model: 'claude-sonnet-4-6=x' });
});
it('ignores positional args and a bare --', () => {
assert.deepEqual(parseArgs(['positional', '--', '--real', 'v']), { real: 'v' });
});
it('lets a later occurrence win', () => {
assert.deepEqual(parseArgs(['--agent', 'fake', '--agent', 'llm']), { agent: 'llm' });
});
});
describe('toCamel', () => {
it('upcases after hyphens only', () => {
assert.equal(toCamel('simulated-tail-ms'), 'simulatedTailMs');
assert.equal(toCamel('p95-target'), 'p95Target');
assert.equal(toCamel('already'), 'already');
});
});
describe('boolFlag', () => {
it('accepts the bare-flag sentinel and the explicit spellings alike', () => {
// --headed and --headed=true must not diverge.
assert.equal(boolFlag(true), true);
assert.equal(boolFlag('true'), true);
assert.equal(boolFlag('1'), true);
assert.equal(boolFlag('yes'), true);
assert.equal(boolFlag(''), true);
});
it('recognizes negative spellings', () => {
assert.equal(boolFlag('false'), false);
assert.equal(boolFlag('0'), false);
assert.equal(boolFlag('no'), false);
});
it('falls back when absent or unrecognized', () => {
assert.equal(boolFlag(undefined), false);
assert.equal(boolFlag(undefined, true), true);
assert.equal(boolFlag('maybe', true), true);
});
});
describe('positiveIntFlag', () => {
it('parses positive integers', () => {
assert.equal(positiveIntFlag('20', 5), 20);
});
it('falls back when absent or given as a bare flag', () => {
assert.equal(positiveIntFlag(undefined, 5), 5);
assert.equal(positiveIntFlag(true, 5), 5);
});
it('throws rather than silently using the default', () => {
// Quietly benchmarking 5 iterations when 20 were asked for is the failure
// this replaces.
for (const bad of ['abc', '0', '-3', '2.5', '20x']) {
assert.throws(() => positiveIntFlag(bad, 5), /positive integer/, `accepted ${bad}`);
}
});
});
describe('resolveEnum', () => {
it('accepts an allowed value, case-insensitively', () => {
assert.equal(resolveEnum('llm', ['fake', 'llm'], 'fake', '--agent'), 'llm');
assert.equal(resolveEnum('LLM', ['fake', 'llm'], 'fake', '--agent'), 'llm');
});
it('falls back when absent or given as a bare flag', () => {
assert.equal(resolveEnum(undefined, ['fake', 'llm'], 'fake', '--agent'), 'fake');
assert.equal(resolveEnum(true, ['fake', 'llm'], 'fake', '--agent'), 'fake');
});
it('throws on an unrecognized value instead of silently using the default', () => {
// The private evals Live runner passes --agent=codex. Falling back to the
// canned fake agent produced a clean report of a deterministic stub labelled
// as a real harness run.
assert.throws(
() => resolveEnum('codex', ['fake', 'llm'], 'fake', '--agent'),
/--agent must be one of fake, llm; got: codex/,
);
assert.throws(
() => resolveEnum('progresive', ['atomic', 'progressive'], 'atomic', '--delivery'),
/--delivery must be one of atomic, progressive/,
);
});
});
+20
View File
@@ -119,6 +119,9 @@ for (const name of listFixtures()) {
'.impeccable/live/server.json',
'.impeccable/live/sessions/example.jsonl',
'.impeccable/live/previews/example/v1.html',
'.impeccable/live/artifacts/example-r1.jsx',
'.impeccable/live/accept-receipts/example.json',
'.impeccable/live/locks/example.lock',
'.impeccable/live/deferred-svelte-component-accepts.json',
'src/lib/impeccable/ImpeccableLiveRoot.svelte',
'src/lib/impeccable/__runtime.js',
@@ -127,6 +130,9 @@ for (const name of listFixtures()) {
assert.match(ignored, /\.impeccable\/live\/server\.json/);
assert.match(ignored, /\.impeccable\/live\/sessions\/example\.jsonl/);
assert.match(ignored, /\.impeccable\/live\/previews\/example\/v1\.html/);
assert.match(ignored, /\.impeccable\/live\/artifacts\/example-r1\.jsx/);
assert.match(ignored, /\.impeccable\/live\/accept-receipts\/example\.json/);
assert.match(ignored, /\.impeccable\/live\/locks\/example\.lock/);
assert.match(ignored, /\.impeccable\/live\/deferred-svelte-component-accepts\.json/);
assert.match(ignored, /src\/lib\/impeccable\/ImpeccableLiveRoot\.svelte/);
assert.match(ignored, /src\/lib\/impeccable\/__runtime\.js/);
@@ -142,6 +148,15 @@ for (const name of listFixtures()) {
assert.match(root, /localhost:9999\/live\.js/, 'SvelteKit root component loads live.js');
return;
}
if (result.adapter === 'nuxt') {
const plugin = result.results[0];
const body = readFileSync(join(tmp, plugin.file), 'utf-8');
assert.equal(plugin.inserted, true, 'Nuxt client plugin was created');
assert.match(body, /impeccable-live-nuxt-plugin/);
assert.match(body, /if \(!import\.meta\.dev/);
assert.match(body, /localhost:9999\/live\.js/);
return;
}
for (const r of result.results) {
assert.ok(r.inserted, `${r.file} got the tag (result: ${JSON.stringify(r)})`);
const body = readFileSync(join(tmp, r.file), 'utf-8');
@@ -169,6 +184,11 @@ for (const name of listFixtures()) {
assert.equal(existsSync(join(tmp, 'src/lib/impeccable/ImpeccableLiveRoot.svelte')), false);
return;
}
if (result.adapter === 'nuxt') {
assert.equal(result.results[0].removed, true);
assert.equal(existsSync(join(tmp, result.results[0].file)), false, 'Nuxt client plugin was removed');
return;
}
for (const r of result.results) {
const body = readFileSync(join(tmp, r.file), 'utf-8');
assert.doesNotMatch(body, /impeccable-live-start/);
+1
View File
@@ -112,6 +112,7 @@ When `preActions` is omitted, steer smoke inherits `runtime.preActions` to revea
| `nextjs-app/` | `app/layout.tsx` as JSX inject target (commentSyntax `jsx`). |
| `astro/` | `src/layouts/Layout.astro` as inject target. HTML comments. |
| `sveltekit/` | `src/app.html` shell + `src/routes/+page.svelte`. |
| `nuxt-vite7/` | Nuxt 4 `app/` structure + Vue 3 SFC. Live loads through a generated dev-only client plugin. |
| `multipage-with-generator/` | `src/` tracked, `dist/` gitignored. Exercises the is-generated guard and `element_not_in_source` fallback. |
| `nextjs-turborepo/` | Monorepo with shared CSP helper (`createBaseNextConfig`). CSP shape `append-arrays`. |
| `nextjs-inline-csp/` | App-level `next.config.js` with a literal CSP string. CSP shape `append-string`. |
@@ -1,10 +0,0 @@
<template>
<html lang="en">
<head>
<title>Nuxt + Vite 7 Fixture</title>
</head>
<body>
<NuxtPage />
</body>
</html>
</template>
@@ -0,0 +1,3 @@
<template>
<NuxtPage />
</template>
@@ -1,4 +1,5 @@
export default defineNuxtConfig({
compatibilityDate: '2025-07-15',
devtools: { enabled: false },
ssr: false,
});
@@ -1,18 +1,38 @@
{
"name": "Nuxt 4 + Vue 3 (static fixture only — runtime inject unsupported)",
"name": "Nuxt 4 + Vue 3",
"config": {
"files": ["app.vue"],
"insertBefore": "</body>",
"files": ["app/app.vue"],
"insertBefore": "</template>",
"commentSyntax": "html"
},
"sourceFiles": ["app.vue", "pages/index.vue", "nuxt.config.ts"],
"sourceFiles": ["app/app.vue", "app/pages/index.vue", "nuxt.config.ts"],
"generatedFiles": [],
"wrapCases": [
{
"name": "wraps hero in pages/index.vue",
"args": { "classes": "hero-title", "tag": "h1" },
"expectedFile": "pages/index.vue"
"expectedFile": "app/.impeccable-live/wraptest0/manifest.json",
"expectedSourceFile": "app/pages/index.vue",
"expectedPreviewMode": "vue-component"
}
],
"_runtimeOmitted": "Nuxt's app.vue is a Vue template that compiles to a render function — a <script> tag inserted there renders as a DOM node but does not execute. Nuxt needs a config-based inject (nuxt.config.ts -> app.head.script), which live-inject.mjs does not currently support. Static checks (is-generated, inject syntax, wrap routing) still validate."
"runtime": {
"styling": "vue-scoped-css",
"install": ["npm", "install", "--no-audit", "--no-fund"],
"devCommand": ["npm", "run", "dev"],
"scheme": "http",
"ignoreHTTPSErrors": false,
"readyPattern": "Local:\\s+http://[^:]+:(\\d+)",
"readyTimeoutMs": 120000,
"pickSelector": "h1.hero-title",
"steer": {
"message": "steer-e2e mark hero",
"sourceFile": "app/pages/index.vue",
"expectSelector": "h1.hero-title[data-impeccable-steer=\"e2e\"]"
},
"probe": {
"expectLiveInit": true,
"expectConsoleClean": true
}
}
}
@@ -6,3 +6,10 @@
<article class="feature-card">Two</article>
</section>
</main>
<style>
.feature-card {
min-height: 64px;
padding: 12px;
}
</style>
+189 -2
View File
@@ -5,11 +5,12 @@
import { describe, it, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
import { existsSync, mkdirSync, mkdtempSync, realpathSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { tmpdir } from 'node:os';
import { fileURLToPath } from 'node:url';
import { execFileSync } from 'node:child_process';
import { execFileSync, spawnSync } from 'node:child_process';
import { sourceLockPath } from '../skill/scripts/live/source-lock.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ACCEPT = resolve(__dirname, '..', 'skill/scripts/live-accept.mjs');
@@ -29,6 +30,165 @@ function runAccept(cwd, args) {
}
}
// The failure that broke the first real Claude Code Live run. Progressive
// publication stages `.impeccable/live/artifacts/<id>-r<n>.<source-ext>`, which
// carries the session marker. findSessionFile walks `src`, `app`, `pages`, ... and
// then `.`; a project whose source is not under one of those (this repo's own site
// lives in `site/pages/`) falls through to the `.` walk, where dot-directories sort
// before letters — so the artifact was found before the real file.
describe('live-accept — marker search must ignore Impeccable state', () => {
let tmp;
beforeEach(() => { tmp = mkdtempSync(join(tmpdir(), 'impeccable-accept-decoy-')); });
afterEach(() => rmSync(tmp, { recursive: true, force: true }));
const SOURCE = [
'<main>',
'<!-- impeccable-variants-start ab12cd34 -->',
'<div data-impeccable-variant="original">ORIGINAL</div>',
'<div data-impeccable-variant="1">VARIANT ONE</div>',
'<!-- impeccable-variants-end ab12cd34 -->',
'</main>',
'',
].join('\n');
function seed({ revisions = 3 } = {}) {
mkdirSync(join(tmp, 'site', 'pages'), { recursive: true });
mkdirSync(join(tmp, '.impeccable', 'live', 'artifacts'), { recursive: true });
writeFileSync(join(tmp, 'site', 'pages', 'index.astro'), SOURCE);
for (let r = 1; r <= revisions; r += 1) {
writeFileSync(join(tmp, '.impeccable', 'live', 'artifacts', `ab12cd34-r${r}.astro`), SOURCE);
}
}
it('accepts into real source when a staged artifact carries the same marker', () => {
seed();
const result = runAccept(tmp, ['--id', 'ab12cd34', '--variant', '1']);
assert.equal(result.handled, true, JSON.stringify(result));
assert.equal(
result.file,
'site/pages/index.astro',
'accept must resolve the project file, not the .impeccable artifact decoy',
);
const source = readFileSync(join(tmp, 'site', 'pages', 'index.astro'), 'utf-8');
assert.match(source, /VARIANT ONE/);
assert.doesNotMatch(source, /impeccable-variants-start/, 'the wrapper must be gone from real source');
});
it('discards into real source with an artifact decoy present', () => {
seed({ revisions: 1 });
const result = runAccept(tmp, ['--id', 'ab12cd34', '--discard']);
assert.equal(result.handled, true, JSON.stringify(result));
assert.equal(result.file, 'site/pages/index.astro');
assert.match(readFileSync(join(tmp, 'site', 'pages', 'index.astro'), 'utf-8'), /ORIGINAL/);
});
});
describe('live-accept — session id validation', () => {
let tmp;
beforeEach(() => { tmp = mkdtempSync(join(tmpdir(), 'impeccable-accept-id-')); });
afterEach(() => rmSync(tmp, { recursive: true, force: true }));
// --id becomes a path segment for the accept receipt. Traversal here wrote
// JSON to arbitrary absolute paths (e.g. `--id ../../../../etc/evil`).
for (const id of ['../../../../etc/evil', 'a/b', '..', 'a\\b', '']) {
it(`refuses --id ${JSON.stringify(id)} without writing a receipt`, () => {
const res = spawnSync('node', [ACCEPT, '--id', id, '--discard'], {
cwd: tmp,
encoding: 'utf-8',
});
assert.equal(res.status, 1, 'must exit non-zero');
assert.match(res.stderr, /Invalid --id|Missing --id/);
assert.equal(existsSync(join(tmp, '.impeccable', 'live', 'accept-receipts')), false);
});
}
it('still accepts a well-formed id', () => {
const res = spawnSync('node', [ACCEPT, '--id', 'ab12cd34', '--discard'], {
cwd: tmp,
encoding: 'utf-8',
});
assert.doesNotMatch(res.stderr || '', /Invalid --id/);
});
// --variant is interpolated into a RegExp and into the markup written back to
// source. `.*` matched the `original` block first, so the CLI reported a
// successful accept while actually restoring the original.
for (const variant of ['.*', '[12]', 'original', '1e2', '']) {
it(`refuses --variant ${JSON.stringify(variant)} rather than matching by regex`, () => {
writeFileSync(join(tmp, 'page.html'), [
'<!-- impeccable-variants-start ab12cd34 -->',
'<div data-impeccable-variant="original">ORIGINAL CONTENT</div>',
'<div data-impeccable-variant="1">VARIANT ONE</div>',
'<!-- impeccable-variants-end ab12cd34 -->',
'',
].join('\n'));
const res = spawnSync('node', [ACCEPT, '--id', 'ab12cd34', '--variant', variant], {
cwd: tmp,
encoding: 'utf-8',
});
assert.equal(res.status, 1);
assert.match(res.stderr, /Invalid --variant|Need --discard/);
assert.match(
readFileSync(join(tmp, 'page.html'), 'utf-8'),
/impeccable-variants-start/,
'a rejected variant must leave the wrapper untouched',
);
});
}
});
// The plain wrapper is the only non-component preview path now that the isolated
// source-artifact mode is gone, so its lock-contention behaviour is what carries
// these guarantees.
describe('live-accept — plain wrapper under source-lock contention', () => {
let tmp;
beforeEach(() => { tmp = mkdtempSync(join(tmpdir(), 'impeccable-accept-lock-')); });
afterEach(() => rmSync(tmp, { recursive: true, force: true }));
const PAGE = [
'<!-- impeccable-variants-start ab12cd34 -->',
'<div data-impeccable-variant="original">ORIGINAL</div>',
'<div data-impeccable-variant="1">VARIANT ONE</div>',
'<!-- impeccable-variants-end ab12cd34 -->',
'',
].join('\n');
function holdLock() {
// realpath: mkdtemp hands back /var/... on macOS while the child's cwd
// resolves to /private/var/..., and the lock digest hashes the absolute path.
const realTmp = realpathSync(tmp);
const lockPath = sourceLockPath(join(realTmp, 'page.html'), realTmp);
mkdirSync(dirname(lockPath), { recursive: true });
// process.pid is alive, so the lock is a live holder rather than stale.
writeFileSync(lockPath, JSON.stringify({
owner: 'generation:ab12cd34:1', token: 'other', pid: process.pid, at: Date.now(),
}) + '\n');
}
for (const [label, args] of [['accept', ['--variant', '1']], ['discard', ['--discard']]]) {
it(`reports a blocked ${label} as mode:error rather than a manual handoff`, () => {
writeFileSync(join(tmp, 'page.html'), PAGE);
holdLock();
const result = runAccept(tmp, ['--id', 'ab12cd34', ...args]);
assert.equal(result.handled, false, JSON.stringify(result));
assert.equal(result.error, 'source_locked');
// Without mode:error, completion.mjs classifies this as agent_done with an ok
// ack and live.md tells the agent to hand-edit the file — racing the publisher
// that holds the lock.
assert.equal(result.mode, 'error');
assert.equal(readFileSync(join(tmp, 'page.html'), 'utf-8'), PAGE, 'source must be untouched');
assert.equal(existsSync(join(tmp, '.impeccable', 'live', 'accept-receipts')), false, 'no receipt for a failed op');
});
}
it('succeeds once the lock is gone', () => {
writeFileSync(join(tmp, 'page.html'), PAGE);
const result = runAccept(tmp, ['--id', 'ab12cd34', '--variant', '1']);
assert.equal(result.handled, true, JSON.stringify(result));
assert.match(readFileSync(join(tmp, 'page.html'), 'utf-8'), /VARIANT ONE/);
});
});
describe('live-accept — style-element edge cases', () => {
let tmp;
beforeEach(() => { tmp = mkdtempSync(join(tmpdir(), 'impeccable-accept-test-')); });
@@ -74,6 +234,33 @@ describe('live-accept — style-element edge cases', () => {
assert.ok(!after.includes('original text'), 'original content dropped');
});
it('replays a durable receipt when Accept is retried after source was already written', () => {
const html = `<body>
<!-- impeccable-variants-start RECEIPT1 -->
<div data-impeccable-variants="RECEIPT1" data-impeccable-variant-count="2" style="display: contents">
<div data-impeccable-variant="original"><p>original</p></div>
<style data-impeccable-css="RECEIPT1" />
<div data-impeccable-variant="1"><p>accepted once</p></div>
<div data-impeccable-variant="2" style="display: none"><p>other</p></div>
</div>
<!-- impeccable-variants-end RECEIPT1 -->
</body>`;
writeFileSync(join(tmp, 'page.html'), html);
const first = runAccept(tmp, ['--id', 'RECEIPT1', '--variant', '1']);
const afterFirst = readFileSync(join(tmp, 'page.html'), 'utf-8');
const replay = runAccept(tmp, ['--id', 'RECEIPT1', '--variant', '1']);
assert.equal(first.handled, true);
assert.equal(replay.handled, true);
assert.equal(replay.alreadyApplied, true);
assert.equal(replay.file, 'page.html');
assert.equal(readFileSync(join(tmp, 'page.html'), 'utf-8'), afterFirst);
const conflict = runAccept(tmp, ['--id', 'RECEIPT1', '--variant', '2']);
assert.equal(conflict.handled, false);
assert.equal(conflict.error, 'accept_receipt_conflict');
});
// Variant: same-line <style>…</style> block should also be treated as a
// single skipped unit; the line has both open and close tags.
it('finds the accepted variant after a single-line <style>…</style> block', () => {
+94 -4
View File
@@ -74,7 +74,7 @@ describe('live-browser.js regression guards', () => {
);
});
it('uses a Svelte-gated painted-ancestor crop proxy for shader capture', () => {
it('uses a framework-component-gated painted-ancestor crop proxy for shader capture', () => {
assert.match(
SOURCE,
/function findShaderProxyCaptureRoot\(el\) \{[\s\S]{0,500}?let node = el\.parentElement;[\s\S]{0,700}?containsElement && paintsShaderProxySurface\(node\)[\s\S]{0,120}?return null;/,
@@ -87,8 +87,8 @@ describe('live-browser.js regression guards', () => {
);
assert.match(
SOURCE,
/function shouldUseAncestorCropShaderProxy\(el\) \{[\s\S]{0,260}?window\.__IMPECCABLE_LIVE_ADAPTER__[\s\S]{0,280}?currentPreviewMode === 'svelte-component' \|\| svelteComponentSession[\s\S]{0,260}?dataset\?\.impeccablePreview === 'svelte-component';/,
'ancestor crop proxy must be gated to the Svelte adapter / Svelte component previews',
/function shouldUseAncestorCropShaderProxy\(el\) \{[\s\S]{0,260}?window\.__IMPECCABLE_LIVE_ADAPTER__[\s\S]{0,280}?isFrameworkComponentPreviewMode\(currentPreviewMode\) \|\| svelteComponentSession[\s\S]{0,260}?isFrameworkComponentPreviewMode\(wrapper\?\.dataset\?\.impeccablePreview\);/,
'ancestor crop proxy must be gated to Svelte/Vue component previews',
);
assert.match(
SOURCE,
@@ -141,11 +141,30 @@ describe('live-browser.js regression guards', () => {
it('restores unsaved inline edit drafts before hideBar tears editing down', () => {
assert.match(
SOURCE,
/function hideBar\(\) \{[\s\S]{0,620}?if \(state === 'EDITING'\) restoreInlineEditDrafts\(\);[\s\S]{0,80}?disableInlineEdit\(\);/,
/function hideBar\(instant\) \{[\s\S]{0,720}?if \(state === 'EDITING'\) restoreInlineEditDrafts\(\);[\s\S]{0,80}?disableInlineEdit\(\);/,
'hideBar should not leave unsaved contenteditable drafts in the DOM when an external event hides the bar',
);
});
it('discards variants without hiding the original or animating stale chrome', () => {
assert.match(SOURCE, /function showOriginalDuringDiscard\(sessionId\)[\s\S]{0,900}?data-impeccable-variant="original"/);
assert.match(SOURCE, /function handleDiscard\(\)[\s\S]{0,420}?cleanup\(\{ restoreOriginal: true, instantChrome: true \}\)/);
assert.match(SOURCE, /if \(instant\) barEl\.style\.display = 'none'/);
assert.match(
SOURCE,
/if \(restoreOriginal\) showOriginalDuringDiscard\(cleanupSessionId\);\s*else wrapper\.style\.display = 'none';/,
'only non-discard cleanup may blank the wrapper while waiting for HMR',
);
});
it('stores live state off the document root and preserves the selected anchor top', () => {
assert.match(SOURCE, /window\.__IMPECCABLE_LIVE_STATE__ = next/);
assert.doesNotMatch(SOURCE, /document\.documentElement\.dataset\.impeccableLiveState/);
assert.match(SOURCE, /pickedAnchorViewportTop: Number\.isFinite\(pickedAnchorViewportTop\)/);
assert.match(SOURCE, /scrollLockAnchorTop = typeof initialAnchorTop === 'number' && isFinite\(initialAnchorTop\)/);
assert.match(SOURCE, /const anchorDelta = anchorTop - scrollLockAnchorTop/);
});
it('does not autofocus the steering chat while inline editing', () => {
assert.match(
SOURCE,
@@ -443,6 +462,25 @@ describe('live-browser.js regression guards', () => {
/function syncAgentPollingUi\(/,
'global bar brand must reflect agent poll connectivity',
);
// The indicator goes quiet both when nobody is polling and when the agent
// holds leased work. Under one-shot foreground polling the second case is
// every normal generation, so a single "run live-poll.mjs to connect" tip
// told users to fix a healthy session.
assert.match(
SOURCE,
/function agentHasWorkInFlight\(\)\s*\{\s*return state === 'GENERATING' \|\| state === 'SAVING';/,
'agent poll copy must distinguish a busy agent from an absent one',
);
assert.match(
SOURCE,
/agentHasWorkInFlight\(\) \? AGENT_BUSY_TIP : AGENT_DISCONNECTED_TIP/,
'a busy agent must not be described as disconnected',
);
assert.match(
SOURCE,
/tip\.textContent = agentStatusText\(\)/,
'tooltip copy must be derived at display time, not read from a cache the 5s status poll last wrote',
);
assert.match(
SOURCE,
/case 'agent_polling':/,
@@ -841,6 +879,58 @@ describe('live-browser.js regression guards', () => {
);
});
it('makes every arrived progressive variant immediately actionable', () => {
assert.match(
SOURCE,
/if \(arrivedVariants > 0\) \{[\s\S]{0,180}?setLiveState\('CYCLING'\)/,
'the first arrived variant should leave the generating-only state',
);
assert.doesNotMatch(
SOURCE,
/arrivedVariants < expectedVariants\) \{[\s\S]{0,180}?accept\.style\.pointerEvents = 'none'/,
'Accept must not wait for variants the user did not choose',
);
assert.doesNotMatch(
SOURCE,
/arrivedVariants < expectedVariants\) \{[\s\S]{0,180}?discard\.style\.pointerEvents = 'none'/,
'Discard must cancel remaining work immediately',
);
assert.match(
SOURCE,
/const resumedState = arrivedVariants > 0 \? 'CYCLING' : 'GENERATING'/,
'reload recovery should preserve a partially delivered review state',
);
assert.match(
SOURCE,
/arrivedVariants >= expectedVariants && expectedVariants > 0[\s\S]{0,100}?\? 'variants_ready'[\s\S]{0,60}?: 'variants_progress'/,
'checkpoint timing must distinguish partial review from complete delivery by counts',
);
});
it('keeps deferred Tune controls visible and refreshes params-only publications', () => {
assert.match(
SOURCE,
/const paramsPending = !hasParams && \(parameterGenerationState === 'pending' \|\| parameterGenerationState === 'loading'\)/,
'the cycling bar must expose Tune while parameter generation is outstanding',
);
assert.match(SOURCE, /tune\.disabled = true/, 'pending Tune must be visibly loading but non-interactive');
assert.match(SOURCE, /Tune controls are ready\./, 'parameter arrival needs a clear ready indication');
assert.match(
SOURCE,
/msg\.publicationKind !== 'params' && arrivedVariants >= targetArrived/,
'a params-only publication must refresh even though the variant count is unchanged',
);
assert.match(SOURCE, /revisionDomain: 'browser'/, 'browser checkpoints must use their own revision domain');
});
it('promotes an early-accepted Svelte preview before releasing the picker', () => {
assert.match(
SOURCE,
/function scheduleAcceptCleanup\(accepted\) \{[\s\S]{0,420}?if \(accepted\?\.isSvelteComponent\) \{[\s\S]{0,120}?commitAcceptedSvelteComponentToDom\(accepted\.id\);[\s\S]{0,120}?cleanupAcceptedSession\(\);/,
'Svelte early accept must tear down its adapter mount before the next picking session starts',
);
});
it('variant injection resolves the picked anchor before entering recovery', () => {
assert.match(
SOURCE,
+56 -8
View File
@@ -5,8 +5,48 @@ import { join } from 'node:path';
const SOURCE = readFileSync(join(process.cwd(), 'skill/scripts/live-browser.js'), 'utf-8');
const PENDING_DOCK_POSITION_SOURCE = SOURCE.match(/function positionPendingDock\(\) \{[\s\S]*?\n \}/)?.[0] || '';
const CAPTURE_AND_EMIT_SOURCE = SOURCE.match(/async function captureAndEmit\([\s\S]*?\n \}/)?.[0] || '';
describe('live-browser source contracts', () => {
it('reports foreground poll connectivity without a background worker dependency', () => {
assert.match(
SOURCE,
/syncAgentPollingUi\(!!msg\.agentPolling\)/,
'the initial SSE state should include foreground poll connectivity',
);
assert.doesNotMatch(SOURCE, /codexWorker|codex-worker|codex_cli_unavailable/);
});
it('routes Nuxt Vue preview modules through the Vite build-assets base', () => {
assert.match(
SOURCE,
/function resolveComponentModuleUrl\(manifest, modulePath\)[\s\S]*?manifest\?\.previewMode === 'vue-component'[\s\S]*?window\.__NUXT__\?\.config\?\.app\?\.buildAssetsDir[\s\S]*?pathValue\.slice\('\/@fs\/'.length\)/,
'Nuxt must not send app-local preview modules through the page-route fallback',
);
assert.match(
SOURCE,
/const moduleBase = manifest\.componentModuleBase[\s\S]*?resolveComponentModuleUrl\(manifest, modulePath\)/,
'Vue SFC variants should use the manifest Vite module base rather than componentDir as a route URL',
);
});
it('dispatches plain generation before screenshot capture without bypassing annotated evidence', () => {
const dispatchIndex = CAPTURE_AND_EMIT_SOURCE.indexOf('await sendEvent(basePayload);');
const captureIndex = CAPTURE_AND_EMIT_SOURCE.indexOf('await captureElementToBlob');
assert.ok(dispatchIndex >= 0, 'plain generation should dispatch immediately');
assert.ok(captureIndex > dispatchIndex, 'plain generation dispatch must happen before capture begins');
assert.match(
CAPTURE_AND_EMIT_SOURCE,
/if \(blob && hasAnnotations\)[\s\S]*?\/annotation\?token=/,
'annotation screenshots should still upload before annotated generation dispatch',
);
assert.match(
CAPTURE_AND_EMIT_SOURCE,
/if \(hasAnnotations\) \{[\s\S]*?basePayload\.clientSentAt = Date\.now\(\);\s*sendEvent\(screenshotPath \? \{ \.\.\.basePayload, screenshotPath \} : basePayload\);\s*\}/,
'annotated generation should dispatch exactly after capture and upload resolve',
);
});
it('saves copy edits to the staged buffer with rich AI context', () => {
assert.doesNotMatch(
SOURCE,
@@ -285,7 +325,7 @@ describe('live-browser source contracts', () => {
assert.match(SOURCE, /sendEvent\(\{ type: 'discard', id: currentSessionId \}, \{ throwOnError: true \}\)/);
});
it('waits for post-carbonize completion before final accepted DOM cleanup', () => {
it('releases the foreground picker after deterministic accept while carbonize finishes', () => {
assert.match(
SOURCE,
/let pendingAcceptedSession = null;/,
@@ -309,8 +349,8 @@ describe('live-browser source contracts', () => {
const agentDoneStart = SOURCE.indexOf("case 'agent_done':");
const errorCaseStart = SOURCE.indexOf("case 'error':", agentDoneStart);
const agentDoneSource = SOURCE.slice(agentDoneStart, errorCaseStart);
assert.match(agentDoneSource, /Carbonize accepts are not terminal/);
assert.match(agentDoneSource, /break;/);
assert.match(agentDoneSource, /must not hold the foreground picker hostage/);
assert.match(agentDoneSource, /maybeCompleteAcceptedSession\(msg\)/);
assert.match(
SOURCE,
/function handleGo\(\)[\s\S]{0,900}?pendingAcceptedSession = null;[\s\S]{0,80}?currentSessionId = id8\(\);/,
@@ -319,15 +359,15 @@ describe('live-browser source contracts', () => {
const handleAcceptStart = SOURCE.indexOf('function handleAccept()');
const maybeCompleteStart = SOURCE.indexOf('function maybeCompleteAcceptedSession', handleAcceptStart);
const handleAcceptSource = SOURCE.slice(handleAcceptStart, maybeCompleteStart);
assert.doesNotMatch(
assert.match(
handleAcceptSource,
/state = 'CONFIRMED'|cleanupAcceptedSession\(|hideBar\(\)/,
'accept enqueue should not clear or confirm the browser session before source cleanup completes',
/sendEvent\(acceptPayload, \{ throwOnError: true \}\)[\s\S]*?markSessionHandled\(\);[\s\S]*?setLiveState\('CONFIRMED'\);[\s\S]*?scheduleAcceptCleanup\(pending\);/,
'durable accept intent should release the foreground picker before background source cleanup completes',
);
assert.match(
SOURCE,
/function scheduleAcceptCleanup\(accepted\)[\s\S]*?acceptedDomAlreadyClean\(accepted\)[\s\S]*?setTimeout\(function\(\) \{[\s\S]*?ensureAcceptedDomClean\(accepted\);[\s\S]*?cleanupAcceptedSession\(\);[\s\S]*?\}, 1800\);/,
'post-cleanup fallback should give HMR a second chance before mutating React-owned DOM',
/function scheduleAcceptCleanup\(accepted\)[\s\S]*?queueMicrotask\(function\(\) \{[\s\S]*?cleanupAcceptedSession\(\);[\s\S]*?setTimeout\(function\(\) \{[\s\S]*?ensureAcceptedDomClean\(accepted\);[\s\S]*?\}, 1200\);/,
'foreground cleanup should be immediate while the no-HMR DOM fallback stays deferred',
);
assert.match(
SOURCE,
@@ -393,4 +433,12 @@ describe('live-browser source contracts', () => {
'source fallback should translate simple JSX style objects such as display:none',
);
});
it('loads progressive source checkpoints through the no-HMR fallback', () => {
assert.match(
SOURCE,
/case 'variant_progress':[\s\S]{0,1400}?msg\.previewMode === 'source'[\s\S]{0,1000}?arrivedVariants >= targetArrived[\s\S]{0,260}?injectVariantsFromSource\(msg\.previewFile \|\| msg\.file, msg\.id\)/,
'source-mode progress should let framework HMR settle before using the no-HMR fallback',
);
});
});
+22
View File
@@ -53,6 +53,28 @@ describe('live completion type classification', () => {
);
});
// Component previews keep their variants in module files, not in the user's
// source, so a failed accept leaves nothing to hand-edit: that is a failure, not
// live.md's "read file, find markers, edit" handoff. Only svelte-component was
// special cased, so the identical failure on a Vue preview read as success.
for (const previewMode of ['svelte-component', 'vue-component']) {
it(`treats a failed ${previewMode} accept as an error, not a manual handoff`, () => {
assert.equal(
completionTypeForAcceptResult('accept', { handled: false, error: 'source_locked', previewMode }),
'error',
);
});
}
it('still treats a failed plain-wrapper accept as a manual handoff', () => {
// The one shape with editable markers in source. This must not regress into
// an error, or every hand-editable session starts failing the poll loop.
assert.equal(
completionTypeForAcceptResult('accept', { handled: false, error: 'Markers not found' }),
'agent_done',
);
});
it('classifies handled accept/discard and real failures explicitly', () => {
assert.equal(completionTypeForAcceptResult('accept', { handled: true }), 'complete');
assert.equal(completionTypeForAcceptResult('discard', { handled: true }), 'discarded');
+11 -1
View File
@@ -1,8 +1,18 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { htmlToJsx, normalizeVariantOutput } from './live-e2e/agent.mjs';
import {
htmlToJsx,
isExpectedGenerationCancellation,
normalizeVariantOutput,
} from './live-e2e/agent.mjs';
describe('live-e2e agent output translation', () => {
it('treats a fenced late generation as expected cancellation only', () => {
assert.equal(isExpectedGenerationCancellation(new Error('Source publication prepare failed: stale_generation_epoch')), true);
assert.equal(isExpectedGenerationCancellation(new Error('Source publication failed: stale_source_revision')), false);
assert.equal(isExpectedGenerationCancellation(new Error('provider unavailable')), false);
});
it('converts HTML class and inline style attributes to JSX syntax', () => {
const jsx = htmlToJsx(
'<h1 class="hero-title" style="--p-scale:1; font-size:2.25rem; font-weight:700">Title</h1>',
+63
View File
@@ -9,10 +9,13 @@ import {
createLlmAgent,
parseManualEditResponse,
parseVariantResponse,
progressiveVariantGuidance,
resolveLlmAgentConfig,
validateManualEditCoverage,
validateManualEditPlanningCoverage,
validateVariantMaterialChange,
validateVariantCount,
validateProgressiveVariantOutput,
validateVariantVisibleCopy,
} from './live-e2e/agents/llm-agent.mjs';
@@ -1459,6 +1462,19 @@ describe('live-e2e LLM agent manual edit coverage validation', () => {
});
describe('live-e2e LLM agent variant prompt', () => {
it('makes progressive phase boundaries and lazy parameters explicit', () => {
const first = progressiveVariantGuidance({ count: 1, progressive: { phase: 'first' } });
const remaining = progressiveVariantGuidance({
count: 3,
progressive: { phase: 'remaining', omitFirstVariantCss: true },
});
assert.match(first, /params: \[\]/);
assert.match(first, /materially different/);
assert.match(remaining, /complete final set of exactly 3 variants/);
assert.match(remaining, /Keep its innerHtml exactly unchanged/);
assert.match(remaining, /Do not repeat or modify any scopedCss rule/);
});
it('tells the model not to nest duplicate picked containers', () => {
assert.match(VARIANT_SYSTEM_INSTRUCTIONS, /replacement root itself/);
assert.match(VARIANT_SYSTEM_INSTRUCTIONS, /do not wrap a duplicate/);
@@ -1484,6 +1500,53 @@ describe('live-e2e LLM agent variant prompt', () => {
});
describe('live-e2e LLM agent variant copy validation', () => {
it('enforces the exact requested variant count', () => {
const parsed = { scopedCss: '', variants: [{ innerHtml: '<h1>One</h1>', params: [] }] };
assert.match(validateVariantCount(parsed, { count: 2 }), /expected exactly 2 variants, received 1/);
assert.equal(validateVariantCount(parsed, { count: 1 }), null);
});
it('defers progressive params and preserves the visible first variant', () => {
const firstHtml = '<h1 class="hero-title"><span>One</span></h1>';
assert.match(
validateProgressiveVariantOutput(
{ variants: [{ innerHtml: firstHtml, params: [{ id: 'weight' }] }] },
{ progressive: { phase: 'first' } },
),
/defer params/,
);
assert.equal(
validateProgressiveVariantOutput(
{ variants: [{ innerHtml: firstHtml, params: [] }] },
{ progressive: { phase: 'remaining', firstVariant: { innerHtml: firstHtml } } },
),
null,
);
assert.match(
validateProgressiveVariantOutput(
{ variants: [{ innerHtml: '<h1>Changed</h1>', params: [] }] },
{ progressive: { phase: 'remaining', firstVariant: { innerHtml: firstHtml } } },
),
/preserve variant 1/,
);
assert.match(
validateProgressiveVariantOutput(
{
scopedCss: '@scope ([data-impeccable-variant="1"]) { .hero-title { color: red; } }',
variants: [{ innerHtml: firstHtml, params: [] }],
},
{
progressive: {
phase: 'remaining',
firstVariant: { innerHtml: firstHtml },
omitFirstVariantCss: true,
},
},
),
/omit already-published variant 1 CSS/,
);
});
it('allows variants that preserve the picked element text', () => {
const result = validateVariantVisibleCopy(
{
+59 -11
View File
@@ -22,7 +22,7 @@
import { describe, it, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
import { appendFileSync, cpSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, join, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -38,6 +38,7 @@ import {
clickAccept,
clickApplyEdits,
clickEditCopy,
clickDiscard,
clickSaveEdit,
clickGo,
clickNext,
@@ -45,6 +46,7 @@ import {
editTextLeaf,
drawAnnotationPinAndStroke,
getVisibleVariant,
installLiveQueryHelpers,
pickElement,
runLiveChromeBottomBarSmoke,
waitForApplyDockHidden,
@@ -220,7 +222,7 @@ for (const { name, fixture } of fixtures) {
const domSelector = isInsert
? insertDomSelector
: pickSelector;
const usesSvelteComponentPreview = fixtureUsesSvelteKitAdapter(fixture);
const usesSvelteComponentPreview = fixtureUsesSvelteKitAdapter(fixture) || name === 'nuxt-vite7';
const variantContentSelector = isInsert
? (usesSvelteComponentPreview ? '.inserted-copy' : '[data-impeccable-variant="2"] .inserted-copy')
: usesSvelteComponentPreview
@@ -314,10 +316,11 @@ for (const { name, fixture } of fixtures) {
const after = readFileSync(sourceFile, 'utf-8');
const svelteComponentSession = svelteComponentTargetFor(sourceFile);
if (svelteComponentSession) {
const variantFile = join(tmp, svelteComponentSession.manifest.componentDir, 'v2.svelte');
const componentExtension = svelteComponentSession.manifest.componentExtension || 'svelte';
const variantFile = join(tmp, svelteComponentSession.manifest.componentDir, `v2.${componentExtension}`);
const variantBody = readFileSync(variantFile, 'utf-8');
const routeBody = readFileSync(join(tmp, svelteComponentSession.manifest.sourceFile), 'utf-8');
assert.match(after, /"previewMode": "svelte-component"/, 'Svelte component manifest inserted');
assert.match(after, /"previewMode": "(?:svelte|vue)-component"/, 'framework component manifest inserted');
if (isInsert) {
assert.equal(svelteComponentSession.manifest.mode, 'insert', 'Svelte insert manifest marks insert mode');
if (agentMode === 'fake') {
@@ -328,9 +331,9 @@ for (const { name, fixture } of fixtures) {
assert.match(variantBody, /<([a-z][\w:-]*)\b[\s\S]*<\/\1>|<[a-z][\w:-]*\b[^>]*\/>/i, 'Svelte insert variant component contains a root element');
}
} else {
assert.match(variantBody, new RegExp(`<${svelteComponentSession.expectedTag}\\b`), 'Svelte variant component contains target element');
assert.match(variantBody, new RegExp(`<${svelteComponentSession.expectedTag}\\b`), 'component variant contains target element');
}
assert.doesNotMatch(routeBody, /data-impeccable-variants="/, 'Svelte route source is not edited during generation');
assert.doesNotMatch(routeBody, /data-impeccable-variants="/, 'route source is not edited during component preview');
} else {
assert.match(after, /data-impeccable-variants="/, 'wrapper inserted');
}
@@ -349,7 +352,8 @@ for (const { name, fixture } of fixtures) {
}
}
if (svelteComponentSession) {
assert.match(readFileSync(join(tmp, svelteComponentSession.manifest.componentDir, 'v2.svelte'), 'utf-8'), /<style>/, 'Svelte component variant has scoped style block');
const componentExtension = svelteComponentSession.manifest.componentExtension || 'svelte';
assert.match(readFileSync(join(tmp, svelteComponentSession.manifest.componentDir, `v2.${componentExtension}`), 'utf-8'), /<style\b/, 'component variant has a style block');
} else if (sourceFile.endsWith('.astro')) {
assert.match(after, /<style is:inline data-impeccable-css="/, 'Astro live CSS uses an inline compiler-bypassing style block');
assert.match(
@@ -376,6 +380,13 @@ for (const { name, fixture } of fixtures) {
for (const kind of ['range', 'steps', 'toggle']) {
assert.match(paramsSource, new RegExp(`"kind"\\s*:\\s*"${kind}"`), `param kind ${kind} present`);
}
await page.waitForFunction(() => {
const root = window.__IMPECCABLE_LIVE_CHROME_CORE__?.root?.()
|| window.__IMPECCABLE_LIVE_UI_ROOT__
|| document;
const tune = root.querySelector('[data-iceq-tune="1"]');
return tune && tune.disabled === false && /Tune/.test(tune.textContent || '');
}, { timeout: 5_000 });
}
// 6. Cycle variants. Most fixtures stop at variant 2; Svelte Insert
@@ -649,6 +660,8 @@ for (const { name, fixture } of fixtures) {
}
});
if (shouldRunScenario('manual') && Array.isArray(fixture.runtime.manualEditScenarios) && fixture.runtime.manualEditScenarios.length > 0) {
const manualScenarioFilter = process.env.IMPECCABLE_E2E_MANUAL_SCENARIO || '';
for (const scenario of fixture.runtime.manualEditScenarios) {
@@ -798,6 +811,39 @@ function recordGenerateEvents(agent, events) {
};
}
function countSourceVariants(source) {
return (String(source).match(/<div\s+data-impeccable-variant="(?!original")/g) || []).length;
}
function escapeRegExp(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
async function waitForGenerationTimings(tmp, id, { timeoutMs = 5_000, requireAllVariants = true } = {}) {
const snapshotPath = join(tmp, '.impeccable', 'live', 'sessions', `${id}.snapshot.json`);
const journalPath = join(tmp, '.impeccable', 'live', 'sessions', `${id}.jsonl`);
const deadline = Date.now() + timeoutMs;
let lastTimings = null;
while (Date.now() < deadline) {
if (existsSync(snapshotPath)) {
const snapshot = JSON.parse(readFileSync(snapshotPath, 'utf-8'));
const timings = snapshot.generationTimings || {};
lastTimings = timings;
if (timings.generation_ready && timings.first_reviewable && (!requireAllVariants || timings.all_variants_ready)) return timings;
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
const checkpointReasons = existsSync(journalPath)
? readFileSync(journalPath, 'utf-8')
.split('\n')
.filter(Boolean)
.map((line) => JSON.parse(line)?.event)
.filter((event) => event?.type === 'checkpoint')
.map((event) => ({ reason: event.reason, arrivedVariants: event.arrivedVariants, expectedVariants: event.expectedVariants }))
: [];
throw new Error(`generation timings did not complete for ${id}: timings=${JSON.stringify(lastTimings)} checkpoints=${JSON.stringify(checkpointReasons)}`);
}
async function captureLiveE2eFailure({ name, fixture, session, sourceFile, error, log = () => {} }) {
const root = process.env.IMPECCABLE_E2E_ARTIFACT_DIR;
if (!root || !session?.tmp) return;
@@ -1453,11 +1499,12 @@ function svelteComponentTargetFor(filePath) {
if (!filePath.endsWith('/manifest.json') && !filePath.endsWith('\\manifest.json')) return null;
let manifest;
try { manifest = JSON.parse(readFileSync(filePath, 'utf-8')); } catch { return null; }
if (manifest.previewMode !== 'svelte-component' || !manifest.sourceFile || !manifest.componentDir) return null;
if (!['svelte-component', 'vue-component'].includes(manifest.previewMode) || !manifest.sourceFile || !manifest.componentDir) return null;
const sep = pathSepFor(filePath);
const markers = [
`${sep}node_modules${sep}.impeccable-live${sep}`,
`${sep}src${sep}lib${sep}impeccable${sep}`,
`${sep}app${sep}.impeccable-live${sep}`,
];
const marker = markers.find((candidate) => filePath.includes(candidate));
const idx = marker ? filePath.indexOf(marker) : -1;
@@ -1547,18 +1594,19 @@ async function locateSessionFile(tmp) {
return f;
}
}
for (const f of walkSvelteComponentManifests(tmp)) {
for (const f of walkComponentManifests(tmp)) {
const body = readFileSync(f, 'utf-8');
if (body.includes('"previewMode": "svelte-component"')) return f;
if (/"previewMode": "(?:svelte|vue)-component"/.test(body)) return f;
}
throw new Error('Could not locate session source file under ' + tmp);
}
function walkSvelteComponentManifests(root) {
function walkComponentManifests(root) {
const results = [];
const stack = [
join(root, 'node_modules/.impeccable-live'),
join(root, 'src/lib/impeccable'),
join(root, 'app/.impeccable-live'),
];
while (stack.length) {
const dir = stack.pop();
+111 -11
View File
@@ -1325,15 +1325,25 @@ async function spliceVariantsIntoWrapper({ tmp, wrapInfo, sessionId, output }) {
styleMode: wrapInfo.styleMode,
});
const endMarkerIdx = lines.findIndex((line, index) =>
index > markerIdx && line.includes('impeccable-variants-end ' + sessionId),
);
if (endMarkerIdx === -1) {
throw new Error('end marker not found in ' + wrapInfo.file);
}
const tailIdx = wrapInfo.commentSyntax.open === '{/*'
? endMarkerIdx
: endMarkerIdx - 1;
const next = [
...lines.slice(0, markerIdx + 1),
block,
...lines.slice(markerIdx + 1),
...lines.slice(tailIdx),
];
await fs.writeFile(filePath, next.join('\n'), 'utf-8');
}
async function writeSvelteComponentVariants({ tmp, wrapInfo, event, output }) {
async function writeSvelteComponentVariants({ tmp, wrapInfo, event, output, writeParams = true }) {
const manifestPath = path.join(tmp, wrapInfo.file);
const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf-8'));
const componentDir = path.join(tmp, manifest.componentDir);
@@ -1373,7 +1383,52 @@ async function writeSvelteComponentVariants({ tmp, wrapInfo, event, output }) {
paramsByVariant[String(variantId)] = Array.isArray(variant.params) ? variant.params : [];
}
await fs.writeFile(path.join(componentDir, 'params.json'), JSON.stringify(paramsByVariant, null, 2) + '\n', 'utf-8');
if (writeParams) {
await fs.writeFile(path.join(componentDir, 'params.json'), JSON.stringify(paramsByVariant, null, 2) + '\n', 'utf-8');
}
manifest.arrivedVariants = output.variants.length;
await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2) + '\n', 'utf-8');
}
async function writeVueComponentVariants({ tmp, wrapInfo, event, output, writeParams = true }) {
const manifestPath = path.join(tmp, wrapInfo.file);
const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf-8'));
const componentDir = path.join(tmp, manifest.componentDir);
const contract = Array.isArray(manifest.propContract) ? manifest.propContract : [];
const textValues = extractTextPieces(event.element?.outerHTML || event.element?.textContent || '');
const paramsByVariant = {};
for (let i = 0; i < output.variants.length; i++) {
const variantId = i + 1;
const variant = output.variants[i];
let markup = substituteLiveTextWithProps(variant.innerHtml || '', contract, textValues).trim();
for (const entry of contract) {
markup = markup.replaceAll(`{${entry.prop}}`, `{{ ${entry.prop} }}`);
}
const css = svelteCssForVariant(output.scopedCss || '', variantId, firstTagName(markup) || 'div');
const propsScript = contract.length > 0
? ['<script setup>', 'defineProps({', ...contract.map((entry) => ` ${entry.prop}: { default: '' },`), '});', '</script>', '']
: [];
const component = [
...propsScript,
'<template>',
markup || '<div></div>',
'</template>',
'',
'<style scoped>',
css || ':where(*) {}',
'</style>',
'',
].join('\n');
await fs.writeFile(path.join(componentDir, `v${variantId}.vue`), component, 'utf-8');
paramsByVariant[String(variantId)] = Array.isArray(variant.params) ? variant.params : [];
}
if (writeParams) {
await fs.writeFile(path.join(componentDir, 'params.json'), JSON.stringify(paramsByVariant, null, 2) + '\n', 'utf-8');
}
manifest.arrivedVariants = output.variants.length;
await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2) + '\n', 'utf-8');
}
function variantMarkupHasVisibleContent(markup) {
@@ -1507,6 +1562,8 @@ export async function runAgentLoop({
agent,
signal,
log = () => {},
trace = () => {},
atomicDelayMs = 0,
wrapTarget = { classes: 'hero-title', tag: 'h1' },
steerSourceFile,
steerTarget,
@@ -1530,6 +1587,8 @@ export async function runAgentLoop({
if (event.type === 'prefetch') continue;
if (event.type === 'connected') continue;
trace('agent.event.received', { id: event.id, type: event.type, clientSentAt: event.clientSentAt ?? null });
if (event.type === 'steer') {
log(`steer id=${event.id} message=${JSON.stringify(event.message)}`);
try {
@@ -1578,7 +1637,16 @@ export async function runAgentLoop({
log(`generate id=${event.id} mode=${isInsert ? 'insert' : 'replace'}${isInsert ? '' : ` action=${event.action}`} count=${event.count}`);
try {
let wrapInfo;
if (isInsert) {
if (event.scaffold) {
wrapInfo = event.scaffold;
trace('agent.scaffold.reused', {
id: event.id,
file: wrapInfo.file,
previewMode: wrapInfo.previewMode || 'source',
durationMs: event.scaffoldDurationMs ?? null,
});
} else if (isInsert) {
trace('agent.scaffold.start', { id: event.id, mode: 'insert' });
const insertTarget = insertTargetFromEvent(event);
wrapInfo = await runInsert({
tmp,
@@ -1587,7 +1655,9 @@ export async function runAgentLoop({
count: event.count,
...insertTarget,
});
trace('agent.scaffold.end', { id: event.id, file: wrapInfo.file, previewMode: wrapInfo.previewMode || 'source' });
} else {
trace('agent.scaffold.start', { id: event.id, mode: 'replace' });
// 1. Wrap the original element in the variant scaffold (deterministic CLI)
// wrapTarget can be a static {classes, tag, elementId} (test fixtures
// know what they pick) or a function (event) => target (real-use
@@ -1606,41 +1676,65 @@ export async function runAgentLoop({
...target,
text,
});
trace('agent.scaffold.end', { id: event.id, file: wrapInfo.file, previewMode: wrapInfo.previewMode || 'source' });
}
log(`scaffolded: ${wrapInfo.file} insertLine=${wrapInfo.insertLine}`);
// 2. Agent generates variant content (LLM-pluggable seam)
let output = await agent.generateVariants(event, { wrapTarget, wrapInfo });
output = normalizeVariantOutput(output, wrapInfo);
// 2. Agent generates variant content (LLM-pluggable seam).
// Providers may expose a true split path so variant 1 is written before
// the request for the remaining variants completes.
trace('agent.generate.start', { id: event.id, count: event.count });
let output = normalizeVariantOutput(
await agent.generateVariants(event, { wrapTarget, wrapInfo }),
wrapInfo,
);
if (atomicDelayMs > 0) {
await new Promise((resolve) => setTimeout(resolve, atomicDelayMs));
}
trace('agent.generate.first_ready', { id: event.id, count: output?.variants?.length || 0 });
trace('agent.generate.end', { id: event.id, count: output?.variants?.length || 0 });
if (output.variants.length !== event.count) {
log(`warning: agent returned ${output.variants.length} variants, expected ${event.count}`);
}
// 3. Write variants into the deterministic preview target.
// 3. Write the complete set into the deterministic preview target.
trace('agent.write.start', { id: event.id, file: wrapInfo.file });
if (wrapInfo.previewMode === 'svelte-component') {
await writeSvelteComponentVariants({ tmp, wrapInfo, event, output });
await writeSvelteComponentVariants({ tmp, wrapInfo, event, output, writeParams: true });
} else if (wrapInfo.previewMode === 'vue-component') {
await writeVueComponentVariants({ tmp, wrapInfo, event, output, writeParams: true });
} else {
await spliceVariantsIntoWrapper({ tmp, wrapInfo, sessionId: event.id, output });
}
trace('agent.write.end', { id: event.id, file: wrapInfo.file });
if (process.env.IMPECCABLE_E2E_DEBUG) {
const post = await fs.readFile(path.join(tmp, wrapInfo.file), 'utf-8');
log(`--- post-splice (variants written) ---\n${post}`);
}
// 4. Tell the server we're done (broadcasts SSE done → browser settles to CYCLING)
trace('agent.reply.start', { id: event.id });
await fetch(`${base}/poll`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token, type: 'done', id: event.id, file: wrapInfo.file }),
body: JSON.stringify({ token, type: 'done', sourceEventType: 'generate', id: event.id, file: wrapInfo.file }),
signal,
});
trace('agent.reply.end', { id: event.id });
} catch (err) {
if (signal.aborted) return;
if (isExpectedGenerationCancellation(err)) {
trace('agent.generate.canceled', { id: event.id, reason: 'stale_generation_epoch' });
log('generate canceled after Accept/Discard: ' + err.message);
continue;
}
trace('agent.generate.error', { id: event.id, message: err.message });
log('generate failed: ' + err.message);
await fetch(`${base}/poll`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token, type: 'error', id: event.id, message: err.message }),
body: JSON.stringify({ token, type: 'error', sourceEventType: 'generate', id: event.id, message: err.message }),
signal,
}).catch(() => {});
}
@@ -1740,6 +1834,7 @@ export async function runAgentLoop({
body: JSON.stringify({
token,
type: completionType,
sourceEventType: 'accept',
id: event.id,
file: acceptResult.file,
message: acceptResult.error,
@@ -1769,6 +1864,7 @@ export async function runAgentLoop({
body: JSON.stringify({
token,
type: completionType,
sourceEventType: 'discard',
id: event.id,
file: discardResult.file,
message: discardResult.error,
@@ -1787,6 +1883,10 @@ export async function runAgentLoop({
}
}
export function isExpectedGenerationCancellation(error) {
return /(?:^|\b)stale_generation_epoch(?:\b|$)/.test(String(error?.message || error || ''));
}
async function runPollReply({ tmp, scriptsDir, id, status, message, data }) {
const args = [path.join(scriptsDir, 'live-poll.mjs'), '--reply', id, status];
if (data !== undefined) args.push('--data', JSON.stringify(data));
+76 -25
View File
@@ -192,6 +192,7 @@ const STEER_SYSTEM_INSTRUCTIONS = [
* @property {string=} model Override the selected provider's default model.
* @property {string=} baseURL Override the provider API base URL.
* @property {object=} config Pre-resolved provider config from resolveLlmAgentConfig().
* @property {boolean=} includeLiveSpec Attach the full live.md reference. Defaults to true; latency benchmarks disable it to export only the synthetic element contract.
* @property {(msg: string) => void=} log Optional logger for debug output.
*/
@@ -240,14 +241,22 @@ export async function createLlmAgent(opts = {}) {
const { apiKey, baseURL, model, provider } = config;
const log = opts.log || (() => {});
const liveMd = await fs.readFile(LIVE_MD_PATH, 'utf-8');
const liveMd = opts.includeLiveSpec === false ? null : await fs.readFile(LIVE_MD_PATH, 'utf-8');
const client = new Anthropic({ apiKey, ...(baseURL ? { baseURL } : {}) });
const systemBlocks = (instructions) => [
{
type: 'text',
text: liveMd ? instructions : instructions.replace(/\n\nCONTEXT —[^\n]+$/, ''),
},
...(liveMd ? [{ type: 'text', text: liveMd, cache_control: { type: 'ephemeral' } }] : []),
];
return {
async generateVariants(event, context = {}) {
const isInsert = event.mode === 'insert';
const baseUserMessage = [
`Produce variants for the following ${isInsert ? 'insert request' : 'pick'}. Reply with the JSON object only — no prose.`,
progressiveVariantGuidance(event),
'',
'```json',
JSON.stringify(buildVariantRequestPayload(event, context), null, 2),
@@ -256,6 +265,7 @@ export async function createLlmAgent(opts = {}) {
let userMessage = baseUserMessage;
for (let attempt = 0; attempt < MANUAL_EDIT_RESPONSE_MAX_ATTEMPTS; attempt += 1) {
const lastAttempt = attempt + 1 >= MANUAL_EDIT_RESPONSE_MAX_ATTEMPTS;
let response;
try {
response = await client.messages.create(
@@ -263,15 +273,10 @@ export async function createLlmAgent(opts = {}) {
model,
temperature: 0,
max_tokens: 16000,
system: [
{ type: 'text', text: VARIANT_SYSTEM_INSTRUCTIONS },
// Cacheable: the entire stable prefix (instructions + spec) is
// cached up to this breakpoint. The user message holds all the
// per-call volatile content. DeepSeek compatibility support is
// provider-reported and best-effort; the usage log below tells us
// whether cache reads/writes actually happened.
{ type: 'text', text: liveMd, cache_control: { type: 'ephemeral' } },
],
// When present, live.md is the final cacheable stable prefix.
// Benchmarks omit it so external payloads contain only the
// synthetic element contract and per-run event.
system: systemBlocks(VARIANT_SYSTEM_INSTRUCTIONS),
messages: [{ role: 'user', content: userMessage }],
},
{
@@ -280,7 +285,7 @@ export async function createLlmAgent(opts = {}) {
},
);
} catch (err) {
if (attempt === 1) throw err;
if (lastAttempt) throw err;
log(`variant request failed; retrying: ${err.message}`);
userMessage = [
baseUserMessage,
@@ -300,7 +305,7 @@ export async function createLlmAgent(opts = {}) {
`provider=${provider} model=${model} attempt=${attempt + 1} input=${inputTokens} output=${outputTokens} cache_read=${cacheRead} cache_write=${cacheWrite}`,
);
if (!response || !Array.isArray(response.content)) {
if (attempt === 1) throw new Error('LLM agent: provider returned an empty variant response');
if (lastAttempt) throw new Error('LLM agent: provider returned an empty variant response');
log('variant response validation failed; retrying: provider returned an empty response');
userMessage = [
baseUserMessage,
@@ -320,7 +325,7 @@ export async function createLlmAgent(opts = {}) {
try {
parsed = parseVariantResponse(text);
} catch (err) {
if (attempt === 1) throw err;
if (lastAttempt) throw err;
log(`variant response validation failed; retrying: ${err.message.split('\n')[0]}`);
userMessage = [
baseUserMessage,
@@ -332,11 +337,13 @@ export async function createLlmAgent(opts = {}) {
continue;
}
const validationError = isInsert
? validateInsertVariantOutput(parsed, event)
: (validateVariantVisibleCopy(parsed, event.element) || validateVariantMaterialChange(parsed, event.element));
const validationError = validateVariantCount(parsed, event)
|| validateProgressiveVariantOutput(parsed, event)
|| (isInsert
? validateInsertVariantOutput(parsed, event)
: (validateVariantVisibleCopy(parsed, event.element) || validateVariantMaterialChange(parsed, event.element)));
if (!validationError) return parsed;
if (attempt === 1) throw new Error(`LLM agent: ${validationError}`);
if (lastAttempt) throw new Error(`LLM agent: ${validationError}`);
log(`variant validation failed; retrying: ${validationError}`);
if (isInsert) {
@@ -411,10 +418,7 @@ export async function createLlmAgent(opts = {}) {
model,
temperature: 0,
max_tokens: 16000,
system: [
{ type: 'text', text: MANUAL_EDIT_SYSTEM_INSTRUCTIONS },
{ type: 'text', text: liveMd, cache_control: { type: 'ephemeral' } },
],
system: systemBlocks(MANUAL_EDIT_SYSTEM_INSTRUCTIONS),
messages: [{ role: 'user', content: userMessage }],
},
{
@@ -542,10 +546,7 @@ export async function createLlmAgent(opts = {}) {
const response = await client.messages.create({
model,
max_tokens: 4096,
system: [
{ type: 'text', text: STEER_SYSTEM_INSTRUCTIONS },
{ type: 'text', text: liveMd, cache_control: { type: 'ephemeral' } },
],
system: systemBlocks(STEER_SYSTEM_INSTRUCTIONS),
messages: [{ role: 'user', content: userMessage }],
});
@@ -672,6 +673,7 @@ export function buildVariantRequestPayload(event, context = {}) {
action: event?.action,
freeformPrompt: event?.freeformPrompt,
count: event?.count,
progressive: event?.progressive,
element: isInsert ? null : {
outerHTML: event?.element?.outerHTML,
tagName: event?.element?.tagName,
@@ -691,6 +693,31 @@ export function buildVariantRequestPayload(event, context = {}) {
};
}
export function progressiveVariantGuidance(event = {}) {
if (event.progressive?.phase === 'first') {
return [
'PROGRESSIVE FIRST DELIVERY:',
`- Return exactly ${event.count} variant now.`,
'- Return params: [] for this variant; tunable parameters are generated in the final phase.',
'- The innerHtml must be materially different from the picked source, not merely paired with different CSS.',
'- For a bare-text element, preserve the full exact copy in one child span inside the unchanged root tag/class.',
].join('\n');
}
if (event.progressive?.phase === 'remaining') {
return [
'PROGRESSIVE FINAL DELIVERY:',
`- Return the complete final set of exactly ${event.count} variants, including variant 1.`,
'- progressive.firstVariant is the already-visible variant 1. Keep its innerHtml exactly unchanged and add its deferred params now.',
...(event.progressive.omitFirstVariantCss ? [
'- Variant 1 CSS is already published and immutable. Do not repeat or modify any scopedCss rule for data-impeccable-variant="1"; return scopedCss rules for variants 2+ only.',
] : []),
'- Generate the remaining distinct variants and their params in the other array positions.',
'- Every remaining variant innerHtml must be materially changed too; for bare text, wrap the full exact copy in one child span with a distinct class instead of relying on CSS alone.',
].join('\n');
}
return '';
}
/**
* Parse and validate a model response into the variant-output schema. Throws
* with a `Parsed (first 500 chars): ...` echo on every schema failure so the
@@ -850,6 +877,30 @@ export function validateInsertVariantOutput(parsed, event = {}) {
return null;
}
export function validateVariantCount(parsed, event = {}) {
const expected = Number(event.count);
if (!Number.isInteger(expected) || expected < 1) return 'event count must be a positive integer';
const actual = Array.isArray(parsed?.variants) ? parsed.variants.length : 0;
return actual === expected ? null : `expected exactly ${expected} variants, received ${actual}`;
}
export function validateProgressiveVariantOutput(parsed, event = {}) {
if (event.progressive?.phase === 'first') {
const hasEarlyParams = (parsed.variants || []).some((variant) => Array.isArray(variant.params) && variant.params.length > 0);
return hasEarlyParams ? 'progressive first delivery must defer params with an empty params array' : null;
}
if (event.progressive?.phase === 'remaining' && event.progressive.firstVariant?.innerHtml) {
const expected = String(event.progressive.firstVariant.innerHtml).trim();
const actual = String(parsed.variants?.[0]?.innerHtml || '').trim();
if (actual !== expected) return 'progressive final delivery must preserve variant 1 innerHtml exactly';
if (event.progressive.omitFirstVariantCss && /\[data-impeccable-variant\s*=\s*["']1["'][^\]]*\]/.test(parsed.scopedCss || '')) {
return 'progressive final delivery must omit already-published variant 1 CSS';
}
return null;
}
return null;
}
export function validateVariantMaterialChange(parsed, element) {
const originalHtml = normalizeVariantHtml(element?.outerHTML || '');
if (!originalHtml) return null;
+82 -20
View File
@@ -14,7 +14,7 @@
*/
import { execFileSync, spawn } from 'node:child_process';
import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -32,8 +32,7 @@ export { SCRIPTS_DIR, FIXTURES_DIR, REPO_ROOT };
// Stage
// ---------------------------------------------------------------------------
export function stageFixture(name, fixture) {
const fixtureRoot = join(FIXTURES_DIR, name);
export function stageFixture(name, fixture, { fixtureRoot = join(FIXTURES_DIR, name) } = {}) {
const gitignore = readFileSync(join(fixtureRoot, 'gitignore.txt'), 'utf-8');
const tmp = mkdtempSync(join(tmpdir(), 'impeccable-e2e-'));
@@ -56,6 +55,7 @@ export function runInstall(tmp, command, { timeoutMs = readTimeoutEnv('IMPECCABL
const installArgs = addNpmInstallDefaults(cmd, args);
try {
execFileSync(cmd, installArgs, { cwd: tmp, stdio: 'inherit', timeout: timeoutMs });
repairMissingRollupOptionalBinary(tmp, { timeoutMs });
} catch (err) {
if (err.signal === 'SIGTERM' || err.signal === 'SIGKILL' || err.killed) {
err.message = `fixture dependency install timed out after ${timeoutMs}ms: ${cmd} ${installArgs.join(' ')}`;
@@ -64,11 +64,26 @@ export function runInstall(tmp, command, { timeoutMs = readTimeoutEnv('IMPECCABL
}
}
function repairMissingRollupOptionalBinary(tmp, { timeoutMs }) {
if (process.platform !== 'darwin' || process.arch !== 'arm64') return;
const rollupPackage = join(tmp, 'node_modules', 'rollup', 'package.json');
const nativePackage = join(tmp, 'node_modules', '@rollup', 'rollup-darwin-arm64', 'package.json');
if (!existsSync(rollupPackage) || existsSync(nativePackage)) return;
const version = JSON.parse(readFileSync(rollupPackage, 'utf-8')).version;
execFileSync('npm', [
'install', '--no-save', '--no-audit', '--no-fund', '--no-progress',
`@rollup/rollup-darwin-arm64@${version}`,
], { cwd: tmp, stdio: 'inherit', timeout: timeoutMs });
}
function addNpmInstallDefaults(cmd, args) {
if (cmd !== 'npm') return args;
if (!['install', 'ci'].includes(args[0])) return args;
const out = [...args];
for (const flag of ['--prefer-offline', '--no-progress']) {
// npm can omit platform-specific Rollup binaries unless optional
// dependencies are requested explicitly (npm/cli#4828). Astro/Vite then
// fail before Live starts on fresh staged fixtures.
for (const flag of ['--no-progress', '--include=optional']) {
if (!out.some((arg) => arg === flag || arg.startsWith(flag + '='))) out.push(flag);
}
return out;
@@ -200,29 +215,54 @@ export async function stopDevServer(child) {
* @param {object} opts
* @param {string} opts.name fixture name
* @param {object} opts.fixture fixture.json contents
* @param {string=} opts.fixtureRoot fixture directory; defaults to the public framework fixture tree
* @param {import('playwright').Browser} opts.browser shared browser instance
* @param {object} opts.agent VariantAgent (defaults to fake)
* @param {object|function=} opts.wrapTarget live-wrap target or event mapper
* @param {(context: object) => Promise<object|void>} [opts.startWorker]
* Optional production worker factory. Return {stop, done}; when used,
* omit `agent` so the deterministic in-process loop is not started.
* @param {(context: object) => Promise<void>|void} [opts.prepareTmp]
* @param {(msg: string) => void} [opts.log]
*/
export async function bootFixtureSession({ name, fixture, browser, agent, wrapTarget, log = () => {} }) {
export async function bootFixtureSession({
name,
fixture,
fixtureRoot,
browser,
agent,
wrapTarget,
startWorker,
prepareTmp,
log = () => {},
trace = () => {},
atomicDelayMs = 0,
keepTmp = false,
}) {
const runtime = fixture.runtime;
if (!runtime) throw new Error(`fixture ${name} has no runtime block`);
const tmp = stageFixture(name, fixture);
const tmp = stageFixture(name, fixture, { fixtureRoot });
let live;
let dev;
let agentAbort;
let agentDone;
let externalWorker;
let ctx;
const teardown = async () => {
try { if (ctx) await ctx.close(); } catch {}
try { if (agentAbort) agentAbort.abort(); } catch {}
try { if (agentDone) await agentDone.catch(() => {}); } catch {}
try { if (externalWorker?.stop) await externalWorker.stop(); } catch {}
try { if (externalWorker?.done) await externalWorker.done.catch(() => {}); } catch {}
try { if (dev?.child) await stopDevServer(dev.child); } catch {}
try { if (live) stopLiveServer(tmp); } catch {}
try { rmSync(tmp, { recursive: true, force: true }); } catch {}
if (!keepTmp) {
try { rmSync(tmp, { recursive: true, force: true }); } catch {}
} else {
log(`kept staged fixture at ${tmp}`);
}
};
const stopLiveForDeferredWork = () => {
@@ -233,41 +273,60 @@ export async function bootFixtureSession({ name, fixture, browser, agent, wrapTa
try {
const startedAt = Date.now();
if (prepareTmp) await prepareTmp({ tmp, fixture, scriptsDir: SCRIPTS_DIR, trace, log });
trace('setup.install.start', { fixture: name });
log(`installing deps`);
runInstall(tmp, runtime.install);
trace('setup.install.end', { fixture: name });
log(`deps installed in ${formatDuration(Date.now() - startedAt)}`);
const liveStartedAt = Date.now();
trace('setup.live_server.start', { fixture: name });
log(`starting live-server`);
live = startLiveServer(tmp);
trace('setup.live_server.end', { fixture: name, port: live.port });
log(`live-server ready in ${formatDuration(Date.now() - liveStartedAt)}`);
if (startWorker) {
trace('setup.worker.start', { fixture: name });
externalWorker = await startWorker({ tmp, fixture, scriptsDir: SCRIPTS_DIR, live, trace, log });
trace('setup.worker.end', { fixture: name });
}
const injectStartedAt = Date.now();
trace('setup.inject.start', { fixture: name });
log(`live-inject --port ${live.port}`);
const injectResult = runInject(tmp, live.port);
if (!injectResult.ok) throw new Error('live-inject failed: ' + JSON.stringify(injectResult));
trace('setup.inject.end', { fixture: name, files: injectResult.files || injectResult.pageFiles || [] });
log(`live-inject complete in ${formatDuration(Date.now() - injectStartedAt)}`);
const devStartedAt = Date.now();
trace('setup.dev_server.start', { fixture: name });
log(`spawning dev server: ${runtime.devCommand.join(' ')}`);
dev = startDevServer(tmp, runtime);
const { port: devPort } = await dev.ready;
trace('setup.dev_server.end', { fixture: name, port: devPort });
log(`dev server ready on ${devPort} in ${formatDuration(Date.now() - devStartedAt)}`);
// Agent loop runs concurrently — abort on teardown.
agentAbort = new AbortController();
agentDone = runAgentLoop({
tmp,
scriptsDir: SCRIPTS_DIR,
port: live.port,
token: live.token,
agent,
wrapTarget,
signal: agentAbort.signal,
log: (m) => log('[agent] ' + m),
steerSourceFile: runtime.steer?.sourceFile,
steerTarget: runtime.steer?.target,
});
if (agent) {
agentAbort = new AbortController();
const loopOptions = {
tmp,
scriptsDir: SCRIPTS_DIR,
port: live.port,
token: live.token,
agent,
wrapTarget,
signal: agentAbort.signal,
trace,
atomicDelayMs,
steerSourceFile: runtime.steer?.sourceFile,
steerTarget: runtime.steer?.target,
};
agentDone = Promise.all([runAgentLoop({ ...loopOptions, log: (m) => log('[worker] ' + m) })]);
}
const scheme = runtime.scheme || 'http';
ctx = await browser.newContext({
@@ -283,10 +342,12 @@ export async function bootFixtureSession({ name, fixture, browser, agent, wrapTa
});
const pageStartedAt = Date.now();
trace('setup.page_load.start', { fixture: name });
await page.goto(`${scheme}://127.0.0.1:${devPort}`, {
waitUntil: 'domcontentloaded',
timeout: 30_000,
});
trace('setup.page_load.end', { fixture: name });
log(`page loaded in ${formatDuration(Date.now() - pageStartedAt)}`);
return {
@@ -295,6 +356,7 @@ export async function bootFixtureSession({ name, fixture, browser, agent, wrapTa
ctx,
dev,
live,
worker: externalWorker,
consoleErrors,
stopLiveServer: stopLiveForDeferredWork,
teardown,
+58 -4
View File
@@ -424,7 +424,23 @@ export async function pickElement(page, selector, opts = {}) {
if (visible) break;
await resetPickMode(page);
if (attempt === 2) {
await page.waitForSelector(BAR_ID, { state: 'visible', timeout: 1 });
const snapshot = await page.evaluate(({ selector, barSel, pickSel }) => {
const target = document.querySelector(selector);
const rect = target?.getBoundingClientRect();
const hit = rect ? document.elementFromPoint(rect.x + rect.width / 2, rect.y + rect.height / 2) : null;
const query = window.__impeccableLiveQuery || ((sel) => document.querySelector(sel));
const bar = query(barSel);
const pick = query(pickSel);
return {
liveState: window.__IMPECCABLE_LIVE_STATE__ || null,
target: target ? { tag: target.tagName, classes: target.className, rect: rect?.toJSON?.() || null } : null,
hit: hit ? { tag: hit.tagName, classes: hit.className, text: (hit.textContent || '').slice(0, 80) } : null,
pickActive: pick?.dataset.active || null,
bar: bar ? { display: bar.style.display, text: bar.textContent } : null,
debugState: window.__IMPECCABLE_LIVE_CHROME_CORE__?.debugState?.() || null,
};
}, { selector, barSel: BAR_ID, pickSel: PICK_TOGGLE_ID }).catch((error) => ({ error: error.message }));
throw new Error(`pick did not open configure bar for ${selector}: ${JSON.stringify(snapshot)}`);
}
}
// Wait specifically for the Configure-row submit button to be in the bar.
@@ -528,6 +544,36 @@ export async function setCount(page, count) {
throw new Error(`could not cycle count to ${count}`);
}
/** Select a named Impeccable sub-command from the configure-row picker. */
export async function selectAction(page, action) {
const pickerSelector = '#impeccable-live-picker';
const opened = await page.evaluate(({ barSel, pickerSel }) => {
const query = window.__impeccableLiveQuery || ((selector) => document.querySelector(selector));
const bar = query(barSel);
const picker = query(pickerSel);
const actionControl = [...(bar?.querySelectorAll('button') || [])]
.find((button) => (button.textContent || '').includes('\u25BE'));
if (!actionControl || !picker) return false;
actionControl.click();
return true;
}, { barSel: BAR_ID, pickerSel: pickerSelector });
if (!opened) throw new Error('could not open Live action picker');
await page.waitForFunction((selector) => {
const picker = window.__impeccableLiveQuery(selector);
return picker && picker.style.display !== 'none';
}, pickerSelector, { timeout: 5_000 });
const selected = await page.evaluate(({ pickerSel, value }) => {
const picker = window.__impeccableLiveQuery(pickerSel);
const chip = picker?.querySelector(`button[data-action="${CSS.escape(value)}"]`);
if (!chip) return false;
chip.click();
return true;
}, { pickerSel: pickerSelector, value: action });
if (!selected) throw new Error(`Live action ${JSON.stringify(action)} is unavailable`);
}
/**
* Click Go. Browser POSTs the generate event; the agent picks it up. Headed
* browser runs can occasionally accept the click without leaving configure
@@ -578,7 +624,14 @@ export async function waitForCycling(page, expectedCount, { timeout = 30_000 } =
// Counter format: "1/3", "2/3" etc. Look for any "i/N" with N matching.
const m = text.match(/(\d+)\s*\/\s*(\d+)/);
if (!m) return false;
return parseInt(m[2], 10) === expected;
const wrapper = window.__impeccableLiveQuery('[data-impeccable-variants]');
const debugState = window.__IMPECCABLE_LIVE_CHROME_CORE__?.debugState?.();
const arrived = /^(?:svelte|vue)-component$/.test(wrapper?.dataset.impeccablePreview || '')
? Number(debugState?.arrivedVariants || 0)
: wrapper
? wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length
: 0;
return parseInt(m[2], 10) === expected && arrived >= expected;
},
{ barSel: BAR_ID, expected: expectedCount },
{ timeout },
@@ -590,7 +643,7 @@ export async function waitForCycling(page, expectedCount, { timeout = 30_000 } =
const root = window.__IMPECCABLE_LIVE_CHROME_CORE__?.root?.() || window.__IMPECCABLE_LIVE_UI_ROOT__ || null;
const bar = query(barSel);
const toast = query('#impeccable-live-toast');
const wrapper = document.querySelector('[data-impeccable-variants]');
const wrapper = query('[data-impeccable-variants]');
return {
liveInit: window.__IMPECCABLE_LIVE_INIT__,
adapter: window.__IMPECCABLE_LIVE_ADAPTER__,
@@ -751,7 +804,8 @@ async function ensureVisibleVariant(page, expectedVariant) {
*/
export async function clickDiscard(page) {
// The discard button has just a "✕" glyph as text content.
await page.locator(`${BAR_ID} button`, { hasText: '✕' }).click();
if (await dispatchBarButton(page, '✕')) return;
await clickBarButton(page, '✕');
}
export async function clickEditCopy(page) {
+13
View File
@@ -97,3 +97,16 @@ describe('validateEvent — replace generate (regression)', () => {
);
});
});
describe('validateEvent — worker progress', () => {
it('accepts bounded agent phases and rejects malformed telemetry', () => {
assert.equal(validateEvent({
type: 'agent_phase',
id: VALID_ID,
phase: 'first_variant_generating',
durationMs: 123,
}), null);
assert.match(validateEvent({ type: 'agent_phase', id: VALID_ID, phase: 'Not valid' }), /phase/);
assert.match(validateEvent({ type: 'agent_phase', id: VALID_ID, phase: 'valid', durationMs: -1 }), /durationMs/);
});
});
+126
View File
@@ -0,0 +1,126 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import path from 'node:path';
import {
buildGenerationPreflight,
runGenerationPreflight,
} from '../skill/scripts/live/generation-preflight.mjs';
const SCRIPTS_DIR = path.resolve('skill/scripts');
test('builds a replace preflight from the picker locator', () => {
const command = buildGenerationPreflight({
type: 'generate',
id: 'session-1',
count: 3,
pageUrl: '/pricing',
element: {
id: 'hero',
classes: ['hero', 'hero--dark'],
tagName: 'SECTION',
textContent: 'A faster way to ship',
},
}, SCRIPTS_DIR);
assert.equal(command.mode, 'replace');
assert.deepEqual(command.args.slice(1), [
'--id', 'session-1', '--count', '3',
'--element-id', 'hero',
'--classes', 'hero hero--dark',
'--tag', 'SECTION',
'--text', 'A faster way to ship',
'--page-url', '/pricing',
]);
});
test('builds an insert preflight from the anchor locator', () => {
const command = buildGenerationPreflight({
type: 'generate',
id: 'session-2',
count: 2,
mode: 'insert',
insert: {
position: 'before',
anchor: { classes: ['card'], tagName: 'ARTICLE', textContent: 'Plan' },
},
}, SCRIPTS_DIR);
assert.equal(command.mode, 'insert');
assert.deepEqual(command.args.slice(1), [
'--id', 'session-2', '--count', '2', '--position', 'before',
'--classes', 'card', '--tag', 'ARTICLE', '--text', 'Plan',
]);
});
test('returns scaffold metadata without exposing child-process details', async () => {
const calls = [];
const result = await runGenerationPreflight({
type: 'generate',
id: 'session-3',
count: 1,
element: { classes: ['hero'] },
}, {
scriptsDir: SCRIPTS_DIR,
cwd: '/tmp/example',
async execFileImpl(file, args, options) {
calls.push({ file, args, options });
return { stdout: '{"file":"src/App.jsx","insertLine":12}\n', stderr: '' };
},
});
assert.equal(result.ok, true);
assert.deepEqual(result.scaffold, { file: 'src/App.jsx', insertLine: 12 });
assert.equal(calls[0].file, process.execPath);
assert.equal(calls[0].options.cwd, '/tmp/example');
});
test('skips preflight when the picker has no source locator', async () => {
const result = await runGenerationPreflight({
type: 'generate',
id: 'session-4',
count: 3,
element: { tagName: 'DIV' },
}, { scriptsDir: SCRIPTS_DIR });
assert.deepEqual(result, { ok: false, skipped: true, reason: 'insufficient_locator' });
});
test('yields to the event loop instead of blocking on the child process', async () => {
// The server is single-threaded and leases polls through this call. A
// synchronous spawn froze every other request (Accept, Discard, SSE) for the
// scaffold's full duration — measured at ~7.6s on a large repo.
let tickedDuringPreflight = false;
const pending = runGenerationPreflight({
type: 'generate',
id: 'session-async',
count: 1,
element: { classes: ['hero'] },
}, {
scriptsDir: SCRIPTS_DIR,
execFileImpl: () => new Promise((resolve) => {
setTimeout(() => resolve({ stdout: '{"file":"src/App.jsx"}\n', stderr: '' }), 25);
}),
});
setTimeout(() => { tickedDuringPreflight = true; }, 5);
const result = await pending;
assert.equal(result.ok, true);
assert.equal(tickedDuringPreflight, true, 'the event loop must stay responsive during preflight');
});
test('reports a child-process failure without leaking internals or throwing', async () => {
const error = new Error('spawn failed');
error.stderr = 'live-wrap.mjs: element not found\n';
const result = await runGenerationPreflight({
type: 'generate',
id: 'session-fail',
count: 1,
element: { classes: ['hero'] },
}, {
scriptsDir: SCRIPTS_DIR,
execFileImpl: () => Promise.reject(error),
});
assert.equal(result.ok, false);
assert.equal(result.error, 'live-wrap.mjs: element not found');
assert.ok(typeof result.durationMs === 'number');
});
+68 -1
View File
@@ -5,7 +5,7 @@
import { describe, it, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import { mkdirSync, mkdtempSync, writeFileSync, readFileSync, realpathSync, rmSync } from 'node:fs';
import { existsSync, mkdirSync, mkdtempSync, writeFileSync, readFileSync, realpathSync, rmSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { tmpdir } from 'node:os';
import { fileURLToPath } from 'node:url';
@@ -389,4 +389,71 @@ const title = 'Test';
const afterRemove = readFileSync(file, 'utf-8');
assert.equal(afterRemove, original, 'CRLF file should round-trip cleanly after remove');
});
it('uses an idempotent dev-only client plugin for a Nuxt 4 app directory', () => {
const configSource = `export default defineNuxtConfig({\n devtools: { enabled: false },\n});\n`;
const appSource = `<template>\n <NuxtPage />\n</template>\n`;
writeFileSync(join(tmp, 'nuxt.config.ts'), configSource);
mkdirSync(join(tmp, 'app'), { recursive: true });
writeFileSync(join(tmp, 'app', 'app.vue'), appSource);
const cfgPath = join(tmp, 'config.json');
writeFileSync(cfgPath, JSON.stringify({
files: ['app/app.vue'],
insertBefore: '</template>',
commentSyntax: 'html',
}));
const first = runInject(tmp, cfgPath, ['--port', '8400']);
const pluginPath = join(tmp, 'app', 'plugins', 'impeccable-live.client.ts');
const firstPlugin = readFileSync(pluginPath, 'utf-8');
assert.equal(first.ok, true);
assert.equal(first.adapter, 'nuxt');
assert.equal(first.results[0].file, 'app/plugins/impeccable-live.client.ts');
assert.equal(first.results[0].changed, true);
assert.match(firstPlugin, /if \(!import\.meta\.dev/);
assert.match(firstPlugin, /data-impeccable-live-nuxt/);
assert.match(firstPlugin, /localhost:8400\/live\.js/);
assert.equal(readFileSync(join(tmp, 'nuxt.config.ts'), 'utf-8'), configSource, 'Nuxt config remains user-owned');
assert.equal(readFileSync(join(tmp, 'app', 'app.vue'), 'utf-8'), appSource, 'app.vue remains user-owned');
const second = runInject(tmp, cfgPath, ['--port', '8400']);
assert.equal(second.ok, true);
assert.equal(second.results[0].changed, false, 'same-port reinjection is byte-idempotent');
assert.equal(readFileSync(pluginPath, 'utf-8'), firstPlugin);
const moved = runInject(tmp, cfgPath, ['--port', '8401']);
assert.equal(moved.ok, true);
assert.equal(moved.results[0].changed, true);
assert.match(readFileSync(pluginPath, 'utf-8'), /localhost:8401\/live\.js/);
assert.doesNotMatch(readFileSync(pluginPath, 'utf-8'), /localhost:8400\/live\.js/);
const removed = runInject(tmp, cfgPath, ['--remove']);
assert.equal(removed.ok, true);
assert.equal(removed.adapter, 'nuxt');
assert.equal(removed.results[0].removed, true);
assert.equal(existsSync(pluginPath), false);
assert.equal(readFileSync(join(tmp, 'nuxt.config.ts'), 'utf-8'), configSource);
assert.equal(readFileSync(join(tmp, 'app', 'app.vue'), 'utf-8'), appSource);
});
it('respects a literal Nuxt srcDir and never overwrites a user plugin', () => {
writeFileSync(join(tmp, 'nuxt.config.ts'), `export default defineNuxtConfig({ srcDir: 'client/' });\n`);
mkdirSync(join(tmp, 'client', 'plugins'), { recursive: true });
const pluginPath = join(tmp, 'client', 'plugins', 'impeccable-live.client.ts');
const userPlugin = `export default defineNuxtPlugin(() => {});\n`;
writeFileSync(pluginPath, userPlugin);
const cfgPath = join(tmp, 'config.json');
writeFileSync(cfgPath, JSON.stringify({
files: ['client/app.vue'],
insertBefore: '</template>',
commentSyntax: 'html',
}));
const result = runInject(tmp, cfgPath, ['--port', '8400']);
assert.equal(result.ok, false);
assert.equal(result.adapter, 'nuxt');
assert.equal(result.results[0].error, 'nuxt_plugin_conflict');
assert.equal(readFileSync(pluginPath, 'utf-8'), userPlugin);
});
});
+99
View File
@@ -0,0 +1,99 @@
/**
* Tests for live/poll-lanes.mjs which pending event a poll gets next.
* Run with: node --test tests/live-poll-lanes.test.mjs
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { eventPriority, selectAvailablePendingEvent } from '../skill/scripts/live/poll-lanes.mjs';
const entry = (type, seq, leaseUntil = 0, id = type + seq) => ({ event: { id, type }, leaseUntil, seq });
describe('poll lane priority', () => {
it('puts terminal user actions ahead of generation', () => {
for (const type of ['accept', 'discard', 'exit']) {
assert.ok(
eventPriority({ type }) < eventPriority({ type: 'generate' }),
`${type} must outrank generate`,
);
}
});
it('ranks unknown event types last rather than first', () => {
assert.ok(eventPriority({ type: 'something-new' }) > eventPriority({ type: 'generate' }));
assert.ok(eventPriority({}) > eventPriority({ type: 'generate' }));
});
// This is what makes the browser's optimistic Accept safe. The browser returns
// to PICKING as soon as /events durably journals the accept, before the source
// write happens, so the user can pick and hit Go while the accept is still
// queued. If that generate were leased first, its preflight would wrap source
// that still contains the previous session's variant markers.
it('delivers a queued accept before a generate the user queued afterwards', () => {
const selected = selectAvailablePendingEvent([
entry('accept', 1),
entry('generate', 2),
]);
assert.equal(selected.event.type, 'accept');
});
it('delivers the accept first even when the generate was queued earlier', () => {
const selected = selectAvailablePendingEvent([
entry('generate', 1),
entry('accept', 2),
]);
assert.equal(
selected.event.type,
'accept',
'priority must beat arrival order, or a slow poller preflights against stale source',
);
});
it('breaks ties within one lane by arrival order', () => {
const selected = selectAvailablePendingEvent([
entry('generate', 7),
entry('generate', 3),
]);
assert.equal(selected.seq, 3);
});
});
describe('poll lane availability', () => {
it('skips an entry whose lease is still held', () => {
const now = 1_000_000;
const selected = selectAvailablePendingEvent([
entry('accept', 1, now + 30_000),
entry('generate', 2),
], { now });
assert.equal(selected.event.type, 'generate', 'a leased accept must not be handed out twice');
});
it('re-offers an entry once its lease has expired', () => {
const now = 1_000_000;
const selected = selectAvailablePendingEvent([entry('accept', 1, now - 1)], { now });
assert.equal(selected.event.type, 'accept');
});
it('returns null when everything is leased', () => {
const now = 1_000_000;
assert.equal(selectAvailablePendingEvent([entry('accept', 1, now + 5_000)], { now }), null);
});
it('returns null for an empty queue', () => {
assert.equal(selectAvailablePendingEvent([]), null);
});
it('restricts delivery to the requested types', () => {
const entries = [entry('accept', 1), entry('generate', 2)];
assert.equal(selectAvailablePendingEvent(entries, { types: ['generate'] }).event.type, 'generate');
assert.equal(selectAvailablePendingEvent(entries, { types: new Set(['generate']) }).event.type, 'generate');
assert.equal(selectAvailablePendingEvent(entries, { types: ['steer'] }), null);
});
it('ignores an empty or absent type filter instead of starving the queue', () => {
const entries = [entry('generate', 1)];
assert.equal(selectAvailablePendingEvent(entries, { types: null }).event.type, 'generate');
assert.equal(selectAvailablePendingEvent(entries, {}).event.type, 'generate');
});
});
+19
View File
@@ -6,6 +6,7 @@ import {
buildPollReplyPayload,
isEventPending,
manualApplyPollBanner,
normalizePollTypes,
parseReplyArgs,
requiresAgentReply,
} from '../skill/scripts/live-poll.mjs';
@@ -25,6 +26,15 @@ describe('live-poll reply payloads', () => {
'event=live_poll.reply_data actor=agent operation=completion_ack risk=carbonize_flag_dropped_before_server_journal expected={"carbonize":true} actual=' + JSON.stringify(payload.data),
);
});
it('preserves the leased source event type when concurrent work shares a session id', () => {
const payload = buildPollReplyPayload('token-1', {
id: 'abc12345',
type: 'agent_done',
sourceEventType: 'accept',
});
assert.equal(payload.sourceEventType, 'accept');
});
});
describe('live-poll accept handling', () => {
@@ -134,6 +144,7 @@ describe('live-poll stream helpers', () => {
assert.equal(requiresAgentReply({ type: 'generate' }), true);
assert.equal(requiresAgentReply({ type: 'steer' }), true);
assert.equal(requiresAgentReply({ type: 'manual_edit_apply' }), true);
assert.equal(requiresAgentReply({ type: 'carbonize_cleanup' }), true);
assert.equal(requiresAgentReply({ type: 'prefetch' }), false);
assert.equal(requiresAgentReply({ type: 'accept' }), false);
assert.equal(requiresAgentReply({ type: 'timeout' }), false);
@@ -149,4 +160,12 @@ describe('live-poll stream helpers', () => {
assert.equal(isEventPending(status, 'abc12345'), true);
assert.equal(isEventPending(status, '00000000'), false);
});
it('normalizes a non-overlapping foreground control lane', () => {
assert.deepEqual(
normalizePollTypes('steer,manual_edit_apply,carbonize_cleanup,exit,steer'),
['steer', 'manual_edit_apply', 'carbonize_cleanup', 'exit'],
);
});
});
+46 -7
View File
@@ -1,17 +1,18 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { compileProviderBlocks } from '../scripts/lib/utils.js';
import { PROVIDERS } from '../scripts/lib/transformers/providers.js';
const ROOT = process.cwd();
describe('live reference authoring contract', () => {
it('keeps setup guidance focused on inferred target paths', () => {
it('keeps setup guidance focused on routing live to its reference', () => {
const skillSrc = readFileSync(join(ROOT, 'skill/SKILL.src.md'), 'utf-8');
const liveMd = readFileSync(join(ROOT, 'skill/reference/live.md'), 'utf-8');
assert.match(skillSrc, /infer the concrete path and append `--target <path>` to the same command/);
assert.match(skillSrc, /If the user invoked a sub-command[\s\S]*?reference\/<command>\.md/);
assert.doesNotMatch(skillSrc, /Use this same scripts directory for all Impeccable helper commands/);
assert.doesNotMatch(skillSrc, /walk upward for the nearest project `\.agents`, `\.claude`, or `\.cursor` skill/);
assert.doesNotMatch(skillSrc, /## Context diagnostics/);
@@ -22,7 +23,7 @@ describe('live reference authoring contract', () => {
const skillSrc = readFileSync(join(ROOT, 'skill/SKILL.src.md'), 'utf-8');
const liveMd = readFileSync(join(ROOT, 'skill/reference/live.md'), 'utf-8');
assert.match(skillSrc, /--target <path>/);
assert.match(skillSrc, /If the user invoked a sub-command[\s\S]*?reference\/<command>\.md/);
assert.doesNotMatch(skillSrc, /TARGET_SELECTION_REQUIRED/);
assert.doesNotMatch(skillSrc, /productStatus/);
assert.doesNotMatch(skillSrc, /designStatus/);
@@ -40,13 +41,16 @@ describe('live reference authoring contract', () => {
const openingContract = liveMd.split('\n').slice(0, 60).join('\n');
assert.match(liveMd, /1\. `live\.mjs`: boot\./);
assert.match(liveMd, /3\. Poll loop with the default long timeout \(600000 ms\)\. After every event or `--reply`, run `live-poll\.mjs` again immediately\. Never pass a short `--timeout=`\./);
assert.match(liveMd, /3\. Poll loop with the default long timeout \(600000 ms\)\. Run `live-poll\.mjs` again immediately.*Codex runs this one-shot poll in the foreground\./);
assert.match(openingContract, /## Poll loop/);
assert.match(openingContract, /No step skipped, no step reordered\./);
assert.doesNotMatch(liveMd, /live-copy-edits\.md/);
assert.doesNotMatch(liveMd, /IMPECCABLE_LIVE_COPY_AGENT|mock/);
assert.match(liveMd, /"manual_edit_apply" → Handle Manual Edit Apply/);
assert.match(liveMd, /## Handle `manual_edit_apply`/);
assert.match(openingContract, /Codex.*one-shot poll in a \*\*yielded foreground exec session\*\*/);
assert.doesNotMatch(openingContract, /dedicated app-server generation lane by default/);
assert.doesNotMatch(liveMd, /app-server|IMPECCABLE_LIVE_CODEX_WORKER|codexWorker/);
assert.ok(
liveMd.indexOf('## Handle `manual_edit_apply`') > liveMd.indexOf('## Handle `prefetch`'),
'manual_edit_apply handler section must sit after prefetch in the dispatch order',
@@ -60,6 +64,25 @@ describe('live reference authoring contract', () => {
assert.match(liveMd, /delegate source edits to `impeccable_manual_edit_applier`/);
assert.match(liveMd, /The subagent must not poll or reply/);
assert.match(liveMd, /parent live thread keeps the foreground poll loop/);
// Generation stays in the main thread on every harness. The generator subagent
// was removed after the first real Claude Code run: the parent has to
// hand-compress the design system into the handoff, and compression is lossy.
// It shipped 0 `var(--token)` uses and 22 raw oklch literals, violating its own
// "never invent raw colors" rule, then needed hundreds of lines of hand
// carbonize to repair. The parent's context is the job, not overhead.
assert.doesNotMatch(
liveMd,
/impeccable[-_]live[-_]generator/,
'live generation must not be delegated to a subagent',
);
assert.equal(
existsSync(join(ROOT, 'skill/agents/impeccable-live-generator.md')),
false,
'the live generator agent must not come back without the context problem being solved',
);
// Copy edits keep their subagent: applying a known set of ops to a named file
// is self-contained work, so an isolated context costs nothing.
assert.match(manualAgentMd, /codex-name: impeccable_manual_edit_applier/);
assert.match(liveMd, /live-accept\.mjs --page-url PAGE_URL/);
assert.match(liveMd, /If `repair` is present/);
assert.match(liveMd, /Fix the current source/);
@@ -106,8 +129,11 @@ describe('live reference authoring contract', () => {
it('keeps Codex sandbox guidance Codex-only', () => {
const liveMd = readFileSync(join(ROOT, 'skill/reference/live.md'), 'utf-8');
const codexLiveMd = compileProviderBlocks(liveMd, ['codex']);
const claudeLiveMd = compileProviderBlocks(liveMd, ['claude-code', 'claude']);
// Compile with each provider's real tags rather than hand-written ones, so a
// providers.js misconfiguration fails here instead of shipping.
const compileFor = (provider) => compileProviderBlocks(liveMd, PROVIDERS[provider].providerTags);
const codexLiveMd = compileFor('codex');
const claudeLiveMd = compileFor('claude-code');
assert.match(
codexLiveMd,
@@ -131,6 +157,19 @@ describe('live reference authoring contract', () => {
);
});
it('routes every helper command through the per-provider scripts path', () => {
const liveMd = readFileSync(join(ROOT, 'skill/reference/live.md'), 'utf-8');
// A recipe that hardcodes `.agents/skills/...` is only correct for the Codex
// repo-skills bundle. Every other harness would be told to run the helper
// from a directory its install never creates.
assert.doesNotMatch(
liveMd,
/node\s+\.[a-z-]+\/skills\/impeccable\/scripts\//,
'live.md must not hardcode a harness config dir; use {{scripts_path}}',
);
});
it('keeps live preview CSS guidance capability-mode driven', () => {
const liveMd = readFileSync(join(ROOT, 'skill/reference/live.md'), 'utf-8');
+466 -1
View File
@@ -111,6 +111,31 @@ it('gitignores local Impeccable runtime artifacts', () => {
assert.match(ignored, /\.impeccable\/live\/deferred-svelte-component-accepts\.json/);
});
it('Stop Live removes Nuxt Vue preview modules and their generated root', async () => {
const cwd = mkdtempSync(join(tmpdir(), 'impeccable-live-nuxt-stop-'));
const generatedRoot = join(cwd, 'app/.impeccable-live');
mkdirSync(join(generatedRoot, 'session123'), { recursive: true });
writeFileSync(join(cwd, 'nuxt.config.ts'), 'export default defineNuxtConfig({});\n');
writeFileSync(join(generatedRoot, '__runtime.js'), 'export const runtime = true;\n');
writeFileSync(join(generatedRoot, 'session123', 'v1.vue'), '<template><h1>Preview</h1></template>\n');
let live;
try {
live = await startServer(8498, { cwd });
const exited = new Promise((resolve) => live.proc.once('exit', resolve));
await stopServer(live.port, live.token);
await Promise.race([
exited,
new Promise((_, reject) => setTimeout(() => reject(new Error('live server did not stop')), 2_000)),
]);
assert.equal(existsSync(join(generatedRoot, '__runtime.js')), false);
assert.equal(existsSync(generatedRoot), false);
} finally {
live?.proc?.kill();
rmSync(cwd, { recursive: true, force: true });
}
});
async function readSseUntil(reader, decoder, needle, maxReads = 12) {
let text = '';
for (let i = 0; i < maxReads; i++) {
@@ -224,6 +249,40 @@ describe('live-server integration', () => {
assert.equal(data.agentPolling, false);
});
it('/status stops reporting agentPolling as soon as a poll returns an event', async () => {
await drainPolls(server);
const pollPromise = fetch(
`http://localhost:${server.port}/poll?token=${server.token}&timeout=5000&leaseMs=30000`,
).then((response) => response.json());
await new Promise((resolve) => setTimeout(resolve, 50));
const eventRes = await fetch(`http://localhost:${server.port}/events`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
token: server.token,
type: 'generate',
id: 'aabbcc77',
action: 'impeccable',
count: 1,
pageUrl: '/',
element: { outerHTML: '<button>Truthful poll</button>', tagName: 'BUTTON' },
}),
});
assert.equal(eventRes.status, 200);
const event = await pollPromise;
assert.equal(event.id, 'aabbcc77');
const status = await fetch(`http://localhost:${server.port}/status?token=${server.token}`).then((response) => response.json());
assert.equal(status.agentPolling, false);
await fetch(`http://localhost:${server.port}/poll`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: server.token, id: event.id, type: 'done', sourceEventType: 'generate' }),
});
});
it('/live.js serves script with token injected', async () => {
const res = await fetch(`http://localhost:${server.port}/live.js`);
assert.equal(res.status, 200);
@@ -2023,6 +2082,59 @@ colors: {}
assert.equal(data.type, 'timeout');
});
it('/poll type filters keep parallel poll consumers disjoint', async () => {
await drainPolls(server);
const controlPoll = fetch(
`http://localhost:${server.port}/poll?token=${server.token}&timeout=2000&types=steer,manual_edit_apply,carbonize_cleanup,exit`,
).then((response) => response.json());
const workerPoll = fetch(
`http://localhost:${server.port}/poll?token=${server.token}&timeout=2000&types=generate,accept,discard,prefetch`,
).then((response) => response.json());
const steer = {
token: server.token,
type: 'steer',
id: 'aabbcc01',
pageUrl: '/',
message: 'Keep this on the foreground lane',
};
const generate = {
token: server.token,
type: 'generate',
id: 'aabbcc02',
action: 'impeccable',
count: 1,
pageUrl: '/',
element: { outerHTML: '<button id="lane-test">Book</button>', id: 'lane-test', tagName: 'BUTTON' },
};
for (const event of [steer, generate]) {
const response = await fetch(`http://localhost:${server.port}/events`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(event),
});
assert.equal(response.status, 200);
}
const [controlEvent, workerEvent] = await Promise.all([controlPoll, workerPoll]);
assert.equal(controlEvent.type, 'steer');
assert.equal(controlEvent.id, steer.id);
assert.equal(workerEvent.type, 'generate');
assert.equal(workerEvent.id, generate.id);
for (const reply of [
{ id: steer.id, type: 'steer_done', message: 'Control lane handled it', sourceEventType: 'steer' },
{ id: generate.id, type: 'done', sourceEventType: 'generate' },
]) {
const response = await fetch(`http://localhost:${server.port}/poll`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: server.token, ...reply }),
});
assert.equal(response.status, 200);
}
});
it('/poll rejects invalid token', async () => {
const res = await fetch(`http://localhost:${server.port}/poll?token=wrong&timeout=100`);
assert.equal(res.status, 401);
@@ -2142,6 +2254,9 @@ colors: {}
assert.equal(event.id, 'a1b2c3d4');
assert.equal(event.action, 'bolder');
assert.equal(event.count, 2);
assert.equal(event.scaffoldAttempted, true);
assert.equal(event.scaffoldError, 'insufficient_locator');
assert.equal(Number.isFinite(event.generationReadyAt), true);
await fetch(`http://localhost:${server.port}/poll`, {
method: 'POST',
@@ -2187,6 +2302,42 @@ colors: {}
it('accepts checkpoint events without exposing them as agent poll work', async () => {
await drainPolls(server);
const partialRes = await fetch(`http://localhost:${server.port}/events`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
token: server.token,
type: 'checkpoint',
id: 'a1b2c3d7',
phase: 'cycling',
reason: 'browser_resumed',
revision: 1,
owner: 'browser-a',
expectedVariants: 3,
arrivedVariants: 1,
visibleVariant: 1,
}),
});
assert.equal(partialRes.status, 200);
const secondRes = await fetch(`http://localhost:${server.port}/events`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
token: server.token,
type: 'checkpoint',
id: 'a1b2c3d7',
phase: 'cycling',
reason: 'variants_progress',
revision: 2,
owner: 'browser-a',
expectedVariants: 3,
arrivedVariants: 2,
visibleVariant: 2,
}),
});
assert.equal(secondRes.status, 200);
const res = await fetch(`http://localhost:${server.port}/events`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -2195,8 +2346,10 @@ colors: {}
type: 'checkpoint',
id: 'a1b2c3d7',
phase: 'cycling',
revision: 2,
reason: 'variants_ready',
revision: 3,
owner: 'browser-a',
expectedVariants: 3,
arrivedVariants: 3,
visibleVariant: 2,
paramValues: { density: 'packed' },
@@ -2214,6 +2367,148 @@ colors: {}
const snapshot = JSON.parse(readFileSync(join(getLiveSessionsDir(server.cwd), 'a1b2c3d7.snapshot.json'), 'utf-8'));
assert.equal(snapshot.visibleVariant, 2);
assert.deepEqual(snapshot.paramValues, { density: 'packed' });
assert.ok(snapshot.generationTimings.first_reviewable?.at);
assert.ok(snapshot.generationTimings.second_reviewable?.at);
assert.ok(snapshot.generationTimings.all_variants_ready?.at);
assert.ok(snapshot.generationTimings.first_reviewable.at <= snapshot.generationTimings.second_reviewable.at);
assert.ok(snapshot.generationTimings.second_reviewable.at <= snapshot.generationTimings.all_variants_ready.at);
const atomicRes = await fetch(`http://localhost:${server.port}/events`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
token: server.token,
type: 'checkpoint',
id: 'a1b2c3da',
phase: 'cycling',
reason: 'variants_ready',
revision: 1,
owner: 'browser-a',
expectedVariants: 3,
arrivedVariants: 3,
visibleVariant: 1,
}),
});
assert.equal(atomicRes.status, 200);
const atomicSnapshot = JSON.parse(readFileSync(join(getLiveSessionsDir(server.cwd), 'a1b2c3da.snapshot.json'), 'utf-8'));
assert.ok(atomicSnapshot.generationTimings.first_reviewable?.at);
assert.equal(
atomicSnapshot.generationTimings.first_reviewable.at,
atomicSnapshot.generationTimings.all_variants_ready?.at,
'atomic delivery makes the first variant and full set reviewable together',
);
});
it('journals and streams agent progress without leasing it as work', async () => {
await drainPolls(server);
const controller = new AbortController();
const sseRes = await fetch(
`http://localhost:${server.port}/events?token=${server.token}`,
{ signal: controller.signal },
);
const reader = sseRes.body.getReader();
await reader.read();
const progress = await fetch(`http://localhost:${server.port}/events`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
token: server.token,
type: 'agent_phase',
id: 'a1b2c3e1',
phase: 'first_variant_generating',
owner: 'live-agent',
}),
});
assert.equal(progress.status, 200);
const message = new TextDecoder().decode((await reader.read()).value);
controller.abort();
assert.match(message, /"type":"agent_phase"/);
assert.match(message, /"phase":"first_variant_generating"/);
const polled = await fetch(`http://localhost:${server.port}/poll?token=${server.token}&timeout=50`).then(r => r.json());
assert.equal(polled.type, 'timeout');
const snapshot = JSON.parse(readFileSync(join(getLiveSessionsDir(server.cwd), 'a1b2c3e1.snapshot.json'), 'utf-8'));
assert.ok(snapshot.generationTimings.first_variant_generating?.at);
});
it('streams Svelte component checkpoints as progressive preview updates', async () => {
const controller = new AbortController();
const sseRes = await fetch(
`http://localhost:${server.port}/events?token=${server.token}`,
{ signal: controller.signal },
);
const reader = sseRes.body.getReader();
const decoder = new TextDecoder();
await reader.read(); // connected
const res = await fetch(`http://localhost:${server.port}/events`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
token: server.token,
type: 'checkpoint',
id: 'a1b2c3de',
phase: 'cycling',
reason: 'variants_progress',
revision: 1,
owner: 'svelte-worker',
expectedVariants: 3,
arrivedVariants: 1,
visibleVariant: 1,
previewMode: 'svelte-component',
previewFile: 'node_modules/.impeccable-live/a1b2c3de/manifest.json',
sourceFile: 'src/routes/+page.svelte',
}),
});
assert.equal(res.status, 200);
const { value } = await reader.read();
const message = decoder.decode(value);
assert.match(message, /"type":"variant_progress"/);
assert.match(message, /"arrivedVariants":1/);
assert.match(message, /"previewMode":"svelte-component"/);
controller.abort();
});
it('streams source checkpoints so no-HMR frameworks can review variant 1', async () => {
const controller = new AbortController();
const sseRes = await fetch(
`http://localhost:${server.port}/events?token=${server.token}`,
{ signal: controller.signal },
);
const reader = sseRes.body.getReader();
const decoder = new TextDecoder();
await reader.read(); // connected
const res = await fetch(`http://localhost:${server.port}/events`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
token: server.token,
type: 'checkpoint',
id: 'a1b2c3df',
phase: 'cycling',
reason: 'variants_progress',
revision: 1,
owner: 'source-worker',
expectedVariants: 3,
arrivedVariants: 1,
visibleVariant: 1,
previewMode: 'source',
previewFile: 'app/pages/index.vue',
sourceFile: 'app/pages/index.vue',
publicationKind: 'params',
}),
});
assert.equal(res.status, 200);
const { value } = await reader.read();
const message = decoder.decode(value);
assert.match(message, /"type":"variant_progress"/);
assert.match(message, /"arrivedVariants":1/);
assert.match(message, /"previewMode":"source"/);
assert.match(message, /"previewFile":"app\/pages\/index.vue"/);
assert.match(message, /"publicationKind":"params"/);
controller.abort();
});
it('redelivers an unacknowledged browser event after helper server restart', async () => {
@@ -2360,6 +2655,105 @@ colors: {}
assert.equal(acked.type, 'timeout', 'acked event should be removed from the poll queue');
});
it('retires the leased Generate when early Accept or Discard takes ownership', async () => {
await drainPolls(server);
for (const [type, id] of [['accept', 'ea11ac01'], ['discard', 'ea11dc01']]) {
const generated = await fetch(`http://localhost:${server.port}/events`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
token: server.token,
type: 'generate',
id,
action: 'bolder',
count: 3,
element: { outerHTML: '<section>early choice</section>', tagName: 'section' },
}),
});
assert.equal(generated.status, 200);
const generation = await fetch(`http://localhost:${server.port}/poll?token=${server.token}&types=generate&timeout=100&leaseMs=40`).then((response) => response.json());
assert.equal(generation.id, id);
const chosen = await fetch(`http://localhost:${server.port}/events`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
token: server.token,
type,
id,
...(type === 'accept' ? { variantId: '1' } : {}),
}),
});
assert.equal(chosen.status, 200);
const choice = await fetch(`http://localhost:${server.port}/poll?token=${server.token}&types=${type}&timeout=100&leaseMs=40`).then((response) => response.json());
assert.equal(choice.type, type);
assert.equal(choice.id, id);
const reply = await fetch(`http://localhost:${server.port}/poll`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
token: server.token,
id,
sourceEventType: type,
type: type === 'discard' ? 'discarded' : 'complete',
}),
});
assert.equal(reply.status, 200);
await new Promise((resolve) => setTimeout(resolve, 60));
const stale = await fetch(`http://localhost:${server.port}/poll?token=${server.token}&types=generate&timeout=30&leaseMs=20`).then((response) => response.json());
assert.equal(stale.type, 'timeout', `${type} must prevent Generate redelivery after its old lease expires`);
const status = await fetch(`http://localhost:${server.port}/status?token=${server.token}`).then((response) => response.json());
assert.equal(status.pendingEvents.some((event) => event.id === id && event.type === 'generate'), false);
}
});
it('releases a failed worker Generate lease without consuming or broadcasting it', async () => {
await drainPolls(server);
const id = 'fa11bac1';
const generated = await fetch(`http://localhost:${server.port}/events`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
token: server.token,
type: 'generate',
id,
action: 'bolder',
count: 3,
element: { outerHTML: '<article>fallback</article>', tagName: 'article' },
}),
});
assert.equal(generated.status, 200);
const leased = await fetch(`http://localhost:${server.port}/poll?token=${server.token}&types=generate&timeout=100&leaseMs=5000`).then((response) => response.json());
assert.equal(leased.id, id);
const retried = await fetch(`http://localhost:${server.port}/poll`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
token: server.token,
id,
type: 'retry',
sourceEventType: 'generate',
}),
});
assert.equal(retried.status, 200);
assert.equal((await retried.json()).released, true);
const fallback = await fetch(`http://localhost:${server.port}/poll?token=${server.token}&types=generate&timeout=100&leaseMs=100`).then((response) => response.json());
assert.equal(fallback.id, id);
assert.equal(fallback.type, 'generate');
const status = await fetch(`http://localhost:${server.port}/status?token=${server.token}`).then((response) => response.json());
assert.equal(status.pendingEvents.some((event) => event.id === id && event.type === 'generate'), true);
const done = await fetch(`http://localhost:${server.port}/poll`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: server.token, id, type: 'done', sourceEventType: 'generate' }),
});
assert.equal(done.status, 200);
});
it('wakes a parked poll as soon as a missed-ack lease expires', async () => {
await drainPolls(server);
@@ -2690,4 +3084,75 @@ colors: {}
const data = await postRes.json();
assert.match(data.error, /freeformPrompt or annotations/i);
});
// A stale generate worker's `error` reply used to acknowledge *any* pending
// event for its id, because inferSourceEventType returned undefined and
// acknowledgePendingEvent treats that as a wildcard. It ate the user's queued
// Accept, which was then never handed to an agent: the browser sat in SAVING
// forever and a restart could not requeue it.
it('a stale generate error reply does not consume a queued accept', async () => {
await drainPolls(server);
const id = 'ee55ff66';
await fetch(`http://localhost:${server.port}/events`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
token: server.token,
type: 'generate',
id,
action: 'impeccable',
count: 1,
pageUrl: '/',
element: { outerHTML: '<button>Book</button>' },
}),
});
// Agent leases the generate.
const leased = await (await fetch(
`http://localhost:${server.port}/poll?token=${server.token}&timeout=200&leaseMs=60000`,
)).json();
assert.equal(leased.id, id);
assert.equal(leased.type, 'generate');
// User accepts. This retires the pending generate and queues the accept.
await fetch(`http://localhost:${server.port}/events`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: server.token, type: 'accept', id, variantId: '1' }),
});
// The stale generate worker now fails, using live.md's documented reply.
const errRes = await fetch(`http://localhost:${server.port}/poll`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: server.token, id, type: 'error', message: 'late failure' }),
});
assert.equal(errRes.status, 200);
const status = await (await fetch(
`http://localhost:${server.port}/status?token=${server.token}`,
)).json();
assert.equal(
status.pendingEvents.some((e) => e.id === id && e.type === 'accept'),
true,
'the queued accept must survive a stale generate error',
);
// And it must still be deliverable to the next agent that polls.
const next = await (await fetch(
`http://localhost:${server.port}/poll?token=${server.token}&timeout=500&leaseMs=30000`,
)).json();
assert.equal(next.id, id);
assert.equal(next.type, 'accept', 'the accept must reach an agent');
// Acknowledge the accept explicitly. drainPolls replies `done`, which maps
// to `generate`, so it can never retire an accept and would re-lease it in
// a loop forever.
await fetch(`http://localhost:${server.port}/poll`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: server.token, id, type: 'complete', sourceEventType: 'accept' }),
});
});
});
+109
View File
@@ -62,6 +62,69 @@ describe('live-session-store', () => {
assert.equal(active[0].id, 'session-a');
});
it('persists the progressive variant plan across worker restarts', () => {
const store = createLiveSessionStore({ cwd: tmp, sessionId: 'planned-session' });
const plan = {
identityLock: ['Preserve copy'],
directions: [
{ variantId: 1, name: 'Hierarchy', axis: 'scale', intent: 'Increase hierarchy' },
{ variantId: 2, name: 'Composition', axis: 'layout', intent: 'Recompose the root' },
{ variantId: 3, name: 'Rhythm', axis: 'spacing', intent: 'Increase rhythm' },
],
};
store.appendEvent({ type: 'generate', id: 'planned-session', count: 3 });
store.appendEvent({ type: 'variant_plan', id: 'planned-session', plan });
store.appendEvent({ type: 'checkpoint', id: 'planned-session', revision: 1, arrivedVariants: 1 });
const restarted = createLiveSessionStore({ cwd: tmp, sessionId: 'planned-session' });
assert.deepEqual(restarted.getSnapshot('planned-session').variantPlan, plan);
});
it('tombstones generation on early accept and ignores late generation writes', () => {
const store = createLiveSessionStore({ cwd: tmp, sessionId: 'early-accept' });
store.appendEvent({
type: 'generate',
id: 'early-accept',
action: 'polish',
count: 3,
element: { outerHTML: '<section>Hero</section>', tagName: 'section' },
});
store.appendEvent({
type: 'checkpoint',
id: 'early-accept',
revision: 1,
phase: 'cycling',
arrivedVariants: 1,
visibleVariant: 1,
});
store.appendEvent({ type: 'accept', id: 'early-accept', variantId: '1' });
store.appendEvent({
type: 'checkpoint',
id: 'early-accept',
revision: 2,
phase: 'variants_ready',
arrivedVariants: 3,
visibleVariant: 3,
});
store.appendEvent({
type: 'agent_done',
id: 'early-accept',
file: 'src/App.jsx',
arrivedVariants: 3,
});
const snapshot = store.getSnapshot('early-accept');
assert.equal(snapshot.phase, 'accept_requested');
assert.equal(snapshot.generationCanceled, true);
assert.equal(snapshot.cancelReason, 'accept');
assert.equal(snapshot.arrivedVariants, 1);
assert.equal(snapshot.visibleVariant, 1);
assert.equal(
snapshot.diagnostics.some((entry) => entry.error === 'late_generation_event_ignored'),
true,
);
});
it('reports corrupted journal lines while preserving valid prior events', () => {
const store = createLiveSessionStore({ cwd: tmp, sessionId: 'corrupt-session' });
store.appendEvent({
@@ -161,6 +224,30 @@ describe('live-session-store', () => {
);
});
it('tracks publication and browser checkpoint revisions independently', () => {
const store = createLiveSessionStore({ cwd: tmp, sessionId: 'split-revisions' });
store.appendEvent({
type: 'generate', id: 'split-revisions', count: 3,
element: { outerHTML: '<section>Hero</section>', tagName: 'section' },
});
store.appendEvent({
type: 'checkpoint', id: 'split-revisions', revision: 8, revisionDomain: 'browser',
owner: 'browser-a', phase: 'cycling', visibleVariant: 2,
});
store.appendEvent({
type: 'checkpoint', id: 'split-revisions', revision: 3, revisionDomain: 'publication',
reason: 'variants_progress', phase: 'cycling', arrivedVariants: 3,
});
const snapshot = store.getSnapshot('split-revisions');
assert.equal(snapshot.browserCheckpointRevision, 8);
assert.equal(snapshot.checkpointRevision, 8);
assert.equal(snapshot.publicationCheckpointRevision, 3);
assert.equal(snapshot.visibleVariant, 2);
assert.equal(snapshot.arrivedVariants, 3);
assert.equal(snapshot.diagnostics.some((entry) => entry.error === 'stale_checkpoint_ignored'), false);
});
it('keeps carbonize-required accepted sessions active until explicit completion', () => {
const store = createLiveSessionStore({ cwd: tmp, sessionId: 'carbonize-session' });
store.appendEvent({
@@ -284,4 +371,26 @@ describe('live-session-store', () => {
assert.equal(migratedSnapshot.expectedVariants, 2);
assert.equal(migratedSnapshot.sourceFile, 'src/App.jsx');
});
it('records generation phase timings without replacing the workflow phase', () => {
const store = createLiveSessionStore({ cwd: tmp, sessionId: 'phase-session' });
store.appendEvent({
type: 'generate',
id: 'phase-session',
count: 3,
element: { classes: ['hero'] },
});
store.appendEvent({
type: 'agent_phase',
id: 'phase-session',
phase: 'source_ready',
at: 1234,
durationMs: 42,
});
const snapshot = store.getSnapshot('phase-session');
assert.equal(snapshot.phase, 'generate_requested');
assert.equal(snapshot.generationPhase, 'source_ready');
assert.deepEqual(snapshot.generationTimings.source_ready, { at: 1234, durationMs: 42 });
});
});
+109
View File
@@ -0,0 +1,109 @@
/**
* Tests for live/source-lock.mjs the per-source-file mutex guarding the
* accept/publish critical sections.
* Run with: node --test tests/live-source-lock.test.mjs
*/
import { describe, it, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, mkdtempSync, readFileSync, rmSync, utimesSync, writeFileSync, mkdirSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { tmpdir } from 'node:os';
import { sourceLockPath, withSourceLockSync } from '../skill/scripts/live/source-lock.mjs';
const TARGET = 'src/page.html';
describe('live source-lock', () => {
let tmp;
beforeEach(() => {
tmp = mkdtempSync(join(tmpdir(), 'impeccable-source-lock-'));
mkdirSync(join(tmp, 'src'), { recursive: true });
writeFileSync(join(tmp, TARGET), '<div>original</div>\n');
});
afterEach(() => rmSync(tmp, { recursive: true, force: true }));
const writeLock = (body) => {
const lockPath = sourceLockPath(TARGET, tmp);
mkdirSync(dirname(lockPath), { recursive: true });
writeFileSync(lockPath, JSON.stringify(body) + '\n');
return lockPath;
};
it('runs the critical section and releases the lock', () => {
const lockPath = sourceLockPath(TARGET, tmp);
const result = withSourceLockSync(TARGET, 'accept:a', () => {
assert.equal(existsSync(lockPath), true, 'lock must exist while held');
return 'done';
}, { cwd: tmp });
assert.equal(result, 'done');
assert.equal(existsSync(lockPath), false, 'lock must be released');
});
it('throws SOURCE_LOCKED when a live owner holds the lock', () => {
// process.pid is this very process, so the recorded owner is alive.
writeLock({ owner: 'publish:x', token: 'other', pid: process.pid, at: Date.now() });
assert.throws(
() => withSourceLockSync(TARGET, 'accept:a', () => 'should not run', { cwd: tmp }),
(err) => err.code === 'SOURCE_LOCKED',
);
});
it('does not sweep a live owners lock no matter how old it is', () => {
// Age alone must not make a lock stale: a holder suspended mid-write would
// otherwise have a second writer admitted to the same source file.
const lockPath = writeLock({ owner: 'publish:x', token: 'other', pid: process.pid, at: 0 });
const ancient = new Date(Date.now() - 10 * 60_000);
utimesSync(lockPath, ancient, ancient);
assert.throws(
() => withSourceLockSync(TARGET, 'accept:a', () => 'should not run', { cwd: tmp }),
(err) => err.code === 'SOURCE_LOCKED',
'an old but live lock was stolen',
);
});
it('reclaims a lock whose owner process is gone, without waiting out a timeout', () => {
// PID 2^22 is above the platform maximum, so it can never be running.
writeLock({ owner: 'publish:crashed', token: 'other', pid: 4194304, at: Date.now() });
const result = withSourceLockSync(TARGET, 'accept:a', () => 'acquired', { cwd: tmp });
assert.equal(result, 'acquired', 'a crashed holder must not block the next writer');
});
it('leaves a replacement lock alone when its own was swept', () => {
// Simulates: our lock got reclaimed and another writer now owns the file.
// Releasing must not unlink the replacement and admit a third writer.
const lockPath = sourceLockPath(TARGET, tmp);
withSourceLockSync(TARGET, 'accept:a', () => {
writeFileSync(lockPath, JSON.stringify({
owner: 'publish:other', token: 'a-different-token', pid: process.pid, at: Date.now(),
}) + '\n');
}, { cwd: tmp });
assert.equal(existsSync(lockPath), true, 'another owners lock must survive our release');
assert.match(readFileSync(lockPath, 'utf-8'), /a-different-token/);
});
it('retires an unreadable lock only once it is older than the fallback window', () => {
const lockPath = writeLock('');
assert.throws(
() => withSourceLockSync(TARGET, 'accept:a', () => 'x', { cwd: tmp }),
(err) => err.code === 'SOURCE_LOCKED',
'a fresh unreadable lock is an in-flight acquisition, not garbage',
);
const ancient = new Date(Date.now() - 120_000);
utimesSync(lockPath, ancient, ancient);
assert.equal(
withSourceLockSync(TARGET, 'accept:a', () => 'acquired', { cwd: tmp }),
'acquired',
'a stale unreadable lock must be retired',
);
});
it('releases the lock even when the critical section throws', () => {
const lockPath = sourceLockPath(TARGET, tmp);
assert.throws(() => withSourceLockSync(TARGET, 'accept:a', () => {
throw new Error('boom');
}, { cwd: tmp }), /boom/);
assert.equal(existsSync(lockPath), false, 'a thrown critical section must not leak the lock');
});
});
+215
View File
@@ -0,0 +1,215 @@
import assert from 'node:assert/strict';
import { afterEach, beforeEach, describe, it } from 'node:test';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { createLiveSessionStore } from '../skill/scripts/live/session-store.mjs';
import {
inlineVueComponentAccept,
nuxtViteFsModulePath,
removeAllVueComponentSessions,
scaffoldVueComponentSession,
} from '../skill/scripts/live/vue-component.mjs';
describe('Nuxt Vue component preview', () => {
let tmp;
let source;
beforeEach(() => {
tmp = mkdtempSync(join(tmpdir(), 'impeccable-vue-component-'));
source = join(tmp, 'app', 'pages', 'index.vue');
mkdirSync(join(tmp, 'app', 'pages'), { recursive: true });
writeFileSync(join(tmp, 'nuxt.config.ts'), 'export default defineNuxtConfig({ ssr: false });\n');
writeFileSync(source, [
'<template>',
' <main>',
' <h1 class="hero-title">Hello {{ user.name }}</h1>',
' </main>',
'</template>',
'',
'<style scoped>',
'.hero-title { font-size: 2rem; }',
'</style>',
'',
].join('\n'));
});
afterEach(() => rmSync(tmp, { recursive: true, force: true }));
it('stages real Vue SFCs without rewriting the active route', () => {
const before = readFileSync(source, 'utf-8');
const result = scaffoldVueComponentSession({
id: 'vue12345',
count: 3,
sourceFile: 'app/pages/index.vue',
sourceStartLine: 3,
sourceEndLine: 3,
originalLines: [' <h1 class="hero-title">Hello {{ user.name }}</h1>'],
cwd: tmp,
});
assert.equal(readFileSync(source, 'utf-8'), before);
assert.equal(result.manifest.previewMode, 'vue-component');
assert.equal(result.manifest.componentExtension, 'vue');
assert.match(result.manifestFile, /^app\/\.impeccable-live\/vue12345\/manifest\.json$/);
const variant = readFileSync(join(tmp, result.componentDir, 'v1.vue'), 'utf-8');
assert.match(variant, /<template>/);
assert.match(variant, /Hello \{\{ name \}\}/);
assert.equal(existsSync(join(tmp, 'app/.impeccable-live/__runtime.js')), true);
assert.equal(
result.manifest.runtimeModule,
nuxtViteFsModulePath(join(tmp, 'app/.impeccable-live/__runtime.js'), tmp),
);
assert.equal(
result.manifest.componentModuleBase,
nuxtViteFsModulePath(join(tmp, result.componentDir), tmp),
);
assert.match(result.manifest.runtimeModule, /^\/@fs\//);
assert.doesNotMatch(result.manifest.runtimeModule, /^\/app\//);
assert.match(result.manifest.componentModuleBase, /^\/@fs\//);
});
it('keeps Vite module URLs valid for literal Nuxt srcDir projects', () => {
writeFileSync(join(tmp, 'nuxt.config.ts'), "export default defineNuxtConfig({ srcDir: 'client/' });\n");
const clientSource = join(tmp, 'client', 'pages', 'index.vue');
mkdirSync(join(tmp, 'client', 'pages'), { recursive: true });
writeFileSync(clientSource, '<template><h1>Client app</h1></template>\n');
const result = scaffoldVueComponentSession({
id: 'clientsrc',
count: 1,
sourceFile: 'client/pages/index.vue',
sourceStartLine: 1,
sourceEndLine: 1,
originalLines: ['<h1>Client app</h1>'],
cwd: tmp,
});
assert.match(result.manifestFile, /^client\/\.impeccable-live\/clientsrc\/manifest\.json$/);
assert.match(result.manifest.runtimeModule, /^\/@fs\/.*\/client\/\.impeccable-live\/__runtime\.js$/);
assert.match(result.manifest.componentModuleBase, /^\/@fs\/.*\/client\/\.impeccable-live\/clientsrc$/);
});
it('accepts one generated SFC into clean Vue source and restores route expressions', () => {
const result = scaffoldVueComponentSession({
id: 'vue12345',
count: 3,
sourceFile: 'app/pages/index.vue',
sourceStartLine: 3,
sourceEndLine: 3,
originalLines: [' <h1 class="hero-title">Hello {{ user.name }}</h1>'],
cwd: tmp,
});
writeFileSync(join(tmp, result.componentDir, 'v1.vue'), [
'<script setup>',
"defineProps({ name: { default: '' } });",
'</script>',
'<template>',
' <h1 class="hero-title variant-one">Welcome {{ name }}</h1>',
'</template>',
'<style scoped>',
'.variant-one { letter-spacing: 0.02em; }',
'</style>',
'',
].join('\n'));
const accepted = inlineVueComponentAccept(result.manifest, 1, tmp);
assert.equal(accepted.handled, true);
const next = readFileSync(source, 'utf-8');
assert.match(next, /Welcome \{\{ user\.name \}\}/);
assert.match(next, /class="hero-title variant-one"|class="variant-one hero-title"/);
assert.match(next, /\.variant-one \{ letter-spacing: 0\.02em; \}/);
assert.doesNotMatch(next, /data-impeccable/);
assert.equal(existsSync(join(tmp, result.componentDir, 'manifest.json')), false);
assert.equal(existsSync(join(tmp, result.componentDir, 'v1.vue')), true, 'imported SFC remains until Live shutdown');
});
it('preserves original root directives and valueless attrs a variant omits', () => {
const originalRoot = ' <button class="cta" @click="submit" :aria-label="label" v-bind:title="tip" disabled v-cloak>Go</button>';
writeFileSync(source, [
'<template>',
' <main>',
originalRoot,
' </main>',
'</template>',
'',
].join('\n'));
const result = scaffoldVueComponentSession({
id: 'vue12345',
count: 1,
sourceFile: 'app/pages/index.vue',
sourceStartLine: 3,
sourceEndLine: 3,
originalLines: [originalRoot],
cwd: tmp,
});
// A restyle variant that keeps only class: every behavior attribute must survive Accept.
writeFileSync(join(tmp, result.componentDir, 'v1.vue'), [
'<template>',
' <button class="cta cta--bold">Go</button>',
'</template>',
'<style scoped>',
'.cta--bold { font-weight: 700; }',
'</style>',
'',
].join('\n'));
assert.equal(inlineVueComponentAccept(result.manifest, 1, tmp).handled, true);
const next = readFileSync(source, 'utf-8');
assert.match(next, /@click="submit"/, 'v-on shorthand must not degrade to a literal click attribute');
assert.doesNotMatch(next, /\sclick="submit"/, 'sigil-stripped event handler leaked into source');
assert.match(next, /:aria-label="label"/);
assert.match(next, /v-bind:title="tip"/);
assert.match(next, /\bdisabled\b/, 'valueless boolean attr dropped');
assert.match(next, /\bv-cloak\b/, 'valueless directive dropped');
assert.match(next, /class="cta cta--bold"|class="cta--bold cta"/);
});
it('does not duplicate an attribute the variant wrote in the other shorthand form', () => {
const originalRoot = ' <button class="cta" :aria-label="label">Go</button>';
writeFileSync(source, ['<template>', ' <main>', originalRoot, ' </main>', '</template>', ''].join('\n'));
const result = scaffoldVueComponentSession({
id: 'vue12345',
count: 1,
sourceFile: 'app/pages/index.vue',
sourceStartLine: 3,
sourceEndLine: 3,
originalLines: [originalRoot],
cwd: tmp,
});
writeFileSync(join(tmp, result.componentDir, 'v1.vue'), [
'<template>',
' <button class="cta" v-bind:aria-label="label">Go</button>',
'</template>',
'',
].join('\n'));
assert.equal(inlineVueComponentAccept(result.manifest, 1, tmp).handled, true);
const next = readFileSync(source, 'utf-8');
assert.doesNotMatch(next, /:aria-label="label"[^>]*v-bind:aria-label|v-bind:aria-label="label"[^>]*:aria-label/,
'shorthand and longhand of one attr both emitted, which is a Vue compile error');
});
it('removes deferred SFCs, the shared runtime, and the generated root on Live shutdown', () => {
const result = scaffoldVueComponentSession({
id: 'vue12345',
count: 1,
sourceFile: 'app/pages/index.vue',
sourceStartLine: 3,
sourceEndLine: 3,
originalLines: [' <h1 class="hero-title">Hello {{ user.name }}</h1>'],
cwd: tmp,
});
inlineVueComponentAccept(result.manifest, 1, tmp);
const root = join(tmp, 'app/.impeccable-live');
assert.equal(existsSync(join(root, '__runtime.js')), true);
assert.equal(existsSync(join(tmp, result.componentDir, 'v1.vue')), true);
removeAllVueComponentSessions(tmp);
assert.equal(existsSync(join(root, '__runtime.js')), false);
assert.equal(existsSync(root), false);
});
});
+31 -2
View File
@@ -6,9 +6,9 @@
import { describe, it, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, writeFileSync, readFileSync, rmSync, mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { join, resolve } from 'node:path';
import { tmpdir } from 'node:os';
import { execSync } from 'node:child_process';
import { execFileSync, execSync } from 'node:child_process';
import {
buildSearchQueries,
@@ -253,6 +253,7 @@ describe('wrapCli integration', () => {
assert.ok(!modified.includes('data-impeccable-variant="original" style="display: none"'));
});
it('wraps a JSX element and uses JSX comment syntax', () => {
const jsx = `export default function App() {
return (
@@ -780,6 +781,34 @@ export default function App() {
assert.ok(modified.includes('data-impeccable-variants="dyn1"'), 'wrapped (first-match fallback)');
});
it('refuses multiple dynamic source branches when rendered text cannot identify one', () => {
const astro = `---
const results = [{ title: 'Result 01' }, { title: 'Result 02' }];
---
<main>
<article class="result-card"><h2>{results[0].title}</h2></article>
<article class="result-card"><h2>{results[1].title}</h2></article>
</main>`;
const file = join(tmp, 'Results.astro');
writeFileSync(file, astro);
let errPayload;
try {
execSync(
`node skill/scripts/live-wrap.mjs --id dyn2 --count 3 --classes "result-card" --tag "article" --text "Result 02 rendered body" --file "${file}"`,
{ cwd: process.cwd(), encoding: 'utf-8', stdio: 'pipe' },
);
assert.fail('Should have refused an unsafe first-match fallback');
} catch (err) {
errPayload = JSON.parse(err.stderr.toString().trim());
}
assert.equal(errPayload.error, 'element_ambiguous');
assert.equal(errPayload.reason, 'rendered_text_not_in_source');
assert.equal(errPayload.candidates.length, 2);
assert.doesNotMatch(readFileSync(file, 'utf-8'), /impeccable-variants-start/);
});
it('errors with element_ambiguous when --text matches multiple identical branches', () => {
// Two <aside className="card"> with truly identical body text. --text
// can't pick a winner — wrap should refuse rather than silently land.