From 1f9f3d0f32bcca506e5fc4397f7d5958c5a544fd Mon Sep 17 00:00:00 2001 From: NQH Date: Tue, 28 Apr 2026 21:08:25 -0500 Subject: [PATCH 01/13] feat(live): make live sessions recoverable tired of live mode losing the plot when the browser moved faster than the agent. now the state is boring: journal it, resume it, finish it. --- - add durable live-session journal, checkpoint events, and status/resume/complete commands - split browser session storage into a testable helper and harden accept/discard completion - fix Astro live CSS preview mode and add recovery/live E2E coverage - declare Bun as the package manager and add a Bun-native audit script --- .agents/skills/impeccable/reference/live.md | 65 +- .../scripts/live-browser-session.js | 123 ++++ .../skills/impeccable/scripts/live-browser.js | 165 ++++-- .../impeccable/scripts/live-complete.mjs | 42 ++ .../skills/impeccable/scripts/live-poll.mjs | 45 +- .../skills/impeccable/scripts/live-resume.mjs | 46 ++ .../skills/impeccable/scripts/live-server.mjs | 140 ++++- .../impeccable/scripts/live-session-store.mjs | 228 +++++++ .../skills/impeccable/scripts/live-status.mjs | 51 ++ .../skills/impeccable/scripts/live-wrap.mjs | 23 + .claude/skills/impeccable/reference/live.md | 65 +- .../scripts/live-browser-session.js | 123 ++++ .../skills/impeccable/scripts/live-browser.js | 165 ++++-- .../impeccable/scripts/live-complete.mjs | 42 ++ .../skills/impeccable/scripts/live-poll.mjs | 45 +- .../skills/impeccable/scripts/live-resume.mjs | 46 ++ .../skills/impeccable/scripts/live-server.mjs | 140 ++++- .../impeccable/scripts/live-session-store.mjs | 228 +++++++ .../skills/impeccable/scripts/live-status.mjs | 51 ++ .../skills/impeccable/scripts/live-wrap.mjs | 23 + .cursor/skills/impeccable/reference/live.md | 65 +- .../scripts/live-browser-session.js | 123 ++++ .../skills/impeccable/scripts/live-browser.js | 165 ++++-- .../impeccable/scripts/live-complete.mjs | 42 ++ .../skills/impeccable/scripts/live-poll.mjs | 45 +- .../skills/impeccable/scripts/live-resume.mjs | 46 ++ .../skills/impeccable/scripts/live-server.mjs | 140 ++++- .../impeccable/scripts/live-session-store.mjs | 228 +++++++ .../skills/impeccable/scripts/live-status.mjs | 51 ++ .../skills/impeccable/scripts/live-wrap.mjs | 23 + .gemini/skills/impeccable/reference/live.md | 65 +- .../scripts/live-browser-session.js | 123 ++++ .../skills/impeccable/scripts/live-browser.js | 165 ++++-- .../impeccable/scripts/live-complete.mjs | 42 ++ .../skills/impeccable/scripts/live-poll.mjs | 45 +- .../skills/impeccable/scripts/live-resume.mjs | 46 ++ .../skills/impeccable/scripts/live-server.mjs | 140 ++++- .../impeccable/scripts/live-session-store.mjs | 228 +++++++ .../skills/impeccable/scripts/live-status.mjs | 51 ++ .../skills/impeccable/scripts/live-wrap.mjs | 23 + .github/skills/impeccable/reference/live.md | 65 +- .../scripts/live-browser-session.js | 123 ++++ .../skills/impeccable/scripts/live-browser.js | 165 ++++-- .../impeccable/scripts/live-complete.mjs | 42 ++ .../skills/impeccable/scripts/live-poll.mjs | 45 +- .../skills/impeccable/scripts/live-resume.mjs | 46 ++ .../skills/impeccable/scripts/live-server.mjs | 140 ++++- .../impeccable/scripts/live-session-store.mjs | 228 +++++++ .../skills/impeccable/scripts/live-status.mjs | 51 ++ .../skills/impeccable/scripts/live-wrap.mjs | 23 + .kiro/skills/impeccable/reference/live.md | 65 +- .../scripts/live-browser-session.js | 123 ++++ .../skills/impeccable/scripts/live-browser.js | 165 ++++-- .../impeccable/scripts/live-complete.mjs | 42 ++ .kiro/skills/impeccable/scripts/live-poll.mjs | 45 +- .../skills/impeccable/scripts/live-resume.mjs | 46 ++ .../skills/impeccable/scripts/live-server.mjs | 140 ++++- .../impeccable/scripts/live-session-store.mjs | 228 +++++++ .../skills/impeccable/scripts/live-status.mjs | 51 ++ .kiro/skills/impeccable/scripts/live-wrap.mjs | 23 + .opencode/skills/impeccable/reference/live.md | 65 +- .../scripts/live-browser-session.js | 123 ++++ .../skills/impeccable/scripts/live-browser.js | 165 ++++-- .../impeccable/scripts/live-complete.mjs | 42 ++ .../skills/impeccable/scripts/live-poll.mjs | 45 +- .../skills/impeccable/scripts/live-resume.mjs | 46 ++ .../skills/impeccable/scripts/live-server.mjs | 140 ++++- .../impeccable/scripts/live-session-store.mjs | 228 +++++++ .../skills/impeccable/scripts/live-status.mjs | 51 ++ .../skills/impeccable/scripts/live-wrap.mjs | 23 + .pi/skills/impeccable/reference/live.md | 65 +- .../scripts/live-browser-session.js | 123 ++++ .pi/skills/impeccable/scripts/live-browser.js | 165 ++++-- .../impeccable/scripts/live-complete.mjs | 42 ++ .pi/skills/impeccable/scripts/live-poll.mjs | 45 +- .pi/skills/impeccable/scripts/live-resume.mjs | 46 ++ .pi/skills/impeccable/scripts/live-server.mjs | 140 ++++- .../impeccable/scripts/live-session-store.mjs | 228 +++++++ .pi/skills/impeccable/scripts/live-status.mjs | 51 ++ .pi/skills/impeccable/scripts/live-wrap.mjs | 23 + .qoder/skills/impeccable/reference/live.md | 65 +- .../scripts/live-browser-session.js | 123 ++++ .../skills/impeccable/scripts/live-browser.js | 165 ++++-- .../impeccable/scripts/live-complete.mjs | 42 ++ .../skills/impeccable/scripts/live-poll.mjs | 45 +- .../skills/impeccable/scripts/live-resume.mjs | 46 ++ .../skills/impeccable/scripts/live-server.mjs | 140 ++++- .../impeccable/scripts/live-session-store.mjs | 228 +++++++ .../skills/impeccable/scripts/live-status.mjs | 51 ++ .../skills/impeccable/scripts/live-wrap.mjs | 23 + .rovodev/skills/impeccable/reference/live.md | 65 +- .../scripts/live-browser-session.js | 123 ++++ .../skills/impeccable/scripts/live-browser.js | 165 ++++-- .../impeccable/scripts/live-complete.mjs | 42 ++ .../skills/impeccable/scripts/live-poll.mjs | 45 +- .../skills/impeccable/scripts/live-resume.mjs | 46 ++ .../skills/impeccable/scripts/live-server.mjs | 140 ++++- .../impeccable/scripts/live-session-store.mjs | 228 +++++++ .../skills/impeccable/scripts/live-status.mjs | 51 ++ .../skills/impeccable/scripts/live-wrap.mjs | 23 + .trae-cn/skills/impeccable/reference/live.md | 65 +- .../scripts/live-browser-session.js | 123 ++++ .../skills/impeccable/scripts/live-browser.js | 165 ++++-- .../impeccable/scripts/live-complete.mjs | 42 ++ .../skills/impeccable/scripts/live-poll.mjs | 45 +- .../skills/impeccable/scripts/live-resume.mjs | 46 ++ .../skills/impeccable/scripts/live-server.mjs | 140 ++++- .../impeccable/scripts/live-session-store.mjs | 228 +++++++ .../skills/impeccable/scripts/live-status.mjs | 51 ++ .../skills/impeccable/scripts/live-wrap.mjs | 23 + .trae/skills/impeccable/reference/live.md | 65 +- .../scripts/live-browser-session.js | 123 ++++ .../skills/impeccable/scripts/live-browser.js | 165 ++++-- .../impeccable/scripts/live-complete.mjs | 42 ++ .trae/skills/impeccable/scripts/live-poll.mjs | 45 +- .../skills/impeccable/scripts/live-resume.mjs | 46 ++ .../skills/impeccable/scripts/live-server.mjs | 140 ++++- .../impeccable/scripts/live-session-store.mjs | 228 +++++++ .../skills/impeccable/scripts/live-status.mjs | 51 ++ .trae/skills/impeccable/scripts/live-wrap.mjs | 23 + ...-28-001-feat-live-session-recovery-plan.md | 555 ++++++++++++++++++ package.json | 4 +- plugin/skills/impeccable/reference/live.md | 65 +- .../scripts/live-browser-session.js | 123 ++++ .../skills/impeccable/scripts/live-browser.js | 165 ++++-- .../impeccable/scripts/live-complete.mjs | 42 ++ .../skills/impeccable/scripts/live-poll.mjs | 45 +- .../skills/impeccable/scripts/live-resume.mjs | 46 ++ .../skills/impeccable/scripts/live-server.mjs | 140 ++++- .../impeccable/scripts/live-session-store.mjs | 228 +++++++ .../skills/impeccable/scripts/live-status.mjs | 51 ++ .../skills/impeccable/scripts/live-wrap.mjs | 23 + source/skills/impeccable/reference/live.md | 65 +- .../scripts/live-browser-session.js | 123 ++++ .../skills/impeccable/scripts/live-browser.js | 165 ++++-- .../impeccable/scripts/live-complete.mjs | 42 ++ .../skills/impeccable/scripts/live-poll.mjs | 45 +- .../skills/impeccable/scripts/live-resume.mjs | 46 ++ .../skills/impeccable/scripts/live-server.mjs | 140 ++++- .../impeccable/scripts/live-session-store.mjs | 228 +++++++ .../skills/impeccable/scripts/live-status.mjs | 51 ++ .../skills/impeccable/scripts/live-wrap.mjs | 23 + tests/live-browser-session.test.mjs | 64 ++ tests/live-e2e.test.mjs | 33 +- tests/live-e2e/agent.mjs | 59 +- tests/live-recovery-commands.test.mjs | 62 ++ tests/live-server.test.mjs | 259 +++++++- tests/live-session-store.test.mjs | 147 +++++ tests/live-wrap.test.mjs | 26 + 149 files changed, 12719 insertions(+), 1482 deletions(-) create mode 100644 .agents/skills/impeccable/scripts/live-browser-session.js create mode 100644 .agents/skills/impeccable/scripts/live-complete.mjs create mode 100644 .agents/skills/impeccable/scripts/live-resume.mjs create mode 100644 .agents/skills/impeccable/scripts/live-session-store.mjs create mode 100644 .agents/skills/impeccable/scripts/live-status.mjs create mode 100644 .claude/skills/impeccable/scripts/live-browser-session.js create mode 100644 .claude/skills/impeccable/scripts/live-complete.mjs create mode 100644 .claude/skills/impeccable/scripts/live-resume.mjs create mode 100644 .claude/skills/impeccable/scripts/live-session-store.mjs create mode 100644 .claude/skills/impeccable/scripts/live-status.mjs create mode 100644 .cursor/skills/impeccable/scripts/live-browser-session.js create mode 100644 .cursor/skills/impeccable/scripts/live-complete.mjs create mode 100644 .cursor/skills/impeccable/scripts/live-resume.mjs create mode 100644 .cursor/skills/impeccable/scripts/live-session-store.mjs create mode 100644 .cursor/skills/impeccable/scripts/live-status.mjs create mode 100644 .gemini/skills/impeccable/scripts/live-browser-session.js create mode 100644 .gemini/skills/impeccable/scripts/live-complete.mjs create mode 100644 .gemini/skills/impeccable/scripts/live-resume.mjs create mode 100644 .gemini/skills/impeccable/scripts/live-session-store.mjs create mode 100644 .gemini/skills/impeccable/scripts/live-status.mjs create mode 100644 .github/skills/impeccable/scripts/live-browser-session.js create mode 100644 .github/skills/impeccable/scripts/live-complete.mjs create mode 100644 .github/skills/impeccable/scripts/live-resume.mjs create mode 100644 .github/skills/impeccable/scripts/live-session-store.mjs create mode 100644 .github/skills/impeccable/scripts/live-status.mjs create mode 100644 .kiro/skills/impeccable/scripts/live-browser-session.js create mode 100644 .kiro/skills/impeccable/scripts/live-complete.mjs create mode 100644 .kiro/skills/impeccable/scripts/live-resume.mjs create mode 100644 .kiro/skills/impeccable/scripts/live-session-store.mjs create mode 100644 .kiro/skills/impeccable/scripts/live-status.mjs create mode 100644 .opencode/skills/impeccable/scripts/live-browser-session.js create mode 100644 .opencode/skills/impeccable/scripts/live-complete.mjs create mode 100644 .opencode/skills/impeccable/scripts/live-resume.mjs create mode 100644 .opencode/skills/impeccable/scripts/live-session-store.mjs create mode 100644 .opencode/skills/impeccable/scripts/live-status.mjs create mode 100644 .pi/skills/impeccable/scripts/live-browser-session.js create mode 100644 .pi/skills/impeccable/scripts/live-complete.mjs create mode 100644 .pi/skills/impeccable/scripts/live-resume.mjs create mode 100644 .pi/skills/impeccable/scripts/live-session-store.mjs create mode 100644 .pi/skills/impeccable/scripts/live-status.mjs create mode 100644 .qoder/skills/impeccable/scripts/live-browser-session.js create mode 100644 .qoder/skills/impeccable/scripts/live-complete.mjs create mode 100644 .qoder/skills/impeccable/scripts/live-resume.mjs create mode 100644 .qoder/skills/impeccable/scripts/live-session-store.mjs create mode 100644 .qoder/skills/impeccable/scripts/live-status.mjs create mode 100644 .rovodev/skills/impeccable/scripts/live-browser-session.js create mode 100644 .rovodev/skills/impeccable/scripts/live-complete.mjs create mode 100644 .rovodev/skills/impeccable/scripts/live-resume.mjs create mode 100644 .rovodev/skills/impeccable/scripts/live-session-store.mjs create mode 100644 .rovodev/skills/impeccable/scripts/live-status.mjs create mode 100644 .trae-cn/skills/impeccable/scripts/live-browser-session.js create mode 100644 .trae-cn/skills/impeccable/scripts/live-complete.mjs create mode 100644 .trae-cn/skills/impeccable/scripts/live-resume.mjs create mode 100644 .trae-cn/skills/impeccable/scripts/live-session-store.mjs create mode 100644 .trae-cn/skills/impeccable/scripts/live-status.mjs create mode 100644 .trae/skills/impeccable/scripts/live-browser-session.js create mode 100644 .trae/skills/impeccable/scripts/live-complete.mjs create mode 100644 .trae/skills/impeccable/scripts/live-resume.mjs create mode 100644 .trae/skills/impeccable/scripts/live-session-store.mjs create mode 100644 .trae/skills/impeccable/scripts/live-status.mjs create mode 100644 docs/plans/2026-04-28-001-feat-live-session-recovery-plan.md create mode 100644 plugin/skills/impeccable/scripts/live-browser-session.js create mode 100644 plugin/skills/impeccable/scripts/live-complete.mjs create mode 100644 plugin/skills/impeccable/scripts/live-resume.mjs create mode 100644 plugin/skills/impeccable/scripts/live-session-store.mjs create mode 100644 plugin/skills/impeccable/scripts/live-status.mjs create mode 100644 source/skills/impeccable/scripts/live-browser-session.js create mode 100644 source/skills/impeccable/scripts/live-complete.mjs create mode 100644 source/skills/impeccable/scripts/live-resume.mjs create mode 100644 source/skills/impeccable/scripts/live-session-store.mjs create mode 100644 source/skills/impeccable/scripts/live-status.mjs create mode 100644 tests/live-browser-session.test.mjs create mode 100644 tests/live-recovery-commands.test.mjs create mode 100644 tests/live-session-store.test.mjs diff --git a/.agents/skills/impeccable/reference/live.md b/.agents/skills/impeccable/reference/live.md index 598c1d00..3e04af54 100644 --- a/.agents/skills/impeccable/reference/live.md +++ b/.agents/skills/impeccable/reference/live.md @@ -12,8 +12,9 @@ Execute in order. No step skipped, no step reordered. 2. Navigate to the URL that serves `pageFile` (infer from `package.json`, docs, terminal output, or an open tab). If you can't infer it confidently, tell the user once to open their dev/preview URL. Never use `serverPort` as that URL; it's the helper, not the app. 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=`. 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. -5. On `accept` / `discard`: the poll script already cleaned up; just poll again. -6. On `exit`: run the cleanup at the bottom. +5. On `accept` / `discard`: the poll script runs `live-accept.mjs`, acknowledges durable completion (`complete` / `discarded`), and prints `_completionAck`; finish any carbonize cleanup before polling again. +6. If interrupted, run `live-status.mjs` or `live-resume.mjs` before guessing. The durable journal replays unacknowledged work after helper restart. +7. 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. @@ -43,13 +44,31 @@ LOOP: Read JSON; dispatch on "type" "generate" → Handle Generate; reply done; LOOP - "accept" → Handle Accept; LOOP + "accept" → Handle Accept; complete carbonize cleanup if required; LOOP "discard" → Handle Discard; LOOP "prefetch" → Handle Prefetch; LOOP "timeout" → LOOP "exit" → break → Cleanup ``` +## Recovery commands + +The live helper persists an append-only journal under `.impeccable-live/sessions`. Browser checkpoints are advisory but durable; the journal is canonical. + +Use these commands when the chat was interrupted, polling was missed, the helper restarted, or the browser reloaded: + +```bash +node .agents/skills/impeccable/scripts/live-status.mjs +node .agents/skills/impeccable/scripts/live-resume.mjs --id SESSION_ID +node .agents/skills/impeccable/scripts/live-complete.mjs --id SESSION_ID +``` + +- `live-status.mjs` prints connected helper state, active durable sessions, and queued pending events. It works even when the helper is down by reading the journal directly. +- `live-resume.mjs` prints the active snapshot, pending event, checkpoint phase, visible variant, parameter values, and the next safe agent action. +- `live-complete.mjs` is the canonical manual final acknowledgement. Use it only after you have verified cleanup is complete and no further poll acknowledgement will happen automatically. + +Server restart rule: start `live-server.mjs` again, then poll. Startup requeues unacknowledged pending events from the journal, so do not ask the user to click Go again unless `live-resume.mjs` says no active session exists. + ## Handle `generate` Event: `{id, action, freeformPrompt?, count, pageUrl, element, screenshotPath?, comments?, strokes?}`. @@ -88,7 +107,12 @@ The helper searches ID first, then classes, then tag + class combo. If `event.pa If `--text` matches multiple candidates equally well, wrap exits with `{ error: "element_ambiguous", candidates: [...] }` and `fallback: "agent-driven"`: read the candidate line ranges, decide which one matches the picked element from page context, and write the wrapper manually per the fallback flow. -Output on success: `{ file, insertLine, commentSyntax }`. +Output on success: `{ file, insertLine, commentSyntax, styleMode, styleTag, cssSelectorPrefixExamples }`. + +`styleMode` controls how preview CSS must be authored: + +- `scoped`: default for HTML, JSX, Vue, and Svelte. Use a normal ` +
+ +
+
+ +
+
+ +
+``` + +Astro rule: never use raw `@scope ([data-impeccable-variant="N"])` inside `.astro` live preview styles. Prefix every selector with `[data-impeccable-variant="N"]` and add `is:inline` to the temporary style tag so Astro does not transform the preview CSS. + **Each variant div contains exactly one top-level element: the full replacement for the original.** Use the same tag as the original (e.g. `
` if the user picked a `
`). Loose siblings (heading + paragraph + div as direct children of the variant div) break the outline tracking and the accept flow, which both assume one child. -The first variant has no `display: none` (visible by default). All others do. If variants use only inline styles and no scoped CSS, omit the ` +
+ +
+
+ +
+
+ +
+``` + +Astro rule: never use raw `@scope ([data-impeccable-variant="N"])` inside `.astro` live preview styles. Prefix every selector with `[data-impeccable-variant="N"]` and add `is:inline` to the temporary style tag so Astro does not transform the preview CSS. + **Each variant div contains exactly one top-level element: the full replacement for the original.** Use the same tag as the original (e.g. `
` if the user picked a `
`). Loose siblings (heading + paragraph + div as direct children of the variant div) break the outline tracking and the accept flow, which both assume one child. -The first variant has no `display: none` (visible by default). All others do. If variants use only inline styles and no scoped CSS, omit the ` +
+ +
+
+ +
+
+ +
+``` + +Astro rule: never use raw `@scope ([data-impeccable-variant="N"])` inside `.astro` live preview styles. Prefix every selector with `[data-impeccable-variant="N"]` and add `is:inline` to the temporary style tag so Astro does not transform the preview CSS. + **Each variant div contains exactly one top-level element: the full replacement for the original.** Use the same tag as the original (e.g. `
` if the user picked a `
`). Loose siblings (heading + paragraph + div as direct children of the variant div) break the outline tracking and the accept flow, which both assume one child. -The first variant has no `display: none` (visible by default). All others do. If variants use only inline styles and no scoped CSS, omit the ` +
+ +
+
+ +
+
+ +
+``` + +Astro rule: never use raw `@scope ([data-impeccable-variant="N"])` inside `.astro` live preview styles. Prefix every selector with `[data-impeccable-variant="N"]` and add `is:inline` to the temporary style tag so Astro does not transform the preview CSS. + **Each variant div contains exactly one top-level element: the full replacement for the original.** Use the same tag as the original (e.g. `
` if the user picked a `
`). Loose siblings (heading + paragraph + div as direct children of the variant div) break the outline tracking and the accept flow, which both assume one child. -The first variant has no `display: none` (visible by default). All others do. If variants use only inline styles and no scoped CSS, omit the ` +
+ +
+
+ +
+
+ +
+``` + +Astro rule: never use raw `@scope ([data-impeccable-variant="N"])` inside `.astro` live preview styles. Prefix every selector with `[data-impeccable-variant="N"]` and add `is:inline` to the temporary style tag so Astro does not transform the preview CSS. + **Each variant div contains exactly one top-level element: the full replacement for the original.** Use the same tag as the original (e.g. `
` if the user picked a `
`). Loose siblings (heading + paragraph + div as direct children of the variant div) break the outline tracking and the accept flow, which both assume one child. -The first variant has no `display: none` (visible by default). All others do. If variants use only inline styles and no scoped CSS, omit the ` +
+ +
+
+ +
+
+ +
+``` + +Astro rule: never use raw `@scope ([data-impeccable-variant="N"])` inside `.astro` live preview styles. Prefix every selector with `[data-impeccable-variant="N"]` and add `is:inline` to the temporary style tag so Astro does not transform the preview CSS. + **Each variant div contains exactly one top-level element: the full replacement for the original.** Use the same tag as the original (e.g. `
` if the user picked a `
`). Loose siblings (heading + paragraph + div as direct children of the variant div) break the outline tracking and the accept flow, which both assume one child. -The first variant has no `display: none` (visible by default). All others do. If variants use only inline styles and no scoped CSS, omit the ` +
+ +
+
+ +
+
+ +
+``` + +Astro rule: never use raw `@scope ([data-impeccable-variant="N"])` inside `.astro` live preview styles. Prefix every selector with `[data-impeccable-variant="N"]` and add `is:inline` to the temporary style tag so Astro does not transform the preview CSS. + **Each variant div contains exactly one top-level element: the full replacement for the original.** Use the same tag as the original (e.g. `
` if the user picked a `
`). Loose siblings (heading + paragraph + div as direct children of the variant div) break the outline tracking and the accept flow, which both assume one child. -The first variant has no `display: none` (visible by default). All others do. If variants use only inline styles and no scoped CSS, omit the ` +
+ +
+
+ +
+
+ +
+``` + +Astro rule: never use raw `@scope ([data-impeccable-variant="N"])` inside `.astro` live preview styles. Prefix every selector with `[data-impeccable-variant="N"]` and add `is:inline` to the temporary style tag so Astro does not transform the preview CSS. + **Each variant div contains exactly one top-level element: the full replacement for the original.** Use the same tag as the original (e.g. `
` if the user picked a `
`). Loose siblings (heading + paragraph + div as direct children of the variant div) break the outline tracking and the accept flow, which both assume one child. -The first variant has no `display: none` (visible by default). All others do. If variants use only inline styles and no scoped CSS, omit the ` +
+ +
+
+ +
+
+ +
+``` + +Astro rule: never use raw `@scope ([data-impeccable-variant="N"])` inside `.astro` live preview styles. Prefix every selector with `[data-impeccable-variant="N"]` and add `is:inline` to the temporary style tag so Astro does not transform the preview CSS. + **Each variant div contains exactly one top-level element: the full replacement for the original.** Use the same tag as the original (e.g. `
` if the user picked a `
`). Loose siblings (heading + paragraph + div as direct children of the variant div) break the outline tracking and the accept flow, which both assume one child. -The first variant has no `display: none` (visible by default). All others do. If variants use only inline styles and no scoped CSS, omit the ` +
+ +
+
+ +
+
+ +
+``` + +Astro rule: never use raw `@scope ([data-impeccable-variant="N"])` inside `.astro` live preview styles. Prefix every selector with `[data-impeccable-variant="N"]` and add `is:inline` to the temporary style tag so Astro does not transform the preview CSS. + **Each variant div contains exactly one top-level element: the full replacement for the original.** Use the same tag as the original (e.g. `
` if the user picked a `
`). Loose siblings (heading + paragraph + div as direct children of the variant div) break the outline tracking and the accept flow, which both assume one child. -The first variant has no `display: none` (visible by default). All others do. If variants use only inline styles and no scoped CSS, omit the ` +
+ +
+
+ +
+
+ +
+``` + +Astro rule: never use raw `@scope ([data-impeccable-variant="N"])` inside `.astro` live preview styles. Prefix every selector with `[data-impeccable-variant="N"]` and add `is:inline` to the temporary style tag so Astro does not transform the preview CSS. + **Each variant div contains exactly one top-level element: the full replacement for the original.** Use the same tag as the original (e.g. `
` if the user picked a `
`). Loose siblings (heading + paragraph + div as direct children of the variant div) break the outline tracking and the accept flow, which both assume one child. -The first variant has no `display: none` (visible by default). All others do. If variants use only inline styles and no scoped CSS, omit the ` +
+ +
+
+ +
+
+ +
+``` + +Astro rule: never use raw `@scope ([data-impeccable-variant="N"])` inside `.astro` live preview styles. Prefix every selector with `[data-impeccable-variant="N"]` and add `is:inline` to the temporary style tag so Astro does not transform the preview CSS. + **Each variant div contains exactly one top-level element: the full replacement for the original.** Use the same tag as the original (e.g. `
` if the user picked a `
`). Loose siblings (heading + paragraph + div as direct children of the variant div) break the outline tracking and the accept flow, which both assume one child. -The first variant has no `display: none` (visible by default). All others do. If variants use only inline styles and no scoped CSS, omit the ` +
+ +
+
+ +
+
+ +
+``` + +Astro rule: never use raw `@scope ([data-impeccable-variant="N"])` inside `.astro` live preview styles. Prefix every selector with `[data-impeccable-variant="N"]` and add `is:inline` to the temporary style tag so Astro does not transform the preview CSS. + **Each variant div contains exactly one top-level element: the full replacement for the original.** Use the same tag as the original (e.g. `
` if the user picked a `
`). Loose siblings (heading + paragraph + div as direct children of the variant div) break the outline tracking and the accept flow, which both assume one child. -The first variant has no `display: none` (visible by default). All others do. If variants use only inline styles and no scoped CSS, omit the ` +
+ +
+
+ +
+
+ +
+``` + +Astro rule: never use raw `@scope ([data-impeccable-variant="N"])` inside `.astro` live preview styles. Prefix every selector with `[data-impeccable-variant="N"]` and add `is:inline` to the temporary style tag so Astro does not transform the preview CSS. + **Each variant div contains exactly one top-level element: the full replacement for the original.** Use the same tag as the original (e.g. `
` if the user picked a `
`). Loose siblings (heading + paragraph + div as direct children of the variant div) break the outline tracking and the accept flow, which both assume one child. -The first variant has no `display: none` (visible by default). All others do. If variants use only inline styles and no scoped CSS, omit the `', ] : [ - indent + ' ', ]; @@ -258,6 +272,7 @@ async function spliceVariantsIntoWrapper({ tmp, wrapInfo, sessionId, output }) { output, commentSyntax: wrapInfo.commentSyntax, file: wrapInfo.file, + styleMode: wrapInfo.styleMode, }); const next = [ @@ -338,7 +353,7 @@ export async function runAgentLoop({ log(`wrapped: ${wrapInfo.file} insertLine=${wrapInfo.insertLine}`); // 2. Agent generates variant content (LLM-pluggable seam) - const output = await agent.generateVariants(event, { wrapTarget }); + const output = await agent.generateVariants(event, { wrapTarget, wrapInfo }); if (output.variants.length !== event.count) { log(`warning: agent returned ${output.variants.length} variants, expected ${event.count}`); } diff --git a/tests/live-recovery-commands.test.mjs b/tests/live-recovery-commands.test.mjs new file mode 100644 index 00000000..1d8eef83 --- /dev/null +++ b/tests/live-recovery-commands.test.mjs @@ -0,0 +1,62 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { execFileSync } from 'node:child_process'; +import { createLiveSessionStore } from '../source/skills/impeccable/scripts/live-session-store.mjs'; + +const REPO_ROOT = process.cwd(); +const STATUS_SCRIPT = join(REPO_ROOT, 'source/skills/impeccable/scripts/live-status.mjs'); +const RESUME_SCRIPT = join(REPO_ROOT, 'source/skills/impeccable/scripts/live-resume.mjs'); +const COMPLETE_SCRIPT = join(REPO_ROOT, 'source/skills/impeccable/scripts/live-complete.mjs'); + +function withTempProject(fn) { + const cwd = mkdtempSync(join(tmpdir(), 'impeccable-live-recovery-')); + try { return fn(cwd); } + finally { rmSync(cwd, { recursive: true, force: true }); } +} + +function runJson(script, args, cwd) { + const out = execFileSync(process.execPath, [script, ...args], { cwd, encoding: 'utf-8' }); + return JSON.parse(out); +} + +describe('live recovery CLI commands', () => { + it('prints active durable session status without a running helper server', () => withTempProject((cwd) => { + const store = createLiveSessionStore({ cwd }); + store.appendEvent({ type: 'generate', id: 'cli-recover-1', action: 'impeccable', count: 3, pageUrl: '/', element: { outerHTML: '' } }); + + const status = runJson(STATUS_SCRIPT, [], cwd); + assert.equal(status.liveServer, null); + assert.equal(status.activeSessions.length, 1); + assert.equal(status.activeSessions[0].id, 'cli-recover-1'); + assert.match(status.recoveryHint, /Start live-server/); + })); + + it('resumes the pending event and reports the next safe agent action', () => withTempProject((cwd) => { + const store = createLiveSessionStore({ cwd }); + store.appendEvent({ type: 'generate', id: 'cli-recover-2', action: 'impeccable', count: 2, pageUrl: '/', element: { outerHTML: '
Hero
' } }); + + const resume = runJson(RESUME_SCRIPT, ['--id', 'cli-recover-2'], cwd); + assert.equal(resume.active, true); + assert.equal(resume.pendingEvent.type, 'generate'); + assert.match( + resume.nextAction, + /live-poll\.mjs/, + 'event=live_resume.next_action actor=agent operation=recover_session risk=agent_has_state_but_no_next_step expected=live-poll.mjs actual=' + resume.nextAction, + ); + })); + + it('marks a session completed through the canonical completion command', () => withTempProject((cwd) => { + const store = createLiveSessionStore({ cwd }); + store.appendEvent({ type: 'generate', id: 'cli-recover-3', action: 'impeccable', count: 1, pageUrl: '/', element: { outerHTML: '

Copy

' } }); + + const completed = runJson(COMPLETE_SCRIPT, ['--id', 'cli-recover-3'], cwd); + assert.equal(completed.ok, true); + assert.equal(completed.phase, 'completed'); + + const status = runJson(STATUS_SCRIPT, [], cwd); + assert.deepEqual(status.activeSessions, []); + })); +}); diff --git a/tests/live-server.test.mjs b/tests/live-server.test.mjs index 88dd1a79..768ea8aa 100644 --- a/tests/live-server.test.mjs +++ b/tests/live-server.test.mjs @@ -5,21 +5,21 @@ import { describe, it, before, after } from 'node:test'; import assert from 'node:assert/strict'; -import { readFileSync, writeFileSync, mkdirSync, rmSync } from 'node:fs'; +import { existsSync, mkdtempSync, readFileSync, writeFileSync, mkdirSync, rmSync } from 'node:fs'; import { join } from 'node:path'; +import { tmpdir } from 'node:os'; import { execSync, spawn } from 'node:child_process'; -const SERVER_SCRIPT = 'source/skills/impeccable/scripts/live-server.mjs'; -// Matches LIVE_PID_FILE in live-server.mjs: project root, not tmpdir(). -const PID_FILE = join(process.cwd(), '.impeccable-live.json'); - +const REPO_ROOT = process.cwd(); +const SERVER_SCRIPT = join(REPO_ROOT, 'source/skills/impeccable/scripts/live-server.mjs'); // --------------------------------------------------------------------------- // Helper: start/stop server for integration tests // --------------------------------------------------------------------------- -function startServer(port = 8499) { +function startServer(port = 8499, { cwd = REPO_ROOT } = {}) { return new Promise((resolve, reject) => { const proc = spawn('node', [SERVER_SCRIPT, '--port=' + port], { + cwd, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env }, }); @@ -29,8 +29,8 @@ function startServer(port = 8499) { if (output.includes('running on')) { // Read token from PID file try { - const info = JSON.parse(readFileSync(PID_FILE, 'utf-8')); - resolve({ proc, port: info.port, token: info.token }); + const info = JSON.parse(readFileSync(join(cwd, '.impeccable-live.json'), 'utf-8')); + resolve({ proc, port: info.port, token: info.token, cwd }); } catch { reject(new Error('Server started but PID file not readable')); } @@ -48,6 +48,21 @@ async function stopServer(port, token) { } catch { /* server already gone */ } } +async function drainPolls(server) { + let drained; + do { + const r = await fetch(`http://localhost:${server.port}/poll?token=${server.token}&timeout=50&leaseMs=1`); + drained = await r.json(); + if (drained.id) { + await fetch(`http://localhost:${server.port}/poll`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token: server.token, id: drained.id, type: 'done' }), + }); + } + } while (drained.type !== 'timeout'); +} + // --------------------------------------------------------------------------- // Server integration tests // --------------------------------------------------------------------------- @@ -56,6 +71,7 @@ describe('live-server integration', () => { let server; before(async () => { + rmSync(join(REPO_ROOT, '.impeccable-live', 'sessions'), { recursive: true, force: true }); server = await startServer(8499); }); @@ -77,6 +93,33 @@ describe('live-server integration', () => { assert.equal(data.connectedClients, 0); }); + it('/status returns durable recovery state', async () => { + await drainPolls(server); + 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: 'status-test-1', + action: 'impeccable', + count: 1, + pageUrl: '/', + element: { outerHTML: '' }, + }), + }); + assert.equal(eventRes.status, 200); + + const res = await fetch(`http://localhost:${server.port}/status?token=${server.token}`); + assert.equal(res.status, 200); + const data = await res.json(); + assert.equal(data.status, 'ok'); + assert.equal(data.activeSessions.some((s) => s.id === 'status-test-1'), true); + assert.equal(data.pendingEvents.some((e) => e.id === 'status-test-1' && e.type === 'generate'), true); + + await drainPolls(server); + }); + it('/live.js serves script with token injected', async () => { const res = await fetch(`http://localhost:${server.port}/live.js`); assert.equal(res.status, 200); @@ -85,6 +128,14 @@ describe('live-server integration', () => { assert.ok(text.includes('__IMPECCABLE_TOKEN__')); assert.ok(text.includes(server.token)); assert.ok(text.includes('__IMPECCABLE_PORT__')); + const sessionHelperIndex = text.indexOf('__IMPECCABLE_LIVE_SESSION__'); + const browserInitIndex = text.indexOf('__IMPECCABLE_LIVE_INIT__'); + assert.ok(sessionHelperIndex !== -1); + assert.ok(browserInitIndex !== -1); + assert.ok( + sessionHelperIndex < browserInitIndex, + 'event=live_server.browser_helper_order actor=browser operation=load_live_js risk=session_helper_missing_before_browser_init expected=session helper before live init actual=' + sessionHelperIndex + ':' + browserInitIndex, + ); }); it('/detect.js serves the detection overlay', async () => { @@ -189,11 +240,7 @@ describe('live-server integration', () => { it('events flow from browser POST to agent poll', async () => { // Drain any queued events from previous tests - let drained; - do { - const r = await fetch(`http://localhost:${server.port}/poll?token=${server.token}&timeout=100`); - drained = await r.json(); - } while (drained.type !== 'timeout'); + await drainPolls(server); // Start a poll (will block until event arrives or timeout) const pollPromise = fetch(`http://localhost:${server.port}/poll?token=${server.token}&timeout=5000`) @@ -223,6 +270,192 @@ describe('live-server integration', () => { assert.equal(event.id, 'a1b2c3d4'); assert.equal(event.action, 'bolder'); assert.equal(event.count, 2); + + await fetch(`http://localhost:${server.port}/poll`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token: server.token, id: 'test-e2e-1', type: 'done' }), + }); + }); + + it('persists browser events to the durable session journal before poll delivery', async () => { + await drainPolls(server); + const journalPath = join(REPO_ROOT, '.impeccable-live', 'sessions', 'persist-test-1.jsonl'); + rmSync(journalPath, { force: true }); + + const postRes = await fetch(`http://localhost:${server.port}/events`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + token: server.token, + type: 'generate', + id: 'persist-test-1', + action: 'layout', + count: 3, + pageUrl: 'http://localhost:4321/', + element: { outerHTML: '
persist
', tagName: 'section' }, + }), + }); + assert.equal(postRes.status, 200); + + assert.equal( + existsSync(journalPath), + true, + 'event=live_server.journal_before_poll actor=browser operation=post_generate risk=server_restart_loses_unpolled_event expected=journal exists before agent poll actual=missing suggestion=append to live-session-store before enqueueing event', + ); + const journal = readFileSync(journalPath, 'utf-8'); + assert.match(journal, /"type":"generate"/); + + await fetch(`http://localhost:${server.port}/poll`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token: server.token, id: 'persist-test-1', type: 'done' }), + }); + }); + + it('accepts checkpoint events without exposing them as agent poll work', async () => { + await drainPolls(server); + 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: 'checkpoint-test-1', + phase: 'cycling', + revision: 2, + owner: 'browser-a', + arrivedVariants: 3, + visibleVariant: 2, + paramValues: { density: 'packed' }, + }), + }); + assert.equal(res.status, 200); + + const polled = await fetch(`http://localhost:${server.port}/poll?token=${server.token}&timeout=50`).then(r => r.json()); + assert.equal( + polled.type, + 'timeout', + 'event=live_server.checkpoint_not_polled actor=browser operation=checkpoint risk=checkpoint_starves_agent_queue expected=timeout actual=' + polled.type + ' suggestion=journal checkpoint without enqueueing agent work', + ); + + const snapshot = JSON.parse(readFileSync(join(REPO_ROOT, '.impeccable-live', 'sessions', 'checkpoint-test-1.snapshot.json'), 'utf-8')); + assert.equal(snapshot.visibleVariant, 2); + assert.deepEqual(snapshot.paramValues, { density: 'packed' }); + }); + + it('redelivers an unacknowledged browser event after helper server restart', async () => { + const tmp = mkdtempSync(join(tmpdir(), 'impeccable-server-restart-')); + let firstServer; + let restarted; + try { + firstServer = await startServer(8519, { cwd: tmp }); + const postRes = await fetch(`http://localhost:${firstServer.port}/events`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + token: firstServer.token, + type: 'generate', + id: 'restart-replay-1', + action: 'polish', + count: 2, + pageUrl: 'http://localhost:4321/', + element: { outerHTML: '
restart
', tagName: 'section' }, + }), + }); + assert.equal(postRes.status, 200); + + await stopServer(firstServer.port, firstServer.token); + firstServer.proc.kill(); + firstServer = null; + + restarted = await startServer(8519, { cwd: tmp }); + const replayed = await fetch(`http://localhost:${restarted.port}/poll?token=${restarted.token}&timeout=250&leaseMs=50`).then(r => r.json()); + + assert.equal( + replayed.id, + 'restart-replay-1', + 'event=live_server.restart_replay actor=agent operation=poll_after_helper_restart risk=server_restart_loses_unpolled_event expected=restart-replay-1 actual=' + replayed.id + ' suggestion=rebuild pending poll queue from live-session-store active snapshots on startup', + ); + assert.equal(replayed.type, 'generate'); + } finally { + if (firstServer) { + await stopServer(firstServer.port, firstServer.token); + firstServer.proc.kill(); + } + if (restarted) { + await stopServer(restarted.port, restarted.token); + restarted.proc.kill(); + } + rmSync(tmp, { recursive: true, force: true }); + } + }); + + it('records explicit completion acknowledgements as completed durable sessions', async () => { + await drainPolls(server); + await fetch(`http://localhost:${server.port}/events`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + token: server.token, + type: 'generate', + id: 'complete-ack-1', + action: 'impeccable', + count: 1, + pageUrl: '/', + element: { outerHTML: '' }, + }), + }); + const polled = await fetch(`http://localhost:${server.port}/poll?token=${server.token}&timeout=50`).then(r => r.json()); + assert.equal(polled.id, 'complete-ack-1'); + const ack = await fetch(`http://localhost:${server.port}/poll`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token: server.token, id: 'complete-ack-1', type: 'complete' }), + }); + assert.equal(ack.status, 200); + const snapshot = JSON.parse(readFileSync(join(REPO_ROOT, '.impeccable-live', 'sessions', 'complete-ack-1.snapshot.json'), 'utf-8')); + assert.equal(snapshot.phase, 'completed'); + }); + + it('does not drop polled events until the agent acknowledges them', async () => { + await drainPolls(server); + + const postRes = await fetch(`http://localhost:${server.port}/events`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + token: server.token, + type: 'generate', + id: 'lease-test-1', + action: 'polish', + count: 2, + element: { outerHTML: '
lease
', tagName: 'section' }, + }), + }); + assert.equal(postRes.status, 200); + + const first = await fetch(`http://localhost:${server.port}/poll?token=${server.token}&timeout=100&leaseMs=50`).then(r => r.json()); + assert.equal(first.id, 'lease-test-1'); + + const leased = await fetch(`http://localhost:${server.port}/poll?token=${server.token}&timeout=25&leaseMs=50`).then(r => r.json()); + assert.equal(leased.type, 'timeout', 'leased event should not be redelivered before lease expiry'); + + await new Promise(r => setTimeout(r, 75)); + const redelivered = await fetch(`http://localhost:${server.port}/poll?token=${server.token}&timeout=100&leaseMs=50`).then(r => r.json()); + assert.equal( + redelivered.id, + 'lease-test-1', + 'event=live_poll.lease_redelivery actor=agent operation=poll_after_missed_ack risk=agent_missed_event_loses_live_state expected=same event redelivered after lease expiry actual=' + redelivered.id + ' suggestion=inspect pending event lease bookkeeping', + ); + + await fetch(`http://localhost:${server.port}/poll`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token: server.token, id: 'lease-test-1', type: 'done' }), + }); + const acked = await fetch(`http://localhost:${server.port}/poll?token=${server.token}&timeout=50&leaseMs=50`).then(r => r.json()); + assert.equal(acked.type, 'timeout', 'acked event should be removed from the poll queue'); }); it('agent reply is forwarded via SSE to browser', async () => { diff --git a/tests/live-session-store.test.mjs b/tests/live-session-store.test.mjs new file mode 100644 index 00000000..a8130077 --- /dev/null +++ b/tests/live-session-store.test.mjs @@ -0,0 +1,147 @@ +/** + * Tests for durable live-session state. + * Run with: node --test tests/live-session-store.test.mjs + */ + +import { describe, it, beforeEach, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync, appendFileSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +import { createLiveSessionStore } from '../source/skills/impeccable/scripts/live-session-store.mjs'; + +describe('live-session-store', () => { + let tmp; + + beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), 'impeccable-session-store-')); + }); + + afterEach(() => { + rmSync(tmp, { recursive: true, force: true }); + }); + + it('rebuilds an active snapshot from an append-only journal after process restart', () => { + const store = createLiveSessionStore({ cwd: tmp, sessionId: 'session-a' }); + store.appendEvent({ + type: 'generate', + id: 'session-a', + action: 'polish', + count: 3, + pageUrl: 'http://localhost:4321/', + element: { outerHTML: '
Hero
', tagName: 'section' }, + screenshotPath: join(tmp, '.impeccable-live', 'annotations', 'session-a.png'), + }); + store.appendEvent({ type: 'variants_ready', id: 'session-a', file: 'src/pages/index.astro', arrivedVariants: 3 }); + store.appendEvent({ type: 'accept_intent', id: 'session-a', variantId: 2, paramValues: { density: 'packed' } }); + + const restarted = createLiveSessionStore({ cwd: tmp, sessionId: 'session-a' }); + const snapshot = restarted.getSnapshot('session-a'); + + assert.equal(snapshot.id, 'session-a'); + assert.equal(snapshot.phase, 'accept_requested'); + assert.equal(snapshot.expectedVariants, 3); + assert.equal(snapshot.arrivedVariants, 3); + assert.equal(snapshot.sourceFile, 'src/pages/index.astro'); + assert.equal(snapshot.visibleVariant, 2); + assert.deepEqual(snapshot.paramValues, { density: 'packed' }); + assert.equal(snapshot.annotationArtifacts[0].path.endsWith('session-a.png'), true); + + const active = restarted.listActiveSessions(); + assert.equal( + active.length, + 1, + 'event=live_session_store.active_restart actor=agent operation=list_active_sessions risk=server_restart_loses_live_state expected=one active session actual=' + active.length + ' suggestion=inspect journal replay and completed phase filtering', + ); + assert.equal(active[0].id, 'session-a'); + }); + + it('reports corrupted journal lines while preserving valid prior events', () => { + const store = createLiveSessionStore({ cwd: tmp, sessionId: 'corrupt-session' }); + store.appendEvent({ + type: 'generate', + id: 'corrupt-session', + action: 'layout', + count: 2, + element: { outerHTML: '
Card
', tagName: 'div' }, + }); + + appendFileSync(join(tmp, '.impeccable-live', 'sessions', 'corrupt-session.jsonl'), '{not json}\n'); + + const restarted = createLiveSessionStore({ cwd: tmp, sessionId: 'corrupt-session' }); + const snapshot = restarted.getSnapshot('corrupt-session'); + + assert.equal(snapshot.phase, 'generate_requested'); + assert.equal(snapshot.expectedVariants, 2); + assert.equal(snapshot.diagnostics.length, 1); + assert.match(snapshot.diagnostics[0].error, /journal_parse_failed/); + }); + + it('ignores stale checkpoints and keeps the newest browser state', () => { + const store = createLiveSessionStore({ cwd: tmp, sessionId: 'checkpoint-session' }); + store.appendEvent({ + type: 'generate', + id: 'checkpoint-session', + action: 'layout', + count: 3, + element: { outerHTML: '
Hero
', tagName: 'section' }, + }); + store.appendEvent({ type: 'checkpoint', id: 'checkpoint-session', revision: 5, phase: 'cycling', visibleVariant: 3, paramValues: { density: 'packed' } }); + store.appendEvent({ type: 'checkpoint', id: 'checkpoint-session', revision: 2, phase: 'cycling', visibleVariant: 1, paramValues: { density: 'airy' } }); + + const snapshot = store.getSnapshot('checkpoint-session'); + assert.equal(snapshot.checkpointRevision, 5); + assert.equal(snapshot.visibleVariant, 3); + assert.deepEqual(snapshot.paramValues, { density: 'packed' }); + assert.equal( + snapshot.diagnostics.some((d) => d.error === 'stale_checkpoint_ignored' && d.revision === 2), + true, + 'event=live_session_store.stale_checkpoint actor=browser operation=checkpoint_replay risk=old_browser_state_overwrites_newer_choice expected=stale diagnostic actual=' + JSON.stringify(snapshot.diagnostics), + ); + }); + + it('keeps completed sessions auditable but excludes them from active sessions by default', () => { + const store = createLiveSessionStore({ cwd: tmp, sessionId: 'done-session' }); + store.appendEvent({ + type: 'generate', + id: 'done-session', + action: 'bolder', + count: 1, + element: { outerHTML: '

Title

', tagName: 'h1' }, + }); + store.appendEvent({ type: 'agent_done', id: 'done-session', file: 'src/pages/index.astro' }); + store.appendEvent({ type: 'complete', id: 'done-session' }); + + const active = store.listActiveSessions(); + const completed = store.getSnapshot('done-session', { includeCompleted: true }); + + assert.equal(active.length, 0); + assert.equal(completed.phase, 'completed'); + assert.equal(completed.sourceFile, 'src/pages/index.astro'); + }); + + it('writes a rebuildable snapshot cache without making it authoritative', () => { + const store = createLiveSessionStore({ cwd: tmp, sessionId: 'cache-session' }); + store.appendEvent({ + type: 'generate', + id: 'cache-session', + action: 'colorize', + count: 2, + element: { outerHTML: '
Palette
', tagName: 'div' }, + }); + + const snapshotPath = join(tmp, '.impeccable-live', 'sessions', 'cache-session.snapshot.json'); + const cached = JSON.parse(readFileSync(snapshotPath, 'utf-8')); + assert.equal(cached.phase, 'generate_requested'); + + // Simulate stale snapshot cache. Restart must prefer journal truth and repair cache. + appendFileSync(snapshotPath, ''); + const restarted = createLiveSessionStore({ cwd: tmp, sessionId: 'cache-session' }); + restarted.appendEvent({ type: 'agent_done', id: 'cache-session', file: 'src/pages/index.astro' }); + const repaired = JSON.parse(readFileSync(snapshotPath, 'utf-8')); + + assert.equal(repaired.phase, 'variants_ready'); + assert.equal(repaired.sourceFile, 'src/pages/index.astro'); + }); +}); diff --git a/tests/live-wrap.test.mjs b/tests/live-wrap.test.mjs index 70b6eed1..8a7edfbb 100644 --- a/tests/live-wrap.test.mjs +++ b/tests/live-wrap.test.mjs @@ -322,6 +322,32 @@ describe('wrapCli integration', () => { assert.ok(modified.includes('class="after"')); assert.ok(modified.includes('data-impeccable-variants="pres123"')); }); + + it('reports Astro files need global prefixed live CSS instead of raw @scope', () => { + const astro = `--- +const title = 'Astro title'; +--- +
+

{title}

+
`; + writeFileSync(join(tmp, 'Hero.astro'), astro); + + const result = JSON.parse(execSync( + `node source/skills/impeccable/scripts/live-wrap.mjs --id astroCss --count 3 --classes "hero-shell" --tag "section" --file "${join(tmp, 'Hero.astro')}"`, + { cwd: process.cwd(), encoding: 'utf-8' } + )); + + assert.equal( + result.styleMode, + 'astro-global-prefixed', + 'event=live_wrap.astro_css_mode actor=agent operation=wrap_astro_file risk=astro_scopes_preview_css_away expected=styleMode astro-global-prefixed actual=' + result.styleMode + ' suggestion=inspect live-wrap output metadata for .astro files' + ); + assert.deepEqual(result.cssSelectorPrefixExamples, [ + '[data-impeccable-variant="1"]', + '[data-impeccable-variant="2"]', + '[data-impeccable-variant="3"]', + ]); + }); }); // --------------------------------------------------------------------------- From c0279d764bbc9d1ec31fab9ccff96e76cdd9c373 Mon Sep 17 00:00:00 2001 From: NQH Date: Tue, 28 Apr 2026 21:21:05 -0500 Subject: [PATCH 02/13] fix(live): acknowledge fallback recovery states --- .../impeccable/scripts/live-completion.mjs | 6 +++++ .../skills/impeccable/scripts/live-poll.mjs | 5 ++--- .../impeccable/scripts/live-session-store.mjs | 2 ++ .../impeccable/scripts/live-completion.mjs | 6 +++++ .../skills/impeccable/scripts/live-poll.mjs | 5 ++--- .../impeccable/scripts/live-session-store.mjs | 2 ++ .../impeccable/scripts/live-completion.mjs | 6 +++++ .../skills/impeccable/scripts/live-poll.mjs | 5 ++--- .../impeccable/scripts/live-session-store.mjs | 2 ++ .../impeccable/scripts/live-completion.mjs | 6 +++++ .../skills/impeccable/scripts/live-poll.mjs | 5 ++--- .../impeccable/scripts/live-session-store.mjs | 2 ++ .../impeccable/scripts/live-completion.mjs | 6 +++++ .../skills/impeccable/scripts/live-poll.mjs | 5 ++--- .../impeccable/scripts/live-session-store.mjs | 2 ++ .../impeccable/scripts/live-completion.mjs | 6 +++++ .kiro/skills/impeccable/scripts/live-poll.mjs | 5 ++--- .../impeccable/scripts/live-session-store.mjs | 2 ++ .../impeccable/scripts/live-completion.mjs | 6 +++++ .../skills/impeccable/scripts/live-poll.mjs | 5 ++--- .../impeccable/scripts/live-session-store.mjs | 2 ++ .../impeccable/scripts/live-completion.mjs | 6 +++++ .pi/skills/impeccable/scripts/live-poll.mjs | 5 ++--- .../impeccable/scripts/live-session-store.mjs | 2 ++ .../impeccable/scripts/live-completion.mjs | 6 +++++ .../skills/impeccable/scripts/live-poll.mjs | 5 ++--- .../impeccable/scripts/live-session-store.mjs | 2 ++ .../impeccable/scripts/live-completion.mjs | 6 +++++ .../skills/impeccable/scripts/live-poll.mjs | 5 ++--- .../impeccable/scripts/live-session-store.mjs | 2 ++ .../impeccable/scripts/live-completion.mjs | 6 +++++ .trae/skills/impeccable/scripts/live-poll.mjs | 5 ++--- .../impeccable/scripts/live-session-store.mjs | 2 ++ package.json | 2 +- .../impeccable/scripts/live-completion.mjs | 6 +++++ .../skills/impeccable/scripts/live-poll.mjs | 5 ++--- .../impeccable/scripts/live-session-store.mjs | 2 ++ .../impeccable/scripts/live-completion.mjs | 6 +++++ .../skills/impeccable/scripts/live-poll.mjs | 5 ++--- .../impeccable/scripts/live-session-store.mjs | 2 ++ tests/live-completion.test.mjs | 20 +++++++++++++++++ tests/live-session-store.test.mjs | 22 +++++++++++++++++++ 42 files changed, 173 insertions(+), 40 deletions(-) create mode 100644 .agents/skills/impeccable/scripts/live-completion.mjs create mode 100644 .claude/skills/impeccable/scripts/live-completion.mjs create mode 100644 .cursor/skills/impeccable/scripts/live-completion.mjs create mode 100644 .gemini/skills/impeccable/scripts/live-completion.mjs create mode 100644 .github/skills/impeccable/scripts/live-completion.mjs create mode 100644 .kiro/skills/impeccable/scripts/live-completion.mjs create mode 100644 .opencode/skills/impeccable/scripts/live-completion.mjs create mode 100644 .pi/skills/impeccable/scripts/live-completion.mjs create mode 100644 .rovodev/skills/impeccable/scripts/live-completion.mjs create mode 100644 .trae-cn/skills/impeccable/scripts/live-completion.mjs create mode 100644 .trae/skills/impeccable/scripts/live-completion.mjs create mode 100644 plugin/skills/impeccable/scripts/live-completion.mjs create mode 100644 source/skills/impeccable/scripts/live-completion.mjs create mode 100644 tests/live-completion.test.mjs diff --git a/.agents/skills/impeccable/scripts/live-completion.mjs b/.agents/skills/impeccable/scripts/live-completion.mjs new file mode 100644 index 00000000..c3a7ff6e --- /dev/null +++ b/.agents/skills/impeccable/scripts/live-completion.mjs @@ -0,0 +1,6 @@ +export function completionTypeForAcceptResult(eventType, acceptResult) { + if (eventType === 'discard') return acceptResult?.handled === true ? 'discarded' : 'error'; + if (acceptResult?.handled === true) return 'complete'; + if (acceptResult?.mode === 'fallback') return 'agent_done'; + return 'error'; +} diff --git a/.agents/skills/impeccable/scripts/live-poll.mjs b/.agents/skills/impeccable/scripts/live-poll.mjs index d1e36977..491a0aab 100644 --- a/.agents/skills/impeccable/scripts/live-poll.mjs +++ b/.agents/skills/impeccable/scripts/live-poll.mjs @@ -13,6 +13,7 @@ import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; import { fileURLToPath } from 'node:url'; +import { completionTypeForAcceptResult } from './live-completion.mjs'; // Node's built-in fetch (undici under the hood) enforces a 300s headers // timeout that can't be lowered per-request. We cap each request below @@ -155,9 +156,7 @@ Options: event._acceptResult = { handled: false, error: err.message }; } - const completionType = event._acceptResult?.handled === true - ? (event.type === 'discard' ? 'discarded' : 'complete') - : 'error'; + const completionType = completionTypeForAcceptResult(event.type, event._acceptResult); try { await postReply(base, info.token, { id: event.id, diff --git a/.agents/skills/impeccable/scripts/live-session-store.mjs b/.agents/skills/impeccable/scripts/live-session-store.mjs index f2aaaa9f..af61b998 100644 --- a/.agents/skills/impeccable/scripts/live-session-store.mjs +++ b/.agents/skills/impeccable/scripts/live-session-store.mjs @@ -202,6 +202,8 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { break; case 'agent_error': next.phase = 'agent_error'; + next.pendingEventSeq = null; + next.pendingEvent = null; next.diagnostics.push({ error: 'agent_error', message: event.message || 'unknown agent error' }); break; default: diff --git a/.claude/skills/impeccable/scripts/live-completion.mjs b/.claude/skills/impeccable/scripts/live-completion.mjs new file mode 100644 index 00000000..c3a7ff6e --- /dev/null +++ b/.claude/skills/impeccable/scripts/live-completion.mjs @@ -0,0 +1,6 @@ +export function completionTypeForAcceptResult(eventType, acceptResult) { + if (eventType === 'discard') return acceptResult?.handled === true ? 'discarded' : 'error'; + if (acceptResult?.handled === true) return 'complete'; + if (acceptResult?.mode === 'fallback') return 'agent_done'; + return 'error'; +} diff --git a/.claude/skills/impeccable/scripts/live-poll.mjs b/.claude/skills/impeccable/scripts/live-poll.mjs index d1e36977..491a0aab 100644 --- a/.claude/skills/impeccable/scripts/live-poll.mjs +++ b/.claude/skills/impeccable/scripts/live-poll.mjs @@ -13,6 +13,7 @@ import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; import { fileURLToPath } from 'node:url'; +import { completionTypeForAcceptResult } from './live-completion.mjs'; // Node's built-in fetch (undici under the hood) enforces a 300s headers // timeout that can't be lowered per-request. We cap each request below @@ -155,9 +156,7 @@ Options: event._acceptResult = { handled: false, error: err.message }; } - const completionType = event._acceptResult?.handled === true - ? (event.type === 'discard' ? 'discarded' : 'complete') - : 'error'; + const completionType = completionTypeForAcceptResult(event.type, event._acceptResult); try { await postReply(base, info.token, { id: event.id, diff --git a/.claude/skills/impeccable/scripts/live-session-store.mjs b/.claude/skills/impeccable/scripts/live-session-store.mjs index f2aaaa9f..af61b998 100644 --- a/.claude/skills/impeccable/scripts/live-session-store.mjs +++ b/.claude/skills/impeccable/scripts/live-session-store.mjs @@ -202,6 +202,8 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { break; case 'agent_error': next.phase = 'agent_error'; + next.pendingEventSeq = null; + next.pendingEvent = null; next.diagnostics.push({ error: 'agent_error', message: event.message || 'unknown agent error' }); break; default: diff --git a/.cursor/skills/impeccable/scripts/live-completion.mjs b/.cursor/skills/impeccable/scripts/live-completion.mjs new file mode 100644 index 00000000..c3a7ff6e --- /dev/null +++ b/.cursor/skills/impeccable/scripts/live-completion.mjs @@ -0,0 +1,6 @@ +export function completionTypeForAcceptResult(eventType, acceptResult) { + if (eventType === 'discard') return acceptResult?.handled === true ? 'discarded' : 'error'; + if (acceptResult?.handled === true) return 'complete'; + if (acceptResult?.mode === 'fallback') return 'agent_done'; + return 'error'; +} diff --git a/.cursor/skills/impeccable/scripts/live-poll.mjs b/.cursor/skills/impeccable/scripts/live-poll.mjs index d1e36977..491a0aab 100644 --- a/.cursor/skills/impeccable/scripts/live-poll.mjs +++ b/.cursor/skills/impeccable/scripts/live-poll.mjs @@ -13,6 +13,7 @@ import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; import { fileURLToPath } from 'node:url'; +import { completionTypeForAcceptResult } from './live-completion.mjs'; // Node's built-in fetch (undici under the hood) enforces a 300s headers // timeout that can't be lowered per-request. We cap each request below @@ -155,9 +156,7 @@ Options: event._acceptResult = { handled: false, error: err.message }; } - const completionType = event._acceptResult?.handled === true - ? (event.type === 'discard' ? 'discarded' : 'complete') - : 'error'; + const completionType = completionTypeForAcceptResult(event.type, event._acceptResult); try { await postReply(base, info.token, { id: event.id, diff --git a/.cursor/skills/impeccable/scripts/live-session-store.mjs b/.cursor/skills/impeccable/scripts/live-session-store.mjs index f2aaaa9f..af61b998 100644 --- a/.cursor/skills/impeccable/scripts/live-session-store.mjs +++ b/.cursor/skills/impeccable/scripts/live-session-store.mjs @@ -202,6 +202,8 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { break; case 'agent_error': next.phase = 'agent_error'; + next.pendingEventSeq = null; + next.pendingEvent = null; next.diagnostics.push({ error: 'agent_error', message: event.message || 'unknown agent error' }); break; default: diff --git a/.gemini/skills/impeccable/scripts/live-completion.mjs b/.gemini/skills/impeccable/scripts/live-completion.mjs new file mode 100644 index 00000000..c3a7ff6e --- /dev/null +++ b/.gemini/skills/impeccable/scripts/live-completion.mjs @@ -0,0 +1,6 @@ +export function completionTypeForAcceptResult(eventType, acceptResult) { + if (eventType === 'discard') return acceptResult?.handled === true ? 'discarded' : 'error'; + if (acceptResult?.handled === true) return 'complete'; + if (acceptResult?.mode === 'fallback') return 'agent_done'; + return 'error'; +} diff --git a/.gemini/skills/impeccable/scripts/live-poll.mjs b/.gemini/skills/impeccable/scripts/live-poll.mjs index d1e36977..491a0aab 100644 --- a/.gemini/skills/impeccable/scripts/live-poll.mjs +++ b/.gemini/skills/impeccable/scripts/live-poll.mjs @@ -13,6 +13,7 @@ import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; import { fileURLToPath } from 'node:url'; +import { completionTypeForAcceptResult } from './live-completion.mjs'; // Node's built-in fetch (undici under the hood) enforces a 300s headers // timeout that can't be lowered per-request. We cap each request below @@ -155,9 +156,7 @@ Options: event._acceptResult = { handled: false, error: err.message }; } - const completionType = event._acceptResult?.handled === true - ? (event.type === 'discard' ? 'discarded' : 'complete') - : 'error'; + const completionType = completionTypeForAcceptResult(event.type, event._acceptResult); try { await postReply(base, info.token, { id: event.id, diff --git a/.gemini/skills/impeccable/scripts/live-session-store.mjs b/.gemini/skills/impeccable/scripts/live-session-store.mjs index f2aaaa9f..af61b998 100644 --- a/.gemini/skills/impeccable/scripts/live-session-store.mjs +++ b/.gemini/skills/impeccable/scripts/live-session-store.mjs @@ -202,6 +202,8 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { break; case 'agent_error': next.phase = 'agent_error'; + next.pendingEventSeq = null; + next.pendingEvent = null; next.diagnostics.push({ error: 'agent_error', message: event.message || 'unknown agent error' }); break; default: diff --git a/.github/skills/impeccable/scripts/live-completion.mjs b/.github/skills/impeccable/scripts/live-completion.mjs new file mode 100644 index 00000000..c3a7ff6e --- /dev/null +++ b/.github/skills/impeccable/scripts/live-completion.mjs @@ -0,0 +1,6 @@ +export function completionTypeForAcceptResult(eventType, acceptResult) { + if (eventType === 'discard') return acceptResult?.handled === true ? 'discarded' : 'error'; + if (acceptResult?.handled === true) return 'complete'; + if (acceptResult?.mode === 'fallback') return 'agent_done'; + return 'error'; +} diff --git a/.github/skills/impeccable/scripts/live-poll.mjs b/.github/skills/impeccable/scripts/live-poll.mjs index d1e36977..491a0aab 100644 --- a/.github/skills/impeccable/scripts/live-poll.mjs +++ b/.github/skills/impeccable/scripts/live-poll.mjs @@ -13,6 +13,7 @@ import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; import { fileURLToPath } from 'node:url'; +import { completionTypeForAcceptResult } from './live-completion.mjs'; // Node's built-in fetch (undici under the hood) enforces a 300s headers // timeout that can't be lowered per-request. We cap each request below @@ -155,9 +156,7 @@ Options: event._acceptResult = { handled: false, error: err.message }; } - const completionType = event._acceptResult?.handled === true - ? (event.type === 'discard' ? 'discarded' : 'complete') - : 'error'; + const completionType = completionTypeForAcceptResult(event.type, event._acceptResult); try { await postReply(base, info.token, { id: event.id, diff --git a/.github/skills/impeccable/scripts/live-session-store.mjs b/.github/skills/impeccable/scripts/live-session-store.mjs index f2aaaa9f..af61b998 100644 --- a/.github/skills/impeccable/scripts/live-session-store.mjs +++ b/.github/skills/impeccable/scripts/live-session-store.mjs @@ -202,6 +202,8 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { break; case 'agent_error': next.phase = 'agent_error'; + next.pendingEventSeq = null; + next.pendingEvent = null; next.diagnostics.push({ error: 'agent_error', message: event.message || 'unknown agent error' }); break; default: diff --git a/.kiro/skills/impeccable/scripts/live-completion.mjs b/.kiro/skills/impeccable/scripts/live-completion.mjs new file mode 100644 index 00000000..c3a7ff6e --- /dev/null +++ b/.kiro/skills/impeccable/scripts/live-completion.mjs @@ -0,0 +1,6 @@ +export function completionTypeForAcceptResult(eventType, acceptResult) { + if (eventType === 'discard') return acceptResult?.handled === true ? 'discarded' : 'error'; + if (acceptResult?.handled === true) return 'complete'; + if (acceptResult?.mode === 'fallback') return 'agent_done'; + return 'error'; +} diff --git a/.kiro/skills/impeccable/scripts/live-poll.mjs b/.kiro/skills/impeccable/scripts/live-poll.mjs index d1e36977..491a0aab 100644 --- a/.kiro/skills/impeccable/scripts/live-poll.mjs +++ b/.kiro/skills/impeccable/scripts/live-poll.mjs @@ -13,6 +13,7 @@ import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; import { fileURLToPath } from 'node:url'; +import { completionTypeForAcceptResult } from './live-completion.mjs'; // Node's built-in fetch (undici under the hood) enforces a 300s headers // timeout that can't be lowered per-request. We cap each request below @@ -155,9 +156,7 @@ Options: event._acceptResult = { handled: false, error: err.message }; } - const completionType = event._acceptResult?.handled === true - ? (event.type === 'discard' ? 'discarded' : 'complete') - : 'error'; + const completionType = completionTypeForAcceptResult(event.type, event._acceptResult); try { await postReply(base, info.token, { id: event.id, diff --git a/.kiro/skills/impeccable/scripts/live-session-store.mjs b/.kiro/skills/impeccable/scripts/live-session-store.mjs index f2aaaa9f..af61b998 100644 --- a/.kiro/skills/impeccable/scripts/live-session-store.mjs +++ b/.kiro/skills/impeccable/scripts/live-session-store.mjs @@ -202,6 +202,8 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { break; case 'agent_error': next.phase = 'agent_error'; + next.pendingEventSeq = null; + next.pendingEvent = null; next.diagnostics.push({ error: 'agent_error', message: event.message || 'unknown agent error' }); break; default: diff --git a/.opencode/skills/impeccable/scripts/live-completion.mjs b/.opencode/skills/impeccable/scripts/live-completion.mjs new file mode 100644 index 00000000..c3a7ff6e --- /dev/null +++ b/.opencode/skills/impeccable/scripts/live-completion.mjs @@ -0,0 +1,6 @@ +export function completionTypeForAcceptResult(eventType, acceptResult) { + if (eventType === 'discard') return acceptResult?.handled === true ? 'discarded' : 'error'; + if (acceptResult?.handled === true) return 'complete'; + if (acceptResult?.mode === 'fallback') return 'agent_done'; + return 'error'; +} diff --git a/.opencode/skills/impeccable/scripts/live-poll.mjs b/.opencode/skills/impeccable/scripts/live-poll.mjs index d1e36977..491a0aab 100644 --- a/.opencode/skills/impeccable/scripts/live-poll.mjs +++ b/.opencode/skills/impeccable/scripts/live-poll.mjs @@ -13,6 +13,7 @@ import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; import { fileURLToPath } from 'node:url'; +import { completionTypeForAcceptResult } from './live-completion.mjs'; // Node's built-in fetch (undici under the hood) enforces a 300s headers // timeout that can't be lowered per-request. We cap each request below @@ -155,9 +156,7 @@ Options: event._acceptResult = { handled: false, error: err.message }; } - const completionType = event._acceptResult?.handled === true - ? (event.type === 'discard' ? 'discarded' : 'complete') - : 'error'; + const completionType = completionTypeForAcceptResult(event.type, event._acceptResult); try { await postReply(base, info.token, { id: event.id, diff --git a/.opencode/skills/impeccable/scripts/live-session-store.mjs b/.opencode/skills/impeccable/scripts/live-session-store.mjs index f2aaaa9f..af61b998 100644 --- a/.opencode/skills/impeccable/scripts/live-session-store.mjs +++ b/.opencode/skills/impeccable/scripts/live-session-store.mjs @@ -202,6 +202,8 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { break; case 'agent_error': next.phase = 'agent_error'; + next.pendingEventSeq = null; + next.pendingEvent = null; next.diagnostics.push({ error: 'agent_error', message: event.message || 'unknown agent error' }); break; default: diff --git a/.pi/skills/impeccable/scripts/live-completion.mjs b/.pi/skills/impeccable/scripts/live-completion.mjs new file mode 100644 index 00000000..c3a7ff6e --- /dev/null +++ b/.pi/skills/impeccable/scripts/live-completion.mjs @@ -0,0 +1,6 @@ +export function completionTypeForAcceptResult(eventType, acceptResult) { + if (eventType === 'discard') return acceptResult?.handled === true ? 'discarded' : 'error'; + if (acceptResult?.handled === true) return 'complete'; + if (acceptResult?.mode === 'fallback') return 'agent_done'; + return 'error'; +} diff --git a/.pi/skills/impeccable/scripts/live-poll.mjs b/.pi/skills/impeccable/scripts/live-poll.mjs index d1e36977..491a0aab 100644 --- a/.pi/skills/impeccable/scripts/live-poll.mjs +++ b/.pi/skills/impeccable/scripts/live-poll.mjs @@ -13,6 +13,7 @@ import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; import { fileURLToPath } from 'node:url'; +import { completionTypeForAcceptResult } from './live-completion.mjs'; // Node's built-in fetch (undici under the hood) enforces a 300s headers // timeout that can't be lowered per-request. We cap each request below @@ -155,9 +156,7 @@ Options: event._acceptResult = { handled: false, error: err.message }; } - const completionType = event._acceptResult?.handled === true - ? (event.type === 'discard' ? 'discarded' : 'complete') - : 'error'; + const completionType = completionTypeForAcceptResult(event.type, event._acceptResult); try { await postReply(base, info.token, { id: event.id, diff --git a/.pi/skills/impeccable/scripts/live-session-store.mjs b/.pi/skills/impeccable/scripts/live-session-store.mjs index f2aaaa9f..af61b998 100644 --- a/.pi/skills/impeccable/scripts/live-session-store.mjs +++ b/.pi/skills/impeccable/scripts/live-session-store.mjs @@ -202,6 +202,8 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { break; case 'agent_error': next.phase = 'agent_error'; + next.pendingEventSeq = null; + next.pendingEvent = null; next.diagnostics.push({ error: 'agent_error', message: event.message || 'unknown agent error' }); break; default: diff --git a/.rovodev/skills/impeccable/scripts/live-completion.mjs b/.rovodev/skills/impeccable/scripts/live-completion.mjs new file mode 100644 index 00000000..c3a7ff6e --- /dev/null +++ b/.rovodev/skills/impeccable/scripts/live-completion.mjs @@ -0,0 +1,6 @@ +export function completionTypeForAcceptResult(eventType, acceptResult) { + if (eventType === 'discard') return acceptResult?.handled === true ? 'discarded' : 'error'; + if (acceptResult?.handled === true) return 'complete'; + if (acceptResult?.mode === 'fallback') return 'agent_done'; + return 'error'; +} diff --git a/.rovodev/skills/impeccable/scripts/live-poll.mjs b/.rovodev/skills/impeccable/scripts/live-poll.mjs index d1e36977..491a0aab 100644 --- a/.rovodev/skills/impeccable/scripts/live-poll.mjs +++ b/.rovodev/skills/impeccable/scripts/live-poll.mjs @@ -13,6 +13,7 @@ import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; import { fileURLToPath } from 'node:url'; +import { completionTypeForAcceptResult } from './live-completion.mjs'; // Node's built-in fetch (undici under the hood) enforces a 300s headers // timeout that can't be lowered per-request. We cap each request below @@ -155,9 +156,7 @@ Options: event._acceptResult = { handled: false, error: err.message }; } - const completionType = event._acceptResult?.handled === true - ? (event.type === 'discard' ? 'discarded' : 'complete') - : 'error'; + const completionType = completionTypeForAcceptResult(event.type, event._acceptResult); try { await postReply(base, info.token, { id: event.id, diff --git a/.rovodev/skills/impeccable/scripts/live-session-store.mjs b/.rovodev/skills/impeccable/scripts/live-session-store.mjs index f2aaaa9f..af61b998 100644 --- a/.rovodev/skills/impeccable/scripts/live-session-store.mjs +++ b/.rovodev/skills/impeccable/scripts/live-session-store.mjs @@ -202,6 +202,8 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { break; case 'agent_error': next.phase = 'agent_error'; + next.pendingEventSeq = null; + next.pendingEvent = null; next.diagnostics.push({ error: 'agent_error', message: event.message || 'unknown agent error' }); break; default: diff --git a/.trae-cn/skills/impeccable/scripts/live-completion.mjs b/.trae-cn/skills/impeccable/scripts/live-completion.mjs new file mode 100644 index 00000000..c3a7ff6e --- /dev/null +++ b/.trae-cn/skills/impeccable/scripts/live-completion.mjs @@ -0,0 +1,6 @@ +export function completionTypeForAcceptResult(eventType, acceptResult) { + if (eventType === 'discard') return acceptResult?.handled === true ? 'discarded' : 'error'; + if (acceptResult?.handled === true) return 'complete'; + if (acceptResult?.mode === 'fallback') return 'agent_done'; + return 'error'; +} diff --git a/.trae-cn/skills/impeccable/scripts/live-poll.mjs b/.trae-cn/skills/impeccable/scripts/live-poll.mjs index d1e36977..491a0aab 100644 --- a/.trae-cn/skills/impeccable/scripts/live-poll.mjs +++ b/.trae-cn/skills/impeccable/scripts/live-poll.mjs @@ -13,6 +13,7 @@ import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; import { fileURLToPath } from 'node:url'; +import { completionTypeForAcceptResult } from './live-completion.mjs'; // Node's built-in fetch (undici under the hood) enforces a 300s headers // timeout that can't be lowered per-request. We cap each request below @@ -155,9 +156,7 @@ Options: event._acceptResult = { handled: false, error: err.message }; } - const completionType = event._acceptResult?.handled === true - ? (event.type === 'discard' ? 'discarded' : 'complete') - : 'error'; + const completionType = completionTypeForAcceptResult(event.type, event._acceptResult); try { await postReply(base, info.token, { id: event.id, diff --git a/.trae-cn/skills/impeccable/scripts/live-session-store.mjs b/.trae-cn/skills/impeccable/scripts/live-session-store.mjs index f2aaaa9f..af61b998 100644 --- a/.trae-cn/skills/impeccable/scripts/live-session-store.mjs +++ b/.trae-cn/skills/impeccable/scripts/live-session-store.mjs @@ -202,6 +202,8 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { break; case 'agent_error': next.phase = 'agent_error'; + next.pendingEventSeq = null; + next.pendingEvent = null; next.diagnostics.push({ error: 'agent_error', message: event.message || 'unknown agent error' }); break; default: diff --git a/.trae/skills/impeccable/scripts/live-completion.mjs b/.trae/skills/impeccable/scripts/live-completion.mjs new file mode 100644 index 00000000..c3a7ff6e --- /dev/null +++ b/.trae/skills/impeccable/scripts/live-completion.mjs @@ -0,0 +1,6 @@ +export function completionTypeForAcceptResult(eventType, acceptResult) { + if (eventType === 'discard') return acceptResult?.handled === true ? 'discarded' : 'error'; + if (acceptResult?.handled === true) return 'complete'; + if (acceptResult?.mode === 'fallback') return 'agent_done'; + return 'error'; +} diff --git a/.trae/skills/impeccable/scripts/live-poll.mjs b/.trae/skills/impeccable/scripts/live-poll.mjs index d1e36977..491a0aab 100644 --- a/.trae/skills/impeccable/scripts/live-poll.mjs +++ b/.trae/skills/impeccable/scripts/live-poll.mjs @@ -13,6 +13,7 @@ import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; import { fileURLToPath } from 'node:url'; +import { completionTypeForAcceptResult } from './live-completion.mjs'; // Node's built-in fetch (undici under the hood) enforces a 300s headers // timeout that can't be lowered per-request. We cap each request below @@ -155,9 +156,7 @@ Options: event._acceptResult = { handled: false, error: err.message }; } - const completionType = event._acceptResult?.handled === true - ? (event.type === 'discard' ? 'discarded' : 'complete') - : 'error'; + const completionType = completionTypeForAcceptResult(event.type, event._acceptResult); try { await postReply(base, info.token, { id: event.id, diff --git a/.trae/skills/impeccable/scripts/live-session-store.mjs b/.trae/skills/impeccable/scripts/live-session-store.mjs index f2aaaa9f..af61b998 100644 --- a/.trae/skills/impeccable/scripts/live-session-store.mjs +++ b/.trae/skills/impeccable/scripts/live-session-store.mjs @@ -202,6 +202,8 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { break; case 'agent_error': next.phase = 'agent_error'; + next.pendingEventSeq = null; + next.pendingEvent = null; next.diagnostics.push({ error: 'agent_error', message: event.message || 'unknown agent error' }); break; default: diff --git a/package.json b/package.json index f28c16f2..28b78edf 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ "dev": "npx astro dev", "preview": "bun run build && npx astro preview", "deploy": "bun run build && wrangler pages deploy build/", - "test": "bun test tests/build.test.js tests/detect-antipatterns.test.js tests/windows-path-fix.test.js && node --test tests/detect-antipatterns-fixtures.test.mjs && node --test tests/detect-antipatterns-browser.test.mjs && node --test tests/cleanup-deprecated.test.mjs && node --test tests/live-wrap.test.mjs && node --test tests/live-accept.test.mjs && node --test tests/live-inject.test.mjs && node --test tests/live-server.test.mjs && node --test tests/live-browser-regression.test.mjs && node --test tests/live-session-store.test.mjs && node --test tests/live-browser-session.test.mjs && node --test tests/live-recovery-commands.test.mjs && node --test tests/framework-fixtures.test.mjs", + "test": "bun test tests/build.test.js tests/detect-antipatterns.test.js tests/windows-path-fix.test.js && node --test tests/detect-antipatterns-fixtures.test.mjs && node --test tests/detect-antipatterns-browser.test.mjs && node --test tests/cleanup-deprecated.test.mjs && node --test tests/live-wrap.test.mjs && node --test tests/live-accept.test.mjs && node --test tests/live-inject.test.mjs && node --test tests/live-server.test.mjs && node --test tests/live-browser-regression.test.mjs && node --test tests/live-session-store.test.mjs && node --test tests/live-browser-session.test.mjs && node --test tests/live-completion.test.mjs && node --test tests/live-recovery-commands.test.mjs && node --test tests/framework-fixtures.test.mjs", "test:live-e2e": "node --test --test-timeout=600000 tests/live-e2e.test.mjs", "audit": "bun audit --audit-level=moderate", "prepack": "cp README.md README.repo.md && cp README.npm.md README.md", diff --git a/plugin/skills/impeccable/scripts/live-completion.mjs b/plugin/skills/impeccable/scripts/live-completion.mjs new file mode 100644 index 00000000..c3a7ff6e --- /dev/null +++ b/plugin/skills/impeccable/scripts/live-completion.mjs @@ -0,0 +1,6 @@ +export function completionTypeForAcceptResult(eventType, acceptResult) { + if (eventType === 'discard') return acceptResult?.handled === true ? 'discarded' : 'error'; + if (acceptResult?.handled === true) return 'complete'; + if (acceptResult?.mode === 'fallback') return 'agent_done'; + return 'error'; +} diff --git a/plugin/skills/impeccable/scripts/live-poll.mjs b/plugin/skills/impeccable/scripts/live-poll.mjs index d1e36977..491a0aab 100644 --- a/plugin/skills/impeccable/scripts/live-poll.mjs +++ b/plugin/skills/impeccable/scripts/live-poll.mjs @@ -13,6 +13,7 @@ import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; import { fileURLToPath } from 'node:url'; +import { completionTypeForAcceptResult } from './live-completion.mjs'; // Node's built-in fetch (undici under the hood) enforces a 300s headers // timeout that can't be lowered per-request. We cap each request below @@ -155,9 +156,7 @@ Options: event._acceptResult = { handled: false, error: err.message }; } - const completionType = event._acceptResult?.handled === true - ? (event.type === 'discard' ? 'discarded' : 'complete') - : 'error'; + const completionType = completionTypeForAcceptResult(event.type, event._acceptResult); try { await postReply(base, info.token, { id: event.id, diff --git a/plugin/skills/impeccable/scripts/live-session-store.mjs b/plugin/skills/impeccable/scripts/live-session-store.mjs index f2aaaa9f..af61b998 100644 --- a/plugin/skills/impeccable/scripts/live-session-store.mjs +++ b/plugin/skills/impeccable/scripts/live-session-store.mjs @@ -202,6 +202,8 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { break; case 'agent_error': next.phase = 'agent_error'; + next.pendingEventSeq = null; + next.pendingEvent = null; next.diagnostics.push({ error: 'agent_error', message: event.message || 'unknown agent error' }); break; default: diff --git a/source/skills/impeccable/scripts/live-completion.mjs b/source/skills/impeccable/scripts/live-completion.mjs new file mode 100644 index 00000000..c3a7ff6e --- /dev/null +++ b/source/skills/impeccable/scripts/live-completion.mjs @@ -0,0 +1,6 @@ +export function completionTypeForAcceptResult(eventType, acceptResult) { + if (eventType === 'discard') return acceptResult?.handled === true ? 'discarded' : 'error'; + if (acceptResult?.handled === true) return 'complete'; + if (acceptResult?.mode === 'fallback') return 'agent_done'; + return 'error'; +} diff --git a/source/skills/impeccable/scripts/live-poll.mjs b/source/skills/impeccable/scripts/live-poll.mjs index d1e36977..491a0aab 100644 --- a/source/skills/impeccable/scripts/live-poll.mjs +++ b/source/skills/impeccable/scripts/live-poll.mjs @@ -13,6 +13,7 @@ import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; import { fileURLToPath } from 'node:url'; +import { completionTypeForAcceptResult } from './live-completion.mjs'; // Node's built-in fetch (undici under the hood) enforces a 300s headers // timeout that can't be lowered per-request. We cap each request below @@ -155,9 +156,7 @@ Options: event._acceptResult = { handled: false, error: err.message }; } - const completionType = event._acceptResult?.handled === true - ? (event.type === 'discard' ? 'discarded' : 'complete') - : 'error'; + const completionType = completionTypeForAcceptResult(event.type, event._acceptResult); try { await postReply(base, info.token, { id: event.id, diff --git a/source/skills/impeccable/scripts/live-session-store.mjs b/source/skills/impeccable/scripts/live-session-store.mjs index f2aaaa9f..af61b998 100644 --- a/source/skills/impeccable/scripts/live-session-store.mjs +++ b/source/skills/impeccable/scripts/live-session-store.mjs @@ -202,6 +202,8 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { break; case 'agent_error': next.phase = 'agent_error'; + next.pendingEventSeq = null; + next.pendingEvent = null; next.diagnostics.push({ error: 'agent_error', message: event.message || 'unknown agent error' }); break; default: diff --git a/tests/live-completion.test.mjs b/tests/live-completion.test.mjs new file mode 100644 index 00000000..d5fc79a3 --- /dev/null +++ b/tests/live-completion.test.mjs @@ -0,0 +1,20 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +import { completionTypeForAcceptResult } from '../source/skills/impeccable/scripts/live-completion.mjs'; + +describe('live completion type classification', () => { + it('treats generated-file fallback accept as normal agent handoff, not error', () => { + assert.equal( + completionTypeForAcceptResult('accept', { handled: false, mode: 'fallback' }), + 'agent_done', + 'event=live_poll.fallback_completion actor=agent operation=accept_generated_file risk=fallback_handoff_recorded_as_agent_error expected=agent_done actual=error', + ); + }); + + it('classifies handled accept/discard and real failures explicitly', () => { + assert.equal(completionTypeForAcceptResult('accept', { handled: true }), 'complete'); + assert.equal(completionTypeForAcceptResult('discard', { handled: true }), 'discarded'); + assert.equal(completionTypeForAcceptResult('accept', { handled: false, error: 'boom' }), 'error'); + }); +}); diff --git a/tests/live-session-store.test.mjs b/tests/live-session-store.test.mjs index a8130077..e2fd31af 100644 --- a/tests/live-session-store.test.mjs +++ b/tests/live-session-store.test.mjs @@ -101,6 +101,28 @@ describe('live-session-store', () => { ); }); + it('clears pending events when an agent error is acknowledged', () => { + const store = createLiveSessionStore({ cwd: tmp, sessionId: 'error-session' }); + store.appendEvent({ + type: 'generate', + id: 'error-session', + action: 'polish', + count: 1, + element: { outerHTML: '', tagName: 'button' }, + }); + store.appendEvent({ type: 'agent_error', id: 'error-session', message: 'accept failed' }); + + const snapshot = store.getSnapshot('error-session'); + assert.equal(snapshot.phase, 'agent_error'); + assert.equal(snapshot.pendingEvent, null); + assert.equal(snapshot.pendingEventSeq, null); + assert.equal( + store.listActiveSessions()[0].pendingEvent, + null, + 'event=live_session_store.agent_error_ack actor=agent operation=restart_replay risk=acknowledged_error_event_redelivered expected=null actual=' + JSON.stringify(store.listActiveSessions()[0].pendingEvent), + ); + }); + it('keeps completed sessions auditable but excludes them from active sessions by default', () => { const store = createLiveSessionStore({ cwd: tmp, sessionId: 'done-session' }); store.appendEvent({ From 9ff62cf50de4ef2c13a2bfcbe1e96758ef110859 Mon Sep 17 00:00:00 2001 From: NQH Date: Tue, 28 Apr 2026 21:33:36 -0500 Subject: [PATCH 03/13] fix(live): flush recoverable handoffs promptly --- .../impeccable/scripts/live-completion.mjs | 4 +- .../skills/impeccable/scripts/live-poll.mjs | 2 +- .../skills/impeccable/scripts/live-server.mjs | 29 ++++++++++++- .../impeccable/scripts/live-session-store.mjs | 16 ++++---- .../impeccable/scripts/live-completion.mjs | 4 +- .../skills/impeccable/scripts/live-poll.mjs | 2 +- .../skills/impeccable/scripts/live-server.mjs | 29 ++++++++++++- .../impeccable/scripts/live-session-store.mjs | 16 ++++---- .../impeccable/scripts/live-completion.mjs | 4 +- .../skills/impeccable/scripts/live-poll.mjs | 2 +- .../skills/impeccable/scripts/live-server.mjs | 29 ++++++++++++- .../impeccable/scripts/live-session-store.mjs | 16 ++++---- .../impeccable/scripts/live-completion.mjs | 4 +- .../skills/impeccable/scripts/live-poll.mjs | 2 +- .../skills/impeccable/scripts/live-server.mjs | 29 ++++++++++++- .../impeccable/scripts/live-session-store.mjs | 16 ++++---- .../impeccable/scripts/live-completion.mjs | 4 +- .../skills/impeccable/scripts/live-poll.mjs | 2 +- .../skills/impeccable/scripts/live-server.mjs | 29 ++++++++++++- .../impeccable/scripts/live-session-store.mjs | 16 ++++---- .../impeccable/scripts/live-completion.mjs | 4 +- .kiro/skills/impeccable/scripts/live-poll.mjs | 2 +- .../skills/impeccable/scripts/live-server.mjs | 29 ++++++++++++- .../impeccable/scripts/live-session-store.mjs | 16 ++++---- .../impeccable/scripts/live-completion.mjs | 4 +- .../skills/impeccable/scripts/live-poll.mjs | 2 +- .../skills/impeccable/scripts/live-server.mjs | 29 ++++++++++++- .../impeccable/scripts/live-session-store.mjs | 16 ++++---- .../impeccable/scripts/live-completion.mjs | 4 +- .pi/skills/impeccable/scripts/live-poll.mjs | 2 +- .pi/skills/impeccable/scripts/live-server.mjs | 29 ++++++++++++- .../impeccable/scripts/live-session-store.mjs | 16 ++++---- .../impeccable/scripts/live-completion.mjs | 4 +- .../skills/impeccable/scripts/live-poll.mjs | 2 +- .../skills/impeccable/scripts/live-server.mjs | 29 ++++++++++++- .../impeccable/scripts/live-session-store.mjs | 16 ++++---- .../impeccable/scripts/live-completion.mjs | 4 +- .../skills/impeccable/scripts/live-poll.mjs | 2 +- .../skills/impeccable/scripts/live-server.mjs | 29 ++++++++++++- .../impeccable/scripts/live-session-store.mjs | 16 ++++---- .../impeccable/scripts/live-completion.mjs | 4 +- .trae/skills/impeccable/scripts/live-poll.mjs | 2 +- .../skills/impeccable/scripts/live-server.mjs | 29 ++++++++++++- .../impeccable/scripts/live-session-store.mjs | 16 ++++---- .../impeccable/scripts/live-completion.mjs | 4 +- .../skills/impeccable/scripts/live-poll.mjs | 2 +- .../skills/impeccable/scripts/live-server.mjs | 29 ++++++++++++- .../impeccable/scripts/live-session-store.mjs | 16 ++++---- .../impeccable/scripts/live-completion.mjs | 4 +- .../skills/impeccable/scripts/live-poll.mjs | 2 +- .../skills/impeccable/scripts/live-server.mjs | 29 ++++++++++++- .../impeccable/scripts/live-session-store.mjs | 16 ++++---- tests/live-completion.test.mjs | 10 ++++- tests/live-server.test.mjs | 41 +++++++++++++++++++ tests/live-session-store.test.mjs | 25 +++++++++++ 55 files changed, 582 insertions(+), 157 deletions(-) diff --git a/.agents/skills/impeccable/scripts/live-completion.mjs b/.agents/skills/impeccable/scripts/live-completion.mjs index c3a7ff6e..62dec8c3 100644 --- a/.agents/skills/impeccable/scripts/live-completion.mjs +++ b/.agents/skills/impeccable/scripts/live-completion.mjs @@ -1,6 +1,6 @@ export function completionTypeForAcceptResult(eventType, acceptResult) { if (eventType === 'discard') return acceptResult?.handled === true ? 'discarded' : 'error'; if (acceptResult?.handled === true) return 'complete'; - if (acceptResult?.mode === 'fallback') return 'agent_done'; - return 'error'; + if (acceptResult?.mode === 'error') return 'error'; + return 'agent_done'; } diff --git a/.agents/skills/impeccable/scripts/live-poll.mjs b/.agents/skills/impeccable/scripts/live-poll.mjs index 491a0aab..015a15c5 100644 --- a/.agents/skills/impeccable/scripts/live-poll.mjs +++ b/.agents/skills/impeccable/scripts/live-poll.mjs @@ -153,7 +153,7 @@ Options: ); event._acceptResult = JSON.parse(out.trim()); } catch (err) { - event._acceptResult = { handled: false, error: err.message }; + event._acceptResult = { handled: false, mode: 'error', error: err.message }; } const completionType = completionTypeForAcceptResult(event.type, event._acceptResult); diff --git a/.agents/skills/impeccable/scripts/live-server.mjs b/.agents/skills/impeccable/scripts/live-server.mjs index 025a7633..8145e26f 100644 --- a/.agents/skills/impeccable/scripts/live-server.mjs +++ b/.agents/skills/impeccable/scripts/live-server.mjs @@ -63,6 +63,7 @@ const state = { exitTimer: null, sessionDir: null, // per-session tmp dir for annotation screenshots sessionStore: null, + leaseTimer: null, }; // Cap per-annotation upload size. A full 1920×1080 PNG is typically <1 MB; @@ -101,16 +102,39 @@ function acknowledgePendingEvent(id) { const idx = state.pendingEvents.findIndex((entry) => entry.event?.id === id); if (idx === -1) return false; state.pendingEvents.splice(idx, 1); + scheduleLeaseFlush(); return true; } +function scheduleLeaseFlush() { + if (state.leaseTimer) { + clearTimeout(state.leaseTimer); + state.leaseTimer = null; + } + if (state.pendingPolls.length === 0) return; + const now = Date.now(); + const nextLeaseUntil = state.pendingEvents + .map((entry) => entry.leaseUntil || 0) + .filter((leaseUntil) => leaseUntil > now) + .sort((a, b) => a - b)[0]; + if (!nextLeaseUntil) return; + state.leaseTimer = setTimeout(() => { + state.leaseTimer = null; + flushPendingPolls(); + }, Math.max(0, nextLeaseUntil - now)); +} + function flushPendingPolls() { while (state.pendingPolls.length > 0) { const entry = findAvailablePendingEvent(); - if (!entry) return; + if (!entry) { + scheduleLeaseFlush(); + return; + } const poll = state.pendingPolls.shift(); poll.resolve(leaseEvent(entry, poll.leaseMs)); } + scheduleLeaseFlush(); } /** Push a message to all connected SSE clients. */ @@ -592,6 +616,7 @@ function handlePollGet(req, res, url) { res.end(JSON.stringify(event)); } state.pendingPolls.push(poll); + scheduleLeaseFlush(); req.on('close', () => { clearTimeout(timer); const idx = state.pendingPolls.indexOf(poll); @@ -643,6 +668,8 @@ let httpServer = null; function shutdown() { try { fs.unlinkSync(LIVE_PID_FILE); } catch {} + if (state.leaseTimer) clearTimeout(state.leaseTimer); + state.leaseTimer = null; if (state.sessionDir) { try { fs.rmSync(state.sessionDir, { recursive: true, force: true }); } catch {} } diff --git a/.agents/skills/impeccable/scripts/live-session-store.mjs b/.agents/skills/impeccable/scripts/live-session-store.mjs index af61b998..feaa56b2 100644 --- a/.agents/skills/impeccable/scripts/live-session-store.mjs +++ b/.agents/skills/impeccable/scripts/live-session-store.mjs @@ -151,8 +151,8 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { switch (event.type) { case 'generate': next.phase = 'generate_requested'; - next.pageUrl = event.pageUrl || next.pageUrl; - next.expectedVariants = event.count || next.expectedVariants; + next.pageUrl = event.pageUrl ?? next.pageUrl; + next.expectedVariants = event.count ?? next.expectedVariants; next.pendingEventSeq = entry.seq ?? next.pendingEventSeq; next.pendingEvent = toPendingEvent(event); if (event.screenshotPath) upsertArtifact(next.annotationArtifacts, { type: 'screenshot', path: event.screenshotPath }); @@ -160,16 +160,16 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { case 'variants_ready': case 'agent_done': next.phase = 'variants_ready'; - next.sourceFile = event.file || next.sourceFile; - next.arrivedVariants = event.arrivedVariants ?? (next.arrivedVariants || next.expectedVariants); + next.sourceFile = event.file ?? next.sourceFile; + next.arrivedVariants = event.arrivedVariants ?? (next.arrivedVariants ?? next.expectedVariants); next.pendingEventSeq = null; next.pendingEvent = null; break; case 'checkpoint': - 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; + 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; if (event.paramValues) next.paramValues = { ...event.paramValues }; diff --git a/.claude/skills/impeccable/scripts/live-completion.mjs b/.claude/skills/impeccable/scripts/live-completion.mjs index c3a7ff6e..62dec8c3 100644 --- a/.claude/skills/impeccable/scripts/live-completion.mjs +++ b/.claude/skills/impeccable/scripts/live-completion.mjs @@ -1,6 +1,6 @@ export function completionTypeForAcceptResult(eventType, acceptResult) { if (eventType === 'discard') return acceptResult?.handled === true ? 'discarded' : 'error'; if (acceptResult?.handled === true) return 'complete'; - if (acceptResult?.mode === 'fallback') return 'agent_done'; - return 'error'; + if (acceptResult?.mode === 'error') return 'error'; + return 'agent_done'; } diff --git a/.claude/skills/impeccable/scripts/live-poll.mjs b/.claude/skills/impeccable/scripts/live-poll.mjs index 491a0aab..015a15c5 100644 --- a/.claude/skills/impeccable/scripts/live-poll.mjs +++ b/.claude/skills/impeccable/scripts/live-poll.mjs @@ -153,7 +153,7 @@ Options: ); event._acceptResult = JSON.parse(out.trim()); } catch (err) { - event._acceptResult = { handled: false, error: err.message }; + event._acceptResult = { handled: false, mode: 'error', error: err.message }; } const completionType = completionTypeForAcceptResult(event.type, event._acceptResult); diff --git a/.claude/skills/impeccable/scripts/live-server.mjs b/.claude/skills/impeccable/scripts/live-server.mjs index 025a7633..8145e26f 100644 --- a/.claude/skills/impeccable/scripts/live-server.mjs +++ b/.claude/skills/impeccable/scripts/live-server.mjs @@ -63,6 +63,7 @@ const state = { exitTimer: null, sessionDir: null, // per-session tmp dir for annotation screenshots sessionStore: null, + leaseTimer: null, }; // Cap per-annotation upload size. A full 1920×1080 PNG is typically <1 MB; @@ -101,16 +102,39 @@ function acknowledgePendingEvent(id) { const idx = state.pendingEvents.findIndex((entry) => entry.event?.id === id); if (idx === -1) return false; state.pendingEvents.splice(idx, 1); + scheduleLeaseFlush(); return true; } +function scheduleLeaseFlush() { + if (state.leaseTimer) { + clearTimeout(state.leaseTimer); + state.leaseTimer = null; + } + if (state.pendingPolls.length === 0) return; + const now = Date.now(); + const nextLeaseUntil = state.pendingEvents + .map((entry) => entry.leaseUntil || 0) + .filter((leaseUntil) => leaseUntil > now) + .sort((a, b) => a - b)[0]; + if (!nextLeaseUntil) return; + state.leaseTimer = setTimeout(() => { + state.leaseTimer = null; + flushPendingPolls(); + }, Math.max(0, nextLeaseUntil - now)); +} + function flushPendingPolls() { while (state.pendingPolls.length > 0) { const entry = findAvailablePendingEvent(); - if (!entry) return; + if (!entry) { + scheduleLeaseFlush(); + return; + } const poll = state.pendingPolls.shift(); poll.resolve(leaseEvent(entry, poll.leaseMs)); } + scheduleLeaseFlush(); } /** Push a message to all connected SSE clients. */ @@ -592,6 +616,7 @@ function handlePollGet(req, res, url) { res.end(JSON.stringify(event)); } state.pendingPolls.push(poll); + scheduleLeaseFlush(); req.on('close', () => { clearTimeout(timer); const idx = state.pendingPolls.indexOf(poll); @@ -643,6 +668,8 @@ let httpServer = null; function shutdown() { try { fs.unlinkSync(LIVE_PID_FILE); } catch {} + if (state.leaseTimer) clearTimeout(state.leaseTimer); + state.leaseTimer = null; if (state.sessionDir) { try { fs.rmSync(state.sessionDir, { recursive: true, force: true }); } catch {} } diff --git a/.claude/skills/impeccable/scripts/live-session-store.mjs b/.claude/skills/impeccable/scripts/live-session-store.mjs index af61b998..feaa56b2 100644 --- a/.claude/skills/impeccable/scripts/live-session-store.mjs +++ b/.claude/skills/impeccable/scripts/live-session-store.mjs @@ -151,8 +151,8 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { switch (event.type) { case 'generate': next.phase = 'generate_requested'; - next.pageUrl = event.pageUrl || next.pageUrl; - next.expectedVariants = event.count || next.expectedVariants; + next.pageUrl = event.pageUrl ?? next.pageUrl; + next.expectedVariants = event.count ?? next.expectedVariants; next.pendingEventSeq = entry.seq ?? next.pendingEventSeq; next.pendingEvent = toPendingEvent(event); if (event.screenshotPath) upsertArtifact(next.annotationArtifacts, { type: 'screenshot', path: event.screenshotPath }); @@ -160,16 +160,16 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { case 'variants_ready': case 'agent_done': next.phase = 'variants_ready'; - next.sourceFile = event.file || next.sourceFile; - next.arrivedVariants = event.arrivedVariants ?? (next.arrivedVariants || next.expectedVariants); + next.sourceFile = event.file ?? next.sourceFile; + next.arrivedVariants = event.arrivedVariants ?? (next.arrivedVariants ?? next.expectedVariants); next.pendingEventSeq = null; next.pendingEvent = null; break; case 'checkpoint': - 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; + 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; if (event.paramValues) next.paramValues = { ...event.paramValues }; diff --git a/.cursor/skills/impeccable/scripts/live-completion.mjs b/.cursor/skills/impeccable/scripts/live-completion.mjs index c3a7ff6e..62dec8c3 100644 --- a/.cursor/skills/impeccable/scripts/live-completion.mjs +++ b/.cursor/skills/impeccable/scripts/live-completion.mjs @@ -1,6 +1,6 @@ export function completionTypeForAcceptResult(eventType, acceptResult) { if (eventType === 'discard') return acceptResult?.handled === true ? 'discarded' : 'error'; if (acceptResult?.handled === true) return 'complete'; - if (acceptResult?.mode === 'fallback') return 'agent_done'; - return 'error'; + if (acceptResult?.mode === 'error') return 'error'; + return 'agent_done'; } diff --git a/.cursor/skills/impeccable/scripts/live-poll.mjs b/.cursor/skills/impeccable/scripts/live-poll.mjs index 491a0aab..015a15c5 100644 --- a/.cursor/skills/impeccable/scripts/live-poll.mjs +++ b/.cursor/skills/impeccable/scripts/live-poll.mjs @@ -153,7 +153,7 @@ Options: ); event._acceptResult = JSON.parse(out.trim()); } catch (err) { - event._acceptResult = { handled: false, error: err.message }; + event._acceptResult = { handled: false, mode: 'error', error: err.message }; } const completionType = completionTypeForAcceptResult(event.type, event._acceptResult); diff --git a/.cursor/skills/impeccable/scripts/live-server.mjs b/.cursor/skills/impeccable/scripts/live-server.mjs index 025a7633..8145e26f 100644 --- a/.cursor/skills/impeccable/scripts/live-server.mjs +++ b/.cursor/skills/impeccable/scripts/live-server.mjs @@ -63,6 +63,7 @@ const state = { exitTimer: null, sessionDir: null, // per-session tmp dir for annotation screenshots sessionStore: null, + leaseTimer: null, }; // Cap per-annotation upload size. A full 1920×1080 PNG is typically <1 MB; @@ -101,16 +102,39 @@ function acknowledgePendingEvent(id) { const idx = state.pendingEvents.findIndex((entry) => entry.event?.id === id); if (idx === -1) return false; state.pendingEvents.splice(idx, 1); + scheduleLeaseFlush(); return true; } +function scheduleLeaseFlush() { + if (state.leaseTimer) { + clearTimeout(state.leaseTimer); + state.leaseTimer = null; + } + if (state.pendingPolls.length === 0) return; + const now = Date.now(); + const nextLeaseUntil = state.pendingEvents + .map((entry) => entry.leaseUntil || 0) + .filter((leaseUntil) => leaseUntil > now) + .sort((a, b) => a - b)[0]; + if (!nextLeaseUntil) return; + state.leaseTimer = setTimeout(() => { + state.leaseTimer = null; + flushPendingPolls(); + }, Math.max(0, nextLeaseUntil - now)); +} + function flushPendingPolls() { while (state.pendingPolls.length > 0) { const entry = findAvailablePendingEvent(); - if (!entry) return; + if (!entry) { + scheduleLeaseFlush(); + return; + } const poll = state.pendingPolls.shift(); poll.resolve(leaseEvent(entry, poll.leaseMs)); } + scheduleLeaseFlush(); } /** Push a message to all connected SSE clients. */ @@ -592,6 +616,7 @@ function handlePollGet(req, res, url) { res.end(JSON.stringify(event)); } state.pendingPolls.push(poll); + scheduleLeaseFlush(); req.on('close', () => { clearTimeout(timer); const idx = state.pendingPolls.indexOf(poll); @@ -643,6 +668,8 @@ let httpServer = null; function shutdown() { try { fs.unlinkSync(LIVE_PID_FILE); } catch {} + if (state.leaseTimer) clearTimeout(state.leaseTimer); + state.leaseTimer = null; if (state.sessionDir) { try { fs.rmSync(state.sessionDir, { recursive: true, force: true }); } catch {} } diff --git a/.cursor/skills/impeccable/scripts/live-session-store.mjs b/.cursor/skills/impeccable/scripts/live-session-store.mjs index af61b998..feaa56b2 100644 --- a/.cursor/skills/impeccable/scripts/live-session-store.mjs +++ b/.cursor/skills/impeccable/scripts/live-session-store.mjs @@ -151,8 +151,8 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { switch (event.type) { case 'generate': next.phase = 'generate_requested'; - next.pageUrl = event.pageUrl || next.pageUrl; - next.expectedVariants = event.count || next.expectedVariants; + next.pageUrl = event.pageUrl ?? next.pageUrl; + next.expectedVariants = event.count ?? next.expectedVariants; next.pendingEventSeq = entry.seq ?? next.pendingEventSeq; next.pendingEvent = toPendingEvent(event); if (event.screenshotPath) upsertArtifact(next.annotationArtifacts, { type: 'screenshot', path: event.screenshotPath }); @@ -160,16 +160,16 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { case 'variants_ready': case 'agent_done': next.phase = 'variants_ready'; - next.sourceFile = event.file || next.sourceFile; - next.arrivedVariants = event.arrivedVariants ?? (next.arrivedVariants || next.expectedVariants); + next.sourceFile = event.file ?? next.sourceFile; + next.arrivedVariants = event.arrivedVariants ?? (next.arrivedVariants ?? next.expectedVariants); next.pendingEventSeq = null; next.pendingEvent = null; break; case 'checkpoint': - 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; + 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; if (event.paramValues) next.paramValues = { ...event.paramValues }; diff --git a/.gemini/skills/impeccable/scripts/live-completion.mjs b/.gemini/skills/impeccable/scripts/live-completion.mjs index c3a7ff6e..62dec8c3 100644 --- a/.gemini/skills/impeccable/scripts/live-completion.mjs +++ b/.gemini/skills/impeccable/scripts/live-completion.mjs @@ -1,6 +1,6 @@ export function completionTypeForAcceptResult(eventType, acceptResult) { if (eventType === 'discard') return acceptResult?.handled === true ? 'discarded' : 'error'; if (acceptResult?.handled === true) return 'complete'; - if (acceptResult?.mode === 'fallback') return 'agent_done'; - return 'error'; + if (acceptResult?.mode === 'error') return 'error'; + return 'agent_done'; } diff --git a/.gemini/skills/impeccable/scripts/live-poll.mjs b/.gemini/skills/impeccable/scripts/live-poll.mjs index 491a0aab..015a15c5 100644 --- a/.gemini/skills/impeccable/scripts/live-poll.mjs +++ b/.gemini/skills/impeccable/scripts/live-poll.mjs @@ -153,7 +153,7 @@ Options: ); event._acceptResult = JSON.parse(out.trim()); } catch (err) { - event._acceptResult = { handled: false, error: err.message }; + event._acceptResult = { handled: false, mode: 'error', error: err.message }; } const completionType = completionTypeForAcceptResult(event.type, event._acceptResult); diff --git a/.gemini/skills/impeccable/scripts/live-server.mjs b/.gemini/skills/impeccable/scripts/live-server.mjs index 025a7633..8145e26f 100644 --- a/.gemini/skills/impeccable/scripts/live-server.mjs +++ b/.gemini/skills/impeccable/scripts/live-server.mjs @@ -63,6 +63,7 @@ const state = { exitTimer: null, sessionDir: null, // per-session tmp dir for annotation screenshots sessionStore: null, + leaseTimer: null, }; // Cap per-annotation upload size. A full 1920×1080 PNG is typically <1 MB; @@ -101,16 +102,39 @@ function acknowledgePendingEvent(id) { const idx = state.pendingEvents.findIndex((entry) => entry.event?.id === id); if (idx === -1) return false; state.pendingEvents.splice(idx, 1); + scheduleLeaseFlush(); return true; } +function scheduleLeaseFlush() { + if (state.leaseTimer) { + clearTimeout(state.leaseTimer); + state.leaseTimer = null; + } + if (state.pendingPolls.length === 0) return; + const now = Date.now(); + const nextLeaseUntil = state.pendingEvents + .map((entry) => entry.leaseUntil || 0) + .filter((leaseUntil) => leaseUntil > now) + .sort((a, b) => a - b)[0]; + if (!nextLeaseUntil) return; + state.leaseTimer = setTimeout(() => { + state.leaseTimer = null; + flushPendingPolls(); + }, Math.max(0, nextLeaseUntil - now)); +} + function flushPendingPolls() { while (state.pendingPolls.length > 0) { const entry = findAvailablePendingEvent(); - if (!entry) return; + if (!entry) { + scheduleLeaseFlush(); + return; + } const poll = state.pendingPolls.shift(); poll.resolve(leaseEvent(entry, poll.leaseMs)); } + scheduleLeaseFlush(); } /** Push a message to all connected SSE clients. */ @@ -592,6 +616,7 @@ function handlePollGet(req, res, url) { res.end(JSON.stringify(event)); } state.pendingPolls.push(poll); + scheduleLeaseFlush(); req.on('close', () => { clearTimeout(timer); const idx = state.pendingPolls.indexOf(poll); @@ -643,6 +668,8 @@ let httpServer = null; function shutdown() { try { fs.unlinkSync(LIVE_PID_FILE); } catch {} + if (state.leaseTimer) clearTimeout(state.leaseTimer); + state.leaseTimer = null; if (state.sessionDir) { try { fs.rmSync(state.sessionDir, { recursive: true, force: true }); } catch {} } diff --git a/.gemini/skills/impeccable/scripts/live-session-store.mjs b/.gemini/skills/impeccable/scripts/live-session-store.mjs index af61b998..feaa56b2 100644 --- a/.gemini/skills/impeccable/scripts/live-session-store.mjs +++ b/.gemini/skills/impeccable/scripts/live-session-store.mjs @@ -151,8 +151,8 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { switch (event.type) { case 'generate': next.phase = 'generate_requested'; - next.pageUrl = event.pageUrl || next.pageUrl; - next.expectedVariants = event.count || next.expectedVariants; + next.pageUrl = event.pageUrl ?? next.pageUrl; + next.expectedVariants = event.count ?? next.expectedVariants; next.pendingEventSeq = entry.seq ?? next.pendingEventSeq; next.pendingEvent = toPendingEvent(event); if (event.screenshotPath) upsertArtifact(next.annotationArtifacts, { type: 'screenshot', path: event.screenshotPath }); @@ -160,16 +160,16 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { case 'variants_ready': case 'agent_done': next.phase = 'variants_ready'; - next.sourceFile = event.file || next.sourceFile; - next.arrivedVariants = event.arrivedVariants ?? (next.arrivedVariants || next.expectedVariants); + next.sourceFile = event.file ?? next.sourceFile; + next.arrivedVariants = event.arrivedVariants ?? (next.arrivedVariants ?? next.expectedVariants); next.pendingEventSeq = null; next.pendingEvent = null; break; case 'checkpoint': - 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; + 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; if (event.paramValues) next.paramValues = { ...event.paramValues }; diff --git a/.github/skills/impeccable/scripts/live-completion.mjs b/.github/skills/impeccable/scripts/live-completion.mjs index c3a7ff6e..62dec8c3 100644 --- a/.github/skills/impeccable/scripts/live-completion.mjs +++ b/.github/skills/impeccable/scripts/live-completion.mjs @@ -1,6 +1,6 @@ export function completionTypeForAcceptResult(eventType, acceptResult) { if (eventType === 'discard') return acceptResult?.handled === true ? 'discarded' : 'error'; if (acceptResult?.handled === true) return 'complete'; - if (acceptResult?.mode === 'fallback') return 'agent_done'; - return 'error'; + if (acceptResult?.mode === 'error') return 'error'; + return 'agent_done'; } diff --git a/.github/skills/impeccable/scripts/live-poll.mjs b/.github/skills/impeccable/scripts/live-poll.mjs index 491a0aab..015a15c5 100644 --- a/.github/skills/impeccable/scripts/live-poll.mjs +++ b/.github/skills/impeccable/scripts/live-poll.mjs @@ -153,7 +153,7 @@ Options: ); event._acceptResult = JSON.parse(out.trim()); } catch (err) { - event._acceptResult = { handled: false, error: err.message }; + event._acceptResult = { handled: false, mode: 'error', error: err.message }; } const completionType = completionTypeForAcceptResult(event.type, event._acceptResult); diff --git a/.github/skills/impeccable/scripts/live-server.mjs b/.github/skills/impeccable/scripts/live-server.mjs index 025a7633..8145e26f 100644 --- a/.github/skills/impeccable/scripts/live-server.mjs +++ b/.github/skills/impeccable/scripts/live-server.mjs @@ -63,6 +63,7 @@ const state = { exitTimer: null, sessionDir: null, // per-session tmp dir for annotation screenshots sessionStore: null, + leaseTimer: null, }; // Cap per-annotation upload size. A full 1920×1080 PNG is typically <1 MB; @@ -101,16 +102,39 @@ function acknowledgePendingEvent(id) { const idx = state.pendingEvents.findIndex((entry) => entry.event?.id === id); if (idx === -1) return false; state.pendingEvents.splice(idx, 1); + scheduleLeaseFlush(); return true; } +function scheduleLeaseFlush() { + if (state.leaseTimer) { + clearTimeout(state.leaseTimer); + state.leaseTimer = null; + } + if (state.pendingPolls.length === 0) return; + const now = Date.now(); + const nextLeaseUntil = state.pendingEvents + .map((entry) => entry.leaseUntil || 0) + .filter((leaseUntil) => leaseUntil > now) + .sort((a, b) => a - b)[0]; + if (!nextLeaseUntil) return; + state.leaseTimer = setTimeout(() => { + state.leaseTimer = null; + flushPendingPolls(); + }, Math.max(0, nextLeaseUntil - now)); +} + function flushPendingPolls() { while (state.pendingPolls.length > 0) { const entry = findAvailablePendingEvent(); - if (!entry) return; + if (!entry) { + scheduleLeaseFlush(); + return; + } const poll = state.pendingPolls.shift(); poll.resolve(leaseEvent(entry, poll.leaseMs)); } + scheduleLeaseFlush(); } /** Push a message to all connected SSE clients. */ @@ -592,6 +616,7 @@ function handlePollGet(req, res, url) { res.end(JSON.stringify(event)); } state.pendingPolls.push(poll); + scheduleLeaseFlush(); req.on('close', () => { clearTimeout(timer); const idx = state.pendingPolls.indexOf(poll); @@ -643,6 +668,8 @@ let httpServer = null; function shutdown() { try { fs.unlinkSync(LIVE_PID_FILE); } catch {} + if (state.leaseTimer) clearTimeout(state.leaseTimer); + state.leaseTimer = null; if (state.sessionDir) { try { fs.rmSync(state.sessionDir, { recursive: true, force: true }); } catch {} } diff --git a/.github/skills/impeccable/scripts/live-session-store.mjs b/.github/skills/impeccable/scripts/live-session-store.mjs index af61b998..feaa56b2 100644 --- a/.github/skills/impeccable/scripts/live-session-store.mjs +++ b/.github/skills/impeccable/scripts/live-session-store.mjs @@ -151,8 +151,8 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { switch (event.type) { case 'generate': next.phase = 'generate_requested'; - next.pageUrl = event.pageUrl || next.pageUrl; - next.expectedVariants = event.count || next.expectedVariants; + next.pageUrl = event.pageUrl ?? next.pageUrl; + next.expectedVariants = event.count ?? next.expectedVariants; next.pendingEventSeq = entry.seq ?? next.pendingEventSeq; next.pendingEvent = toPendingEvent(event); if (event.screenshotPath) upsertArtifact(next.annotationArtifacts, { type: 'screenshot', path: event.screenshotPath }); @@ -160,16 +160,16 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { case 'variants_ready': case 'agent_done': next.phase = 'variants_ready'; - next.sourceFile = event.file || next.sourceFile; - next.arrivedVariants = event.arrivedVariants ?? (next.arrivedVariants || next.expectedVariants); + next.sourceFile = event.file ?? next.sourceFile; + next.arrivedVariants = event.arrivedVariants ?? (next.arrivedVariants ?? next.expectedVariants); next.pendingEventSeq = null; next.pendingEvent = null; break; case 'checkpoint': - 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; + 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; if (event.paramValues) next.paramValues = { ...event.paramValues }; diff --git a/.kiro/skills/impeccable/scripts/live-completion.mjs b/.kiro/skills/impeccable/scripts/live-completion.mjs index c3a7ff6e..62dec8c3 100644 --- a/.kiro/skills/impeccable/scripts/live-completion.mjs +++ b/.kiro/skills/impeccable/scripts/live-completion.mjs @@ -1,6 +1,6 @@ export function completionTypeForAcceptResult(eventType, acceptResult) { if (eventType === 'discard') return acceptResult?.handled === true ? 'discarded' : 'error'; if (acceptResult?.handled === true) return 'complete'; - if (acceptResult?.mode === 'fallback') return 'agent_done'; - return 'error'; + if (acceptResult?.mode === 'error') return 'error'; + return 'agent_done'; } diff --git a/.kiro/skills/impeccable/scripts/live-poll.mjs b/.kiro/skills/impeccable/scripts/live-poll.mjs index 491a0aab..015a15c5 100644 --- a/.kiro/skills/impeccable/scripts/live-poll.mjs +++ b/.kiro/skills/impeccable/scripts/live-poll.mjs @@ -153,7 +153,7 @@ Options: ); event._acceptResult = JSON.parse(out.trim()); } catch (err) { - event._acceptResult = { handled: false, error: err.message }; + event._acceptResult = { handled: false, mode: 'error', error: err.message }; } const completionType = completionTypeForAcceptResult(event.type, event._acceptResult); diff --git a/.kiro/skills/impeccable/scripts/live-server.mjs b/.kiro/skills/impeccable/scripts/live-server.mjs index 025a7633..8145e26f 100644 --- a/.kiro/skills/impeccable/scripts/live-server.mjs +++ b/.kiro/skills/impeccable/scripts/live-server.mjs @@ -63,6 +63,7 @@ const state = { exitTimer: null, sessionDir: null, // per-session tmp dir for annotation screenshots sessionStore: null, + leaseTimer: null, }; // Cap per-annotation upload size. A full 1920×1080 PNG is typically <1 MB; @@ -101,16 +102,39 @@ function acknowledgePendingEvent(id) { const idx = state.pendingEvents.findIndex((entry) => entry.event?.id === id); if (idx === -1) return false; state.pendingEvents.splice(idx, 1); + scheduleLeaseFlush(); return true; } +function scheduleLeaseFlush() { + if (state.leaseTimer) { + clearTimeout(state.leaseTimer); + state.leaseTimer = null; + } + if (state.pendingPolls.length === 0) return; + const now = Date.now(); + const nextLeaseUntil = state.pendingEvents + .map((entry) => entry.leaseUntil || 0) + .filter((leaseUntil) => leaseUntil > now) + .sort((a, b) => a - b)[0]; + if (!nextLeaseUntil) return; + state.leaseTimer = setTimeout(() => { + state.leaseTimer = null; + flushPendingPolls(); + }, Math.max(0, nextLeaseUntil - now)); +} + function flushPendingPolls() { while (state.pendingPolls.length > 0) { const entry = findAvailablePendingEvent(); - if (!entry) return; + if (!entry) { + scheduleLeaseFlush(); + return; + } const poll = state.pendingPolls.shift(); poll.resolve(leaseEvent(entry, poll.leaseMs)); } + scheduleLeaseFlush(); } /** Push a message to all connected SSE clients. */ @@ -592,6 +616,7 @@ function handlePollGet(req, res, url) { res.end(JSON.stringify(event)); } state.pendingPolls.push(poll); + scheduleLeaseFlush(); req.on('close', () => { clearTimeout(timer); const idx = state.pendingPolls.indexOf(poll); @@ -643,6 +668,8 @@ let httpServer = null; function shutdown() { try { fs.unlinkSync(LIVE_PID_FILE); } catch {} + if (state.leaseTimer) clearTimeout(state.leaseTimer); + state.leaseTimer = null; if (state.sessionDir) { try { fs.rmSync(state.sessionDir, { recursive: true, force: true }); } catch {} } diff --git a/.kiro/skills/impeccable/scripts/live-session-store.mjs b/.kiro/skills/impeccable/scripts/live-session-store.mjs index af61b998..feaa56b2 100644 --- a/.kiro/skills/impeccable/scripts/live-session-store.mjs +++ b/.kiro/skills/impeccable/scripts/live-session-store.mjs @@ -151,8 +151,8 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { switch (event.type) { case 'generate': next.phase = 'generate_requested'; - next.pageUrl = event.pageUrl || next.pageUrl; - next.expectedVariants = event.count || next.expectedVariants; + next.pageUrl = event.pageUrl ?? next.pageUrl; + next.expectedVariants = event.count ?? next.expectedVariants; next.pendingEventSeq = entry.seq ?? next.pendingEventSeq; next.pendingEvent = toPendingEvent(event); if (event.screenshotPath) upsertArtifact(next.annotationArtifacts, { type: 'screenshot', path: event.screenshotPath }); @@ -160,16 +160,16 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { case 'variants_ready': case 'agent_done': next.phase = 'variants_ready'; - next.sourceFile = event.file || next.sourceFile; - next.arrivedVariants = event.arrivedVariants ?? (next.arrivedVariants || next.expectedVariants); + next.sourceFile = event.file ?? next.sourceFile; + next.arrivedVariants = event.arrivedVariants ?? (next.arrivedVariants ?? next.expectedVariants); next.pendingEventSeq = null; next.pendingEvent = null; break; case 'checkpoint': - 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; + 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; if (event.paramValues) next.paramValues = { ...event.paramValues }; diff --git a/.opencode/skills/impeccable/scripts/live-completion.mjs b/.opencode/skills/impeccable/scripts/live-completion.mjs index c3a7ff6e..62dec8c3 100644 --- a/.opencode/skills/impeccable/scripts/live-completion.mjs +++ b/.opencode/skills/impeccable/scripts/live-completion.mjs @@ -1,6 +1,6 @@ export function completionTypeForAcceptResult(eventType, acceptResult) { if (eventType === 'discard') return acceptResult?.handled === true ? 'discarded' : 'error'; if (acceptResult?.handled === true) return 'complete'; - if (acceptResult?.mode === 'fallback') return 'agent_done'; - return 'error'; + if (acceptResult?.mode === 'error') return 'error'; + return 'agent_done'; } diff --git a/.opencode/skills/impeccable/scripts/live-poll.mjs b/.opencode/skills/impeccable/scripts/live-poll.mjs index 491a0aab..015a15c5 100644 --- a/.opencode/skills/impeccable/scripts/live-poll.mjs +++ b/.opencode/skills/impeccable/scripts/live-poll.mjs @@ -153,7 +153,7 @@ Options: ); event._acceptResult = JSON.parse(out.trim()); } catch (err) { - event._acceptResult = { handled: false, error: err.message }; + event._acceptResult = { handled: false, mode: 'error', error: err.message }; } const completionType = completionTypeForAcceptResult(event.type, event._acceptResult); diff --git a/.opencode/skills/impeccable/scripts/live-server.mjs b/.opencode/skills/impeccable/scripts/live-server.mjs index 025a7633..8145e26f 100644 --- a/.opencode/skills/impeccable/scripts/live-server.mjs +++ b/.opencode/skills/impeccable/scripts/live-server.mjs @@ -63,6 +63,7 @@ const state = { exitTimer: null, sessionDir: null, // per-session tmp dir for annotation screenshots sessionStore: null, + leaseTimer: null, }; // Cap per-annotation upload size. A full 1920×1080 PNG is typically <1 MB; @@ -101,16 +102,39 @@ function acknowledgePendingEvent(id) { const idx = state.pendingEvents.findIndex((entry) => entry.event?.id === id); if (idx === -1) return false; state.pendingEvents.splice(idx, 1); + scheduleLeaseFlush(); return true; } +function scheduleLeaseFlush() { + if (state.leaseTimer) { + clearTimeout(state.leaseTimer); + state.leaseTimer = null; + } + if (state.pendingPolls.length === 0) return; + const now = Date.now(); + const nextLeaseUntil = state.pendingEvents + .map((entry) => entry.leaseUntil || 0) + .filter((leaseUntil) => leaseUntil > now) + .sort((a, b) => a - b)[0]; + if (!nextLeaseUntil) return; + state.leaseTimer = setTimeout(() => { + state.leaseTimer = null; + flushPendingPolls(); + }, Math.max(0, nextLeaseUntil - now)); +} + function flushPendingPolls() { while (state.pendingPolls.length > 0) { const entry = findAvailablePendingEvent(); - if (!entry) return; + if (!entry) { + scheduleLeaseFlush(); + return; + } const poll = state.pendingPolls.shift(); poll.resolve(leaseEvent(entry, poll.leaseMs)); } + scheduleLeaseFlush(); } /** Push a message to all connected SSE clients. */ @@ -592,6 +616,7 @@ function handlePollGet(req, res, url) { res.end(JSON.stringify(event)); } state.pendingPolls.push(poll); + scheduleLeaseFlush(); req.on('close', () => { clearTimeout(timer); const idx = state.pendingPolls.indexOf(poll); @@ -643,6 +668,8 @@ let httpServer = null; function shutdown() { try { fs.unlinkSync(LIVE_PID_FILE); } catch {} + if (state.leaseTimer) clearTimeout(state.leaseTimer); + state.leaseTimer = null; if (state.sessionDir) { try { fs.rmSync(state.sessionDir, { recursive: true, force: true }); } catch {} } diff --git a/.opencode/skills/impeccable/scripts/live-session-store.mjs b/.opencode/skills/impeccable/scripts/live-session-store.mjs index af61b998..feaa56b2 100644 --- a/.opencode/skills/impeccable/scripts/live-session-store.mjs +++ b/.opencode/skills/impeccable/scripts/live-session-store.mjs @@ -151,8 +151,8 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { switch (event.type) { case 'generate': next.phase = 'generate_requested'; - next.pageUrl = event.pageUrl || next.pageUrl; - next.expectedVariants = event.count || next.expectedVariants; + next.pageUrl = event.pageUrl ?? next.pageUrl; + next.expectedVariants = event.count ?? next.expectedVariants; next.pendingEventSeq = entry.seq ?? next.pendingEventSeq; next.pendingEvent = toPendingEvent(event); if (event.screenshotPath) upsertArtifact(next.annotationArtifacts, { type: 'screenshot', path: event.screenshotPath }); @@ -160,16 +160,16 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { case 'variants_ready': case 'agent_done': next.phase = 'variants_ready'; - next.sourceFile = event.file || next.sourceFile; - next.arrivedVariants = event.arrivedVariants ?? (next.arrivedVariants || next.expectedVariants); + next.sourceFile = event.file ?? next.sourceFile; + next.arrivedVariants = event.arrivedVariants ?? (next.arrivedVariants ?? next.expectedVariants); next.pendingEventSeq = null; next.pendingEvent = null; break; case 'checkpoint': - 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; + 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; if (event.paramValues) next.paramValues = { ...event.paramValues }; diff --git a/.pi/skills/impeccable/scripts/live-completion.mjs b/.pi/skills/impeccable/scripts/live-completion.mjs index c3a7ff6e..62dec8c3 100644 --- a/.pi/skills/impeccable/scripts/live-completion.mjs +++ b/.pi/skills/impeccable/scripts/live-completion.mjs @@ -1,6 +1,6 @@ export function completionTypeForAcceptResult(eventType, acceptResult) { if (eventType === 'discard') return acceptResult?.handled === true ? 'discarded' : 'error'; if (acceptResult?.handled === true) return 'complete'; - if (acceptResult?.mode === 'fallback') return 'agent_done'; - return 'error'; + if (acceptResult?.mode === 'error') return 'error'; + return 'agent_done'; } diff --git a/.pi/skills/impeccable/scripts/live-poll.mjs b/.pi/skills/impeccable/scripts/live-poll.mjs index 491a0aab..015a15c5 100644 --- a/.pi/skills/impeccable/scripts/live-poll.mjs +++ b/.pi/skills/impeccable/scripts/live-poll.mjs @@ -153,7 +153,7 @@ Options: ); event._acceptResult = JSON.parse(out.trim()); } catch (err) { - event._acceptResult = { handled: false, error: err.message }; + event._acceptResult = { handled: false, mode: 'error', error: err.message }; } const completionType = completionTypeForAcceptResult(event.type, event._acceptResult); diff --git a/.pi/skills/impeccable/scripts/live-server.mjs b/.pi/skills/impeccable/scripts/live-server.mjs index 025a7633..8145e26f 100644 --- a/.pi/skills/impeccable/scripts/live-server.mjs +++ b/.pi/skills/impeccable/scripts/live-server.mjs @@ -63,6 +63,7 @@ const state = { exitTimer: null, sessionDir: null, // per-session tmp dir for annotation screenshots sessionStore: null, + leaseTimer: null, }; // Cap per-annotation upload size. A full 1920×1080 PNG is typically <1 MB; @@ -101,16 +102,39 @@ function acknowledgePendingEvent(id) { const idx = state.pendingEvents.findIndex((entry) => entry.event?.id === id); if (idx === -1) return false; state.pendingEvents.splice(idx, 1); + scheduleLeaseFlush(); return true; } +function scheduleLeaseFlush() { + if (state.leaseTimer) { + clearTimeout(state.leaseTimer); + state.leaseTimer = null; + } + if (state.pendingPolls.length === 0) return; + const now = Date.now(); + const nextLeaseUntil = state.pendingEvents + .map((entry) => entry.leaseUntil || 0) + .filter((leaseUntil) => leaseUntil > now) + .sort((a, b) => a - b)[0]; + if (!nextLeaseUntil) return; + state.leaseTimer = setTimeout(() => { + state.leaseTimer = null; + flushPendingPolls(); + }, Math.max(0, nextLeaseUntil - now)); +} + function flushPendingPolls() { while (state.pendingPolls.length > 0) { const entry = findAvailablePendingEvent(); - if (!entry) return; + if (!entry) { + scheduleLeaseFlush(); + return; + } const poll = state.pendingPolls.shift(); poll.resolve(leaseEvent(entry, poll.leaseMs)); } + scheduleLeaseFlush(); } /** Push a message to all connected SSE clients. */ @@ -592,6 +616,7 @@ function handlePollGet(req, res, url) { res.end(JSON.stringify(event)); } state.pendingPolls.push(poll); + scheduleLeaseFlush(); req.on('close', () => { clearTimeout(timer); const idx = state.pendingPolls.indexOf(poll); @@ -643,6 +668,8 @@ let httpServer = null; function shutdown() { try { fs.unlinkSync(LIVE_PID_FILE); } catch {} + if (state.leaseTimer) clearTimeout(state.leaseTimer); + state.leaseTimer = null; if (state.sessionDir) { try { fs.rmSync(state.sessionDir, { recursive: true, force: true }); } catch {} } diff --git a/.pi/skills/impeccable/scripts/live-session-store.mjs b/.pi/skills/impeccable/scripts/live-session-store.mjs index af61b998..feaa56b2 100644 --- a/.pi/skills/impeccable/scripts/live-session-store.mjs +++ b/.pi/skills/impeccable/scripts/live-session-store.mjs @@ -151,8 +151,8 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { switch (event.type) { case 'generate': next.phase = 'generate_requested'; - next.pageUrl = event.pageUrl || next.pageUrl; - next.expectedVariants = event.count || next.expectedVariants; + next.pageUrl = event.pageUrl ?? next.pageUrl; + next.expectedVariants = event.count ?? next.expectedVariants; next.pendingEventSeq = entry.seq ?? next.pendingEventSeq; next.pendingEvent = toPendingEvent(event); if (event.screenshotPath) upsertArtifact(next.annotationArtifacts, { type: 'screenshot', path: event.screenshotPath }); @@ -160,16 +160,16 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { case 'variants_ready': case 'agent_done': next.phase = 'variants_ready'; - next.sourceFile = event.file || next.sourceFile; - next.arrivedVariants = event.arrivedVariants ?? (next.arrivedVariants || next.expectedVariants); + next.sourceFile = event.file ?? next.sourceFile; + next.arrivedVariants = event.arrivedVariants ?? (next.arrivedVariants ?? next.expectedVariants); next.pendingEventSeq = null; next.pendingEvent = null; break; case 'checkpoint': - 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; + 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; if (event.paramValues) next.paramValues = { ...event.paramValues }; diff --git a/.rovodev/skills/impeccable/scripts/live-completion.mjs b/.rovodev/skills/impeccable/scripts/live-completion.mjs index c3a7ff6e..62dec8c3 100644 --- a/.rovodev/skills/impeccable/scripts/live-completion.mjs +++ b/.rovodev/skills/impeccable/scripts/live-completion.mjs @@ -1,6 +1,6 @@ export function completionTypeForAcceptResult(eventType, acceptResult) { if (eventType === 'discard') return acceptResult?.handled === true ? 'discarded' : 'error'; if (acceptResult?.handled === true) return 'complete'; - if (acceptResult?.mode === 'fallback') return 'agent_done'; - return 'error'; + if (acceptResult?.mode === 'error') return 'error'; + return 'agent_done'; } diff --git a/.rovodev/skills/impeccable/scripts/live-poll.mjs b/.rovodev/skills/impeccable/scripts/live-poll.mjs index 491a0aab..015a15c5 100644 --- a/.rovodev/skills/impeccable/scripts/live-poll.mjs +++ b/.rovodev/skills/impeccable/scripts/live-poll.mjs @@ -153,7 +153,7 @@ Options: ); event._acceptResult = JSON.parse(out.trim()); } catch (err) { - event._acceptResult = { handled: false, error: err.message }; + event._acceptResult = { handled: false, mode: 'error', error: err.message }; } const completionType = completionTypeForAcceptResult(event.type, event._acceptResult); diff --git a/.rovodev/skills/impeccable/scripts/live-server.mjs b/.rovodev/skills/impeccable/scripts/live-server.mjs index 025a7633..8145e26f 100644 --- a/.rovodev/skills/impeccable/scripts/live-server.mjs +++ b/.rovodev/skills/impeccable/scripts/live-server.mjs @@ -63,6 +63,7 @@ const state = { exitTimer: null, sessionDir: null, // per-session tmp dir for annotation screenshots sessionStore: null, + leaseTimer: null, }; // Cap per-annotation upload size. A full 1920×1080 PNG is typically <1 MB; @@ -101,16 +102,39 @@ function acknowledgePendingEvent(id) { const idx = state.pendingEvents.findIndex((entry) => entry.event?.id === id); if (idx === -1) return false; state.pendingEvents.splice(idx, 1); + scheduleLeaseFlush(); return true; } +function scheduleLeaseFlush() { + if (state.leaseTimer) { + clearTimeout(state.leaseTimer); + state.leaseTimer = null; + } + if (state.pendingPolls.length === 0) return; + const now = Date.now(); + const nextLeaseUntil = state.pendingEvents + .map((entry) => entry.leaseUntil || 0) + .filter((leaseUntil) => leaseUntil > now) + .sort((a, b) => a - b)[0]; + if (!nextLeaseUntil) return; + state.leaseTimer = setTimeout(() => { + state.leaseTimer = null; + flushPendingPolls(); + }, Math.max(0, nextLeaseUntil - now)); +} + function flushPendingPolls() { while (state.pendingPolls.length > 0) { const entry = findAvailablePendingEvent(); - if (!entry) return; + if (!entry) { + scheduleLeaseFlush(); + return; + } const poll = state.pendingPolls.shift(); poll.resolve(leaseEvent(entry, poll.leaseMs)); } + scheduleLeaseFlush(); } /** Push a message to all connected SSE clients. */ @@ -592,6 +616,7 @@ function handlePollGet(req, res, url) { res.end(JSON.stringify(event)); } state.pendingPolls.push(poll); + scheduleLeaseFlush(); req.on('close', () => { clearTimeout(timer); const idx = state.pendingPolls.indexOf(poll); @@ -643,6 +668,8 @@ let httpServer = null; function shutdown() { try { fs.unlinkSync(LIVE_PID_FILE); } catch {} + if (state.leaseTimer) clearTimeout(state.leaseTimer); + state.leaseTimer = null; if (state.sessionDir) { try { fs.rmSync(state.sessionDir, { recursive: true, force: true }); } catch {} } diff --git a/.rovodev/skills/impeccable/scripts/live-session-store.mjs b/.rovodev/skills/impeccable/scripts/live-session-store.mjs index af61b998..feaa56b2 100644 --- a/.rovodev/skills/impeccable/scripts/live-session-store.mjs +++ b/.rovodev/skills/impeccable/scripts/live-session-store.mjs @@ -151,8 +151,8 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { switch (event.type) { case 'generate': next.phase = 'generate_requested'; - next.pageUrl = event.pageUrl || next.pageUrl; - next.expectedVariants = event.count || next.expectedVariants; + next.pageUrl = event.pageUrl ?? next.pageUrl; + next.expectedVariants = event.count ?? next.expectedVariants; next.pendingEventSeq = entry.seq ?? next.pendingEventSeq; next.pendingEvent = toPendingEvent(event); if (event.screenshotPath) upsertArtifact(next.annotationArtifacts, { type: 'screenshot', path: event.screenshotPath }); @@ -160,16 +160,16 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { case 'variants_ready': case 'agent_done': next.phase = 'variants_ready'; - next.sourceFile = event.file || next.sourceFile; - next.arrivedVariants = event.arrivedVariants ?? (next.arrivedVariants || next.expectedVariants); + next.sourceFile = event.file ?? next.sourceFile; + next.arrivedVariants = event.arrivedVariants ?? (next.arrivedVariants ?? next.expectedVariants); next.pendingEventSeq = null; next.pendingEvent = null; break; case 'checkpoint': - 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; + 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; if (event.paramValues) next.paramValues = { ...event.paramValues }; diff --git a/.trae-cn/skills/impeccable/scripts/live-completion.mjs b/.trae-cn/skills/impeccable/scripts/live-completion.mjs index c3a7ff6e..62dec8c3 100644 --- a/.trae-cn/skills/impeccable/scripts/live-completion.mjs +++ b/.trae-cn/skills/impeccable/scripts/live-completion.mjs @@ -1,6 +1,6 @@ export function completionTypeForAcceptResult(eventType, acceptResult) { if (eventType === 'discard') return acceptResult?.handled === true ? 'discarded' : 'error'; if (acceptResult?.handled === true) return 'complete'; - if (acceptResult?.mode === 'fallback') return 'agent_done'; - return 'error'; + if (acceptResult?.mode === 'error') return 'error'; + return 'agent_done'; } diff --git a/.trae-cn/skills/impeccable/scripts/live-poll.mjs b/.trae-cn/skills/impeccable/scripts/live-poll.mjs index 491a0aab..015a15c5 100644 --- a/.trae-cn/skills/impeccable/scripts/live-poll.mjs +++ b/.trae-cn/skills/impeccable/scripts/live-poll.mjs @@ -153,7 +153,7 @@ Options: ); event._acceptResult = JSON.parse(out.trim()); } catch (err) { - event._acceptResult = { handled: false, error: err.message }; + event._acceptResult = { handled: false, mode: 'error', error: err.message }; } const completionType = completionTypeForAcceptResult(event.type, event._acceptResult); diff --git a/.trae-cn/skills/impeccable/scripts/live-server.mjs b/.trae-cn/skills/impeccable/scripts/live-server.mjs index 025a7633..8145e26f 100644 --- a/.trae-cn/skills/impeccable/scripts/live-server.mjs +++ b/.trae-cn/skills/impeccable/scripts/live-server.mjs @@ -63,6 +63,7 @@ const state = { exitTimer: null, sessionDir: null, // per-session tmp dir for annotation screenshots sessionStore: null, + leaseTimer: null, }; // Cap per-annotation upload size. A full 1920×1080 PNG is typically <1 MB; @@ -101,16 +102,39 @@ function acknowledgePendingEvent(id) { const idx = state.pendingEvents.findIndex((entry) => entry.event?.id === id); if (idx === -1) return false; state.pendingEvents.splice(idx, 1); + scheduleLeaseFlush(); return true; } +function scheduleLeaseFlush() { + if (state.leaseTimer) { + clearTimeout(state.leaseTimer); + state.leaseTimer = null; + } + if (state.pendingPolls.length === 0) return; + const now = Date.now(); + const nextLeaseUntil = state.pendingEvents + .map((entry) => entry.leaseUntil || 0) + .filter((leaseUntil) => leaseUntil > now) + .sort((a, b) => a - b)[0]; + if (!nextLeaseUntil) return; + state.leaseTimer = setTimeout(() => { + state.leaseTimer = null; + flushPendingPolls(); + }, Math.max(0, nextLeaseUntil - now)); +} + function flushPendingPolls() { while (state.pendingPolls.length > 0) { const entry = findAvailablePendingEvent(); - if (!entry) return; + if (!entry) { + scheduleLeaseFlush(); + return; + } const poll = state.pendingPolls.shift(); poll.resolve(leaseEvent(entry, poll.leaseMs)); } + scheduleLeaseFlush(); } /** Push a message to all connected SSE clients. */ @@ -592,6 +616,7 @@ function handlePollGet(req, res, url) { res.end(JSON.stringify(event)); } state.pendingPolls.push(poll); + scheduleLeaseFlush(); req.on('close', () => { clearTimeout(timer); const idx = state.pendingPolls.indexOf(poll); @@ -643,6 +668,8 @@ let httpServer = null; function shutdown() { try { fs.unlinkSync(LIVE_PID_FILE); } catch {} + if (state.leaseTimer) clearTimeout(state.leaseTimer); + state.leaseTimer = null; if (state.sessionDir) { try { fs.rmSync(state.sessionDir, { recursive: true, force: true }); } catch {} } diff --git a/.trae-cn/skills/impeccable/scripts/live-session-store.mjs b/.trae-cn/skills/impeccable/scripts/live-session-store.mjs index af61b998..feaa56b2 100644 --- a/.trae-cn/skills/impeccable/scripts/live-session-store.mjs +++ b/.trae-cn/skills/impeccable/scripts/live-session-store.mjs @@ -151,8 +151,8 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { switch (event.type) { case 'generate': next.phase = 'generate_requested'; - next.pageUrl = event.pageUrl || next.pageUrl; - next.expectedVariants = event.count || next.expectedVariants; + next.pageUrl = event.pageUrl ?? next.pageUrl; + next.expectedVariants = event.count ?? next.expectedVariants; next.pendingEventSeq = entry.seq ?? next.pendingEventSeq; next.pendingEvent = toPendingEvent(event); if (event.screenshotPath) upsertArtifact(next.annotationArtifacts, { type: 'screenshot', path: event.screenshotPath }); @@ -160,16 +160,16 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { case 'variants_ready': case 'agent_done': next.phase = 'variants_ready'; - next.sourceFile = event.file || next.sourceFile; - next.arrivedVariants = event.arrivedVariants ?? (next.arrivedVariants || next.expectedVariants); + next.sourceFile = event.file ?? next.sourceFile; + next.arrivedVariants = event.arrivedVariants ?? (next.arrivedVariants ?? next.expectedVariants); next.pendingEventSeq = null; next.pendingEvent = null; break; case 'checkpoint': - 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; + 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; if (event.paramValues) next.paramValues = { ...event.paramValues }; diff --git a/.trae/skills/impeccable/scripts/live-completion.mjs b/.trae/skills/impeccable/scripts/live-completion.mjs index c3a7ff6e..62dec8c3 100644 --- a/.trae/skills/impeccable/scripts/live-completion.mjs +++ b/.trae/skills/impeccable/scripts/live-completion.mjs @@ -1,6 +1,6 @@ export function completionTypeForAcceptResult(eventType, acceptResult) { if (eventType === 'discard') return acceptResult?.handled === true ? 'discarded' : 'error'; if (acceptResult?.handled === true) return 'complete'; - if (acceptResult?.mode === 'fallback') return 'agent_done'; - return 'error'; + if (acceptResult?.mode === 'error') return 'error'; + return 'agent_done'; } diff --git a/.trae/skills/impeccable/scripts/live-poll.mjs b/.trae/skills/impeccable/scripts/live-poll.mjs index 491a0aab..015a15c5 100644 --- a/.trae/skills/impeccable/scripts/live-poll.mjs +++ b/.trae/skills/impeccable/scripts/live-poll.mjs @@ -153,7 +153,7 @@ Options: ); event._acceptResult = JSON.parse(out.trim()); } catch (err) { - event._acceptResult = { handled: false, error: err.message }; + event._acceptResult = { handled: false, mode: 'error', error: err.message }; } const completionType = completionTypeForAcceptResult(event.type, event._acceptResult); diff --git a/.trae/skills/impeccable/scripts/live-server.mjs b/.trae/skills/impeccable/scripts/live-server.mjs index 025a7633..8145e26f 100644 --- a/.trae/skills/impeccable/scripts/live-server.mjs +++ b/.trae/skills/impeccable/scripts/live-server.mjs @@ -63,6 +63,7 @@ const state = { exitTimer: null, sessionDir: null, // per-session tmp dir for annotation screenshots sessionStore: null, + leaseTimer: null, }; // Cap per-annotation upload size. A full 1920×1080 PNG is typically <1 MB; @@ -101,16 +102,39 @@ function acknowledgePendingEvent(id) { const idx = state.pendingEvents.findIndex((entry) => entry.event?.id === id); if (idx === -1) return false; state.pendingEvents.splice(idx, 1); + scheduleLeaseFlush(); return true; } +function scheduleLeaseFlush() { + if (state.leaseTimer) { + clearTimeout(state.leaseTimer); + state.leaseTimer = null; + } + if (state.pendingPolls.length === 0) return; + const now = Date.now(); + const nextLeaseUntil = state.pendingEvents + .map((entry) => entry.leaseUntil || 0) + .filter((leaseUntil) => leaseUntil > now) + .sort((a, b) => a - b)[0]; + if (!nextLeaseUntil) return; + state.leaseTimer = setTimeout(() => { + state.leaseTimer = null; + flushPendingPolls(); + }, Math.max(0, nextLeaseUntil - now)); +} + function flushPendingPolls() { while (state.pendingPolls.length > 0) { const entry = findAvailablePendingEvent(); - if (!entry) return; + if (!entry) { + scheduleLeaseFlush(); + return; + } const poll = state.pendingPolls.shift(); poll.resolve(leaseEvent(entry, poll.leaseMs)); } + scheduleLeaseFlush(); } /** Push a message to all connected SSE clients. */ @@ -592,6 +616,7 @@ function handlePollGet(req, res, url) { res.end(JSON.stringify(event)); } state.pendingPolls.push(poll); + scheduleLeaseFlush(); req.on('close', () => { clearTimeout(timer); const idx = state.pendingPolls.indexOf(poll); @@ -643,6 +668,8 @@ let httpServer = null; function shutdown() { try { fs.unlinkSync(LIVE_PID_FILE); } catch {} + if (state.leaseTimer) clearTimeout(state.leaseTimer); + state.leaseTimer = null; if (state.sessionDir) { try { fs.rmSync(state.sessionDir, { recursive: true, force: true }); } catch {} } diff --git a/.trae/skills/impeccable/scripts/live-session-store.mjs b/.trae/skills/impeccable/scripts/live-session-store.mjs index af61b998..feaa56b2 100644 --- a/.trae/skills/impeccable/scripts/live-session-store.mjs +++ b/.trae/skills/impeccable/scripts/live-session-store.mjs @@ -151,8 +151,8 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { switch (event.type) { case 'generate': next.phase = 'generate_requested'; - next.pageUrl = event.pageUrl || next.pageUrl; - next.expectedVariants = event.count || next.expectedVariants; + next.pageUrl = event.pageUrl ?? next.pageUrl; + next.expectedVariants = event.count ?? next.expectedVariants; next.pendingEventSeq = entry.seq ?? next.pendingEventSeq; next.pendingEvent = toPendingEvent(event); if (event.screenshotPath) upsertArtifact(next.annotationArtifacts, { type: 'screenshot', path: event.screenshotPath }); @@ -160,16 +160,16 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { case 'variants_ready': case 'agent_done': next.phase = 'variants_ready'; - next.sourceFile = event.file || next.sourceFile; - next.arrivedVariants = event.arrivedVariants ?? (next.arrivedVariants || next.expectedVariants); + next.sourceFile = event.file ?? next.sourceFile; + next.arrivedVariants = event.arrivedVariants ?? (next.arrivedVariants ?? next.expectedVariants); next.pendingEventSeq = null; next.pendingEvent = null; break; case 'checkpoint': - 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; + 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; if (event.paramValues) next.paramValues = { ...event.paramValues }; diff --git a/plugin/skills/impeccable/scripts/live-completion.mjs b/plugin/skills/impeccable/scripts/live-completion.mjs index c3a7ff6e..62dec8c3 100644 --- a/plugin/skills/impeccable/scripts/live-completion.mjs +++ b/plugin/skills/impeccable/scripts/live-completion.mjs @@ -1,6 +1,6 @@ export function completionTypeForAcceptResult(eventType, acceptResult) { if (eventType === 'discard') return acceptResult?.handled === true ? 'discarded' : 'error'; if (acceptResult?.handled === true) return 'complete'; - if (acceptResult?.mode === 'fallback') return 'agent_done'; - return 'error'; + if (acceptResult?.mode === 'error') return 'error'; + return 'agent_done'; } diff --git a/plugin/skills/impeccable/scripts/live-poll.mjs b/plugin/skills/impeccable/scripts/live-poll.mjs index 491a0aab..015a15c5 100644 --- a/plugin/skills/impeccable/scripts/live-poll.mjs +++ b/plugin/skills/impeccable/scripts/live-poll.mjs @@ -153,7 +153,7 @@ Options: ); event._acceptResult = JSON.parse(out.trim()); } catch (err) { - event._acceptResult = { handled: false, error: err.message }; + event._acceptResult = { handled: false, mode: 'error', error: err.message }; } const completionType = completionTypeForAcceptResult(event.type, event._acceptResult); diff --git a/plugin/skills/impeccable/scripts/live-server.mjs b/plugin/skills/impeccable/scripts/live-server.mjs index 025a7633..8145e26f 100644 --- a/plugin/skills/impeccable/scripts/live-server.mjs +++ b/plugin/skills/impeccable/scripts/live-server.mjs @@ -63,6 +63,7 @@ const state = { exitTimer: null, sessionDir: null, // per-session tmp dir for annotation screenshots sessionStore: null, + leaseTimer: null, }; // Cap per-annotation upload size. A full 1920×1080 PNG is typically <1 MB; @@ -101,16 +102,39 @@ function acknowledgePendingEvent(id) { const idx = state.pendingEvents.findIndex((entry) => entry.event?.id === id); if (idx === -1) return false; state.pendingEvents.splice(idx, 1); + scheduleLeaseFlush(); return true; } +function scheduleLeaseFlush() { + if (state.leaseTimer) { + clearTimeout(state.leaseTimer); + state.leaseTimer = null; + } + if (state.pendingPolls.length === 0) return; + const now = Date.now(); + const nextLeaseUntil = state.pendingEvents + .map((entry) => entry.leaseUntil || 0) + .filter((leaseUntil) => leaseUntil > now) + .sort((a, b) => a - b)[0]; + if (!nextLeaseUntil) return; + state.leaseTimer = setTimeout(() => { + state.leaseTimer = null; + flushPendingPolls(); + }, Math.max(0, nextLeaseUntil - now)); +} + function flushPendingPolls() { while (state.pendingPolls.length > 0) { const entry = findAvailablePendingEvent(); - if (!entry) return; + if (!entry) { + scheduleLeaseFlush(); + return; + } const poll = state.pendingPolls.shift(); poll.resolve(leaseEvent(entry, poll.leaseMs)); } + scheduleLeaseFlush(); } /** Push a message to all connected SSE clients. */ @@ -592,6 +616,7 @@ function handlePollGet(req, res, url) { res.end(JSON.stringify(event)); } state.pendingPolls.push(poll); + scheduleLeaseFlush(); req.on('close', () => { clearTimeout(timer); const idx = state.pendingPolls.indexOf(poll); @@ -643,6 +668,8 @@ let httpServer = null; function shutdown() { try { fs.unlinkSync(LIVE_PID_FILE); } catch {} + if (state.leaseTimer) clearTimeout(state.leaseTimer); + state.leaseTimer = null; if (state.sessionDir) { try { fs.rmSync(state.sessionDir, { recursive: true, force: true }); } catch {} } diff --git a/plugin/skills/impeccable/scripts/live-session-store.mjs b/plugin/skills/impeccable/scripts/live-session-store.mjs index af61b998..feaa56b2 100644 --- a/plugin/skills/impeccable/scripts/live-session-store.mjs +++ b/plugin/skills/impeccable/scripts/live-session-store.mjs @@ -151,8 +151,8 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { switch (event.type) { case 'generate': next.phase = 'generate_requested'; - next.pageUrl = event.pageUrl || next.pageUrl; - next.expectedVariants = event.count || next.expectedVariants; + next.pageUrl = event.pageUrl ?? next.pageUrl; + next.expectedVariants = event.count ?? next.expectedVariants; next.pendingEventSeq = entry.seq ?? next.pendingEventSeq; next.pendingEvent = toPendingEvent(event); if (event.screenshotPath) upsertArtifact(next.annotationArtifacts, { type: 'screenshot', path: event.screenshotPath }); @@ -160,16 +160,16 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { case 'variants_ready': case 'agent_done': next.phase = 'variants_ready'; - next.sourceFile = event.file || next.sourceFile; - next.arrivedVariants = event.arrivedVariants ?? (next.arrivedVariants || next.expectedVariants); + next.sourceFile = event.file ?? next.sourceFile; + next.arrivedVariants = event.arrivedVariants ?? (next.arrivedVariants ?? next.expectedVariants); next.pendingEventSeq = null; next.pendingEvent = null; break; case 'checkpoint': - 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; + 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; if (event.paramValues) next.paramValues = { ...event.paramValues }; diff --git a/source/skills/impeccable/scripts/live-completion.mjs b/source/skills/impeccable/scripts/live-completion.mjs index c3a7ff6e..62dec8c3 100644 --- a/source/skills/impeccable/scripts/live-completion.mjs +++ b/source/skills/impeccable/scripts/live-completion.mjs @@ -1,6 +1,6 @@ export function completionTypeForAcceptResult(eventType, acceptResult) { if (eventType === 'discard') return acceptResult?.handled === true ? 'discarded' : 'error'; if (acceptResult?.handled === true) return 'complete'; - if (acceptResult?.mode === 'fallback') return 'agent_done'; - return 'error'; + if (acceptResult?.mode === 'error') return 'error'; + return 'agent_done'; } diff --git a/source/skills/impeccable/scripts/live-poll.mjs b/source/skills/impeccable/scripts/live-poll.mjs index 491a0aab..015a15c5 100644 --- a/source/skills/impeccable/scripts/live-poll.mjs +++ b/source/skills/impeccable/scripts/live-poll.mjs @@ -153,7 +153,7 @@ Options: ); event._acceptResult = JSON.parse(out.trim()); } catch (err) { - event._acceptResult = { handled: false, error: err.message }; + event._acceptResult = { handled: false, mode: 'error', error: err.message }; } const completionType = completionTypeForAcceptResult(event.type, event._acceptResult); diff --git a/source/skills/impeccable/scripts/live-server.mjs b/source/skills/impeccable/scripts/live-server.mjs index 025a7633..8145e26f 100644 --- a/source/skills/impeccable/scripts/live-server.mjs +++ b/source/skills/impeccable/scripts/live-server.mjs @@ -63,6 +63,7 @@ const state = { exitTimer: null, sessionDir: null, // per-session tmp dir for annotation screenshots sessionStore: null, + leaseTimer: null, }; // Cap per-annotation upload size. A full 1920×1080 PNG is typically <1 MB; @@ -101,16 +102,39 @@ function acknowledgePendingEvent(id) { const idx = state.pendingEvents.findIndex((entry) => entry.event?.id === id); if (idx === -1) return false; state.pendingEvents.splice(idx, 1); + scheduleLeaseFlush(); return true; } +function scheduleLeaseFlush() { + if (state.leaseTimer) { + clearTimeout(state.leaseTimer); + state.leaseTimer = null; + } + if (state.pendingPolls.length === 0) return; + const now = Date.now(); + const nextLeaseUntil = state.pendingEvents + .map((entry) => entry.leaseUntil || 0) + .filter((leaseUntil) => leaseUntil > now) + .sort((a, b) => a - b)[0]; + if (!nextLeaseUntil) return; + state.leaseTimer = setTimeout(() => { + state.leaseTimer = null; + flushPendingPolls(); + }, Math.max(0, nextLeaseUntil - now)); +} + function flushPendingPolls() { while (state.pendingPolls.length > 0) { const entry = findAvailablePendingEvent(); - if (!entry) return; + if (!entry) { + scheduleLeaseFlush(); + return; + } const poll = state.pendingPolls.shift(); poll.resolve(leaseEvent(entry, poll.leaseMs)); } + scheduleLeaseFlush(); } /** Push a message to all connected SSE clients. */ @@ -592,6 +616,7 @@ function handlePollGet(req, res, url) { res.end(JSON.stringify(event)); } state.pendingPolls.push(poll); + scheduleLeaseFlush(); req.on('close', () => { clearTimeout(timer); const idx = state.pendingPolls.indexOf(poll); @@ -643,6 +668,8 @@ let httpServer = null; function shutdown() { try { fs.unlinkSync(LIVE_PID_FILE); } catch {} + if (state.leaseTimer) clearTimeout(state.leaseTimer); + state.leaseTimer = null; if (state.sessionDir) { try { fs.rmSync(state.sessionDir, { recursive: true, force: true }); } catch {} } diff --git a/source/skills/impeccable/scripts/live-session-store.mjs b/source/skills/impeccable/scripts/live-session-store.mjs index af61b998..feaa56b2 100644 --- a/source/skills/impeccable/scripts/live-session-store.mjs +++ b/source/skills/impeccable/scripts/live-session-store.mjs @@ -151,8 +151,8 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { switch (event.type) { case 'generate': next.phase = 'generate_requested'; - next.pageUrl = event.pageUrl || next.pageUrl; - next.expectedVariants = event.count || next.expectedVariants; + next.pageUrl = event.pageUrl ?? next.pageUrl; + next.expectedVariants = event.count ?? next.expectedVariants; next.pendingEventSeq = entry.seq ?? next.pendingEventSeq; next.pendingEvent = toPendingEvent(event); if (event.screenshotPath) upsertArtifact(next.annotationArtifacts, { type: 'screenshot', path: event.screenshotPath }); @@ -160,16 +160,16 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { case 'variants_ready': case 'agent_done': next.phase = 'variants_ready'; - next.sourceFile = event.file || next.sourceFile; - next.arrivedVariants = event.arrivedVariants ?? (next.arrivedVariants || next.expectedVariants); + next.sourceFile = event.file ?? next.sourceFile; + next.arrivedVariants = event.arrivedVariants ?? (next.arrivedVariants ?? next.expectedVariants); next.pendingEventSeq = null; next.pendingEvent = null; break; case 'checkpoint': - 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; + 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; if (event.paramValues) next.paramValues = { ...event.paramValues }; diff --git a/tests/live-completion.test.mjs b/tests/live-completion.test.mjs index d5fc79a3..14ffdd9a 100644 --- a/tests/live-completion.test.mjs +++ b/tests/live-completion.test.mjs @@ -12,9 +12,17 @@ describe('live completion type classification', () => { ); }); + it('treats unhandled non-error accept as normal manual agent handoff', () => { + assert.equal( + completionTypeForAcceptResult('accept', { handled: false, error: 'Session markers not found' }), + 'agent_done', + 'event=live_poll.manual_accept_completion actor=agent operation=accept_manual_cleanup risk=manual_handoff_recorded_as_agent_error expected=agent_done actual=error', + ); + }); + it('classifies handled accept/discard and real failures explicitly', () => { assert.equal(completionTypeForAcceptResult('accept', { handled: true }), 'complete'); assert.equal(completionTypeForAcceptResult('discard', { handled: true }), 'discarded'); - assert.equal(completionTypeForAcceptResult('accept', { handled: false, error: 'boom' }), 'error'); + assert.equal(completionTypeForAcceptResult('accept', { handled: false, mode: 'error', error: 'boom' }), 'error'); }); }); diff --git a/tests/live-server.test.mjs b/tests/live-server.test.mjs index 768ea8aa..cdc7ed8f 100644 --- a/tests/live-server.test.mjs +++ b/tests/live-server.test.mjs @@ -458,6 +458,47 @@ describe('live-server integration', () => { assert.equal(acked.type, 'timeout', 'acked event should be removed from the poll queue'); }); + it('wakes a parked poll as soon as a missed-ack lease expires', async () => { + await drainPolls(server); + + const postRes = await fetch(`http://localhost:${server.port}/events`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + token: server.token, + type: 'generate', + id: 'lease-wakeup-1', + action: 'polish', + count: 1, + element: { outerHTML: '
wakeup
', tagName: 'section' }, + }), + }); + assert.equal(postRes.status, 200); + + const first = await fetch(`http://localhost:${server.port}/poll?token=${server.token}&timeout=100&leaseMs=60`).then(r => r.json()); + assert.equal(first.id, 'lease-wakeup-1'); + + const startedAt = Date.now(); + const redelivered = await fetch(`http://localhost:${server.port}/poll?token=${server.token}&timeout=500&leaseMs=60`).then(r => r.json()); + const elapsed = Date.now() - startedAt; + + assert.equal( + redelivered.id, + 'lease-wakeup-1', + 'event=live_poll.lease_expiry_wakeup actor=agent operation=poll_before_lease_expiry risk=parked_poll_waits_full_timeout expected=lease-wakeup-1 actual=' + redelivered.id, + ); + assert.ok( + elapsed < 250, + 'event=live_poll.lease_expiry_latency actor=agent operation=poll_before_lease_expiry risk=redelivery_waits_full_timeout expected=<250 actual=' + elapsed, + ); + + await fetch(`http://localhost:${server.port}/poll`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token: server.token, id: 'lease-wakeup-1', type: 'done' }), + }); + }); + it('agent reply is forwarded via SSE to browser', async () => { // Use raw HTTP to read SSE (no EventSource in Node.js) const controller = new AbortController(); diff --git a/tests/live-session-store.test.mjs b/tests/live-session-store.test.mjs index e2fd31af..cffa9e16 100644 --- a/tests/live-session-store.test.mjs +++ b/tests/live-session-store.test.mjs @@ -78,6 +78,31 @@ describe('live-session-store', () => { assert.match(snapshot.diagnostics[0].error, /journal_parse_failed/); }); + it('preserves zero-valued checkpoint revisions and empty explicit fields', () => { + const store = createLiveSessionStore({ cwd: tmp, sessionId: 'zero-checkpoint' }); + store.appendEvent({ + type: 'checkpoint', + id: 'zero-checkpoint', + revision: 0, + phase: '', + owner: '', + arrivedVariants: 0, + visibleVariant: 0, + paramValues: { density: 0 }, + }); + + const snapshot = store.getSnapshot('zero-checkpoint'); + assert.equal( + snapshot.checkpointRevision, + 0, + 'event=live_session_store.zero_checkpoint_revision actor=browser operation=checkpoint_replay risk=zero_revision_dropped expected=0 actual=' + snapshot.checkpointRevision, + ); + assert.equal(snapshot.phase, ''); + assert.equal(snapshot.activeOwner, ''); + assert.equal(snapshot.visibleVariant, 0); + assert.deepEqual(snapshot.paramValues, { density: 0 }); + }); + it('ignores stale checkpoints and keeps the newest browser state', () => { const store = createLiveSessionStore({ cwd: tmp, sessionId: 'checkpoint-session' }); store.appendEvent({ From e826da202774ec0385e4f520b8504bd7b90aa450 Mon Sep 17 00:00:00 2001 From: NQH Date: Wed, 29 Apr 2026 01:15:11 -0500 Subject: [PATCH 04/13] fix(live): keep recovery handoffs accurate --- .../skills/impeccable/scripts/live-browser.js | 9 ++++---- .../impeccable/scripts/live-completion.mjs | 1 + .../skills/impeccable/scripts/live-poll.mjs | 1 + .../skills/impeccable/scripts/live-resume.mjs | 8 ++++--- .../skills/impeccable/scripts/live-server.mjs | 8 ++++++- .../impeccable/scripts/live-session-store.mjs | 9 +++++++- .../skills/impeccable/scripts/live-browser.js | 9 ++++---- .../impeccable/scripts/live-completion.mjs | 1 + .../skills/impeccable/scripts/live-poll.mjs | 1 + .../skills/impeccable/scripts/live-resume.mjs | 8 ++++--- .../skills/impeccable/scripts/live-server.mjs | 8 ++++++- .../impeccable/scripts/live-session-store.mjs | 9 +++++++- .../skills/impeccable/scripts/live-browser.js | 9 ++++---- .../impeccable/scripts/live-completion.mjs | 1 + .../skills/impeccable/scripts/live-poll.mjs | 1 + .../skills/impeccable/scripts/live-resume.mjs | 8 ++++--- .../skills/impeccable/scripts/live-server.mjs | 8 ++++++- .../impeccable/scripts/live-session-store.mjs | 9 +++++++- .../skills/impeccable/scripts/live-browser.js | 9 ++++---- .../impeccable/scripts/live-completion.mjs | 1 + .../skills/impeccable/scripts/live-poll.mjs | 1 + .../skills/impeccable/scripts/live-resume.mjs | 8 ++++--- .../skills/impeccable/scripts/live-server.mjs | 8 ++++++- .../impeccable/scripts/live-session-store.mjs | 9 +++++++- .../skills/impeccable/scripts/live-browser.js | 9 ++++---- .../impeccable/scripts/live-completion.mjs | 1 + .../skills/impeccable/scripts/live-poll.mjs | 1 + .../skills/impeccable/scripts/live-resume.mjs | 8 ++++--- .../skills/impeccable/scripts/live-server.mjs | 8 ++++++- .../impeccable/scripts/live-session-store.mjs | 9 +++++++- .../skills/impeccable/scripts/live-browser.js | 9 ++++---- .../impeccable/scripts/live-completion.mjs | 1 + .kiro/skills/impeccable/scripts/live-poll.mjs | 1 + .../skills/impeccable/scripts/live-resume.mjs | 8 ++++--- .../skills/impeccable/scripts/live-server.mjs | 8 ++++++- .../impeccable/scripts/live-session-store.mjs | 9 +++++++- .../skills/impeccable/scripts/live-browser.js | 9 ++++---- .../impeccable/scripts/live-completion.mjs | 1 + .../skills/impeccable/scripts/live-poll.mjs | 1 + .../skills/impeccable/scripts/live-resume.mjs | 8 ++++--- .../skills/impeccable/scripts/live-server.mjs | 8 ++++++- .../impeccable/scripts/live-session-store.mjs | 9 +++++++- .pi/skills/impeccable/scripts/live-browser.js | 9 ++++---- .../impeccable/scripts/live-completion.mjs | 1 + .pi/skills/impeccable/scripts/live-poll.mjs | 1 + .pi/skills/impeccable/scripts/live-resume.mjs | 8 ++++--- .pi/skills/impeccable/scripts/live-server.mjs | 8 ++++++- .../impeccable/scripts/live-session-store.mjs | 9 +++++++- .../skills/impeccable/scripts/live-browser.js | 9 ++++---- .../impeccable/scripts/live-completion.mjs | 1 + .../skills/impeccable/scripts/live-poll.mjs | 1 + .../skills/impeccable/scripts/live-resume.mjs | 8 ++++--- .../skills/impeccable/scripts/live-server.mjs | 8 ++++++- .../impeccable/scripts/live-session-store.mjs | 9 +++++++- .../skills/impeccable/scripts/live-browser.js | 9 ++++---- .../impeccable/scripts/live-completion.mjs | 1 + .../skills/impeccable/scripts/live-poll.mjs | 1 + .../skills/impeccable/scripts/live-resume.mjs | 8 ++++--- .../skills/impeccable/scripts/live-server.mjs | 8 ++++++- .../impeccable/scripts/live-session-store.mjs | 9 +++++++- .../skills/impeccable/scripts/live-browser.js | 9 ++++---- .../impeccable/scripts/live-completion.mjs | 1 + .trae/skills/impeccable/scripts/live-poll.mjs | 1 + .../skills/impeccable/scripts/live-resume.mjs | 8 ++++--- .../skills/impeccable/scripts/live-server.mjs | 8 ++++++- .../impeccable/scripts/live-session-store.mjs | 9 +++++++- package.json | 2 +- .../skills/impeccable/scripts/live-browser.js | 9 ++++---- .../impeccable/scripts/live-completion.mjs | 1 + .../skills/impeccable/scripts/live-poll.mjs | 1 + .../skills/impeccable/scripts/live-resume.mjs | 8 ++++--- .../skills/impeccable/scripts/live-server.mjs | 8 ++++++- .../impeccable/scripts/live-session-store.mjs | 9 +++++++- .../skills/impeccable/scripts/live-browser.js | 9 ++++---- .../impeccable/scripts/live-completion.mjs | 1 + .../skills/impeccable/scripts/live-poll.mjs | 1 + .../skills/impeccable/scripts/live-resume.mjs | 8 ++++--- .../skills/impeccable/scripts/live-server.mjs | 8 ++++++- .../impeccable/scripts/live-session-store.mjs | 9 +++++++- tests/live-browser-source.test.mjs | 18 ++++++++++++++++ tests/live-completion.test.mjs | 8 +++++++ tests/live-recovery-commands.test.mjs | 15 +++++++++++++ tests/live-session-store.test.mjs | 21 +++++++++++++++++++ 83 files changed, 414 insertions(+), 118 deletions(-) create mode 100644 tests/live-browser-source.test.mjs diff --git a/.agents/skills/impeccable/scripts/live-browser.js b/.agents/skills/impeccable/scripts/live-browser.js index 6c6adf05..72869490 100644 --- a/.agents/skills/impeccable/scripts/live-browser.js +++ b/.agents/skills/impeccable/scripts/live-browser.js @@ -2260,7 +2260,7 @@ state = currentSessionId ? 'GENERATING' : 'IDLE'; } - function sendEvent(msg) { + function sendEvent(msg, opts) { msg.token = TOKEN; return fetch('http://localhost:' + PORT + '/events', { method: 'POST', @@ -2268,7 +2268,8 @@ body: JSON.stringify(msg), }).catch(err => { console.error('[impeccable] Failed to send event:', err); - throw err; + if (opts && opts.throwOnError) throw err; + return null; }); } @@ -2976,7 +2977,7 @@ void main() { state = 'SAVING'; updateBarContent('saving'); - sendEvent(acceptPayload) + sendEvent(acceptPayload, { throwOnError: true }) .then(() => { markSessionHandled(); confirmAcceptAfterReceipt(); @@ -3029,7 +3030,7 @@ void main() { function handleDiscard() { if (!currentSessionId) return; - sendEvent({ type: 'discard', id: currentSessionId }) + sendEvent({ type: 'discard', id: currentSessionId }, { throwOnError: true }) .then(() => { markSessionHandled(); cleanup(); diff --git a/.agents/skills/impeccable/scripts/live-completion.mjs b/.agents/skills/impeccable/scripts/live-completion.mjs index 62dec8c3..cf9fb10f 100644 --- a/.agents/skills/impeccable/scripts/live-completion.mjs +++ b/.agents/skills/impeccable/scripts/live-completion.mjs @@ -1,5 +1,6 @@ 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'; return 'agent_done'; diff --git a/.agents/skills/impeccable/scripts/live-poll.mjs b/.agents/skills/impeccable/scripts/live-poll.mjs index 015a15c5..2b0c7936 100644 --- a/.agents/skills/impeccable/scripts/live-poll.mjs +++ b/.agents/skills/impeccable/scripts/live-poll.mjs @@ -163,6 +163,7 @@ Options: type: completionType, message: event._acceptResult?.error, file: event._acceptResult?.file, + data: event._acceptResult?.carbonize === true ? { carbonize: true } : undefined, }); } catch (err) { event._completionAck = { ok: false, error: err.message }; diff --git a/.agents/skills/impeccable/scripts/live-resume.mjs b/.agents/skills/impeccable/scripts/live-resume.mjs index 1234329a..a3465c9b 100644 --- a/.agents/skills/impeccable/scripts/live-resume.mjs +++ b/.agents/skills/impeccable/scripts/live-resume.mjs @@ -33,9 +33,11 @@ export async function resumeCli() { const pending = snapshot.pendingEvent || null; const nextAction = pending ? `Run live-poll.mjs, handle ${pending.type} ${pending.id}, then acknowledge with live-poll.mjs --reply ${pending.id} done.` - : snapshot.phase === 'accept_requested' - ? `Run live-complete.mjs --id ${snapshot.id} after verifying the accepted variant is written.` - : `Inspect ${snapshot.id}; no pending agent event is currently queued.`; + : snapshot.phase === 'carbonize_required' + ? `Finish carbonize cleanup${snapshot.sourceFile ? ` in ${snapshot.sourceFile}` : ''}, then run live-complete.mjs --id ${snapshot.id}.` + : snapshot.phase === 'accept_requested' + ? `Run live-complete.mjs --id ${snapshot.id} after verifying the accepted variant is written.` + : `Inspect ${snapshot.id}; no pending agent event is currently queued.`; console.log(JSON.stringify({ active: true, snapshot, pendingEvent: pending, nextAction }, null, 2)); } diff --git a/.agents/skills/impeccable/scripts/live-server.mjs b/.agents/skills/impeccable/scripts/live-server.mjs index 8145e26f..e78a0657 100644 --- a/.agents/skills/impeccable/scripts/live-server.mjs +++ b/.agents/skills/impeccable/scripts/live-server.mjs @@ -649,7 +649,13 @@ function handlePollPost(req, res) { : msg.type === 'error' ? 'agent_error' : 'agent_done'; - state.sessionStore.appendEvent({ type: eventType, id: msg.id, file: msg.file, message: msg.message }); + state.sessionStore.appendEvent({ + type: eventType, + id: msg.id, + file: msg.file, + message: msg.message, + carbonize: msg.data?.carbonize === true, + }); } catch { /* keep reply path best-effort; browser still needs SSE */ } } flushPendingPolls(); diff --git a/.agents/skills/impeccable/scripts/live-session-store.mjs b/.agents/skills/impeccable/scripts/live-session-store.mjs index feaa56b2..31d748e4 100644 --- a/.agents/skills/impeccable/scripts/live-session-store.mjs +++ b/.agents/skills/impeccable/scripts/live-session-store.mjs @@ -159,11 +159,18 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { break; case 'variants_ready': case 'agent_done': - next.phase = 'variants_ready'; + next.phase = event.carbonize === true ? 'carbonize_required' : 'variants_ready'; next.sourceFile = event.file ?? next.sourceFile; next.arrivedVariants = event.arrivedVariants ?? (next.arrivedVariants ?? next.expectedVariants); next.pendingEventSeq = null; next.pendingEvent = null; + if (event.carbonize === true) { + next.diagnostics.push({ + error: 'carbonize_cleanup_required', + file: event.file || null, + message: 'Accepted variant still has carbonize markers that must be folded into source CSS.', + }); + } break; case 'checkpoint': if ((event.revision ?? 0) >= (next.checkpointRevision ?? 0)) { diff --git a/.claude/skills/impeccable/scripts/live-browser.js b/.claude/skills/impeccable/scripts/live-browser.js index 6c6adf05..72869490 100644 --- a/.claude/skills/impeccable/scripts/live-browser.js +++ b/.claude/skills/impeccable/scripts/live-browser.js @@ -2260,7 +2260,7 @@ state = currentSessionId ? 'GENERATING' : 'IDLE'; } - function sendEvent(msg) { + function sendEvent(msg, opts) { msg.token = TOKEN; return fetch('http://localhost:' + PORT + '/events', { method: 'POST', @@ -2268,7 +2268,8 @@ body: JSON.stringify(msg), }).catch(err => { console.error('[impeccable] Failed to send event:', err); - throw err; + if (opts && opts.throwOnError) throw err; + return null; }); } @@ -2976,7 +2977,7 @@ void main() { state = 'SAVING'; updateBarContent('saving'); - sendEvent(acceptPayload) + sendEvent(acceptPayload, { throwOnError: true }) .then(() => { markSessionHandled(); confirmAcceptAfterReceipt(); @@ -3029,7 +3030,7 @@ void main() { function handleDiscard() { if (!currentSessionId) return; - sendEvent({ type: 'discard', id: currentSessionId }) + sendEvent({ type: 'discard', id: currentSessionId }, { throwOnError: true }) .then(() => { markSessionHandled(); cleanup(); diff --git a/.claude/skills/impeccable/scripts/live-completion.mjs b/.claude/skills/impeccable/scripts/live-completion.mjs index 62dec8c3..cf9fb10f 100644 --- a/.claude/skills/impeccable/scripts/live-completion.mjs +++ b/.claude/skills/impeccable/scripts/live-completion.mjs @@ -1,5 +1,6 @@ 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'; return 'agent_done'; diff --git a/.claude/skills/impeccable/scripts/live-poll.mjs b/.claude/skills/impeccable/scripts/live-poll.mjs index 015a15c5..2b0c7936 100644 --- a/.claude/skills/impeccable/scripts/live-poll.mjs +++ b/.claude/skills/impeccable/scripts/live-poll.mjs @@ -163,6 +163,7 @@ Options: type: completionType, message: event._acceptResult?.error, file: event._acceptResult?.file, + data: event._acceptResult?.carbonize === true ? { carbonize: true } : undefined, }); } catch (err) { event._completionAck = { ok: false, error: err.message }; diff --git a/.claude/skills/impeccable/scripts/live-resume.mjs b/.claude/skills/impeccable/scripts/live-resume.mjs index 1234329a..a3465c9b 100644 --- a/.claude/skills/impeccable/scripts/live-resume.mjs +++ b/.claude/skills/impeccable/scripts/live-resume.mjs @@ -33,9 +33,11 @@ export async function resumeCli() { const pending = snapshot.pendingEvent || null; const nextAction = pending ? `Run live-poll.mjs, handle ${pending.type} ${pending.id}, then acknowledge with live-poll.mjs --reply ${pending.id} done.` - : snapshot.phase === 'accept_requested' - ? `Run live-complete.mjs --id ${snapshot.id} after verifying the accepted variant is written.` - : `Inspect ${snapshot.id}; no pending agent event is currently queued.`; + : snapshot.phase === 'carbonize_required' + ? `Finish carbonize cleanup${snapshot.sourceFile ? ` in ${snapshot.sourceFile}` : ''}, then run live-complete.mjs --id ${snapshot.id}.` + : snapshot.phase === 'accept_requested' + ? `Run live-complete.mjs --id ${snapshot.id} after verifying the accepted variant is written.` + : `Inspect ${snapshot.id}; no pending agent event is currently queued.`; console.log(JSON.stringify({ active: true, snapshot, pendingEvent: pending, nextAction }, null, 2)); } diff --git a/.claude/skills/impeccable/scripts/live-server.mjs b/.claude/skills/impeccable/scripts/live-server.mjs index 8145e26f..e78a0657 100644 --- a/.claude/skills/impeccable/scripts/live-server.mjs +++ b/.claude/skills/impeccable/scripts/live-server.mjs @@ -649,7 +649,13 @@ function handlePollPost(req, res) { : msg.type === 'error' ? 'agent_error' : 'agent_done'; - state.sessionStore.appendEvent({ type: eventType, id: msg.id, file: msg.file, message: msg.message }); + state.sessionStore.appendEvent({ + type: eventType, + id: msg.id, + file: msg.file, + message: msg.message, + carbonize: msg.data?.carbonize === true, + }); } catch { /* keep reply path best-effort; browser still needs SSE */ } } flushPendingPolls(); diff --git a/.claude/skills/impeccable/scripts/live-session-store.mjs b/.claude/skills/impeccable/scripts/live-session-store.mjs index feaa56b2..31d748e4 100644 --- a/.claude/skills/impeccable/scripts/live-session-store.mjs +++ b/.claude/skills/impeccable/scripts/live-session-store.mjs @@ -159,11 +159,18 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { break; case 'variants_ready': case 'agent_done': - next.phase = 'variants_ready'; + next.phase = event.carbonize === true ? 'carbonize_required' : 'variants_ready'; next.sourceFile = event.file ?? next.sourceFile; next.arrivedVariants = event.arrivedVariants ?? (next.arrivedVariants ?? next.expectedVariants); next.pendingEventSeq = null; next.pendingEvent = null; + if (event.carbonize === true) { + next.diagnostics.push({ + error: 'carbonize_cleanup_required', + file: event.file || null, + message: 'Accepted variant still has carbonize markers that must be folded into source CSS.', + }); + } break; case 'checkpoint': if ((event.revision ?? 0) >= (next.checkpointRevision ?? 0)) { diff --git a/.cursor/skills/impeccable/scripts/live-browser.js b/.cursor/skills/impeccable/scripts/live-browser.js index 6c6adf05..72869490 100644 --- a/.cursor/skills/impeccable/scripts/live-browser.js +++ b/.cursor/skills/impeccable/scripts/live-browser.js @@ -2260,7 +2260,7 @@ state = currentSessionId ? 'GENERATING' : 'IDLE'; } - function sendEvent(msg) { + function sendEvent(msg, opts) { msg.token = TOKEN; return fetch('http://localhost:' + PORT + '/events', { method: 'POST', @@ -2268,7 +2268,8 @@ body: JSON.stringify(msg), }).catch(err => { console.error('[impeccable] Failed to send event:', err); - throw err; + if (opts && opts.throwOnError) throw err; + return null; }); } @@ -2976,7 +2977,7 @@ void main() { state = 'SAVING'; updateBarContent('saving'); - sendEvent(acceptPayload) + sendEvent(acceptPayload, { throwOnError: true }) .then(() => { markSessionHandled(); confirmAcceptAfterReceipt(); @@ -3029,7 +3030,7 @@ void main() { function handleDiscard() { if (!currentSessionId) return; - sendEvent({ type: 'discard', id: currentSessionId }) + sendEvent({ type: 'discard', id: currentSessionId }, { throwOnError: true }) .then(() => { markSessionHandled(); cleanup(); diff --git a/.cursor/skills/impeccable/scripts/live-completion.mjs b/.cursor/skills/impeccable/scripts/live-completion.mjs index 62dec8c3..cf9fb10f 100644 --- a/.cursor/skills/impeccable/scripts/live-completion.mjs +++ b/.cursor/skills/impeccable/scripts/live-completion.mjs @@ -1,5 +1,6 @@ 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'; return 'agent_done'; diff --git a/.cursor/skills/impeccable/scripts/live-poll.mjs b/.cursor/skills/impeccable/scripts/live-poll.mjs index 015a15c5..2b0c7936 100644 --- a/.cursor/skills/impeccable/scripts/live-poll.mjs +++ b/.cursor/skills/impeccable/scripts/live-poll.mjs @@ -163,6 +163,7 @@ Options: type: completionType, message: event._acceptResult?.error, file: event._acceptResult?.file, + data: event._acceptResult?.carbonize === true ? { carbonize: true } : undefined, }); } catch (err) { event._completionAck = { ok: false, error: err.message }; diff --git a/.cursor/skills/impeccable/scripts/live-resume.mjs b/.cursor/skills/impeccable/scripts/live-resume.mjs index 1234329a..a3465c9b 100644 --- a/.cursor/skills/impeccable/scripts/live-resume.mjs +++ b/.cursor/skills/impeccable/scripts/live-resume.mjs @@ -33,9 +33,11 @@ export async function resumeCli() { const pending = snapshot.pendingEvent || null; const nextAction = pending ? `Run live-poll.mjs, handle ${pending.type} ${pending.id}, then acknowledge with live-poll.mjs --reply ${pending.id} done.` - : snapshot.phase === 'accept_requested' - ? `Run live-complete.mjs --id ${snapshot.id} after verifying the accepted variant is written.` - : `Inspect ${snapshot.id}; no pending agent event is currently queued.`; + : snapshot.phase === 'carbonize_required' + ? `Finish carbonize cleanup${snapshot.sourceFile ? ` in ${snapshot.sourceFile}` : ''}, then run live-complete.mjs --id ${snapshot.id}.` + : snapshot.phase === 'accept_requested' + ? `Run live-complete.mjs --id ${snapshot.id} after verifying the accepted variant is written.` + : `Inspect ${snapshot.id}; no pending agent event is currently queued.`; console.log(JSON.stringify({ active: true, snapshot, pendingEvent: pending, nextAction }, null, 2)); } diff --git a/.cursor/skills/impeccable/scripts/live-server.mjs b/.cursor/skills/impeccable/scripts/live-server.mjs index 8145e26f..e78a0657 100644 --- a/.cursor/skills/impeccable/scripts/live-server.mjs +++ b/.cursor/skills/impeccable/scripts/live-server.mjs @@ -649,7 +649,13 @@ function handlePollPost(req, res) { : msg.type === 'error' ? 'agent_error' : 'agent_done'; - state.sessionStore.appendEvent({ type: eventType, id: msg.id, file: msg.file, message: msg.message }); + state.sessionStore.appendEvent({ + type: eventType, + id: msg.id, + file: msg.file, + message: msg.message, + carbonize: msg.data?.carbonize === true, + }); } catch { /* keep reply path best-effort; browser still needs SSE */ } } flushPendingPolls(); diff --git a/.cursor/skills/impeccable/scripts/live-session-store.mjs b/.cursor/skills/impeccable/scripts/live-session-store.mjs index feaa56b2..31d748e4 100644 --- a/.cursor/skills/impeccable/scripts/live-session-store.mjs +++ b/.cursor/skills/impeccable/scripts/live-session-store.mjs @@ -159,11 +159,18 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { break; case 'variants_ready': case 'agent_done': - next.phase = 'variants_ready'; + next.phase = event.carbonize === true ? 'carbonize_required' : 'variants_ready'; next.sourceFile = event.file ?? next.sourceFile; next.arrivedVariants = event.arrivedVariants ?? (next.arrivedVariants ?? next.expectedVariants); next.pendingEventSeq = null; next.pendingEvent = null; + if (event.carbonize === true) { + next.diagnostics.push({ + error: 'carbonize_cleanup_required', + file: event.file || null, + message: 'Accepted variant still has carbonize markers that must be folded into source CSS.', + }); + } break; case 'checkpoint': if ((event.revision ?? 0) >= (next.checkpointRevision ?? 0)) { diff --git a/.gemini/skills/impeccable/scripts/live-browser.js b/.gemini/skills/impeccable/scripts/live-browser.js index 6c6adf05..72869490 100644 --- a/.gemini/skills/impeccable/scripts/live-browser.js +++ b/.gemini/skills/impeccable/scripts/live-browser.js @@ -2260,7 +2260,7 @@ state = currentSessionId ? 'GENERATING' : 'IDLE'; } - function sendEvent(msg) { + function sendEvent(msg, opts) { msg.token = TOKEN; return fetch('http://localhost:' + PORT + '/events', { method: 'POST', @@ -2268,7 +2268,8 @@ body: JSON.stringify(msg), }).catch(err => { console.error('[impeccable] Failed to send event:', err); - throw err; + if (opts && opts.throwOnError) throw err; + return null; }); } @@ -2976,7 +2977,7 @@ void main() { state = 'SAVING'; updateBarContent('saving'); - sendEvent(acceptPayload) + sendEvent(acceptPayload, { throwOnError: true }) .then(() => { markSessionHandled(); confirmAcceptAfterReceipt(); @@ -3029,7 +3030,7 @@ void main() { function handleDiscard() { if (!currentSessionId) return; - sendEvent({ type: 'discard', id: currentSessionId }) + sendEvent({ type: 'discard', id: currentSessionId }, { throwOnError: true }) .then(() => { markSessionHandled(); cleanup(); diff --git a/.gemini/skills/impeccable/scripts/live-completion.mjs b/.gemini/skills/impeccable/scripts/live-completion.mjs index 62dec8c3..cf9fb10f 100644 --- a/.gemini/skills/impeccable/scripts/live-completion.mjs +++ b/.gemini/skills/impeccable/scripts/live-completion.mjs @@ -1,5 +1,6 @@ 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'; return 'agent_done'; diff --git a/.gemini/skills/impeccable/scripts/live-poll.mjs b/.gemini/skills/impeccable/scripts/live-poll.mjs index 015a15c5..2b0c7936 100644 --- a/.gemini/skills/impeccable/scripts/live-poll.mjs +++ b/.gemini/skills/impeccable/scripts/live-poll.mjs @@ -163,6 +163,7 @@ Options: type: completionType, message: event._acceptResult?.error, file: event._acceptResult?.file, + data: event._acceptResult?.carbonize === true ? { carbonize: true } : undefined, }); } catch (err) { event._completionAck = { ok: false, error: err.message }; diff --git a/.gemini/skills/impeccable/scripts/live-resume.mjs b/.gemini/skills/impeccable/scripts/live-resume.mjs index 1234329a..a3465c9b 100644 --- a/.gemini/skills/impeccable/scripts/live-resume.mjs +++ b/.gemini/skills/impeccable/scripts/live-resume.mjs @@ -33,9 +33,11 @@ export async function resumeCli() { const pending = snapshot.pendingEvent || null; const nextAction = pending ? `Run live-poll.mjs, handle ${pending.type} ${pending.id}, then acknowledge with live-poll.mjs --reply ${pending.id} done.` - : snapshot.phase === 'accept_requested' - ? `Run live-complete.mjs --id ${snapshot.id} after verifying the accepted variant is written.` - : `Inspect ${snapshot.id}; no pending agent event is currently queued.`; + : snapshot.phase === 'carbonize_required' + ? `Finish carbonize cleanup${snapshot.sourceFile ? ` in ${snapshot.sourceFile}` : ''}, then run live-complete.mjs --id ${snapshot.id}.` + : snapshot.phase === 'accept_requested' + ? `Run live-complete.mjs --id ${snapshot.id} after verifying the accepted variant is written.` + : `Inspect ${snapshot.id}; no pending agent event is currently queued.`; console.log(JSON.stringify({ active: true, snapshot, pendingEvent: pending, nextAction }, null, 2)); } diff --git a/.gemini/skills/impeccable/scripts/live-server.mjs b/.gemini/skills/impeccable/scripts/live-server.mjs index 8145e26f..e78a0657 100644 --- a/.gemini/skills/impeccable/scripts/live-server.mjs +++ b/.gemini/skills/impeccable/scripts/live-server.mjs @@ -649,7 +649,13 @@ function handlePollPost(req, res) { : msg.type === 'error' ? 'agent_error' : 'agent_done'; - state.sessionStore.appendEvent({ type: eventType, id: msg.id, file: msg.file, message: msg.message }); + state.sessionStore.appendEvent({ + type: eventType, + id: msg.id, + file: msg.file, + message: msg.message, + carbonize: msg.data?.carbonize === true, + }); } catch { /* keep reply path best-effort; browser still needs SSE */ } } flushPendingPolls(); diff --git a/.gemini/skills/impeccable/scripts/live-session-store.mjs b/.gemini/skills/impeccable/scripts/live-session-store.mjs index feaa56b2..31d748e4 100644 --- a/.gemini/skills/impeccable/scripts/live-session-store.mjs +++ b/.gemini/skills/impeccable/scripts/live-session-store.mjs @@ -159,11 +159,18 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { break; case 'variants_ready': case 'agent_done': - next.phase = 'variants_ready'; + next.phase = event.carbonize === true ? 'carbonize_required' : 'variants_ready'; next.sourceFile = event.file ?? next.sourceFile; next.arrivedVariants = event.arrivedVariants ?? (next.arrivedVariants ?? next.expectedVariants); next.pendingEventSeq = null; next.pendingEvent = null; + if (event.carbonize === true) { + next.diagnostics.push({ + error: 'carbonize_cleanup_required', + file: event.file || null, + message: 'Accepted variant still has carbonize markers that must be folded into source CSS.', + }); + } break; case 'checkpoint': if ((event.revision ?? 0) >= (next.checkpointRevision ?? 0)) { diff --git a/.github/skills/impeccable/scripts/live-browser.js b/.github/skills/impeccable/scripts/live-browser.js index 6c6adf05..72869490 100644 --- a/.github/skills/impeccable/scripts/live-browser.js +++ b/.github/skills/impeccable/scripts/live-browser.js @@ -2260,7 +2260,7 @@ state = currentSessionId ? 'GENERATING' : 'IDLE'; } - function sendEvent(msg) { + function sendEvent(msg, opts) { msg.token = TOKEN; return fetch('http://localhost:' + PORT + '/events', { method: 'POST', @@ -2268,7 +2268,8 @@ body: JSON.stringify(msg), }).catch(err => { console.error('[impeccable] Failed to send event:', err); - throw err; + if (opts && opts.throwOnError) throw err; + return null; }); } @@ -2976,7 +2977,7 @@ void main() { state = 'SAVING'; updateBarContent('saving'); - sendEvent(acceptPayload) + sendEvent(acceptPayload, { throwOnError: true }) .then(() => { markSessionHandled(); confirmAcceptAfterReceipt(); @@ -3029,7 +3030,7 @@ void main() { function handleDiscard() { if (!currentSessionId) return; - sendEvent({ type: 'discard', id: currentSessionId }) + sendEvent({ type: 'discard', id: currentSessionId }, { throwOnError: true }) .then(() => { markSessionHandled(); cleanup(); diff --git a/.github/skills/impeccable/scripts/live-completion.mjs b/.github/skills/impeccable/scripts/live-completion.mjs index 62dec8c3..cf9fb10f 100644 --- a/.github/skills/impeccable/scripts/live-completion.mjs +++ b/.github/skills/impeccable/scripts/live-completion.mjs @@ -1,5 +1,6 @@ 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'; return 'agent_done'; diff --git a/.github/skills/impeccable/scripts/live-poll.mjs b/.github/skills/impeccable/scripts/live-poll.mjs index 015a15c5..2b0c7936 100644 --- a/.github/skills/impeccable/scripts/live-poll.mjs +++ b/.github/skills/impeccable/scripts/live-poll.mjs @@ -163,6 +163,7 @@ Options: type: completionType, message: event._acceptResult?.error, file: event._acceptResult?.file, + data: event._acceptResult?.carbonize === true ? { carbonize: true } : undefined, }); } catch (err) { event._completionAck = { ok: false, error: err.message }; diff --git a/.github/skills/impeccable/scripts/live-resume.mjs b/.github/skills/impeccable/scripts/live-resume.mjs index 1234329a..a3465c9b 100644 --- a/.github/skills/impeccable/scripts/live-resume.mjs +++ b/.github/skills/impeccable/scripts/live-resume.mjs @@ -33,9 +33,11 @@ export async function resumeCli() { const pending = snapshot.pendingEvent || null; const nextAction = pending ? `Run live-poll.mjs, handle ${pending.type} ${pending.id}, then acknowledge with live-poll.mjs --reply ${pending.id} done.` - : snapshot.phase === 'accept_requested' - ? `Run live-complete.mjs --id ${snapshot.id} after verifying the accepted variant is written.` - : `Inspect ${snapshot.id}; no pending agent event is currently queued.`; + : snapshot.phase === 'carbonize_required' + ? `Finish carbonize cleanup${snapshot.sourceFile ? ` in ${snapshot.sourceFile}` : ''}, then run live-complete.mjs --id ${snapshot.id}.` + : snapshot.phase === 'accept_requested' + ? `Run live-complete.mjs --id ${snapshot.id} after verifying the accepted variant is written.` + : `Inspect ${snapshot.id}; no pending agent event is currently queued.`; console.log(JSON.stringify({ active: true, snapshot, pendingEvent: pending, nextAction }, null, 2)); } diff --git a/.github/skills/impeccable/scripts/live-server.mjs b/.github/skills/impeccable/scripts/live-server.mjs index 8145e26f..e78a0657 100644 --- a/.github/skills/impeccable/scripts/live-server.mjs +++ b/.github/skills/impeccable/scripts/live-server.mjs @@ -649,7 +649,13 @@ function handlePollPost(req, res) { : msg.type === 'error' ? 'agent_error' : 'agent_done'; - state.sessionStore.appendEvent({ type: eventType, id: msg.id, file: msg.file, message: msg.message }); + state.sessionStore.appendEvent({ + type: eventType, + id: msg.id, + file: msg.file, + message: msg.message, + carbonize: msg.data?.carbonize === true, + }); } catch { /* keep reply path best-effort; browser still needs SSE */ } } flushPendingPolls(); diff --git a/.github/skills/impeccable/scripts/live-session-store.mjs b/.github/skills/impeccable/scripts/live-session-store.mjs index feaa56b2..31d748e4 100644 --- a/.github/skills/impeccable/scripts/live-session-store.mjs +++ b/.github/skills/impeccable/scripts/live-session-store.mjs @@ -159,11 +159,18 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { break; case 'variants_ready': case 'agent_done': - next.phase = 'variants_ready'; + next.phase = event.carbonize === true ? 'carbonize_required' : 'variants_ready'; next.sourceFile = event.file ?? next.sourceFile; next.arrivedVariants = event.arrivedVariants ?? (next.arrivedVariants ?? next.expectedVariants); next.pendingEventSeq = null; next.pendingEvent = null; + if (event.carbonize === true) { + next.diagnostics.push({ + error: 'carbonize_cleanup_required', + file: event.file || null, + message: 'Accepted variant still has carbonize markers that must be folded into source CSS.', + }); + } break; case 'checkpoint': if ((event.revision ?? 0) >= (next.checkpointRevision ?? 0)) { diff --git a/.kiro/skills/impeccable/scripts/live-browser.js b/.kiro/skills/impeccable/scripts/live-browser.js index 6c6adf05..72869490 100644 --- a/.kiro/skills/impeccable/scripts/live-browser.js +++ b/.kiro/skills/impeccable/scripts/live-browser.js @@ -2260,7 +2260,7 @@ state = currentSessionId ? 'GENERATING' : 'IDLE'; } - function sendEvent(msg) { + function sendEvent(msg, opts) { msg.token = TOKEN; return fetch('http://localhost:' + PORT + '/events', { method: 'POST', @@ -2268,7 +2268,8 @@ body: JSON.stringify(msg), }).catch(err => { console.error('[impeccable] Failed to send event:', err); - throw err; + if (opts && opts.throwOnError) throw err; + return null; }); } @@ -2976,7 +2977,7 @@ void main() { state = 'SAVING'; updateBarContent('saving'); - sendEvent(acceptPayload) + sendEvent(acceptPayload, { throwOnError: true }) .then(() => { markSessionHandled(); confirmAcceptAfterReceipt(); @@ -3029,7 +3030,7 @@ void main() { function handleDiscard() { if (!currentSessionId) return; - sendEvent({ type: 'discard', id: currentSessionId }) + sendEvent({ type: 'discard', id: currentSessionId }, { throwOnError: true }) .then(() => { markSessionHandled(); cleanup(); diff --git a/.kiro/skills/impeccable/scripts/live-completion.mjs b/.kiro/skills/impeccable/scripts/live-completion.mjs index 62dec8c3..cf9fb10f 100644 --- a/.kiro/skills/impeccable/scripts/live-completion.mjs +++ b/.kiro/skills/impeccable/scripts/live-completion.mjs @@ -1,5 +1,6 @@ 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'; return 'agent_done'; diff --git a/.kiro/skills/impeccable/scripts/live-poll.mjs b/.kiro/skills/impeccable/scripts/live-poll.mjs index 015a15c5..2b0c7936 100644 --- a/.kiro/skills/impeccable/scripts/live-poll.mjs +++ b/.kiro/skills/impeccable/scripts/live-poll.mjs @@ -163,6 +163,7 @@ Options: type: completionType, message: event._acceptResult?.error, file: event._acceptResult?.file, + data: event._acceptResult?.carbonize === true ? { carbonize: true } : undefined, }); } catch (err) { event._completionAck = { ok: false, error: err.message }; diff --git a/.kiro/skills/impeccable/scripts/live-resume.mjs b/.kiro/skills/impeccable/scripts/live-resume.mjs index 1234329a..a3465c9b 100644 --- a/.kiro/skills/impeccable/scripts/live-resume.mjs +++ b/.kiro/skills/impeccable/scripts/live-resume.mjs @@ -33,9 +33,11 @@ export async function resumeCli() { const pending = snapshot.pendingEvent || null; const nextAction = pending ? `Run live-poll.mjs, handle ${pending.type} ${pending.id}, then acknowledge with live-poll.mjs --reply ${pending.id} done.` - : snapshot.phase === 'accept_requested' - ? `Run live-complete.mjs --id ${snapshot.id} after verifying the accepted variant is written.` - : `Inspect ${snapshot.id}; no pending agent event is currently queued.`; + : snapshot.phase === 'carbonize_required' + ? `Finish carbonize cleanup${snapshot.sourceFile ? ` in ${snapshot.sourceFile}` : ''}, then run live-complete.mjs --id ${snapshot.id}.` + : snapshot.phase === 'accept_requested' + ? `Run live-complete.mjs --id ${snapshot.id} after verifying the accepted variant is written.` + : `Inspect ${snapshot.id}; no pending agent event is currently queued.`; console.log(JSON.stringify({ active: true, snapshot, pendingEvent: pending, nextAction }, null, 2)); } diff --git a/.kiro/skills/impeccable/scripts/live-server.mjs b/.kiro/skills/impeccable/scripts/live-server.mjs index 8145e26f..e78a0657 100644 --- a/.kiro/skills/impeccable/scripts/live-server.mjs +++ b/.kiro/skills/impeccable/scripts/live-server.mjs @@ -649,7 +649,13 @@ function handlePollPost(req, res) { : msg.type === 'error' ? 'agent_error' : 'agent_done'; - state.sessionStore.appendEvent({ type: eventType, id: msg.id, file: msg.file, message: msg.message }); + state.sessionStore.appendEvent({ + type: eventType, + id: msg.id, + file: msg.file, + message: msg.message, + carbonize: msg.data?.carbonize === true, + }); } catch { /* keep reply path best-effort; browser still needs SSE */ } } flushPendingPolls(); diff --git a/.kiro/skills/impeccable/scripts/live-session-store.mjs b/.kiro/skills/impeccable/scripts/live-session-store.mjs index feaa56b2..31d748e4 100644 --- a/.kiro/skills/impeccable/scripts/live-session-store.mjs +++ b/.kiro/skills/impeccable/scripts/live-session-store.mjs @@ -159,11 +159,18 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { break; case 'variants_ready': case 'agent_done': - next.phase = 'variants_ready'; + next.phase = event.carbonize === true ? 'carbonize_required' : 'variants_ready'; next.sourceFile = event.file ?? next.sourceFile; next.arrivedVariants = event.arrivedVariants ?? (next.arrivedVariants ?? next.expectedVariants); next.pendingEventSeq = null; next.pendingEvent = null; + if (event.carbonize === true) { + next.diagnostics.push({ + error: 'carbonize_cleanup_required', + file: event.file || null, + message: 'Accepted variant still has carbonize markers that must be folded into source CSS.', + }); + } break; case 'checkpoint': if ((event.revision ?? 0) >= (next.checkpointRevision ?? 0)) { diff --git a/.opencode/skills/impeccable/scripts/live-browser.js b/.opencode/skills/impeccable/scripts/live-browser.js index 6c6adf05..72869490 100644 --- a/.opencode/skills/impeccable/scripts/live-browser.js +++ b/.opencode/skills/impeccable/scripts/live-browser.js @@ -2260,7 +2260,7 @@ state = currentSessionId ? 'GENERATING' : 'IDLE'; } - function sendEvent(msg) { + function sendEvent(msg, opts) { msg.token = TOKEN; return fetch('http://localhost:' + PORT + '/events', { method: 'POST', @@ -2268,7 +2268,8 @@ body: JSON.stringify(msg), }).catch(err => { console.error('[impeccable] Failed to send event:', err); - throw err; + if (opts && opts.throwOnError) throw err; + return null; }); } @@ -2976,7 +2977,7 @@ void main() { state = 'SAVING'; updateBarContent('saving'); - sendEvent(acceptPayload) + sendEvent(acceptPayload, { throwOnError: true }) .then(() => { markSessionHandled(); confirmAcceptAfterReceipt(); @@ -3029,7 +3030,7 @@ void main() { function handleDiscard() { if (!currentSessionId) return; - sendEvent({ type: 'discard', id: currentSessionId }) + sendEvent({ type: 'discard', id: currentSessionId }, { throwOnError: true }) .then(() => { markSessionHandled(); cleanup(); diff --git a/.opencode/skills/impeccable/scripts/live-completion.mjs b/.opencode/skills/impeccable/scripts/live-completion.mjs index 62dec8c3..cf9fb10f 100644 --- a/.opencode/skills/impeccable/scripts/live-completion.mjs +++ b/.opencode/skills/impeccable/scripts/live-completion.mjs @@ -1,5 +1,6 @@ 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'; return 'agent_done'; diff --git a/.opencode/skills/impeccable/scripts/live-poll.mjs b/.opencode/skills/impeccable/scripts/live-poll.mjs index 015a15c5..2b0c7936 100644 --- a/.opencode/skills/impeccable/scripts/live-poll.mjs +++ b/.opencode/skills/impeccable/scripts/live-poll.mjs @@ -163,6 +163,7 @@ Options: type: completionType, message: event._acceptResult?.error, file: event._acceptResult?.file, + data: event._acceptResult?.carbonize === true ? { carbonize: true } : undefined, }); } catch (err) { event._completionAck = { ok: false, error: err.message }; diff --git a/.opencode/skills/impeccable/scripts/live-resume.mjs b/.opencode/skills/impeccable/scripts/live-resume.mjs index 1234329a..a3465c9b 100644 --- a/.opencode/skills/impeccable/scripts/live-resume.mjs +++ b/.opencode/skills/impeccable/scripts/live-resume.mjs @@ -33,9 +33,11 @@ export async function resumeCli() { const pending = snapshot.pendingEvent || null; const nextAction = pending ? `Run live-poll.mjs, handle ${pending.type} ${pending.id}, then acknowledge with live-poll.mjs --reply ${pending.id} done.` - : snapshot.phase === 'accept_requested' - ? `Run live-complete.mjs --id ${snapshot.id} after verifying the accepted variant is written.` - : `Inspect ${snapshot.id}; no pending agent event is currently queued.`; + : snapshot.phase === 'carbonize_required' + ? `Finish carbonize cleanup${snapshot.sourceFile ? ` in ${snapshot.sourceFile}` : ''}, then run live-complete.mjs --id ${snapshot.id}.` + : snapshot.phase === 'accept_requested' + ? `Run live-complete.mjs --id ${snapshot.id} after verifying the accepted variant is written.` + : `Inspect ${snapshot.id}; no pending agent event is currently queued.`; console.log(JSON.stringify({ active: true, snapshot, pendingEvent: pending, nextAction }, null, 2)); } diff --git a/.opencode/skills/impeccable/scripts/live-server.mjs b/.opencode/skills/impeccable/scripts/live-server.mjs index 8145e26f..e78a0657 100644 --- a/.opencode/skills/impeccable/scripts/live-server.mjs +++ b/.opencode/skills/impeccable/scripts/live-server.mjs @@ -649,7 +649,13 @@ function handlePollPost(req, res) { : msg.type === 'error' ? 'agent_error' : 'agent_done'; - state.sessionStore.appendEvent({ type: eventType, id: msg.id, file: msg.file, message: msg.message }); + state.sessionStore.appendEvent({ + type: eventType, + id: msg.id, + file: msg.file, + message: msg.message, + carbonize: msg.data?.carbonize === true, + }); } catch { /* keep reply path best-effort; browser still needs SSE */ } } flushPendingPolls(); diff --git a/.opencode/skills/impeccable/scripts/live-session-store.mjs b/.opencode/skills/impeccable/scripts/live-session-store.mjs index feaa56b2..31d748e4 100644 --- a/.opencode/skills/impeccable/scripts/live-session-store.mjs +++ b/.opencode/skills/impeccable/scripts/live-session-store.mjs @@ -159,11 +159,18 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { break; case 'variants_ready': case 'agent_done': - next.phase = 'variants_ready'; + next.phase = event.carbonize === true ? 'carbonize_required' : 'variants_ready'; next.sourceFile = event.file ?? next.sourceFile; next.arrivedVariants = event.arrivedVariants ?? (next.arrivedVariants ?? next.expectedVariants); next.pendingEventSeq = null; next.pendingEvent = null; + if (event.carbonize === true) { + next.diagnostics.push({ + error: 'carbonize_cleanup_required', + file: event.file || null, + message: 'Accepted variant still has carbonize markers that must be folded into source CSS.', + }); + } break; case 'checkpoint': if ((event.revision ?? 0) >= (next.checkpointRevision ?? 0)) { diff --git a/.pi/skills/impeccable/scripts/live-browser.js b/.pi/skills/impeccable/scripts/live-browser.js index 6c6adf05..72869490 100644 --- a/.pi/skills/impeccable/scripts/live-browser.js +++ b/.pi/skills/impeccable/scripts/live-browser.js @@ -2260,7 +2260,7 @@ state = currentSessionId ? 'GENERATING' : 'IDLE'; } - function sendEvent(msg) { + function sendEvent(msg, opts) { msg.token = TOKEN; return fetch('http://localhost:' + PORT + '/events', { method: 'POST', @@ -2268,7 +2268,8 @@ body: JSON.stringify(msg), }).catch(err => { console.error('[impeccable] Failed to send event:', err); - throw err; + if (opts && opts.throwOnError) throw err; + return null; }); } @@ -2976,7 +2977,7 @@ void main() { state = 'SAVING'; updateBarContent('saving'); - sendEvent(acceptPayload) + sendEvent(acceptPayload, { throwOnError: true }) .then(() => { markSessionHandled(); confirmAcceptAfterReceipt(); @@ -3029,7 +3030,7 @@ void main() { function handleDiscard() { if (!currentSessionId) return; - sendEvent({ type: 'discard', id: currentSessionId }) + sendEvent({ type: 'discard', id: currentSessionId }, { throwOnError: true }) .then(() => { markSessionHandled(); cleanup(); diff --git a/.pi/skills/impeccable/scripts/live-completion.mjs b/.pi/skills/impeccable/scripts/live-completion.mjs index 62dec8c3..cf9fb10f 100644 --- a/.pi/skills/impeccable/scripts/live-completion.mjs +++ b/.pi/skills/impeccable/scripts/live-completion.mjs @@ -1,5 +1,6 @@ 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'; return 'agent_done'; diff --git a/.pi/skills/impeccable/scripts/live-poll.mjs b/.pi/skills/impeccable/scripts/live-poll.mjs index 015a15c5..2b0c7936 100644 --- a/.pi/skills/impeccable/scripts/live-poll.mjs +++ b/.pi/skills/impeccable/scripts/live-poll.mjs @@ -163,6 +163,7 @@ Options: type: completionType, message: event._acceptResult?.error, file: event._acceptResult?.file, + data: event._acceptResult?.carbonize === true ? { carbonize: true } : undefined, }); } catch (err) { event._completionAck = { ok: false, error: err.message }; diff --git a/.pi/skills/impeccable/scripts/live-resume.mjs b/.pi/skills/impeccable/scripts/live-resume.mjs index 1234329a..a3465c9b 100644 --- a/.pi/skills/impeccable/scripts/live-resume.mjs +++ b/.pi/skills/impeccable/scripts/live-resume.mjs @@ -33,9 +33,11 @@ export async function resumeCli() { const pending = snapshot.pendingEvent || null; const nextAction = pending ? `Run live-poll.mjs, handle ${pending.type} ${pending.id}, then acknowledge with live-poll.mjs --reply ${pending.id} done.` - : snapshot.phase === 'accept_requested' - ? `Run live-complete.mjs --id ${snapshot.id} after verifying the accepted variant is written.` - : `Inspect ${snapshot.id}; no pending agent event is currently queued.`; + : snapshot.phase === 'carbonize_required' + ? `Finish carbonize cleanup${snapshot.sourceFile ? ` in ${snapshot.sourceFile}` : ''}, then run live-complete.mjs --id ${snapshot.id}.` + : snapshot.phase === 'accept_requested' + ? `Run live-complete.mjs --id ${snapshot.id} after verifying the accepted variant is written.` + : `Inspect ${snapshot.id}; no pending agent event is currently queued.`; console.log(JSON.stringify({ active: true, snapshot, pendingEvent: pending, nextAction }, null, 2)); } diff --git a/.pi/skills/impeccable/scripts/live-server.mjs b/.pi/skills/impeccable/scripts/live-server.mjs index 8145e26f..e78a0657 100644 --- a/.pi/skills/impeccable/scripts/live-server.mjs +++ b/.pi/skills/impeccable/scripts/live-server.mjs @@ -649,7 +649,13 @@ function handlePollPost(req, res) { : msg.type === 'error' ? 'agent_error' : 'agent_done'; - state.sessionStore.appendEvent({ type: eventType, id: msg.id, file: msg.file, message: msg.message }); + state.sessionStore.appendEvent({ + type: eventType, + id: msg.id, + file: msg.file, + message: msg.message, + carbonize: msg.data?.carbonize === true, + }); } catch { /* keep reply path best-effort; browser still needs SSE */ } } flushPendingPolls(); diff --git a/.pi/skills/impeccable/scripts/live-session-store.mjs b/.pi/skills/impeccable/scripts/live-session-store.mjs index feaa56b2..31d748e4 100644 --- a/.pi/skills/impeccable/scripts/live-session-store.mjs +++ b/.pi/skills/impeccable/scripts/live-session-store.mjs @@ -159,11 +159,18 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { break; case 'variants_ready': case 'agent_done': - next.phase = 'variants_ready'; + next.phase = event.carbonize === true ? 'carbonize_required' : 'variants_ready'; next.sourceFile = event.file ?? next.sourceFile; next.arrivedVariants = event.arrivedVariants ?? (next.arrivedVariants ?? next.expectedVariants); next.pendingEventSeq = null; next.pendingEvent = null; + if (event.carbonize === true) { + next.diagnostics.push({ + error: 'carbonize_cleanup_required', + file: event.file || null, + message: 'Accepted variant still has carbonize markers that must be folded into source CSS.', + }); + } break; case 'checkpoint': if ((event.revision ?? 0) >= (next.checkpointRevision ?? 0)) { diff --git a/.rovodev/skills/impeccable/scripts/live-browser.js b/.rovodev/skills/impeccable/scripts/live-browser.js index 6c6adf05..72869490 100644 --- a/.rovodev/skills/impeccable/scripts/live-browser.js +++ b/.rovodev/skills/impeccable/scripts/live-browser.js @@ -2260,7 +2260,7 @@ state = currentSessionId ? 'GENERATING' : 'IDLE'; } - function sendEvent(msg) { + function sendEvent(msg, opts) { msg.token = TOKEN; return fetch('http://localhost:' + PORT + '/events', { method: 'POST', @@ -2268,7 +2268,8 @@ body: JSON.stringify(msg), }).catch(err => { console.error('[impeccable] Failed to send event:', err); - throw err; + if (opts && opts.throwOnError) throw err; + return null; }); } @@ -2976,7 +2977,7 @@ void main() { state = 'SAVING'; updateBarContent('saving'); - sendEvent(acceptPayload) + sendEvent(acceptPayload, { throwOnError: true }) .then(() => { markSessionHandled(); confirmAcceptAfterReceipt(); @@ -3029,7 +3030,7 @@ void main() { function handleDiscard() { if (!currentSessionId) return; - sendEvent({ type: 'discard', id: currentSessionId }) + sendEvent({ type: 'discard', id: currentSessionId }, { throwOnError: true }) .then(() => { markSessionHandled(); cleanup(); diff --git a/.rovodev/skills/impeccable/scripts/live-completion.mjs b/.rovodev/skills/impeccable/scripts/live-completion.mjs index 62dec8c3..cf9fb10f 100644 --- a/.rovodev/skills/impeccable/scripts/live-completion.mjs +++ b/.rovodev/skills/impeccable/scripts/live-completion.mjs @@ -1,5 +1,6 @@ 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'; return 'agent_done'; diff --git a/.rovodev/skills/impeccable/scripts/live-poll.mjs b/.rovodev/skills/impeccable/scripts/live-poll.mjs index 015a15c5..2b0c7936 100644 --- a/.rovodev/skills/impeccable/scripts/live-poll.mjs +++ b/.rovodev/skills/impeccable/scripts/live-poll.mjs @@ -163,6 +163,7 @@ Options: type: completionType, message: event._acceptResult?.error, file: event._acceptResult?.file, + data: event._acceptResult?.carbonize === true ? { carbonize: true } : undefined, }); } catch (err) { event._completionAck = { ok: false, error: err.message }; diff --git a/.rovodev/skills/impeccable/scripts/live-resume.mjs b/.rovodev/skills/impeccable/scripts/live-resume.mjs index 1234329a..a3465c9b 100644 --- a/.rovodev/skills/impeccable/scripts/live-resume.mjs +++ b/.rovodev/skills/impeccable/scripts/live-resume.mjs @@ -33,9 +33,11 @@ export async function resumeCli() { const pending = snapshot.pendingEvent || null; const nextAction = pending ? `Run live-poll.mjs, handle ${pending.type} ${pending.id}, then acknowledge with live-poll.mjs --reply ${pending.id} done.` - : snapshot.phase === 'accept_requested' - ? `Run live-complete.mjs --id ${snapshot.id} after verifying the accepted variant is written.` - : `Inspect ${snapshot.id}; no pending agent event is currently queued.`; + : snapshot.phase === 'carbonize_required' + ? `Finish carbonize cleanup${snapshot.sourceFile ? ` in ${snapshot.sourceFile}` : ''}, then run live-complete.mjs --id ${snapshot.id}.` + : snapshot.phase === 'accept_requested' + ? `Run live-complete.mjs --id ${snapshot.id} after verifying the accepted variant is written.` + : `Inspect ${snapshot.id}; no pending agent event is currently queued.`; console.log(JSON.stringify({ active: true, snapshot, pendingEvent: pending, nextAction }, null, 2)); } diff --git a/.rovodev/skills/impeccable/scripts/live-server.mjs b/.rovodev/skills/impeccable/scripts/live-server.mjs index 8145e26f..e78a0657 100644 --- a/.rovodev/skills/impeccable/scripts/live-server.mjs +++ b/.rovodev/skills/impeccable/scripts/live-server.mjs @@ -649,7 +649,13 @@ function handlePollPost(req, res) { : msg.type === 'error' ? 'agent_error' : 'agent_done'; - state.sessionStore.appendEvent({ type: eventType, id: msg.id, file: msg.file, message: msg.message }); + state.sessionStore.appendEvent({ + type: eventType, + id: msg.id, + file: msg.file, + message: msg.message, + carbonize: msg.data?.carbonize === true, + }); } catch { /* keep reply path best-effort; browser still needs SSE */ } } flushPendingPolls(); diff --git a/.rovodev/skills/impeccable/scripts/live-session-store.mjs b/.rovodev/skills/impeccable/scripts/live-session-store.mjs index feaa56b2..31d748e4 100644 --- a/.rovodev/skills/impeccable/scripts/live-session-store.mjs +++ b/.rovodev/skills/impeccable/scripts/live-session-store.mjs @@ -159,11 +159,18 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { break; case 'variants_ready': case 'agent_done': - next.phase = 'variants_ready'; + next.phase = event.carbonize === true ? 'carbonize_required' : 'variants_ready'; next.sourceFile = event.file ?? next.sourceFile; next.arrivedVariants = event.arrivedVariants ?? (next.arrivedVariants ?? next.expectedVariants); next.pendingEventSeq = null; next.pendingEvent = null; + if (event.carbonize === true) { + next.diagnostics.push({ + error: 'carbonize_cleanup_required', + file: event.file || null, + message: 'Accepted variant still has carbonize markers that must be folded into source CSS.', + }); + } break; case 'checkpoint': if ((event.revision ?? 0) >= (next.checkpointRevision ?? 0)) { diff --git a/.trae-cn/skills/impeccable/scripts/live-browser.js b/.trae-cn/skills/impeccable/scripts/live-browser.js index 6c6adf05..72869490 100644 --- a/.trae-cn/skills/impeccable/scripts/live-browser.js +++ b/.trae-cn/skills/impeccable/scripts/live-browser.js @@ -2260,7 +2260,7 @@ state = currentSessionId ? 'GENERATING' : 'IDLE'; } - function sendEvent(msg) { + function sendEvent(msg, opts) { msg.token = TOKEN; return fetch('http://localhost:' + PORT + '/events', { method: 'POST', @@ -2268,7 +2268,8 @@ body: JSON.stringify(msg), }).catch(err => { console.error('[impeccable] Failed to send event:', err); - throw err; + if (opts && opts.throwOnError) throw err; + return null; }); } @@ -2976,7 +2977,7 @@ void main() { state = 'SAVING'; updateBarContent('saving'); - sendEvent(acceptPayload) + sendEvent(acceptPayload, { throwOnError: true }) .then(() => { markSessionHandled(); confirmAcceptAfterReceipt(); @@ -3029,7 +3030,7 @@ void main() { function handleDiscard() { if (!currentSessionId) return; - sendEvent({ type: 'discard', id: currentSessionId }) + sendEvent({ type: 'discard', id: currentSessionId }, { throwOnError: true }) .then(() => { markSessionHandled(); cleanup(); diff --git a/.trae-cn/skills/impeccable/scripts/live-completion.mjs b/.trae-cn/skills/impeccable/scripts/live-completion.mjs index 62dec8c3..cf9fb10f 100644 --- a/.trae-cn/skills/impeccable/scripts/live-completion.mjs +++ b/.trae-cn/skills/impeccable/scripts/live-completion.mjs @@ -1,5 +1,6 @@ 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'; return 'agent_done'; diff --git a/.trae-cn/skills/impeccable/scripts/live-poll.mjs b/.trae-cn/skills/impeccable/scripts/live-poll.mjs index 015a15c5..2b0c7936 100644 --- a/.trae-cn/skills/impeccable/scripts/live-poll.mjs +++ b/.trae-cn/skills/impeccable/scripts/live-poll.mjs @@ -163,6 +163,7 @@ Options: type: completionType, message: event._acceptResult?.error, file: event._acceptResult?.file, + data: event._acceptResult?.carbonize === true ? { carbonize: true } : undefined, }); } catch (err) { event._completionAck = { ok: false, error: err.message }; diff --git a/.trae-cn/skills/impeccable/scripts/live-resume.mjs b/.trae-cn/skills/impeccable/scripts/live-resume.mjs index 1234329a..a3465c9b 100644 --- a/.trae-cn/skills/impeccable/scripts/live-resume.mjs +++ b/.trae-cn/skills/impeccable/scripts/live-resume.mjs @@ -33,9 +33,11 @@ export async function resumeCli() { const pending = snapshot.pendingEvent || null; const nextAction = pending ? `Run live-poll.mjs, handle ${pending.type} ${pending.id}, then acknowledge with live-poll.mjs --reply ${pending.id} done.` - : snapshot.phase === 'accept_requested' - ? `Run live-complete.mjs --id ${snapshot.id} after verifying the accepted variant is written.` - : `Inspect ${snapshot.id}; no pending agent event is currently queued.`; + : snapshot.phase === 'carbonize_required' + ? `Finish carbonize cleanup${snapshot.sourceFile ? ` in ${snapshot.sourceFile}` : ''}, then run live-complete.mjs --id ${snapshot.id}.` + : snapshot.phase === 'accept_requested' + ? `Run live-complete.mjs --id ${snapshot.id} after verifying the accepted variant is written.` + : `Inspect ${snapshot.id}; no pending agent event is currently queued.`; console.log(JSON.stringify({ active: true, snapshot, pendingEvent: pending, nextAction }, null, 2)); } diff --git a/.trae-cn/skills/impeccable/scripts/live-server.mjs b/.trae-cn/skills/impeccable/scripts/live-server.mjs index 8145e26f..e78a0657 100644 --- a/.trae-cn/skills/impeccable/scripts/live-server.mjs +++ b/.trae-cn/skills/impeccable/scripts/live-server.mjs @@ -649,7 +649,13 @@ function handlePollPost(req, res) { : msg.type === 'error' ? 'agent_error' : 'agent_done'; - state.sessionStore.appendEvent({ type: eventType, id: msg.id, file: msg.file, message: msg.message }); + state.sessionStore.appendEvent({ + type: eventType, + id: msg.id, + file: msg.file, + message: msg.message, + carbonize: msg.data?.carbonize === true, + }); } catch { /* keep reply path best-effort; browser still needs SSE */ } } flushPendingPolls(); diff --git a/.trae-cn/skills/impeccable/scripts/live-session-store.mjs b/.trae-cn/skills/impeccable/scripts/live-session-store.mjs index feaa56b2..31d748e4 100644 --- a/.trae-cn/skills/impeccable/scripts/live-session-store.mjs +++ b/.trae-cn/skills/impeccable/scripts/live-session-store.mjs @@ -159,11 +159,18 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { break; case 'variants_ready': case 'agent_done': - next.phase = 'variants_ready'; + next.phase = event.carbonize === true ? 'carbonize_required' : 'variants_ready'; next.sourceFile = event.file ?? next.sourceFile; next.arrivedVariants = event.arrivedVariants ?? (next.arrivedVariants ?? next.expectedVariants); next.pendingEventSeq = null; next.pendingEvent = null; + if (event.carbonize === true) { + next.diagnostics.push({ + error: 'carbonize_cleanup_required', + file: event.file || null, + message: 'Accepted variant still has carbonize markers that must be folded into source CSS.', + }); + } break; case 'checkpoint': if ((event.revision ?? 0) >= (next.checkpointRevision ?? 0)) { diff --git a/.trae/skills/impeccable/scripts/live-browser.js b/.trae/skills/impeccable/scripts/live-browser.js index 6c6adf05..72869490 100644 --- a/.trae/skills/impeccable/scripts/live-browser.js +++ b/.trae/skills/impeccable/scripts/live-browser.js @@ -2260,7 +2260,7 @@ state = currentSessionId ? 'GENERATING' : 'IDLE'; } - function sendEvent(msg) { + function sendEvent(msg, opts) { msg.token = TOKEN; return fetch('http://localhost:' + PORT + '/events', { method: 'POST', @@ -2268,7 +2268,8 @@ body: JSON.stringify(msg), }).catch(err => { console.error('[impeccable] Failed to send event:', err); - throw err; + if (opts && opts.throwOnError) throw err; + return null; }); } @@ -2976,7 +2977,7 @@ void main() { state = 'SAVING'; updateBarContent('saving'); - sendEvent(acceptPayload) + sendEvent(acceptPayload, { throwOnError: true }) .then(() => { markSessionHandled(); confirmAcceptAfterReceipt(); @@ -3029,7 +3030,7 @@ void main() { function handleDiscard() { if (!currentSessionId) return; - sendEvent({ type: 'discard', id: currentSessionId }) + sendEvent({ type: 'discard', id: currentSessionId }, { throwOnError: true }) .then(() => { markSessionHandled(); cleanup(); diff --git a/.trae/skills/impeccable/scripts/live-completion.mjs b/.trae/skills/impeccable/scripts/live-completion.mjs index 62dec8c3..cf9fb10f 100644 --- a/.trae/skills/impeccable/scripts/live-completion.mjs +++ b/.trae/skills/impeccable/scripts/live-completion.mjs @@ -1,5 +1,6 @@ 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'; return 'agent_done'; diff --git a/.trae/skills/impeccable/scripts/live-poll.mjs b/.trae/skills/impeccable/scripts/live-poll.mjs index 015a15c5..2b0c7936 100644 --- a/.trae/skills/impeccable/scripts/live-poll.mjs +++ b/.trae/skills/impeccable/scripts/live-poll.mjs @@ -163,6 +163,7 @@ Options: type: completionType, message: event._acceptResult?.error, file: event._acceptResult?.file, + data: event._acceptResult?.carbonize === true ? { carbonize: true } : undefined, }); } catch (err) { event._completionAck = { ok: false, error: err.message }; diff --git a/.trae/skills/impeccable/scripts/live-resume.mjs b/.trae/skills/impeccable/scripts/live-resume.mjs index 1234329a..a3465c9b 100644 --- a/.trae/skills/impeccable/scripts/live-resume.mjs +++ b/.trae/skills/impeccable/scripts/live-resume.mjs @@ -33,9 +33,11 @@ export async function resumeCli() { const pending = snapshot.pendingEvent || null; const nextAction = pending ? `Run live-poll.mjs, handle ${pending.type} ${pending.id}, then acknowledge with live-poll.mjs --reply ${pending.id} done.` - : snapshot.phase === 'accept_requested' - ? `Run live-complete.mjs --id ${snapshot.id} after verifying the accepted variant is written.` - : `Inspect ${snapshot.id}; no pending agent event is currently queued.`; + : snapshot.phase === 'carbonize_required' + ? `Finish carbonize cleanup${snapshot.sourceFile ? ` in ${snapshot.sourceFile}` : ''}, then run live-complete.mjs --id ${snapshot.id}.` + : snapshot.phase === 'accept_requested' + ? `Run live-complete.mjs --id ${snapshot.id} after verifying the accepted variant is written.` + : `Inspect ${snapshot.id}; no pending agent event is currently queued.`; console.log(JSON.stringify({ active: true, snapshot, pendingEvent: pending, nextAction }, null, 2)); } diff --git a/.trae/skills/impeccable/scripts/live-server.mjs b/.trae/skills/impeccable/scripts/live-server.mjs index 8145e26f..e78a0657 100644 --- a/.trae/skills/impeccable/scripts/live-server.mjs +++ b/.trae/skills/impeccable/scripts/live-server.mjs @@ -649,7 +649,13 @@ function handlePollPost(req, res) { : msg.type === 'error' ? 'agent_error' : 'agent_done'; - state.sessionStore.appendEvent({ type: eventType, id: msg.id, file: msg.file, message: msg.message }); + state.sessionStore.appendEvent({ + type: eventType, + id: msg.id, + file: msg.file, + message: msg.message, + carbonize: msg.data?.carbonize === true, + }); } catch { /* keep reply path best-effort; browser still needs SSE */ } } flushPendingPolls(); diff --git a/.trae/skills/impeccable/scripts/live-session-store.mjs b/.trae/skills/impeccable/scripts/live-session-store.mjs index feaa56b2..31d748e4 100644 --- a/.trae/skills/impeccable/scripts/live-session-store.mjs +++ b/.trae/skills/impeccable/scripts/live-session-store.mjs @@ -159,11 +159,18 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { break; case 'variants_ready': case 'agent_done': - next.phase = 'variants_ready'; + next.phase = event.carbonize === true ? 'carbonize_required' : 'variants_ready'; next.sourceFile = event.file ?? next.sourceFile; next.arrivedVariants = event.arrivedVariants ?? (next.arrivedVariants ?? next.expectedVariants); next.pendingEventSeq = null; next.pendingEvent = null; + if (event.carbonize === true) { + next.diagnostics.push({ + error: 'carbonize_cleanup_required', + file: event.file || null, + message: 'Accepted variant still has carbonize markers that must be folded into source CSS.', + }); + } break; case 'checkpoint': if ((event.revision ?? 0) >= (next.checkpointRevision ?? 0)) { diff --git a/package.json b/package.json index 28b78edf..a3b357d7 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ "dev": "npx astro dev", "preview": "bun run build && npx astro preview", "deploy": "bun run build && wrangler pages deploy build/", - "test": "bun test tests/build.test.js tests/detect-antipatterns.test.js tests/windows-path-fix.test.js && node --test tests/detect-antipatterns-fixtures.test.mjs && node --test tests/detect-antipatterns-browser.test.mjs && node --test tests/cleanup-deprecated.test.mjs && node --test tests/live-wrap.test.mjs && node --test tests/live-accept.test.mjs && node --test tests/live-inject.test.mjs && node --test tests/live-server.test.mjs && node --test tests/live-browser-regression.test.mjs && node --test tests/live-session-store.test.mjs && node --test tests/live-browser-session.test.mjs && node --test tests/live-completion.test.mjs && node --test tests/live-recovery-commands.test.mjs && node --test tests/framework-fixtures.test.mjs", + "test": "bun test tests/build.test.js tests/detect-antipatterns.test.js tests/windows-path-fix.test.js && node --test tests/detect-antipatterns-fixtures.test.mjs && node --test tests/detect-antipatterns-browser.test.mjs && node --test tests/cleanup-deprecated.test.mjs && node --test tests/live-wrap.test.mjs && node --test tests/live-accept.test.mjs && node --test tests/live-inject.test.mjs && node --test tests/live-server.test.mjs && node --test tests/live-browser-regression.test.mjs && node --test tests/live-session-store.test.mjs && node --test tests/live-browser-session.test.mjs && node --test tests/live-browser-source.test.mjs && node --test tests/live-completion.test.mjs && node --test tests/live-recovery-commands.test.mjs && node --test tests/framework-fixtures.test.mjs", "test:live-e2e": "node --test --test-timeout=600000 tests/live-e2e.test.mjs", "audit": "bun audit --audit-level=moderate", "prepack": "cp README.md README.repo.md && cp README.npm.md README.md", diff --git a/plugin/skills/impeccable/scripts/live-browser.js b/plugin/skills/impeccable/scripts/live-browser.js index 6c6adf05..72869490 100644 --- a/plugin/skills/impeccable/scripts/live-browser.js +++ b/plugin/skills/impeccable/scripts/live-browser.js @@ -2260,7 +2260,7 @@ state = currentSessionId ? 'GENERATING' : 'IDLE'; } - function sendEvent(msg) { + function sendEvent(msg, opts) { msg.token = TOKEN; return fetch('http://localhost:' + PORT + '/events', { method: 'POST', @@ -2268,7 +2268,8 @@ body: JSON.stringify(msg), }).catch(err => { console.error('[impeccable] Failed to send event:', err); - throw err; + if (opts && opts.throwOnError) throw err; + return null; }); } @@ -2976,7 +2977,7 @@ void main() { state = 'SAVING'; updateBarContent('saving'); - sendEvent(acceptPayload) + sendEvent(acceptPayload, { throwOnError: true }) .then(() => { markSessionHandled(); confirmAcceptAfterReceipt(); @@ -3029,7 +3030,7 @@ void main() { function handleDiscard() { if (!currentSessionId) return; - sendEvent({ type: 'discard', id: currentSessionId }) + sendEvent({ type: 'discard', id: currentSessionId }, { throwOnError: true }) .then(() => { markSessionHandled(); cleanup(); diff --git a/plugin/skills/impeccable/scripts/live-completion.mjs b/plugin/skills/impeccable/scripts/live-completion.mjs index 62dec8c3..cf9fb10f 100644 --- a/plugin/skills/impeccable/scripts/live-completion.mjs +++ b/plugin/skills/impeccable/scripts/live-completion.mjs @@ -1,5 +1,6 @@ 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'; return 'agent_done'; diff --git a/plugin/skills/impeccable/scripts/live-poll.mjs b/plugin/skills/impeccable/scripts/live-poll.mjs index 015a15c5..2b0c7936 100644 --- a/plugin/skills/impeccable/scripts/live-poll.mjs +++ b/plugin/skills/impeccable/scripts/live-poll.mjs @@ -163,6 +163,7 @@ Options: type: completionType, message: event._acceptResult?.error, file: event._acceptResult?.file, + data: event._acceptResult?.carbonize === true ? { carbonize: true } : undefined, }); } catch (err) { event._completionAck = { ok: false, error: err.message }; diff --git a/plugin/skills/impeccable/scripts/live-resume.mjs b/plugin/skills/impeccable/scripts/live-resume.mjs index 1234329a..a3465c9b 100644 --- a/plugin/skills/impeccable/scripts/live-resume.mjs +++ b/plugin/skills/impeccable/scripts/live-resume.mjs @@ -33,9 +33,11 @@ export async function resumeCli() { const pending = snapshot.pendingEvent || null; const nextAction = pending ? `Run live-poll.mjs, handle ${pending.type} ${pending.id}, then acknowledge with live-poll.mjs --reply ${pending.id} done.` - : snapshot.phase === 'accept_requested' - ? `Run live-complete.mjs --id ${snapshot.id} after verifying the accepted variant is written.` - : `Inspect ${snapshot.id}; no pending agent event is currently queued.`; + : snapshot.phase === 'carbonize_required' + ? `Finish carbonize cleanup${snapshot.sourceFile ? ` in ${snapshot.sourceFile}` : ''}, then run live-complete.mjs --id ${snapshot.id}.` + : snapshot.phase === 'accept_requested' + ? `Run live-complete.mjs --id ${snapshot.id} after verifying the accepted variant is written.` + : `Inspect ${snapshot.id}; no pending agent event is currently queued.`; console.log(JSON.stringify({ active: true, snapshot, pendingEvent: pending, nextAction }, null, 2)); } diff --git a/plugin/skills/impeccable/scripts/live-server.mjs b/plugin/skills/impeccable/scripts/live-server.mjs index 8145e26f..e78a0657 100644 --- a/plugin/skills/impeccable/scripts/live-server.mjs +++ b/plugin/skills/impeccable/scripts/live-server.mjs @@ -649,7 +649,13 @@ function handlePollPost(req, res) { : msg.type === 'error' ? 'agent_error' : 'agent_done'; - state.sessionStore.appendEvent({ type: eventType, id: msg.id, file: msg.file, message: msg.message }); + state.sessionStore.appendEvent({ + type: eventType, + id: msg.id, + file: msg.file, + message: msg.message, + carbonize: msg.data?.carbonize === true, + }); } catch { /* keep reply path best-effort; browser still needs SSE */ } } flushPendingPolls(); diff --git a/plugin/skills/impeccable/scripts/live-session-store.mjs b/plugin/skills/impeccable/scripts/live-session-store.mjs index feaa56b2..31d748e4 100644 --- a/plugin/skills/impeccable/scripts/live-session-store.mjs +++ b/plugin/skills/impeccable/scripts/live-session-store.mjs @@ -159,11 +159,18 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { break; case 'variants_ready': case 'agent_done': - next.phase = 'variants_ready'; + next.phase = event.carbonize === true ? 'carbonize_required' : 'variants_ready'; next.sourceFile = event.file ?? next.sourceFile; next.arrivedVariants = event.arrivedVariants ?? (next.arrivedVariants ?? next.expectedVariants); next.pendingEventSeq = null; next.pendingEvent = null; + if (event.carbonize === true) { + next.diagnostics.push({ + error: 'carbonize_cleanup_required', + file: event.file || null, + message: 'Accepted variant still has carbonize markers that must be folded into source CSS.', + }); + } break; case 'checkpoint': if ((event.revision ?? 0) >= (next.checkpointRevision ?? 0)) { diff --git a/source/skills/impeccable/scripts/live-browser.js b/source/skills/impeccable/scripts/live-browser.js index 6c6adf05..72869490 100644 --- a/source/skills/impeccable/scripts/live-browser.js +++ b/source/skills/impeccable/scripts/live-browser.js @@ -2260,7 +2260,7 @@ state = currentSessionId ? 'GENERATING' : 'IDLE'; } - function sendEvent(msg) { + function sendEvent(msg, opts) { msg.token = TOKEN; return fetch('http://localhost:' + PORT + '/events', { method: 'POST', @@ -2268,7 +2268,8 @@ body: JSON.stringify(msg), }).catch(err => { console.error('[impeccable] Failed to send event:', err); - throw err; + if (opts && opts.throwOnError) throw err; + return null; }); } @@ -2976,7 +2977,7 @@ void main() { state = 'SAVING'; updateBarContent('saving'); - sendEvent(acceptPayload) + sendEvent(acceptPayload, { throwOnError: true }) .then(() => { markSessionHandled(); confirmAcceptAfterReceipt(); @@ -3029,7 +3030,7 @@ void main() { function handleDiscard() { if (!currentSessionId) return; - sendEvent({ type: 'discard', id: currentSessionId }) + sendEvent({ type: 'discard', id: currentSessionId }, { throwOnError: true }) .then(() => { markSessionHandled(); cleanup(); diff --git a/source/skills/impeccable/scripts/live-completion.mjs b/source/skills/impeccable/scripts/live-completion.mjs index 62dec8c3..cf9fb10f 100644 --- a/source/skills/impeccable/scripts/live-completion.mjs +++ b/source/skills/impeccable/scripts/live-completion.mjs @@ -1,5 +1,6 @@ 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'; return 'agent_done'; diff --git a/source/skills/impeccable/scripts/live-poll.mjs b/source/skills/impeccable/scripts/live-poll.mjs index 015a15c5..2b0c7936 100644 --- a/source/skills/impeccable/scripts/live-poll.mjs +++ b/source/skills/impeccable/scripts/live-poll.mjs @@ -163,6 +163,7 @@ Options: type: completionType, message: event._acceptResult?.error, file: event._acceptResult?.file, + data: event._acceptResult?.carbonize === true ? { carbonize: true } : undefined, }); } catch (err) { event._completionAck = { ok: false, error: err.message }; diff --git a/source/skills/impeccable/scripts/live-resume.mjs b/source/skills/impeccable/scripts/live-resume.mjs index 1234329a..a3465c9b 100644 --- a/source/skills/impeccable/scripts/live-resume.mjs +++ b/source/skills/impeccable/scripts/live-resume.mjs @@ -33,9 +33,11 @@ export async function resumeCli() { const pending = snapshot.pendingEvent || null; const nextAction = pending ? `Run live-poll.mjs, handle ${pending.type} ${pending.id}, then acknowledge with live-poll.mjs --reply ${pending.id} done.` - : snapshot.phase === 'accept_requested' - ? `Run live-complete.mjs --id ${snapshot.id} after verifying the accepted variant is written.` - : `Inspect ${snapshot.id}; no pending agent event is currently queued.`; + : snapshot.phase === 'carbonize_required' + ? `Finish carbonize cleanup${snapshot.sourceFile ? ` in ${snapshot.sourceFile}` : ''}, then run live-complete.mjs --id ${snapshot.id}.` + : snapshot.phase === 'accept_requested' + ? `Run live-complete.mjs --id ${snapshot.id} after verifying the accepted variant is written.` + : `Inspect ${snapshot.id}; no pending agent event is currently queued.`; console.log(JSON.stringify({ active: true, snapshot, pendingEvent: pending, nextAction }, null, 2)); } diff --git a/source/skills/impeccable/scripts/live-server.mjs b/source/skills/impeccable/scripts/live-server.mjs index 8145e26f..e78a0657 100644 --- a/source/skills/impeccable/scripts/live-server.mjs +++ b/source/skills/impeccable/scripts/live-server.mjs @@ -649,7 +649,13 @@ function handlePollPost(req, res) { : msg.type === 'error' ? 'agent_error' : 'agent_done'; - state.sessionStore.appendEvent({ type: eventType, id: msg.id, file: msg.file, message: msg.message }); + state.sessionStore.appendEvent({ + type: eventType, + id: msg.id, + file: msg.file, + message: msg.message, + carbonize: msg.data?.carbonize === true, + }); } catch { /* keep reply path best-effort; browser still needs SSE */ } } flushPendingPolls(); diff --git a/source/skills/impeccable/scripts/live-session-store.mjs b/source/skills/impeccable/scripts/live-session-store.mjs index feaa56b2..31d748e4 100644 --- a/source/skills/impeccable/scripts/live-session-store.mjs +++ b/source/skills/impeccable/scripts/live-session-store.mjs @@ -159,11 +159,18 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { break; case 'variants_ready': case 'agent_done': - next.phase = 'variants_ready'; + next.phase = event.carbonize === true ? 'carbonize_required' : 'variants_ready'; next.sourceFile = event.file ?? next.sourceFile; next.arrivedVariants = event.arrivedVariants ?? (next.arrivedVariants ?? next.expectedVariants); next.pendingEventSeq = null; next.pendingEvent = null; + if (event.carbonize === true) { + next.diagnostics.push({ + error: 'carbonize_cleanup_required', + file: event.file || null, + message: 'Accepted variant still has carbonize markers that must be folded into source CSS.', + }); + } break; case 'checkpoint': if ((event.revision ?? 0) >= (next.checkpointRevision ?? 0)) { diff --git a/tests/live-browser-source.test.mjs b/tests/live-browser-source.test.mjs new file mode 100644 index 00000000..1e9ad7c4 --- /dev/null +++ b/tests/live-browser-source.test.mjs @@ -0,0 +1,18 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +const SOURCE = readFileSync(join(process.cwd(), 'source/skills/impeccable/scripts/live-browser.js'), 'utf-8'); + +describe('live-browser source contracts', () => { + it('keeps sendEvent fire-and-forget by default while accept/discard opt into rejection', () => { + assert.match( + SOURCE, + /function sendEvent\(msg, opts\)[\s\S]*if \(opts && opts\.throwOnError\) throw err;[\s\S]*return null;/, + 'event=live_browser.send_event_contract actor=browser operation=send_event_failure risk=fire_and_forget_callers_get_unhandled_rejections expected=default swallow with opt-in throw actual=missing', + ); + assert.match(SOURCE, /sendEvent\(acceptPayload, \{ throwOnError: true \}\)/); + assert.match(SOURCE, /sendEvent\(\{ type: 'discard', id: currentSessionId \}, \{ throwOnError: true \}\)/); + }); +}); diff --git a/tests/live-completion.test.mjs b/tests/live-completion.test.mjs index 14ffdd9a..59f474b7 100644 --- a/tests/live-completion.test.mjs +++ b/tests/live-completion.test.mjs @@ -20,6 +20,14 @@ describe('live completion type classification', () => { ); }); + it('keeps carbonize-required accepts recoverable until cleanup is completed', () => { + assert.equal( + completionTypeForAcceptResult('accept', { handled: true, carbonize: true }), + 'agent_done', + 'event=live_poll.carbonize_completion actor=agent operation=accept_with_carbonize risk=carbonize_session_marked_completed_before_cleanup expected=agent_done actual=complete', + ); + }); + it('classifies handled accept/discard and real failures explicitly', () => { assert.equal(completionTypeForAcceptResult('accept', { handled: true }), 'complete'); assert.equal(completionTypeForAcceptResult('discard', { handled: true }), 'discarded'); diff --git a/tests/live-recovery-commands.test.mjs b/tests/live-recovery-commands.test.mjs index 1d8eef83..dd4749ec 100644 --- a/tests/live-recovery-commands.test.mjs +++ b/tests/live-recovery-commands.test.mjs @@ -48,6 +48,21 @@ describe('live recovery CLI commands', () => { ); })); + it('resumes carbonize-required sessions with a cleanup-specific next action', () => withTempProject((cwd) => { + const store = createLiveSessionStore({ cwd }); + store.appendEvent({ type: 'accept', id: 'cli-carbonize-1', variantId: '1' }); + store.appendEvent({ type: 'agent_done', id: 'cli-carbonize-1', file: 'src/App.jsx', carbonize: true }); + + const resume = runJson(RESUME_SCRIPT, ['--id', 'cli-carbonize-1'], cwd); + assert.equal(resume.active, true); + assert.equal(resume.snapshot.phase, 'carbonize_required'); + assert.match( + resume.nextAction, + /Finish carbonize cleanup in src\/App\.jsx/, + 'event=live_resume.carbonize_next_action actor=agent operation=recover_carbonize risk=carbonize_cleanup_hidden_after_accept expected=cleanup-specific action actual=' + resume.nextAction, + ); + })); + it('marks a session completed through the canonical completion command', () => withTempProject((cwd) => { const store = createLiveSessionStore({ cwd }); store.appendEvent({ type: 'generate', id: 'cli-recover-3', action: 'impeccable', count: 1, pageUrl: '/', element: { outerHTML: '

Copy

' } }); diff --git a/tests/live-session-store.test.mjs b/tests/live-session-store.test.mjs index cffa9e16..1deebfb7 100644 --- a/tests/live-session-store.test.mjs +++ b/tests/live-session-store.test.mjs @@ -126,6 +126,27 @@ describe('live-session-store', () => { ); }); + it('keeps carbonize-required accepted sessions active until explicit completion', () => { + const store = createLiveSessionStore({ cwd: tmp, sessionId: 'carbonize-session' }); + store.appendEvent({ + type: 'accept', + id: 'carbonize-session', + variantId: '2', + paramValues: { tone: 'sharp' }, + }); + store.appendEvent({ type: 'agent_done', id: 'carbonize-session', file: 'src/App.jsx', carbonize: true }); + + const snapshot = store.getSnapshot('carbonize-session'); + assert.equal( + snapshot.phase, + 'carbonize_required', + 'event=live_session_store.carbonize_required actor=agent operation=accept_ack risk=carbonize_session_hidden_from_recovery expected=carbonize_required actual=' + snapshot.phase, + ); + assert.equal(snapshot.sourceFile, 'src/App.jsx'); + assert.equal(snapshot.pendingEvent, null); + assert.equal(store.listActiveSessions().some((s) => s.id === 'carbonize-session'), true); + }); + it('clears pending events when an agent error is acknowledged', () => { const store = createLiveSessionStore({ cwd: tmp, sessionId: 'error-session' }); store.appendEvent({ From 587029d31f67d11e6b3581b61b16d8a48015d38e Mon Sep 17 00:00:00 2001 From: NQH Date: Wed, 29 Apr 2026 01:23:07 -0500 Subject: [PATCH 05/13] fix(live): preserve poll reply metadata --- .../skills/impeccable/scripts/live-poll.mjs | 8 +++++-- .../skills/impeccable/scripts/live-poll.mjs | 8 +++++-- .../skills/impeccable/scripts/live-poll.mjs | 8 +++++-- .../skills/impeccable/scripts/live-poll.mjs | 8 +++++-- .../skills/impeccable/scripts/live-poll.mjs | 8 +++++-- .kiro/skills/impeccable/scripts/live-poll.mjs | 8 +++++-- .../skills/impeccable/scripts/live-poll.mjs | 8 +++++-- .pi/skills/impeccable/scripts/live-poll.mjs | 8 +++++-- .../skills/impeccable/scripts/live-poll.mjs | 8 +++++-- .../skills/impeccable/scripts/live-poll.mjs | 8 +++++-- .trae/skills/impeccable/scripts/live-poll.mjs | 8 +++++-- package.json | 2 +- .../skills/impeccable/scripts/live-poll.mjs | 8 +++++-- .../skills/impeccable/scripts/live-poll.mjs | 8 +++++-- tests/live-poll.test.mjs | 21 +++++++++++++++++++ 15 files changed, 100 insertions(+), 27 deletions(-) create mode 100644 tests/live-poll.test.mjs diff --git a/.agents/skills/impeccable/scripts/live-poll.mjs b/.agents/skills/impeccable/scripts/live-poll.mjs index 2b0c7936..9a3f07ae 100644 --- a/.agents/skills/impeccable/scripts/live-poll.mjs +++ b/.agents/skills/impeccable/scripts/live-poll.mjs @@ -32,11 +32,15 @@ function readServerInfo() { } } -async function postReply(base, token, { id, type, message, file }) { +export function buildPollReplyPayload(token, { id, type, message, file, data }) { + return { token, id, type, message, file, data }; +} + +async function postReply(base, token, reply) { const res = await fetch(`${base}/poll`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ token, id, type, message, file }), + body: JSON.stringify(buildPollReplyPayload(token, reply)), }); if (!res.ok) { const body = await res.json().catch(() => ({})); diff --git a/.claude/skills/impeccable/scripts/live-poll.mjs b/.claude/skills/impeccable/scripts/live-poll.mjs index 2b0c7936..9a3f07ae 100644 --- a/.claude/skills/impeccable/scripts/live-poll.mjs +++ b/.claude/skills/impeccable/scripts/live-poll.mjs @@ -32,11 +32,15 @@ function readServerInfo() { } } -async function postReply(base, token, { id, type, message, file }) { +export function buildPollReplyPayload(token, { id, type, message, file, data }) { + return { token, id, type, message, file, data }; +} + +async function postReply(base, token, reply) { const res = await fetch(`${base}/poll`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ token, id, type, message, file }), + body: JSON.stringify(buildPollReplyPayload(token, reply)), }); if (!res.ok) { const body = await res.json().catch(() => ({})); diff --git a/.cursor/skills/impeccable/scripts/live-poll.mjs b/.cursor/skills/impeccable/scripts/live-poll.mjs index 2b0c7936..9a3f07ae 100644 --- a/.cursor/skills/impeccable/scripts/live-poll.mjs +++ b/.cursor/skills/impeccable/scripts/live-poll.mjs @@ -32,11 +32,15 @@ function readServerInfo() { } } -async function postReply(base, token, { id, type, message, file }) { +export function buildPollReplyPayload(token, { id, type, message, file, data }) { + return { token, id, type, message, file, data }; +} + +async function postReply(base, token, reply) { const res = await fetch(`${base}/poll`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ token, id, type, message, file }), + body: JSON.stringify(buildPollReplyPayload(token, reply)), }); if (!res.ok) { const body = await res.json().catch(() => ({})); diff --git a/.gemini/skills/impeccable/scripts/live-poll.mjs b/.gemini/skills/impeccable/scripts/live-poll.mjs index 2b0c7936..9a3f07ae 100644 --- a/.gemini/skills/impeccable/scripts/live-poll.mjs +++ b/.gemini/skills/impeccable/scripts/live-poll.mjs @@ -32,11 +32,15 @@ function readServerInfo() { } } -async function postReply(base, token, { id, type, message, file }) { +export function buildPollReplyPayload(token, { id, type, message, file, data }) { + return { token, id, type, message, file, data }; +} + +async function postReply(base, token, reply) { const res = await fetch(`${base}/poll`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ token, id, type, message, file }), + body: JSON.stringify(buildPollReplyPayload(token, reply)), }); if (!res.ok) { const body = await res.json().catch(() => ({})); diff --git a/.github/skills/impeccable/scripts/live-poll.mjs b/.github/skills/impeccable/scripts/live-poll.mjs index 2b0c7936..9a3f07ae 100644 --- a/.github/skills/impeccable/scripts/live-poll.mjs +++ b/.github/skills/impeccable/scripts/live-poll.mjs @@ -32,11 +32,15 @@ function readServerInfo() { } } -async function postReply(base, token, { id, type, message, file }) { +export function buildPollReplyPayload(token, { id, type, message, file, data }) { + return { token, id, type, message, file, data }; +} + +async function postReply(base, token, reply) { const res = await fetch(`${base}/poll`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ token, id, type, message, file }), + body: JSON.stringify(buildPollReplyPayload(token, reply)), }); if (!res.ok) { const body = await res.json().catch(() => ({})); diff --git a/.kiro/skills/impeccable/scripts/live-poll.mjs b/.kiro/skills/impeccable/scripts/live-poll.mjs index 2b0c7936..9a3f07ae 100644 --- a/.kiro/skills/impeccable/scripts/live-poll.mjs +++ b/.kiro/skills/impeccable/scripts/live-poll.mjs @@ -32,11 +32,15 @@ function readServerInfo() { } } -async function postReply(base, token, { id, type, message, file }) { +export function buildPollReplyPayload(token, { id, type, message, file, data }) { + return { token, id, type, message, file, data }; +} + +async function postReply(base, token, reply) { const res = await fetch(`${base}/poll`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ token, id, type, message, file }), + body: JSON.stringify(buildPollReplyPayload(token, reply)), }); if (!res.ok) { const body = await res.json().catch(() => ({})); diff --git a/.opencode/skills/impeccable/scripts/live-poll.mjs b/.opencode/skills/impeccable/scripts/live-poll.mjs index 2b0c7936..9a3f07ae 100644 --- a/.opencode/skills/impeccable/scripts/live-poll.mjs +++ b/.opencode/skills/impeccable/scripts/live-poll.mjs @@ -32,11 +32,15 @@ function readServerInfo() { } } -async function postReply(base, token, { id, type, message, file }) { +export function buildPollReplyPayload(token, { id, type, message, file, data }) { + return { token, id, type, message, file, data }; +} + +async function postReply(base, token, reply) { const res = await fetch(`${base}/poll`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ token, id, type, message, file }), + body: JSON.stringify(buildPollReplyPayload(token, reply)), }); if (!res.ok) { const body = await res.json().catch(() => ({})); diff --git a/.pi/skills/impeccable/scripts/live-poll.mjs b/.pi/skills/impeccable/scripts/live-poll.mjs index 2b0c7936..9a3f07ae 100644 --- a/.pi/skills/impeccable/scripts/live-poll.mjs +++ b/.pi/skills/impeccable/scripts/live-poll.mjs @@ -32,11 +32,15 @@ function readServerInfo() { } } -async function postReply(base, token, { id, type, message, file }) { +export function buildPollReplyPayload(token, { id, type, message, file, data }) { + return { token, id, type, message, file, data }; +} + +async function postReply(base, token, reply) { const res = await fetch(`${base}/poll`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ token, id, type, message, file }), + body: JSON.stringify(buildPollReplyPayload(token, reply)), }); if (!res.ok) { const body = await res.json().catch(() => ({})); diff --git a/.rovodev/skills/impeccable/scripts/live-poll.mjs b/.rovodev/skills/impeccable/scripts/live-poll.mjs index 2b0c7936..9a3f07ae 100644 --- a/.rovodev/skills/impeccable/scripts/live-poll.mjs +++ b/.rovodev/skills/impeccable/scripts/live-poll.mjs @@ -32,11 +32,15 @@ function readServerInfo() { } } -async function postReply(base, token, { id, type, message, file }) { +export function buildPollReplyPayload(token, { id, type, message, file, data }) { + return { token, id, type, message, file, data }; +} + +async function postReply(base, token, reply) { const res = await fetch(`${base}/poll`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ token, id, type, message, file }), + body: JSON.stringify(buildPollReplyPayload(token, reply)), }); if (!res.ok) { const body = await res.json().catch(() => ({})); diff --git a/.trae-cn/skills/impeccable/scripts/live-poll.mjs b/.trae-cn/skills/impeccable/scripts/live-poll.mjs index 2b0c7936..9a3f07ae 100644 --- a/.trae-cn/skills/impeccable/scripts/live-poll.mjs +++ b/.trae-cn/skills/impeccable/scripts/live-poll.mjs @@ -32,11 +32,15 @@ function readServerInfo() { } } -async function postReply(base, token, { id, type, message, file }) { +export function buildPollReplyPayload(token, { id, type, message, file, data }) { + return { token, id, type, message, file, data }; +} + +async function postReply(base, token, reply) { const res = await fetch(`${base}/poll`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ token, id, type, message, file }), + body: JSON.stringify(buildPollReplyPayload(token, reply)), }); if (!res.ok) { const body = await res.json().catch(() => ({})); diff --git a/.trae/skills/impeccable/scripts/live-poll.mjs b/.trae/skills/impeccable/scripts/live-poll.mjs index 2b0c7936..9a3f07ae 100644 --- a/.trae/skills/impeccable/scripts/live-poll.mjs +++ b/.trae/skills/impeccable/scripts/live-poll.mjs @@ -32,11 +32,15 @@ function readServerInfo() { } } -async function postReply(base, token, { id, type, message, file }) { +export function buildPollReplyPayload(token, { id, type, message, file, data }) { + return { token, id, type, message, file, data }; +} + +async function postReply(base, token, reply) { const res = await fetch(`${base}/poll`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ token, id, type, message, file }), + body: JSON.stringify(buildPollReplyPayload(token, reply)), }); if (!res.ok) { const body = await res.json().catch(() => ({})); diff --git a/package.json b/package.json index a3b357d7..7cb2de0e 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ "dev": "npx astro dev", "preview": "bun run build && npx astro preview", "deploy": "bun run build && wrangler pages deploy build/", - "test": "bun test tests/build.test.js tests/detect-antipatterns.test.js tests/windows-path-fix.test.js && node --test tests/detect-antipatterns-fixtures.test.mjs && node --test tests/detect-antipatterns-browser.test.mjs && node --test tests/cleanup-deprecated.test.mjs && node --test tests/live-wrap.test.mjs && node --test tests/live-accept.test.mjs && node --test tests/live-inject.test.mjs && node --test tests/live-server.test.mjs && node --test tests/live-browser-regression.test.mjs && node --test tests/live-session-store.test.mjs && node --test tests/live-browser-session.test.mjs && node --test tests/live-browser-source.test.mjs && node --test tests/live-completion.test.mjs && node --test tests/live-recovery-commands.test.mjs && node --test tests/framework-fixtures.test.mjs", + "test": "bun test tests/build.test.js tests/detect-antipatterns.test.js tests/windows-path-fix.test.js && node --test tests/detect-antipatterns-fixtures.test.mjs && node --test tests/detect-antipatterns-browser.test.mjs && node --test tests/cleanup-deprecated.test.mjs && node --test tests/live-wrap.test.mjs && node --test tests/live-accept.test.mjs && node --test tests/live-inject.test.mjs && node --test tests/live-poll.test.mjs && node --test tests/live-server.test.mjs && node --test tests/live-browser-regression.test.mjs && node --test tests/live-session-store.test.mjs && node --test tests/live-browser-session.test.mjs && node --test tests/live-browser-source.test.mjs && node --test tests/live-completion.test.mjs && node --test tests/live-recovery-commands.test.mjs && node --test tests/framework-fixtures.test.mjs", "test:live-e2e": "node --test --test-timeout=600000 tests/live-e2e.test.mjs", "audit": "bun audit --audit-level=moderate", "prepack": "cp README.md README.repo.md && cp README.npm.md README.md", diff --git a/plugin/skills/impeccable/scripts/live-poll.mjs b/plugin/skills/impeccable/scripts/live-poll.mjs index 2b0c7936..9a3f07ae 100644 --- a/plugin/skills/impeccable/scripts/live-poll.mjs +++ b/plugin/skills/impeccable/scripts/live-poll.mjs @@ -32,11 +32,15 @@ function readServerInfo() { } } -async function postReply(base, token, { id, type, message, file }) { +export function buildPollReplyPayload(token, { id, type, message, file, data }) { + return { token, id, type, message, file, data }; +} + +async function postReply(base, token, reply) { const res = await fetch(`${base}/poll`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ token, id, type, message, file }), + body: JSON.stringify(buildPollReplyPayload(token, reply)), }); if (!res.ok) { const body = await res.json().catch(() => ({})); diff --git a/source/skills/impeccable/scripts/live-poll.mjs b/source/skills/impeccable/scripts/live-poll.mjs index 2b0c7936..9a3f07ae 100644 --- a/source/skills/impeccable/scripts/live-poll.mjs +++ b/source/skills/impeccable/scripts/live-poll.mjs @@ -32,11 +32,15 @@ function readServerInfo() { } } -async function postReply(base, token, { id, type, message, file }) { +export function buildPollReplyPayload(token, { id, type, message, file, data }) { + return { token, id, type, message, file, data }; +} + +async function postReply(base, token, reply) { const res = await fetch(`${base}/poll`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ token, id, type, message, file }), + body: JSON.stringify(buildPollReplyPayload(token, reply)), }); if (!res.ok) { const body = await res.json().catch(() => ({})); diff --git a/tests/live-poll.test.mjs b/tests/live-poll.test.mjs new file mode 100644 index 00000000..effeee49 --- /dev/null +++ b/tests/live-poll.test.mjs @@ -0,0 +1,21 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +import { buildPollReplyPayload } from '../source/skills/impeccable/scripts/live-poll.mjs'; + +describe('live-poll reply payloads', () => { + it('preserves structured data for durable carbonize recovery acknowledgements', () => { + const payload = buildPollReplyPayload('token-1', { + id: 'carbonize-reply-1', + type: 'agent_done', + file: 'src/App.jsx', + data: { carbonize: true }, + }); + + assert.deepEqual( + payload.data, + { carbonize: true }, + '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), + ); + }); +}); From 7a310c21fd82d9a12ddb1bf0de5390732f582ee6 Mon Sep 17 00:00:00 2001 From: NQH Date: Wed, 29 Apr 2026 01:35:50 -0500 Subject: [PATCH 06/13] fix(live): treat event HTTP failures as failed sends --- .agents/skills/impeccable/scripts/live-browser.js | 14 +++++++++----- .../impeccable/scripts/live-session-store.mjs | 3 +-- .claude/skills/impeccable/scripts/live-browser.js | 14 +++++++++----- .../impeccable/scripts/live-session-store.mjs | 3 +-- .cursor/skills/impeccable/scripts/live-browser.js | 14 +++++++++----- .../impeccable/scripts/live-session-store.mjs | 3 +-- .gemini/skills/impeccable/scripts/live-browser.js | 14 +++++++++----- .../impeccable/scripts/live-session-store.mjs | 3 +-- .github/skills/impeccable/scripts/live-browser.js | 14 +++++++++----- .../impeccable/scripts/live-session-store.mjs | 3 +-- .kiro/skills/impeccable/scripts/live-browser.js | 14 +++++++++----- .../impeccable/scripts/live-session-store.mjs | 3 +-- .../skills/impeccable/scripts/live-browser.js | 14 +++++++++----- .../impeccable/scripts/live-session-store.mjs | 3 +-- .pi/skills/impeccable/scripts/live-browser.js | 14 +++++++++----- .../impeccable/scripts/live-session-store.mjs | 3 +-- .rovodev/skills/impeccable/scripts/live-browser.js | 14 +++++++++----- .../impeccable/scripts/live-session-store.mjs | 3 +-- .trae-cn/skills/impeccable/scripts/live-browser.js | 14 +++++++++----- .../impeccable/scripts/live-session-store.mjs | 3 +-- .trae/skills/impeccable/scripts/live-browser.js | 14 +++++++++----- .../impeccable/scripts/live-session-store.mjs | 3 +-- plugin/skills/impeccable/scripts/live-browser.js | 14 +++++++++----- .../impeccable/scripts/live-session-store.mjs | 3 +-- source/skills/impeccable/scripts/live-browser.js | 14 +++++++++----- .../impeccable/scripts/live-session-store.mjs | 3 +-- tests/live-browser-source.test.mjs | 6 ++++++ 27 files changed, 136 insertions(+), 91 deletions(-) diff --git a/.agents/skills/impeccable/scripts/live-browser.js b/.agents/skills/impeccable/scripts/live-browser.js index 72869490..7f1ff329 100644 --- a/.agents/skills/impeccable/scripts/live-browser.js +++ b/.agents/skills/impeccable/scripts/live-browser.js @@ -2262,15 +2262,19 @@ function sendEvent(msg, opts) { msg.token = TOKEN; + function handleFailure(err) { + console.error('[impeccable] Failed to send event:', err); + if (opts && opts.throwOnError) throw err; + return null; + } return fetch('http://localhost:' + PORT + '/events', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(msg), - }).catch(err => { - console.error('[impeccable] Failed to send event:', err); - if (opts && opts.throwOnError) throw err; - return null; - }); + }).then(res => { + if (res.ok) return res; + return handleFailure(new Error('HTTP ' + res.status + ' ' + res.statusText)); + }).catch(handleFailure); } function checkpointPayload(reason) { diff --git a/.agents/skills/impeccable/scripts/live-session-store.mjs b/.agents/skills/impeccable/scripts/live-session-store.mjs index 31d748e4..cc7744df 100644 --- a/.agents/skills/impeccable/scripts/live-session-store.mjs +++ b/.agents/skills/impeccable/scripts/live-session-store.mjs @@ -56,8 +56,7 @@ export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {}) .filter((name) => name.endsWith('.jsonl')) .map((name) => name.slice(0, -'.jsonl'.length)) .map((id) => this.getSnapshot(id)) - .filter(Boolean) - .filter((snapshot) => !COMPLETED_PHASES.has(snapshot.phase)); + .filter(Boolean); }, }; } diff --git a/.claude/skills/impeccable/scripts/live-browser.js b/.claude/skills/impeccable/scripts/live-browser.js index 72869490..7f1ff329 100644 --- a/.claude/skills/impeccable/scripts/live-browser.js +++ b/.claude/skills/impeccable/scripts/live-browser.js @@ -2262,15 +2262,19 @@ function sendEvent(msg, opts) { msg.token = TOKEN; + function handleFailure(err) { + console.error('[impeccable] Failed to send event:', err); + if (opts && opts.throwOnError) throw err; + return null; + } return fetch('http://localhost:' + PORT + '/events', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(msg), - }).catch(err => { - console.error('[impeccable] Failed to send event:', err); - if (opts && opts.throwOnError) throw err; - return null; - }); + }).then(res => { + if (res.ok) return res; + return handleFailure(new Error('HTTP ' + res.status + ' ' + res.statusText)); + }).catch(handleFailure); } function checkpointPayload(reason) { diff --git a/.claude/skills/impeccable/scripts/live-session-store.mjs b/.claude/skills/impeccable/scripts/live-session-store.mjs index 31d748e4..cc7744df 100644 --- a/.claude/skills/impeccable/scripts/live-session-store.mjs +++ b/.claude/skills/impeccable/scripts/live-session-store.mjs @@ -56,8 +56,7 @@ export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {}) .filter((name) => name.endsWith('.jsonl')) .map((name) => name.slice(0, -'.jsonl'.length)) .map((id) => this.getSnapshot(id)) - .filter(Boolean) - .filter((snapshot) => !COMPLETED_PHASES.has(snapshot.phase)); + .filter(Boolean); }, }; } diff --git a/.cursor/skills/impeccable/scripts/live-browser.js b/.cursor/skills/impeccable/scripts/live-browser.js index 72869490..7f1ff329 100644 --- a/.cursor/skills/impeccable/scripts/live-browser.js +++ b/.cursor/skills/impeccable/scripts/live-browser.js @@ -2262,15 +2262,19 @@ function sendEvent(msg, opts) { msg.token = TOKEN; + function handleFailure(err) { + console.error('[impeccable] Failed to send event:', err); + if (opts && opts.throwOnError) throw err; + return null; + } return fetch('http://localhost:' + PORT + '/events', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(msg), - }).catch(err => { - console.error('[impeccable] Failed to send event:', err); - if (opts && opts.throwOnError) throw err; - return null; - }); + }).then(res => { + if (res.ok) return res; + return handleFailure(new Error('HTTP ' + res.status + ' ' + res.statusText)); + }).catch(handleFailure); } function checkpointPayload(reason) { diff --git a/.cursor/skills/impeccable/scripts/live-session-store.mjs b/.cursor/skills/impeccable/scripts/live-session-store.mjs index 31d748e4..cc7744df 100644 --- a/.cursor/skills/impeccable/scripts/live-session-store.mjs +++ b/.cursor/skills/impeccable/scripts/live-session-store.mjs @@ -56,8 +56,7 @@ export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {}) .filter((name) => name.endsWith('.jsonl')) .map((name) => name.slice(0, -'.jsonl'.length)) .map((id) => this.getSnapshot(id)) - .filter(Boolean) - .filter((snapshot) => !COMPLETED_PHASES.has(snapshot.phase)); + .filter(Boolean); }, }; } diff --git a/.gemini/skills/impeccable/scripts/live-browser.js b/.gemini/skills/impeccable/scripts/live-browser.js index 72869490..7f1ff329 100644 --- a/.gemini/skills/impeccable/scripts/live-browser.js +++ b/.gemini/skills/impeccable/scripts/live-browser.js @@ -2262,15 +2262,19 @@ function sendEvent(msg, opts) { msg.token = TOKEN; + function handleFailure(err) { + console.error('[impeccable] Failed to send event:', err); + if (opts && opts.throwOnError) throw err; + return null; + } return fetch('http://localhost:' + PORT + '/events', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(msg), - }).catch(err => { - console.error('[impeccable] Failed to send event:', err); - if (opts && opts.throwOnError) throw err; - return null; - }); + }).then(res => { + if (res.ok) return res; + return handleFailure(new Error('HTTP ' + res.status + ' ' + res.statusText)); + }).catch(handleFailure); } function checkpointPayload(reason) { diff --git a/.gemini/skills/impeccable/scripts/live-session-store.mjs b/.gemini/skills/impeccable/scripts/live-session-store.mjs index 31d748e4..cc7744df 100644 --- a/.gemini/skills/impeccable/scripts/live-session-store.mjs +++ b/.gemini/skills/impeccable/scripts/live-session-store.mjs @@ -56,8 +56,7 @@ export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {}) .filter((name) => name.endsWith('.jsonl')) .map((name) => name.slice(0, -'.jsonl'.length)) .map((id) => this.getSnapshot(id)) - .filter(Boolean) - .filter((snapshot) => !COMPLETED_PHASES.has(snapshot.phase)); + .filter(Boolean); }, }; } diff --git a/.github/skills/impeccable/scripts/live-browser.js b/.github/skills/impeccable/scripts/live-browser.js index 72869490..7f1ff329 100644 --- a/.github/skills/impeccable/scripts/live-browser.js +++ b/.github/skills/impeccable/scripts/live-browser.js @@ -2262,15 +2262,19 @@ function sendEvent(msg, opts) { msg.token = TOKEN; + function handleFailure(err) { + console.error('[impeccable] Failed to send event:', err); + if (opts && opts.throwOnError) throw err; + return null; + } return fetch('http://localhost:' + PORT + '/events', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(msg), - }).catch(err => { - console.error('[impeccable] Failed to send event:', err); - if (opts && opts.throwOnError) throw err; - return null; - }); + }).then(res => { + if (res.ok) return res; + return handleFailure(new Error('HTTP ' + res.status + ' ' + res.statusText)); + }).catch(handleFailure); } function checkpointPayload(reason) { diff --git a/.github/skills/impeccable/scripts/live-session-store.mjs b/.github/skills/impeccable/scripts/live-session-store.mjs index 31d748e4..cc7744df 100644 --- a/.github/skills/impeccable/scripts/live-session-store.mjs +++ b/.github/skills/impeccable/scripts/live-session-store.mjs @@ -56,8 +56,7 @@ export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {}) .filter((name) => name.endsWith('.jsonl')) .map((name) => name.slice(0, -'.jsonl'.length)) .map((id) => this.getSnapshot(id)) - .filter(Boolean) - .filter((snapshot) => !COMPLETED_PHASES.has(snapshot.phase)); + .filter(Boolean); }, }; } diff --git a/.kiro/skills/impeccable/scripts/live-browser.js b/.kiro/skills/impeccable/scripts/live-browser.js index 72869490..7f1ff329 100644 --- a/.kiro/skills/impeccable/scripts/live-browser.js +++ b/.kiro/skills/impeccable/scripts/live-browser.js @@ -2262,15 +2262,19 @@ function sendEvent(msg, opts) { msg.token = TOKEN; + function handleFailure(err) { + console.error('[impeccable] Failed to send event:', err); + if (opts && opts.throwOnError) throw err; + return null; + } return fetch('http://localhost:' + PORT + '/events', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(msg), - }).catch(err => { - console.error('[impeccable] Failed to send event:', err); - if (opts && opts.throwOnError) throw err; - return null; - }); + }).then(res => { + if (res.ok) return res; + return handleFailure(new Error('HTTP ' + res.status + ' ' + res.statusText)); + }).catch(handleFailure); } function checkpointPayload(reason) { diff --git a/.kiro/skills/impeccable/scripts/live-session-store.mjs b/.kiro/skills/impeccable/scripts/live-session-store.mjs index 31d748e4..cc7744df 100644 --- a/.kiro/skills/impeccable/scripts/live-session-store.mjs +++ b/.kiro/skills/impeccable/scripts/live-session-store.mjs @@ -56,8 +56,7 @@ export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {}) .filter((name) => name.endsWith('.jsonl')) .map((name) => name.slice(0, -'.jsonl'.length)) .map((id) => this.getSnapshot(id)) - .filter(Boolean) - .filter((snapshot) => !COMPLETED_PHASES.has(snapshot.phase)); + .filter(Boolean); }, }; } diff --git a/.opencode/skills/impeccable/scripts/live-browser.js b/.opencode/skills/impeccable/scripts/live-browser.js index 72869490..7f1ff329 100644 --- a/.opencode/skills/impeccable/scripts/live-browser.js +++ b/.opencode/skills/impeccable/scripts/live-browser.js @@ -2262,15 +2262,19 @@ function sendEvent(msg, opts) { msg.token = TOKEN; + function handleFailure(err) { + console.error('[impeccable] Failed to send event:', err); + if (opts && opts.throwOnError) throw err; + return null; + } return fetch('http://localhost:' + PORT + '/events', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(msg), - }).catch(err => { - console.error('[impeccable] Failed to send event:', err); - if (opts && opts.throwOnError) throw err; - return null; - }); + }).then(res => { + if (res.ok) return res; + return handleFailure(new Error('HTTP ' + res.status + ' ' + res.statusText)); + }).catch(handleFailure); } function checkpointPayload(reason) { diff --git a/.opencode/skills/impeccable/scripts/live-session-store.mjs b/.opencode/skills/impeccable/scripts/live-session-store.mjs index 31d748e4..cc7744df 100644 --- a/.opencode/skills/impeccable/scripts/live-session-store.mjs +++ b/.opencode/skills/impeccable/scripts/live-session-store.mjs @@ -56,8 +56,7 @@ export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {}) .filter((name) => name.endsWith('.jsonl')) .map((name) => name.slice(0, -'.jsonl'.length)) .map((id) => this.getSnapshot(id)) - .filter(Boolean) - .filter((snapshot) => !COMPLETED_PHASES.has(snapshot.phase)); + .filter(Boolean); }, }; } diff --git a/.pi/skills/impeccable/scripts/live-browser.js b/.pi/skills/impeccable/scripts/live-browser.js index 72869490..7f1ff329 100644 --- a/.pi/skills/impeccable/scripts/live-browser.js +++ b/.pi/skills/impeccable/scripts/live-browser.js @@ -2262,15 +2262,19 @@ function sendEvent(msg, opts) { msg.token = TOKEN; + function handleFailure(err) { + console.error('[impeccable] Failed to send event:', err); + if (opts && opts.throwOnError) throw err; + return null; + } return fetch('http://localhost:' + PORT + '/events', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(msg), - }).catch(err => { - console.error('[impeccable] Failed to send event:', err); - if (opts && opts.throwOnError) throw err; - return null; - }); + }).then(res => { + if (res.ok) return res; + return handleFailure(new Error('HTTP ' + res.status + ' ' + res.statusText)); + }).catch(handleFailure); } function checkpointPayload(reason) { diff --git a/.pi/skills/impeccable/scripts/live-session-store.mjs b/.pi/skills/impeccable/scripts/live-session-store.mjs index 31d748e4..cc7744df 100644 --- a/.pi/skills/impeccable/scripts/live-session-store.mjs +++ b/.pi/skills/impeccable/scripts/live-session-store.mjs @@ -56,8 +56,7 @@ export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {}) .filter((name) => name.endsWith('.jsonl')) .map((name) => name.slice(0, -'.jsonl'.length)) .map((id) => this.getSnapshot(id)) - .filter(Boolean) - .filter((snapshot) => !COMPLETED_PHASES.has(snapshot.phase)); + .filter(Boolean); }, }; } diff --git a/.rovodev/skills/impeccable/scripts/live-browser.js b/.rovodev/skills/impeccable/scripts/live-browser.js index 72869490..7f1ff329 100644 --- a/.rovodev/skills/impeccable/scripts/live-browser.js +++ b/.rovodev/skills/impeccable/scripts/live-browser.js @@ -2262,15 +2262,19 @@ function sendEvent(msg, opts) { msg.token = TOKEN; + function handleFailure(err) { + console.error('[impeccable] Failed to send event:', err); + if (opts && opts.throwOnError) throw err; + return null; + } return fetch('http://localhost:' + PORT + '/events', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(msg), - }).catch(err => { - console.error('[impeccable] Failed to send event:', err); - if (opts && opts.throwOnError) throw err; - return null; - }); + }).then(res => { + if (res.ok) return res; + return handleFailure(new Error('HTTP ' + res.status + ' ' + res.statusText)); + }).catch(handleFailure); } function checkpointPayload(reason) { diff --git a/.rovodev/skills/impeccable/scripts/live-session-store.mjs b/.rovodev/skills/impeccable/scripts/live-session-store.mjs index 31d748e4..cc7744df 100644 --- a/.rovodev/skills/impeccable/scripts/live-session-store.mjs +++ b/.rovodev/skills/impeccable/scripts/live-session-store.mjs @@ -56,8 +56,7 @@ export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {}) .filter((name) => name.endsWith('.jsonl')) .map((name) => name.slice(0, -'.jsonl'.length)) .map((id) => this.getSnapshot(id)) - .filter(Boolean) - .filter((snapshot) => !COMPLETED_PHASES.has(snapshot.phase)); + .filter(Boolean); }, }; } diff --git a/.trae-cn/skills/impeccable/scripts/live-browser.js b/.trae-cn/skills/impeccable/scripts/live-browser.js index 72869490..7f1ff329 100644 --- a/.trae-cn/skills/impeccable/scripts/live-browser.js +++ b/.trae-cn/skills/impeccable/scripts/live-browser.js @@ -2262,15 +2262,19 @@ function sendEvent(msg, opts) { msg.token = TOKEN; + function handleFailure(err) { + console.error('[impeccable] Failed to send event:', err); + if (opts && opts.throwOnError) throw err; + return null; + } return fetch('http://localhost:' + PORT + '/events', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(msg), - }).catch(err => { - console.error('[impeccable] Failed to send event:', err); - if (opts && opts.throwOnError) throw err; - return null; - }); + }).then(res => { + if (res.ok) return res; + return handleFailure(new Error('HTTP ' + res.status + ' ' + res.statusText)); + }).catch(handleFailure); } function checkpointPayload(reason) { diff --git a/.trae-cn/skills/impeccable/scripts/live-session-store.mjs b/.trae-cn/skills/impeccable/scripts/live-session-store.mjs index 31d748e4..cc7744df 100644 --- a/.trae-cn/skills/impeccable/scripts/live-session-store.mjs +++ b/.trae-cn/skills/impeccable/scripts/live-session-store.mjs @@ -56,8 +56,7 @@ export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {}) .filter((name) => name.endsWith('.jsonl')) .map((name) => name.slice(0, -'.jsonl'.length)) .map((id) => this.getSnapshot(id)) - .filter(Boolean) - .filter((snapshot) => !COMPLETED_PHASES.has(snapshot.phase)); + .filter(Boolean); }, }; } diff --git a/.trae/skills/impeccable/scripts/live-browser.js b/.trae/skills/impeccable/scripts/live-browser.js index 72869490..7f1ff329 100644 --- a/.trae/skills/impeccable/scripts/live-browser.js +++ b/.trae/skills/impeccable/scripts/live-browser.js @@ -2262,15 +2262,19 @@ function sendEvent(msg, opts) { msg.token = TOKEN; + function handleFailure(err) { + console.error('[impeccable] Failed to send event:', err); + if (opts && opts.throwOnError) throw err; + return null; + } return fetch('http://localhost:' + PORT + '/events', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(msg), - }).catch(err => { - console.error('[impeccable] Failed to send event:', err); - if (opts && opts.throwOnError) throw err; - return null; - }); + }).then(res => { + if (res.ok) return res; + return handleFailure(new Error('HTTP ' + res.status + ' ' + res.statusText)); + }).catch(handleFailure); } function checkpointPayload(reason) { diff --git a/.trae/skills/impeccable/scripts/live-session-store.mjs b/.trae/skills/impeccable/scripts/live-session-store.mjs index 31d748e4..cc7744df 100644 --- a/.trae/skills/impeccable/scripts/live-session-store.mjs +++ b/.trae/skills/impeccable/scripts/live-session-store.mjs @@ -56,8 +56,7 @@ export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {}) .filter((name) => name.endsWith('.jsonl')) .map((name) => name.slice(0, -'.jsonl'.length)) .map((id) => this.getSnapshot(id)) - .filter(Boolean) - .filter((snapshot) => !COMPLETED_PHASES.has(snapshot.phase)); + .filter(Boolean); }, }; } diff --git a/plugin/skills/impeccable/scripts/live-browser.js b/plugin/skills/impeccable/scripts/live-browser.js index 72869490..7f1ff329 100644 --- a/plugin/skills/impeccable/scripts/live-browser.js +++ b/plugin/skills/impeccable/scripts/live-browser.js @@ -2262,15 +2262,19 @@ function sendEvent(msg, opts) { msg.token = TOKEN; + function handleFailure(err) { + console.error('[impeccable] Failed to send event:', err); + if (opts && opts.throwOnError) throw err; + return null; + } return fetch('http://localhost:' + PORT + '/events', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(msg), - }).catch(err => { - console.error('[impeccable] Failed to send event:', err); - if (opts && opts.throwOnError) throw err; - return null; - }); + }).then(res => { + if (res.ok) return res; + return handleFailure(new Error('HTTP ' + res.status + ' ' + res.statusText)); + }).catch(handleFailure); } function checkpointPayload(reason) { diff --git a/plugin/skills/impeccable/scripts/live-session-store.mjs b/plugin/skills/impeccable/scripts/live-session-store.mjs index 31d748e4..cc7744df 100644 --- a/plugin/skills/impeccable/scripts/live-session-store.mjs +++ b/plugin/skills/impeccable/scripts/live-session-store.mjs @@ -56,8 +56,7 @@ export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {}) .filter((name) => name.endsWith('.jsonl')) .map((name) => name.slice(0, -'.jsonl'.length)) .map((id) => this.getSnapshot(id)) - .filter(Boolean) - .filter((snapshot) => !COMPLETED_PHASES.has(snapshot.phase)); + .filter(Boolean); }, }; } diff --git a/source/skills/impeccable/scripts/live-browser.js b/source/skills/impeccable/scripts/live-browser.js index 72869490..7f1ff329 100644 --- a/source/skills/impeccable/scripts/live-browser.js +++ b/source/skills/impeccable/scripts/live-browser.js @@ -2262,15 +2262,19 @@ function sendEvent(msg, opts) { msg.token = TOKEN; + function handleFailure(err) { + console.error('[impeccable] Failed to send event:', err); + if (opts && opts.throwOnError) throw err; + return null; + } return fetch('http://localhost:' + PORT + '/events', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(msg), - }).catch(err => { - console.error('[impeccable] Failed to send event:', err); - if (opts && opts.throwOnError) throw err; - return null; - }); + }).then(res => { + if (res.ok) return res; + return handleFailure(new Error('HTTP ' + res.status + ' ' + res.statusText)); + }).catch(handleFailure); } function checkpointPayload(reason) { diff --git a/source/skills/impeccable/scripts/live-session-store.mjs b/source/skills/impeccable/scripts/live-session-store.mjs index 31d748e4..cc7744df 100644 --- a/source/skills/impeccable/scripts/live-session-store.mjs +++ b/source/skills/impeccable/scripts/live-session-store.mjs @@ -56,8 +56,7 @@ export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {}) .filter((name) => name.endsWith('.jsonl')) .map((name) => name.slice(0, -'.jsonl'.length)) .map((id) => this.getSnapshot(id)) - .filter(Boolean) - .filter((snapshot) => !COMPLETED_PHASES.has(snapshot.phase)); + .filter(Boolean); }, }; } diff --git a/tests/live-browser-source.test.mjs b/tests/live-browser-source.test.mjs index 1e9ad7c4..f69c59a0 100644 --- a/tests/live-browser-source.test.mjs +++ b/tests/live-browser-source.test.mjs @@ -12,6 +12,12 @@ describe('live-browser source contracts', () => { /function sendEvent\(msg, opts\)[\s\S]*if \(opts && opts\.throwOnError\) throw err;[\s\S]*return null;/, 'event=live_browser.send_event_contract actor=browser operation=send_event_failure risk=fire_and_forget_callers_get_unhandled_rejections expected=default swallow with opt-in throw actual=missing', ); + assert.match(SOURCE, /if \(res\.ok\) return res;[\s\S]*handleFailure\(new Error\('HTTP ' \+ res\.status \+ ' ' \+ res\.statusText\)\)/); + assert.match( + SOURCE, + /\.then\(res => \{[\s\S]*if \(res\.ok\) return res;[\s\S]*\}\)\.catch\(handleFailure\)/, + 'event=live_browser.http_error_contract actor=browser operation=accept_discard_ack risk=http_500_clears_local_state_without_durable_receipt expected=non-ok response handled before then-success actual=missing', + ); assert.match(SOURCE, /sendEvent\(acceptPayload, \{ throwOnError: true \}\)/); assert.match(SOURCE, /sendEvent\(\{ type: 'discard', id: currentSessionId \}, \{ throwOnError: true \}\)/); }); From 21617e824da4cd237f9a6c7f803ffbdec59e5e3f Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Sun, 3 May 2026 11:30:17 -0700 Subject: [PATCH 07/13] fix(live): acknowledge manual completion through helper --- .../impeccable/scripts/live-complete.mjs | 37 +++++++++ .../impeccable/scripts/live-complete.mjs | 37 +++++++++ .../impeccable/scripts/live-complete.mjs | 37 +++++++++ .../impeccable/scripts/live-complete.mjs | 37 +++++++++ .../impeccable/scripts/live-complete.mjs | 37 +++++++++ .../impeccable/scripts/live-complete.mjs | 37 +++++++++ .../impeccable/scripts/live-complete.mjs | 37 +++++++++ .../impeccable/scripts/live-complete.mjs | 37 +++++++++ .../skills/impeccable/scripts/live-browser.js | 19 +++-- .../impeccable/scripts/live-complete.mjs | 37 +++++++++ .../impeccable/scripts/live-completion.mjs | 7 ++ .../skills/impeccable/scripts/live-poll.mjs | 16 ++-- .../skills/impeccable/scripts/live-resume.mjs | 8 +- .../skills/impeccable/scripts/live-server.mjs | 37 ++++++++- .../impeccable/scripts/live-session-store.mjs | 30 ++++--- .../impeccable/scripts/live-complete.mjs | 37 +++++++++ .../impeccable/scripts/live-complete.mjs | 37 +++++++++ .../impeccable/scripts/live-complete.mjs | 37 +++++++++ .../impeccable/scripts/live-complete.mjs | 37 +++++++++ .../impeccable/scripts/live-complete.mjs | 37 +++++++++ tests/live-server.test.mjs | 81 +++++++++++++------ 21 files changed, 662 insertions(+), 54 deletions(-) create mode 100644 .qoder/skills/impeccable/scripts/live-completion.mjs diff --git a/.agents/skills/impeccable/scripts/live-complete.mjs b/.agents/skills/impeccable/scripts/live-complete.mjs index d2440d16..ca00d86a 100644 --- a/.agents/skills/impeccable/scripts/live-complete.mjs +++ b/.agents/skills/impeccable/scripts/live-complete.mjs @@ -4,6 +4,10 @@ */ import { createLiveSessionStore } from './live-session-store.mjs'; +import fs from 'node:fs'; +import path from 'node:path'; + +const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); function parseArgs(argv) { const out = { status: 'complete' }; @@ -26,6 +30,15 @@ export async function completeCli() { process.exit(args.help ? 0 : 1); } + const serverInfo = readServerInfo(); + const serverResult = serverInfo ? await completeThroughServer(serverInfo, args) : null; + if (serverResult?.ok) { + const store = createLiveSessionStore({ cwd: process.cwd(), sessionId: args.id }); + const snapshot = store.getSnapshot(args.id, { includeCompleted: true }); + console.log(JSON.stringify({ ok: true, id: args.id, phase: snapshot?.phase || args.status, snapshot }, null, 2)); + return; + } + const store = createLiveSessionStore({ cwd: process.cwd(), sessionId: args.id }); const event = args.status === 'discarded' ? { type: 'discarded', id: args.id } @@ -36,6 +49,30 @@ export async function completeCli() { console.log(JSON.stringify({ ok: true, id: args.id, phase: snapshot.phase, snapshot }, null, 2)); } +function readServerInfo() { + try { return JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); } + catch { return null; } +} + +async function completeThroughServer(info, args) { + const type = args.status === 'discarded' + ? 'discarded' + : args.status === 'agent_error' + ? 'error' + : 'complete'; + try { + const res = await fetch(`http://localhost:${info.port}/poll`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token: info.token, id: args.id, type, message: args.message }), + }); + if (!res.ok) return null; + return await res.json(); + } catch { + return null; + } +} + const _running = process.argv[1]; if (_running?.endsWith('live-complete.mjs') || _running?.endsWith('live-complete.mjs/')) { completeCli(); diff --git a/.claude/skills/impeccable/scripts/live-complete.mjs b/.claude/skills/impeccable/scripts/live-complete.mjs index d2440d16..ca00d86a 100644 --- a/.claude/skills/impeccable/scripts/live-complete.mjs +++ b/.claude/skills/impeccable/scripts/live-complete.mjs @@ -4,6 +4,10 @@ */ import { createLiveSessionStore } from './live-session-store.mjs'; +import fs from 'node:fs'; +import path from 'node:path'; + +const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); function parseArgs(argv) { const out = { status: 'complete' }; @@ -26,6 +30,15 @@ export async function completeCli() { process.exit(args.help ? 0 : 1); } + const serverInfo = readServerInfo(); + const serverResult = serverInfo ? await completeThroughServer(serverInfo, args) : null; + if (serverResult?.ok) { + const store = createLiveSessionStore({ cwd: process.cwd(), sessionId: args.id }); + const snapshot = store.getSnapshot(args.id, { includeCompleted: true }); + console.log(JSON.stringify({ ok: true, id: args.id, phase: snapshot?.phase || args.status, snapshot }, null, 2)); + return; + } + const store = createLiveSessionStore({ cwd: process.cwd(), sessionId: args.id }); const event = args.status === 'discarded' ? { type: 'discarded', id: args.id } @@ -36,6 +49,30 @@ export async function completeCli() { console.log(JSON.stringify({ ok: true, id: args.id, phase: snapshot.phase, snapshot }, null, 2)); } +function readServerInfo() { + try { return JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); } + catch { return null; } +} + +async function completeThroughServer(info, args) { + const type = args.status === 'discarded' + ? 'discarded' + : args.status === 'agent_error' + ? 'error' + : 'complete'; + try { + const res = await fetch(`http://localhost:${info.port}/poll`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token: info.token, id: args.id, type, message: args.message }), + }); + if (!res.ok) return null; + return await res.json(); + } catch { + return null; + } +} + const _running = process.argv[1]; if (_running?.endsWith('live-complete.mjs') || _running?.endsWith('live-complete.mjs/')) { completeCli(); diff --git a/.cursor/skills/impeccable/scripts/live-complete.mjs b/.cursor/skills/impeccable/scripts/live-complete.mjs index d2440d16..ca00d86a 100644 --- a/.cursor/skills/impeccable/scripts/live-complete.mjs +++ b/.cursor/skills/impeccable/scripts/live-complete.mjs @@ -4,6 +4,10 @@ */ import { createLiveSessionStore } from './live-session-store.mjs'; +import fs from 'node:fs'; +import path from 'node:path'; + +const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); function parseArgs(argv) { const out = { status: 'complete' }; @@ -26,6 +30,15 @@ export async function completeCli() { process.exit(args.help ? 0 : 1); } + const serverInfo = readServerInfo(); + const serverResult = serverInfo ? await completeThroughServer(serverInfo, args) : null; + if (serverResult?.ok) { + const store = createLiveSessionStore({ cwd: process.cwd(), sessionId: args.id }); + const snapshot = store.getSnapshot(args.id, { includeCompleted: true }); + console.log(JSON.stringify({ ok: true, id: args.id, phase: snapshot?.phase || args.status, snapshot }, null, 2)); + return; + } + const store = createLiveSessionStore({ cwd: process.cwd(), sessionId: args.id }); const event = args.status === 'discarded' ? { type: 'discarded', id: args.id } @@ -36,6 +49,30 @@ export async function completeCli() { console.log(JSON.stringify({ ok: true, id: args.id, phase: snapshot.phase, snapshot }, null, 2)); } +function readServerInfo() { + try { return JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); } + catch { return null; } +} + +async function completeThroughServer(info, args) { + const type = args.status === 'discarded' + ? 'discarded' + : args.status === 'agent_error' + ? 'error' + : 'complete'; + try { + const res = await fetch(`http://localhost:${info.port}/poll`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token: info.token, id: args.id, type, message: args.message }), + }); + if (!res.ok) return null; + return await res.json(); + } catch { + return null; + } +} + const _running = process.argv[1]; if (_running?.endsWith('live-complete.mjs') || _running?.endsWith('live-complete.mjs/')) { completeCli(); diff --git a/.gemini/skills/impeccable/scripts/live-complete.mjs b/.gemini/skills/impeccable/scripts/live-complete.mjs index d2440d16..ca00d86a 100644 --- a/.gemini/skills/impeccable/scripts/live-complete.mjs +++ b/.gemini/skills/impeccable/scripts/live-complete.mjs @@ -4,6 +4,10 @@ */ import { createLiveSessionStore } from './live-session-store.mjs'; +import fs from 'node:fs'; +import path from 'node:path'; + +const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); function parseArgs(argv) { const out = { status: 'complete' }; @@ -26,6 +30,15 @@ export async function completeCli() { process.exit(args.help ? 0 : 1); } + const serverInfo = readServerInfo(); + const serverResult = serverInfo ? await completeThroughServer(serverInfo, args) : null; + if (serverResult?.ok) { + const store = createLiveSessionStore({ cwd: process.cwd(), sessionId: args.id }); + const snapshot = store.getSnapshot(args.id, { includeCompleted: true }); + console.log(JSON.stringify({ ok: true, id: args.id, phase: snapshot?.phase || args.status, snapshot }, null, 2)); + return; + } + const store = createLiveSessionStore({ cwd: process.cwd(), sessionId: args.id }); const event = args.status === 'discarded' ? { type: 'discarded', id: args.id } @@ -36,6 +49,30 @@ export async function completeCli() { console.log(JSON.stringify({ ok: true, id: args.id, phase: snapshot.phase, snapshot }, null, 2)); } +function readServerInfo() { + try { return JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); } + catch { return null; } +} + +async function completeThroughServer(info, args) { + const type = args.status === 'discarded' + ? 'discarded' + : args.status === 'agent_error' + ? 'error' + : 'complete'; + try { + const res = await fetch(`http://localhost:${info.port}/poll`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token: info.token, id: args.id, type, message: args.message }), + }); + if (!res.ok) return null; + return await res.json(); + } catch { + return null; + } +} + const _running = process.argv[1]; if (_running?.endsWith('live-complete.mjs') || _running?.endsWith('live-complete.mjs/')) { completeCli(); diff --git a/.github/skills/impeccable/scripts/live-complete.mjs b/.github/skills/impeccable/scripts/live-complete.mjs index d2440d16..ca00d86a 100644 --- a/.github/skills/impeccable/scripts/live-complete.mjs +++ b/.github/skills/impeccable/scripts/live-complete.mjs @@ -4,6 +4,10 @@ */ import { createLiveSessionStore } from './live-session-store.mjs'; +import fs from 'node:fs'; +import path from 'node:path'; + +const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); function parseArgs(argv) { const out = { status: 'complete' }; @@ -26,6 +30,15 @@ export async function completeCli() { process.exit(args.help ? 0 : 1); } + const serverInfo = readServerInfo(); + const serverResult = serverInfo ? await completeThroughServer(serverInfo, args) : null; + if (serverResult?.ok) { + const store = createLiveSessionStore({ cwd: process.cwd(), sessionId: args.id }); + const snapshot = store.getSnapshot(args.id, { includeCompleted: true }); + console.log(JSON.stringify({ ok: true, id: args.id, phase: snapshot?.phase || args.status, snapshot }, null, 2)); + return; + } + const store = createLiveSessionStore({ cwd: process.cwd(), sessionId: args.id }); const event = args.status === 'discarded' ? { type: 'discarded', id: args.id } @@ -36,6 +49,30 @@ export async function completeCli() { console.log(JSON.stringify({ ok: true, id: args.id, phase: snapshot.phase, snapshot }, null, 2)); } +function readServerInfo() { + try { return JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); } + catch { return null; } +} + +async function completeThroughServer(info, args) { + const type = args.status === 'discarded' + ? 'discarded' + : args.status === 'agent_error' + ? 'error' + : 'complete'; + try { + const res = await fetch(`http://localhost:${info.port}/poll`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token: info.token, id: args.id, type, message: args.message }), + }); + if (!res.ok) return null; + return await res.json(); + } catch { + return null; + } +} + const _running = process.argv[1]; if (_running?.endsWith('live-complete.mjs') || _running?.endsWith('live-complete.mjs/')) { completeCli(); diff --git a/.kiro/skills/impeccable/scripts/live-complete.mjs b/.kiro/skills/impeccable/scripts/live-complete.mjs index d2440d16..ca00d86a 100644 --- a/.kiro/skills/impeccable/scripts/live-complete.mjs +++ b/.kiro/skills/impeccable/scripts/live-complete.mjs @@ -4,6 +4,10 @@ */ import { createLiveSessionStore } from './live-session-store.mjs'; +import fs from 'node:fs'; +import path from 'node:path'; + +const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); function parseArgs(argv) { const out = { status: 'complete' }; @@ -26,6 +30,15 @@ export async function completeCli() { process.exit(args.help ? 0 : 1); } + const serverInfo = readServerInfo(); + const serverResult = serverInfo ? await completeThroughServer(serverInfo, args) : null; + if (serverResult?.ok) { + const store = createLiveSessionStore({ cwd: process.cwd(), sessionId: args.id }); + const snapshot = store.getSnapshot(args.id, { includeCompleted: true }); + console.log(JSON.stringify({ ok: true, id: args.id, phase: snapshot?.phase || args.status, snapshot }, null, 2)); + return; + } + const store = createLiveSessionStore({ cwd: process.cwd(), sessionId: args.id }); const event = args.status === 'discarded' ? { type: 'discarded', id: args.id } @@ -36,6 +49,30 @@ export async function completeCli() { console.log(JSON.stringify({ ok: true, id: args.id, phase: snapshot.phase, snapshot }, null, 2)); } +function readServerInfo() { + try { return JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); } + catch { return null; } +} + +async function completeThroughServer(info, args) { + const type = args.status === 'discarded' + ? 'discarded' + : args.status === 'agent_error' + ? 'error' + : 'complete'; + try { + const res = await fetch(`http://localhost:${info.port}/poll`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token: info.token, id: args.id, type, message: args.message }), + }); + if (!res.ok) return null; + return await res.json(); + } catch { + return null; + } +} + const _running = process.argv[1]; if (_running?.endsWith('live-complete.mjs') || _running?.endsWith('live-complete.mjs/')) { completeCli(); diff --git a/.opencode/skills/impeccable/scripts/live-complete.mjs b/.opencode/skills/impeccable/scripts/live-complete.mjs index d2440d16..ca00d86a 100644 --- a/.opencode/skills/impeccable/scripts/live-complete.mjs +++ b/.opencode/skills/impeccable/scripts/live-complete.mjs @@ -4,6 +4,10 @@ */ import { createLiveSessionStore } from './live-session-store.mjs'; +import fs from 'node:fs'; +import path from 'node:path'; + +const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); function parseArgs(argv) { const out = { status: 'complete' }; @@ -26,6 +30,15 @@ export async function completeCli() { process.exit(args.help ? 0 : 1); } + const serverInfo = readServerInfo(); + const serverResult = serverInfo ? await completeThroughServer(serverInfo, args) : null; + if (serverResult?.ok) { + const store = createLiveSessionStore({ cwd: process.cwd(), sessionId: args.id }); + const snapshot = store.getSnapshot(args.id, { includeCompleted: true }); + console.log(JSON.stringify({ ok: true, id: args.id, phase: snapshot?.phase || args.status, snapshot }, null, 2)); + return; + } + const store = createLiveSessionStore({ cwd: process.cwd(), sessionId: args.id }); const event = args.status === 'discarded' ? { type: 'discarded', id: args.id } @@ -36,6 +49,30 @@ export async function completeCli() { console.log(JSON.stringify({ ok: true, id: args.id, phase: snapshot.phase, snapshot }, null, 2)); } +function readServerInfo() { + try { return JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); } + catch { return null; } +} + +async function completeThroughServer(info, args) { + const type = args.status === 'discarded' + ? 'discarded' + : args.status === 'agent_error' + ? 'error' + : 'complete'; + try { + const res = await fetch(`http://localhost:${info.port}/poll`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token: info.token, id: args.id, type, message: args.message }), + }); + if (!res.ok) return null; + return await res.json(); + } catch { + return null; + } +} + const _running = process.argv[1]; if (_running?.endsWith('live-complete.mjs') || _running?.endsWith('live-complete.mjs/')) { completeCli(); diff --git a/.pi/skills/impeccable/scripts/live-complete.mjs b/.pi/skills/impeccable/scripts/live-complete.mjs index d2440d16..ca00d86a 100644 --- a/.pi/skills/impeccable/scripts/live-complete.mjs +++ b/.pi/skills/impeccable/scripts/live-complete.mjs @@ -4,6 +4,10 @@ */ import { createLiveSessionStore } from './live-session-store.mjs'; +import fs from 'node:fs'; +import path from 'node:path'; + +const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); function parseArgs(argv) { const out = { status: 'complete' }; @@ -26,6 +30,15 @@ export async function completeCli() { process.exit(args.help ? 0 : 1); } + const serverInfo = readServerInfo(); + const serverResult = serverInfo ? await completeThroughServer(serverInfo, args) : null; + if (serverResult?.ok) { + const store = createLiveSessionStore({ cwd: process.cwd(), sessionId: args.id }); + const snapshot = store.getSnapshot(args.id, { includeCompleted: true }); + console.log(JSON.stringify({ ok: true, id: args.id, phase: snapshot?.phase || args.status, snapshot }, null, 2)); + return; + } + const store = createLiveSessionStore({ cwd: process.cwd(), sessionId: args.id }); const event = args.status === 'discarded' ? { type: 'discarded', id: args.id } @@ -36,6 +49,30 @@ export async function completeCli() { console.log(JSON.stringify({ ok: true, id: args.id, phase: snapshot.phase, snapshot }, null, 2)); } +function readServerInfo() { + try { return JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); } + catch { return null; } +} + +async function completeThroughServer(info, args) { + const type = args.status === 'discarded' + ? 'discarded' + : args.status === 'agent_error' + ? 'error' + : 'complete'; + try { + const res = await fetch(`http://localhost:${info.port}/poll`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token: info.token, id: args.id, type, message: args.message }), + }); + if (!res.ok) return null; + return await res.json(); + } catch { + return null; + } +} + const _running = process.argv[1]; if (_running?.endsWith('live-complete.mjs') || _running?.endsWith('live-complete.mjs/')) { completeCli(); diff --git a/.qoder/skills/impeccable/scripts/live-browser.js b/.qoder/skills/impeccable/scripts/live-browser.js index 6c6adf05..7f1ff329 100644 --- a/.qoder/skills/impeccable/scripts/live-browser.js +++ b/.qoder/skills/impeccable/scripts/live-browser.js @@ -2260,16 +2260,21 @@ state = currentSessionId ? 'GENERATING' : 'IDLE'; } - function sendEvent(msg) { + function sendEvent(msg, opts) { msg.token = TOKEN; + function handleFailure(err) { + console.error('[impeccable] Failed to send event:', err); + if (opts && opts.throwOnError) throw err; + return null; + } return fetch('http://localhost:' + PORT + '/events', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(msg), - }).catch(err => { - console.error('[impeccable] Failed to send event:', err); - throw err; - }); + }).then(res => { + if (res.ok) return res; + return handleFailure(new Error('HTTP ' + res.status + ' ' + res.statusText)); + }).catch(handleFailure); } function checkpointPayload(reason) { @@ -2976,7 +2981,7 @@ void main() { state = 'SAVING'; updateBarContent('saving'); - sendEvent(acceptPayload) + sendEvent(acceptPayload, { throwOnError: true }) .then(() => { markSessionHandled(); confirmAcceptAfterReceipt(); @@ -3029,7 +3034,7 @@ void main() { function handleDiscard() { if (!currentSessionId) return; - sendEvent({ type: 'discard', id: currentSessionId }) + sendEvent({ type: 'discard', id: currentSessionId }, { throwOnError: true }) .then(() => { markSessionHandled(); cleanup(); diff --git a/.qoder/skills/impeccable/scripts/live-complete.mjs b/.qoder/skills/impeccable/scripts/live-complete.mjs index d2440d16..ca00d86a 100644 --- a/.qoder/skills/impeccable/scripts/live-complete.mjs +++ b/.qoder/skills/impeccable/scripts/live-complete.mjs @@ -4,6 +4,10 @@ */ import { createLiveSessionStore } from './live-session-store.mjs'; +import fs from 'node:fs'; +import path from 'node:path'; + +const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); function parseArgs(argv) { const out = { status: 'complete' }; @@ -26,6 +30,15 @@ export async function completeCli() { process.exit(args.help ? 0 : 1); } + const serverInfo = readServerInfo(); + const serverResult = serverInfo ? await completeThroughServer(serverInfo, args) : null; + if (serverResult?.ok) { + const store = createLiveSessionStore({ cwd: process.cwd(), sessionId: args.id }); + const snapshot = store.getSnapshot(args.id, { includeCompleted: true }); + console.log(JSON.stringify({ ok: true, id: args.id, phase: snapshot?.phase || args.status, snapshot }, null, 2)); + return; + } + const store = createLiveSessionStore({ cwd: process.cwd(), sessionId: args.id }); const event = args.status === 'discarded' ? { type: 'discarded', id: args.id } @@ -36,6 +49,30 @@ export async function completeCli() { console.log(JSON.stringify({ ok: true, id: args.id, phase: snapshot.phase, snapshot }, null, 2)); } +function readServerInfo() { + try { return JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); } + catch { return null; } +} + +async function completeThroughServer(info, args) { + const type = args.status === 'discarded' + ? 'discarded' + : args.status === 'agent_error' + ? 'error' + : 'complete'; + try { + const res = await fetch(`http://localhost:${info.port}/poll`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token: info.token, id: args.id, type, message: args.message }), + }); + if (!res.ok) return null; + return await res.json(); + } catch { + return null; + } +} + const _running = process.argv[1]; if (_running?.endsWith('live-complete.mjs') || _running?.endsWith('live-complete.mjs/')) { completeCli(); diff --git a/.qoder/skills/impeccable/scripts/live-completion.mjs b/.qoder/skills/impeccable/scripts/live-completion.mjs new file mode 100644 index 00000000..cf9fb10f --- /dev/null +++ b/.qoder/skills/impeccable/scripts/live-completion.mjs @@ -0,0 +1,7 @@ +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'; + return 'agent_done'; +} diff --git a/.qoder/skills/impeccable/scripts/live-poll.mjs b/.qoder/skills/impeccable/scripts/live-poll.mjs index d1e36977..9a3f07ae 100644 --- a/.qoder/skills/impeccable/scripts/live-poll.mjs +++ b/.qoder/skills/impeccable/scripts/live-poll.mjs @@ -13,6 +13,7 @@ import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; import { fileURLToPath } from 'node:url'; +import { completionTypeForAcceptResult } from './live-completion.mjs'; // Node's built-in fetch (undici under the hood) enforces a 300s headers // timeout that can't be lowered per-request. We cap each request below @@ -31,11 +32,15 @@ function readServerInfo() { } } -async function postReply(base, token, { id, type, message, file }) { +export function buildPollReplyPayload(token, { id, type, message, file, data }) { + return { token, id, type, message, file, data }; +} + +async function postReply(base, token, reply) { const res = await fetch(`${base}/poll`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ token, id, type, message, file }), + body: JSON.stringify(buildPollReplyPayload(token, reply)), }); if (!res.ok) { const body = await res.json().catch(() => ({})); @@ -152,18 +157,17 @@ Options: ); event._acceptResult = JSON.parse(out.trim()); } catch (err) { - event._acceptResult = { handled: false, error: err.message }; + event._acceptResult = { handled: false, mode: 'error', error: err.message }; } - const completionType = event._acceptResult?.handled === true - ? (event.type === 'discard' ? 'discarded' : 'complete') - : 'error'; + const completionType = completionTypeForAcceptResult(event.type, event._acceptResult); try { await postReply(base, info.token, { id: event.id, type: completionType, message: event._acceptResult?.error, file: event._acceptResult?.file, + data: event._acceptResult?.carbonize === true ? { carbonize: true } : undefined, }); } catch (err) { event._completionAck = { ok: false, error: err.message }; diff --git a/.qoder/skills/impeccable/scripts/live-resume.mjs b/.qoder/skills/impeccable/scripts/live-resume.mjs index 1234329a..a3465c9b 100644 --- a/.qoder/skills/impeccable/scripts/live-resume.mjs +++ b/.qoder/skills/impeccable/scripts/live-resume.mjs @@ -33,9 +33,11 @@ export async function resumeCli() { const pending = snapshot.pendingEvent || null; const nextAction = pending ? `Run live-poll.mjs, handle ${pending.type} ${pending.id}, then acknowledge with live-poll.mjs --reply ${pending.id} done.` - : snapshot.phase === 'accept_requested' - ? `Run live-complete.mjs --id ${snapshot.id} after verifying the accepted variant is written.` - : `Inspect ${snapshot.id}; no pending agent event is currently queued.`; + : snapshot.phase === 'carbonize_required' + ? `Finish carbonize cleanup${snapshot.sourceFile ? ` in ${snapshot.sourceFile}` : ''}, then run live-complete.mjs --id ${snapshot.id}.` + : snapshot.phase === 'accept_requested' + ? `Run live-complete.mjs --id ${snapshot.id} after verifying the accepted variant is written.` + : `Inspect ${snapshot.id}; no pending agent event is currently queued.`; console.log(JSON.stringify({ active: true, snapshot, pendingEvent: pending, nextAction }, null, 2)); } diff --git a/.qoder/skills/impeccable/scripts/live-server.mjs b/.qoder/skills/impeccable/scripts/live-server.mjs index 025a7633..e78a0657 100644 --- a/.qoder/skills/impeccable/scripts/live-server.mjs +++ b/.qoder/skills/impeccable/scripts/live-server.mjs @@ -63,6 +63,7 @@ const state = { exitTimer: null, sessionDir: null, // per-session tmp dir for annotation screenshots sessionStore: null, + leaseTimer: null, }; // Cap per-annotation upload size. A full 1920×1080 PNG is typically <1 MB; @@ -101,16 +102,39 @@ function acknowledgePendingEvent(id) { const idx = state.pendingEvents.findIndex((entry) => entry.event?.id === id); if (idx === -1) return false; state.pendingEvents.splice(idx, 1); + scheduleLeaseFlush(); return true; } +function scheduleLeaseFlush() { + if (state.leaseTimer) { + clearTimeout(state.leaseTimer); + state.leaseTimer = null; + } + if (state.pendingPolls.length === 0) return; + const now = Date.now(); + const nextLeaseUntil = state.pendingEvents + .map((entry) => entry.leaseUntil || 0) + .filter((leaseUntil) => leaseUntil > now) + .sort((a, b) => a - b)[0]; + if (!nextLeaseUntil) return; + state.leaseTimer = setTimeout(() => { + state.leaseTimer = null; + flushPendingPolls(); + }, Math.max(0, nextLeaseUntil - now)); +} + function flushPendingPolls() { while (state.pendingPolls.length > 0) { const entry = findAvailablePendingEvent(); - if (!entry) return; + if (!entry) { + scheduleLeaseFlush(); + return; + } const poll = state.pendingPolls.shift(); poll.resolve(leaseEvent(entry, poll.leaseMs)); } + scheduleLeaseFlush(); } /** Push a message to all connected SSE clients. */ @@ -592,6 +616,7 @@ function handlePollGet(req, res, url) { res.end(JSON.stringify(event)); } state.pendingPolls.push(poll); + scheduleLeaseFlush(); req.on('close', () => { clearTimeout(timer); const idx = state.pendingPolls.indexOf(poll); @@ -624,7 +649,13 @@ function handlePollPost(req, res) { : msg.type === 'error' ? 'agent_error' : 'agent_done'; - state.sessionStore.appendEvent({ type: eventType, id: msg.id, file: msg.file, message: msg.message }); + state.sessionStore.appendEvent({ + type: eventType, + id: msg.id, + file: msg.file, + message: msg.message, + carbonize: msg.data?.carbonize === true, + }); } catch { /* keep reply path best-effort; browser still needs SSE */ } } flushPendingPolls(); @@ -643,6 +674,8 @@ let httpServer = null; function shutdown() { try { fs.unlinkSync(LIVE_PID_FILE); } catch {} + if (state.leaseTimer) clearTimeout(state.leaseTimer); + state.leaseTimer = null; if (state.sessionDir) { try { fs.rmSync(state.sessionDir, { recursive: true, force: true }); } catch {} } diff --git a/.qoder/skills/impeccable/scripts/live-session-store.mjs b/.qoder/skills/impeccable/scripts/live-session-store.mjs index f2aaaa9f..cc7744df 100644 --- a/.qoder/skills/impeccable/scripts/live-session-store.mjs +++ b/.qoder/skills/impeccable/scripts/live-session-store.mjs @@ -56,8 +56,7 @@ export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {}) .filter((name) => name.endsWith('.jsonl')) .map((name) => name.slice(0, -'.jsonl'.length)) .map((id) => this.getSnapshot(id)) - .filter(Boolean) - .filter((snapshot) => !COMPLETED_PHASES.has(snapshot.phase)); + .filter(Boolean); }, }; } @@ -151,25 +150,32 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { switch (event.type) { case 'generate': next.phase = 'generate_requested'; - next.pageUrl = event.pageUrl || next.pageUrl; - next.expectedVariants = event.count || next.expectedVariants; + next.pageUrl = event.pageUrl ?? next.pageUrl; + next.expectedVariants = event.count ?? next.expectedVariants; next.pendingEventSeq = entry.seq ?? next.pendingEventSeq; next.pendingEvent = toPendingEvent(event); if (event.screenshotPath) upsertArtifact(next.annotationArtifacts, { type: 'screenshot', path: event.screenshotPath }); break; case 'variants_ready': case 'agent_done': - next.phase = 'variants_ready'; - next.sourceFile = event.file || next.sourceFile; - next.arrivedVariants = event.arrivedVariants ?? (next.arrivedVariants || next.expectedVariants); + next.phase = event.carbonize === true ? 'carbonize_required' : 'variants_ready'; + next.sourceFile = event.file ?? next.sourceFile; + next.arrivedVariants = event.arrivedVariants ?? (next.arrivedVariants ?? next.expectedVariants); next.pendingEventSeq = null; next.pendingEvent = null; + if (event.carbonize === true) { + next.diagnostics.push({ + error: 'carbonize_cleanup_required', + file: event.file || null, + message: 'Accepted variant still has carbonize markers that must be folded into source CSS.', + }); + } break; case 'checkpoint': - 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; + 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; if (event.paramValues) next.paramValues = { ...event.paramValues }; @@ -202,6 +208,8 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { break; case 'agent_error': next.phase = 'agent_error'; + next.pendingEventSeq = null; + next.pendingEvent = null; next.diagnostics.push({ error: 'agent_error', message: event.message || 'unknown agent error' }); break; default: diff --git a/.rovodev/skills/impeccable/scripts/live-complete.mjs b/.rovodev/skills/impeccable/scripts/live-complete.mjs index d2440d16..ca00d86a 100644 --- a/.rovodev/skills/impeccable/scripts/live-complete.mjs +++ b/.rovodev/skills/impeccable/scripts/live-complete.mjs @@ -4,6 +4,10 @@ */ import { createLiveSessionStore } from './live-session-store.mjs'; +import fs from 'node:fs'; +import path from 'node:path'; + +const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); function parseArgs(argv) { const out = { status: 'complete' }; @@ -26,6 +30,15 @@ export async function completeCli() { process.exit(args.help ? 0 : 1); } + const serverInfo = readServerInfo(); + const serverResult = serverInfo ? await completeThroughServer(serverInfo, args) : null; + if (serverResult?.ok) { + const store = createLiveSessionStore({ cwd: process.cwd(), sessionId: args.id }); + const snapshot = store.getSnapshot(args.id, { includeCompleted: true }); + console.log(JSON.stringify({ ok: true, id: args.id, phase: snapshot?.phase || args.status, snapshot }, null, 2)); + return; + } + const store = createLiveSessionStore({ cwd: process.cwd(), sessionId: args.id }); const event = args.status === 'discarded' ? { type: 'discarded', id: args.id } @@ -36,6 +49,30 @@ export async function completeCli() { console.log(JSON.stringify({ ok: true, id: args.id, phase: snapshot.phase, snapshot }, null, 2)); } +function readServerInfo() { + try { return JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); } + catch { return null; } +} + +async function completeThroughServer(info, args) { + const type = args.status === 'discarded' + ? 'discarded' + : args.status === 'agent_error' + ? 'error' + : 'complete'; + try { + const res = await fetch(`http://localhost:${info.port}/poll`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token: info.token, id: args.id, type, message: args.message }), + }); + if (!res.ok) return null; + return await res.json(); + } catch { + return null; + } +} + const _running = process.argv[1]; if (_running?.endsWith('live-complete.mjs') || _running?.endsWith('live-complete.mjs/')) { completeCli(); diff --git a/.trae-cn/skills/impeccable/scripts/live-complete.mjs b/.trae-cn/skills/impeccable/scripts/live-complete.mjs index d2440d16..ca00d86a 100644 --- a/.trae-cn/skills/impeccable/scripts/live-complete.mjs +++ b/.trae-cn/skills/impeccable/scripts/live-complete.mjs @@ -4,6 +4,10 @@ */ import { createLiveSessionStore } from './live-session-store.mjs'; +import fs from 'node:fs'; +import path from 'node:path'; + +const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); function parseArgs(argv) { const out = { status: 'complete' }; @@ -26,6 +30,15 @@ export async function completeCli() { process.exit(args.help ? 0 : 1); } + const serverInfo = readServerInfo(); + const serverResult = serverInfo ? await completeThroughServer(serverInfo, args) : null; + if (serverResult?.ok) { + const store = createLiveSessionStore({ cwd: process.cwd(), sessionId: args.id }); + const snapshot = store.getSnapshot(args.id, { includeCompleted: true }); + console.log(JSON.stringify({ ok: true, id: args.id, phase: snapshot?.phase || args.status, snapshot }, null, 2)); + return; + } + const store = createLiveSessionStore({ cwd: process.cwd(), sessionId: args.id }); const event = args.status === 'discarded' ? { type: 'discarded', id: args.id } @@ -36,6 +49,30 @@ export async function completeCli() { console.log(JSON.stringify({ ok: true, id: args.id, phase: snapshot.phase, snapshot }, null, 2)); } +function readServerInfo() { + try { return JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); } + catch { return null; } +} + +async function completeThroughServer(info, args) { + const type = args.status === 'discarded' + ? 'discarded' + : args.status === 'agent_error' + ? 'error' + : 'complete'; + try { + const res = await fetch(`http://localhost:${info.port}/poll`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token: info.token, id: args.id, type, message: args.message }), + }); + if (!res.ok) return null; + return await res.json(); + } catch { + return null; + } +} + const _running = process.argv[1]; if (_running?.endsWith('live-complete.mjs') || _running?.endsWith('live-complete.mjs/')) { completeCli(); diff --git a/.trae/skills/impeccable/scripts/live-complete.mjs b/.trae/skills/impeccable/scripts/live-complete.mjs index d2440d16..ca00d86a 100644 --- a/.trae/skills/impeccable/scripts/live-complete.mjs +++ b/.trae/skills/impeccable/scripts/live-complete.mjs @@ -4,6 +4,10 @@ */ import { createLiveSessionStore } from './live-session-store.mjs'; +import fs from 'node:fs'; +import path from 'node:path'; + +const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); function parseArgs(argv) { const out = { status: 'complete' }; @@ -26,6 +30,15 @@ export async function completeCli() { process.exit(args.help ? 0 : 1); } + const serverInfo = readServerInfo(); + const serverResult = serverInfo ? await completeThroughServer(serverInfo, args) : null; + if (serverResult?.ok) { + const store = createLiveSessionStore({ cwd: process.cwd(), sessionId: args.id }); + const snapshot = store.getSnapshot(args.id, { includeCompleted: true }); + console.log(JSON.stringify({ ok: true, id: args.id, phase: snapshot?.phase || args.status, snapshot }, null, 2)); + return; + } + const store = createLiveSessionStore({ cwd: process.cwd(), sessionId: args.id }); const event = args.status === 'discarded' ? { type: 'discarded', id: args.id } @@ -36,6 +49,30 @@ export async function completeCli() { console.log(JSON.stringify({ ok: true, id: args.id, phase: snapshot.phase, snapshot }, null, 2)); } +function readServerInfo() { + try { return JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); } + catch { return null; } +} + +async function completeThroughServer(info, args) { + const type = args.status === 'discarded' + ? 'discarded' + : args.status === 'agent_error' + ? 'error' + : 'complete'; + try { + const res = await fetch(`http://localhost:${info.port}/poll`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token: info.token, id: args.id, type, message: args.message }), + }); + if (!res.ok) return null; + return await res.json(); + } catch { + return null; + } +} + const _running = process.argv[1]; if (_running?.endsWith('live-complete.mjs') || _running?.endsWith('live-complete.mjs/')) { completeCli(); diff --git a/plugin/skills/impeccable/scripts/live-complete.mjs b/plugin/skills/impeccable/scripts/live-complete.mjs index d2440d16..ca00d86a 100644 --- a/plugin/skills/impeccable/scripts/live-complete.mjs +++ b/plugin/skills/impeccable/scripts/live-complete.mjs @@ -4,6 +4,10 @@ */ import { createLiveSessionStore } from './live-session-store.mjs'; +import fs from 'node:fs'; +import path from 'node:path'; + +const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); function parseArgs(argv) { const out = { status: 'complete' }; @@ -26,6 +30,15 @@ export async function completeCli() { process.exit(args.help ? 0 : 1); } + const serverInfo = readServerInfo(); + const serverResult = serverInfo ? await completeThroughServer(serverInfo, args) : null; + if (serverResult?.ok) { + const store = createLiveSessionStore({ cwd: process.cwd(), sessionId: args.id }); + const snapshot = store.getSnapshot(args.id, { includeCompleted: true }); + console.log(JSON.stringify({ ok: true, id: args.id, phase: snapshot?.phase || args.status, snapshot }, null, 2)); + return; + } + const store = createLiveSessionStore({ cwd: process.cwd(), sessionId: args.id }); const event = args.status === 'discarded' ? { type: 'discarded', id: args.id } @@ -36,6 +49,30 @@ export async function completeCli() { console.log(JSON.stringify({ ok: true, id: args.id, phase: snapshot.phase, snapshot }, null, 2)); } +function readServerInfo() { + try { return JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); } + catch { return null; } +} + +async function completeThroughServer(info, args) { + const type = args.status === 'discarded' + ? 'discarded' + : args.status === 'agent_error' + ? 'error' + : 'complete'; + try { + const res = await fetch(`http://localhost:${info.port}/poll`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token: info.token, id: args.id, type, message: args.message }), + }); + if (!res.ok) return null; + return await res.json(); + } catch { + return null; + } +} + const _running = process.argv[1]; if (_running?.endsWith('live-complete.mjs') || _running?.endsWith('live-complete.mjs/')) { completeCli(); diff --git a/source/skills/impeccable/scripts/live-complete.mjs b/source/skills/impeccable/scripts/live-complete.mjs index d2440d16..ca00d86a 100644 --- a/source/skills/impeccable/scripts/live-complete.mjs +++ b/source/skills/impeccable/scripts/live-complete.mjs @@ -4,6 +4,10 @@ */ import { createLiveSessionStore } from './live-session-store.mjs'; +import fs from 'node:fs'; +import path from 'node:path'; + +const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); function parseArgs(argv) { const out = { status: 'complete' }; @@ -26,6 +30,15 @@ export async function completeCli() { process.exit(args.help ? 0 : 1); } + const serverInfo = readServerInfo(); + const serverResult = serverInfo ? await completeThroughServer(serverInfo, args) : null; + if (serverResult?.ok) { + const store = createLiveSessionStore({ cwd: process.cwd(), sessionId: args.id }); + const snapshot = store.getSnapshot(args.id, { includeCompleted: true }); + console.log(JSON.stringify({ ok: true, id: args.id, phase: snapshot?.phase || args.status, snapshot }, null, 2)); + return; + } + const store = createLiveSessionStore({ cwd: process.cwd(), sessionId: args.id }); const event = args.status === 'discarded' ? { type: 'discarded', id: args.id } @@ -36,6 +49,30 @@ export async function completeCli() { console.log(JSON.stringify({ ok: true, id: args.id, phase: snapshot.phase, snapshot }, null, 2)); } +function readServerInfo() { + try { return JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); } + catch { return null; } +} + +async function completeThroughServer(info, args) { + const type = args.status === 'discarded' + ? 'discarded' + : args.status === 'agent_error' + ? 'error' + : 'complete'; + try { + const res = await fetch(`http://localhost:${info.port}/poll`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token: info.token, id: args.id, type, message: args.message }), + }); + if (!res.ok) return null; + return await res.json(); + } catch { + return null; + } +} + const _running = process.argv[1]; if (_running?.endsWith('live-complete.mjs') || _running?.endsWith('live-complete.mjs/')) { completeCli(); diff --git a/tests/live-server.test.mjs b/tests/live-server.test.mjs index cdc7ed8f..db734e5d 100644 --- a/tests/live-server.test.mjs +++ b/tests/live-server.test.mjs @@ -8,10 +8,11 @@ import assert from 'node:assert/strict'; import { existsSync, mkdtempSync, readFileSync, writeFileSync, mkdirSync, rmSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; -import { execSync, spawn } from 'node:child_process'; +import { execFileSync, execSync, spawn } from 'node:child_process'; const REPO_ROOT = process.cwd(); const SERVER_SCRIPT = join(REPO_ROOT, 'source/skills/impeccable/scripts/live-server.mjs'); +const COMPLETE_SCRIPT = join(REPO_ROOT, 'source/skills/impeccable/scripts/live-complete.mjs'); // --------------------------------------------------------------------------- // Helper: start/stop server for integration tests // --------------------------------------------------------------------------- @@ -101,7 +102,7 @@ describe('live-server integration', () => { body: JSON.stringify({ token: server.token, type: 'generate', - id: 'status-test-1', + id: 'a1b2c3d5', action: 'impeccable', count: 1, pageUrl: '/', @@ -114,8 +115,8 @@ describe('live-server integration', () => { assert.equal(res.status, 200); const data = await res.json(); assert.equal(data.status, 'ok'); - assert.equal(data.activeSessions.some((s) => s.id === 'status-test-1'), true); - assert.equal(data.pendingEvents.some((e) => e.id === 'status-test-1' && e.type === 'generate'), true); + assert.equal(data.activeSessions.some((s) => s.id === 'a1b2c3d5'), true); + assert.equal(data.pendingEvents.some((e) => e.id === 'a1b2c3d5' && e.type === 'generate'), true); await drainPolls(server); }); @@ -280,7 +281,7 @@ describe('live-server integration', () => { it('persists browser events to the durable session journal before poll delivery', async () => { await drainPolls(server); - const journalPath = join(REPO_ROOT, '.impeccable-live', 'sessions', 'persist-test-1.jsonl'); + const journalPath = join(REPO_ROOT, '.impeccable-live', 'sessions', 'a1b2c3d6.jsonl'); rmSync(journalPath, { force: true }); const postRes = await fetch(`http://localhost:${server.port}/events`, { @@ -289,7 +290,7 @@ describe('live-server integration', () => { body: JSON.stringify({ token: server.token, type: 'generate', - id: 'persist-test-1', + id: 'a1b2c3d6', action: 'layout', count: 3, pageUrl: 'http://localhost:4321/', @@ -309,7 +310,7 @@ describe('live-server integration', () => { await fetch(`http://localhost:${server.port}/poll`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ token: server.token, id: 'persist-test-1', type: 'done' }), + body: JSON.stringify({ token: server.token, id: 'a1b2c3d6', type: 'done' }), }); }); @@ -321,7 +322,7 @@ describe('live-server integration', () => { body: JSON.stringify({ token: server.token, type: 'checkpoint', - id: 'checkpoint-test-1', + id: 'a1b2c3d7', phase: 'cycling', revision: 2, owner: 'browser-a', @@ -339,7 +340,7 @@ describe('live-server integration', () => { 'event=live_server.checkpoint_not_polled actor=browser operation=checkpoint risk=checkpoint_starves_agent_queue expected=timeout actual=' + polled.type + ' suggestion=journal checkpoint without enqueueing agent work', ); - const snapshot = JSON.parse(readFileSync(join(REPO_ROOT, '.impeccable-live', 'sessions', 'checkpoint-test-1.snapshot.json'), 'utf-8')); + const snapshot = JSON.parse(readFileSync(join(REPO_ROOT, '.impeccable-live', 'sessions', 'a1b2c3d7.snapshot.json'), 'utf-8')); assert.equal(snapshot.visibleVariant, 2); assert.deepEqual(snapshot.paramValues, { density: 'packed' }); }); @@ -356,7 +357,7 @@ describe('live-server integration', () => { body: JSON.stringify({ token: firstServer.token, type: 'generate', - id: 'restart-replay-1', + id: 'a1b2c3d8', action: 'polish', count: 2, pageUrl: 'http://localhost:4321/', @@ -374,8 +375,8 @@ describe('live-server integration', () => { assert.equal( replayed.id, - 'restart-replay-1', - 'event=live_server.restart_replay actor=agent operation=poll_after_helper_restart risk=server_restart_loses_unpolled_event expected=restart-replay-1 actual=' + replayed.id + ' suggestion=rebuild pending poll queue from live-session-store active snapshots on startup', + 'a1b2c3d8', + 'event=live_server.restart_replay actor=agent operation=poll_after_helper_restart risk=server_restart_loses_unpolled_event expected=a1b2c3d8 actual=' + replayed.id + ' suggestion=rebuild pending poll queue from live-session-store active snapshots on startup', ); assert.equal(replayed.type, 'generate'); } finally { @@ -399,7 +400,7 @@ describe('live-server integration', () => { body: JSON.stringify({ token: server.token, type: 'generate', - id: 'complete-ack-1', + id: 'a1b2c3d9', action: 'impeccable', count: 1, pageUrl: '/', @@ -407,17 +408,47 @@ describe('live-server integration', () => { }), }); const polled = await fetch(`http://localhost:${server.port}/poll?token=${server.token}&timeout=50`).then(r => r.json()); - assert.equal(polled.id, 'complete-ack-1'); + assert.equal(polled.id, 'a1b2c3d9'); const ack = await fetch(`http://localhost:${server.port}/poll`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ token: server.token, id: 'complete-ack-1', type: 'complete' }), + body: JSON.stringify({ token: server.token, id: 'a1b2c3d9', type: 'complete' }), }); assert.equal(ack.status, 200); - const snapshot = JSON.parse(readFileSync(join(REPO_ROOT, '.impeccable-live', 'sessions', 'complete-ack-1.snapshot.json'), 'utf-8')); + const snapshot = JSON.parse(readFileSync(join(REPO_ROOT, '.impeccable-live', 'sessions', 'a1b2c3d9.snapshot.json'), 'utf-8')); assert.equal(snapshot.phase, 'completed'); }); + it('manual live-complete acknowledges the running helper queue before writing fallback journal state', async () => { + await drainPolls(server); + await fetch(`http://localhost:${server.port}/events`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + token: server.token, + type: 'generate', + id: 'a1b2c3dc', + action: 'impeccable', + count: 1, + pageUrl: '/', + element: { outerHTML: '' }, + }), + }); + const polled = await fetch(`http://localhost:${server.port}/poll?token=${server.token}&timeout=50&leaseMs=50`).then(r => r.json()); + assert.equal(polled.id, 'a1b2c3dc'); + + const completed = JSON.parse(execFileSync(process.execPath, [COMPLETE_SCRIPT, '--id', 'a1b2c3dc'], { cwd: REPO_ROOT, encoding: 'utf-8' })); + assert.equal(completed.phase, 'completed'); + + await new Promise(r => setTimeout(r, 75)); + const stale = await fetch(`http://localhost:${server.port}/poll?token=${server.token}&timeout=50&leaseMs=50`).then(r => r.json()); + assert.equal( + stale.type, + 'timeout', + 'event=live_complete.running_server_ack actor=agent operation=manual_complete risk=completed_session_redelivered_from_memory expected=timeout actual=' + stale.id, + ); + }); + it('does not drop polled events until the agent acknowledges them', async () => { await drainPolls(server); @@ -427,7 +458,7 @@ describe('live-server integration', () => { body: JSON.stringify({ token: server.token, type: 'generate', - id: 'lease-test-1', + id: 'a1b2c3da', action: 'polish', count: 2, element: { outerHTML: '
lease
', tagName: 'section' }, @@ -436,7 +467,7 @@ describe('live-server integration', () => { assert.equal(postRes.status, 200); const first = await fetch(`http://localhost:${server.port}/poll?token=${server.token}&timeout=100&leaseMs=50`).then(r => r.json()); - assert.equal(first.id, 'lease-test-1'); + assert.equal(first.id, 'a1b2c3da'); const leased = await fetch(`http://localhost:${server.port}/poll?token=${server.token}&timeout=25&leaseMs=50`).then(r => r.json()); assert.equal(leased.type, 'timeout', 'leased event should not be redelivered before lease expiry'); @@ -445,14 +476,14 @@ describe('live-server integration', () => { const redelivered = await fetch(`http://localhost:${server.port}/poll?token=${server.token}&timeout=100&leaseMs=50`).then(r => r.json()); assert.equal( redelivered.id, - 'lease-test-1', + 'a1b2c3da', 'event=live_poll.lease_redelivery actor=agent operation=poll_after_missed_ack risk=agent_missed_event_loses_live_state expected=same event redelivered after lease expiry actual=' + redelivered.id + ' suggestion=inspect pending event lease bookkeeping', ); await fetch(`http://localhost:${server.port}/poll`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ token: server.token, id: 'lease-test-1', type: 'done' }), + body: JSON.stringify({ token: server.token, id: 'a1b2c3da', type: 'done' }), }); const acked = await fetch(`http://localhost:${server.port}/poll?token=${server.token}&timeout=50&leaseMs=50`).then(r => r.json()); assert.equal(acked.type, 'timeout', 'acked event should be removed from the poll queue'); @@ -467,7 +498,7 @@ describe('live-server integration', () => { body: JSON.stringify({ token: server.token, type: 'generate', - id: 'lease-wakeup-1', + id: 'a1b2c3db', action: 'polish', count: 1, element: { outerHTML: '
wakeup
', tagName: 'section' }, @@ -476,7 +507,7 @@ describe('live-server integration', () => { assert.equal(postRes.status, 200); const first = await fetch(`http://localhost:${server.port}/poll?token=${server.token}&timeout=100&leaseMs=60`).then(r => r.json()); - assert.equal(first.id, 'lease-wakeup-1'); + assert.equal(first.id, 'a1b2c3db'); const startedAt = Date.now(); const redelivered = await fetch(`http://localhost:${server.port}/poll?token=${server.token}&timeout=500&leaseMs=60`).then(r => r.json()); @@ -484,8 +515,8 @@ describe('live-server integration', () => { assert.equal( redelivered.id, - 'lease-wakeup-1', - 'event=live_poll.lease_expiry_wakeup actor=agent operation=poll_before_lease_expiry risk=parked_poll_waits_full_timeout expected=lease-wakeup-1 actual=' + redelivered.id, + 'a1b2c3db', + 'event=live_poll.lease_expiry_wakeup actor=agent operation=poll_before_lease_expiry risk=parked_poll_waits_full_timeout expected=a1b2c3db actual=' + redelivered.id, ); assert.ok( elapsed < 250, @@ -495,7 +526,7 @@ describe('live-server integration', () => { await fetch(`http://localhost:${server.port}/poll`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ token: server.token, id: 'lease-wakeup-1', type: 'done' }), + body: JSON.stringify({ token: server.token, id: 'a1b2c3db', type: 'done' }), }); }); From 326d146c878ece534d3094632a85fdc4124672a1 Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Sun, 3 May 2026 11:52:27 -0700 Subject: [PATCH 08/13] Add .impeccable project state paths --- .../skills/impeccable/reference/document.md | 12 +- .agents/skills/impeccable/reference/live.md | 10 +- .agents/skills/impeccable/reference/teach.md | 4 +- .../impeccable/scripts/impeccable-paths.mjs | 105 ++++++++++++++++++ .../skills/impeccable/scripts/live-browser.js | 10 +- .../impeccable/scripts/live-complete.mjs | 8 +- .../skills/impeccable/scripts/live-inject.mjs | 13 ++- .../skills/impeccable/scripts/live-poll.mjs | 11 +- .../skills/impeccable/scripts/live-server.mjs | 49 ++++---- .../impeccable/scripts/live-session-store.mjs | 36 ++++-- .../skills/impeccable/scripts/live-status.mjs | 8 +- .agents/skills/impeccable/scripts/live.mjs | 12 +- .../impeccable/scripts/load-context.mjs | 2 +- .../skills/impeccable/reference/document.md | 12 +- .claude/skills/impeccable/reference/live.md | 10 +- .claude/skills/impeccable/reference/teach.md | 4 +- .../impeccable/scripts/impeccable-paths.mjs | 105 ++++++++++++++++++ .../skills/impeccable/scripts/live-browser.js | 10 +- .../impeccable/scripts/live-complete.mjs | 8 +- .../skills/impeccable/scripts/live-inject.mjs | 13 ++- .../skills/impeccable/scripts/live-poll.mjs | 11 +- .../skills/impeccable/scripts/live-server.mjs | 49 ++++---- .../impeccable/scripts/live-session-store.mjs | 36 ++++-- .../skills/impeccable/scripts/live-status.mjs | 8 +- .claude/skills/impeccable/scripts/live.mjs | 12 +- .../impeccable/scripts/load-context.mjs | 2 +- .../skills/impeccable/reference/document.md | 12 +- .cursor/skills/impeccable/reference/live.md | 10 +- .cursor/skills/impeccable/reference/teach.md | 4 +- .../impeccable/scripts/impeccable-paths.mjs | 105 ++++++++++++++++++ .../skills/impeccable/scripts/live-browser.js | 10 +- .../impeccable/scripts/live-complete.mjs | 8 +- .../skills/impeccable/scripts/live-inject.mjs | 13 ++- .../skills/impeccable/scripts/live-poll.mjs | 11 +- .../skills/impeccable/scripts/live-server.mjs | 49 ++++---- .../impeccable/scripts/live-session-store.mjs | 36 ++++-- .../skills/impeccable/scripts/live-status.mjs | 8 +- .cursor/skills/impeccable/scripts/live.mjs | 12 +- .../impeccable/scripts/load-context.mjs | 2 +- .../skills/impeccable/reference/document.md | 12 +- .gemini/skills/impeccable/reference/live.md | 10 +- .gemini/skills/impeccable/reference/teach.md | 4 +- .../impeccable/scripts/impeccable-paths.mjs | 105 ++++++++++++++++++ .../skills/impeccable/scripts/live-browser.js | 10 +- .../impeccable/scripts/live-complete.mjs | 8 +- .../skills/impeccable/scripts/live-inject.mjs | 13 ++- .../skills/impeccable/scripts/live-poll.mjs | 11 +- .../skills/impeccable/scripts/live-server.mjs | 49 ++++---- .../impeccable/scripts/live-session-store.mjs | 36 ++++-- .../skills/impeccable/scripts/live-status.mjs | 8 +- .gemini/skills/impeccable/scripts/live.mjs | 12 +- .../impeccable/scripts/load-context.mjs | 2 +- .../skills/impeccable/reference/document.md | 12 +- .github/skills/impeccable/reference/live.md | 10 +- .github/skills/impeccable/reference/teach.md | 4 +- .../impeccable/scripts/impeccable-paths.mjs | 105 ++++++++++++++++++ .../skills/impeccable/scripts/live-browser.js | 10 +- .../impeccable/scripts/live-complete.mjs | 8 +- .../skills/impeccable/scripts/live-inject.mjs | 13 ++- .../skills/impeccable/scripts/live-poll.mjs | 11 +- .../skills/impeccable/scripts/live-server.mjs | 49 ++++---- .../impeccable/scripts/live-session-store.mjs | 36 ++++-- .../skills/impeccable/scripts/live-status.mjs | 8 +- .github/skills/impeccable/scripts/live.mjs | 12 +- .../impeccable/scripts/load-context.mjs | 2 +- .gitignore | 14 ++- DESIGN.json => .impeccable/design.json | 0 .kiro/skills/impeccable/reference/document.md | 12 +- .kiro/skills/impeccable/reference/live.md | 10 +- .kiro/skills/impeccable/reference/teach.md | 4 +- .../impeccable/scripts/impeccable-paths.mjs | 105 ++++++++++++++++++ .../skills/impeccable/scripts/live-browser.js | 10 +- .../impeccable/scripts/live-complete.mjs | 8 +- .../skills/impeccable/scripts/live-inject.mjs | 13 ++- .kiro/skills/impeccable/scripts/live-poll.mjs | 11 +- .../skills/impeccable/scripts/live-server.mjs | 49 ++++---- .../impeccable/scripts/live-session-store.mjs | 36 ++++-- .../skills/impeccable/scripts/live-status.mjs | 8 +- .kiro/skills/impeccable/scripts/live.mjs | 12 +- .../impeccable/scripts/load-context.mjs | 2 +- .../skills/impeccable/reference/document.md | 12 +- .opencode/skills/impeccable/reference/live.md | 10 +- .../skills/impeccable/reference/teach.md | 4 +- .../impeccable/scripts/impeccable-paths.mjs | 105 ++++++++++++++++++ .../skills/impeccable/scripts/live-browser.js | 10 +- .../impeccable/scripts/live-complete.mjs | 8 +- .../skills/impeccable/scripts/live-inject.mjs | 13 ++- .../skills/impeccable/scripts/live-poll.mjs | 11 +- .../skills/impeccable/scripts/live-server.mjs | 49 ++++---- .../impeccable/scripts/live-session-store.mjs | 36 ++++-- .../skills/impeccable/scripts/live-status.mjs | 8 +- .opencode/skills/impeccable/scripts/live.mjs | 12 +- .../impeccable/scripts/load-context.mjs | 2 +- .pi/skills/impeccable/reference/document.md | 12 +- .pi/skills/impeccable/reference/live.md | 10 +- .pi/skills/impeccable/reference/teach.md | 4 +- .../impeccable/scripts/impeccable-paths.mjs | 105 ++++++++++++++++++ .pi/skills/impeccable/scripts/live-browser.js | 10 +- .../impeccable/scripts/live-complete.mjs | 8 +- .pi/skills/impeccable/scripts/live-inject.mjs | 13 ++- .pi/skills/impeccable/scripts/live-poll.mjs | 11 +- .pi/skills/impeccable/scripts/live-server.mjs | 49 ++++---- .../impeccable/scripts/live-session-store.mjs | 36 ++++-- .pi/skills/impeccable/scripts/live-status.mjs | 8 +- .pi/skills/impeccable/scripts/live.mjs | 12 +- .../impeccable/scripts/load-context.mjs | 2 +- .../skills/impeccable/reference/document.md | 12 +- .qoder/skills/impeccable/reference/live.md | 10 +- .qoder/skills/impeccable/reference/teach.md | 4 +- .../impeccable/scripts/impeccable-paths.mjs | 105 ++++++++++++++++++ .../skills/impeccable/scripts/live-browser.js | 10 +- .../impeccable/scripts/live-complete.mjs | 8 +- .../skills/impeccable/scripts/live-inject.mjs | 13 ++- .../skills/impeccable/scripts/live-poll.mjs | 11 +- .../skills/impeccable/scripts/live-server.mjs | 49 ++++---- .../impeccable/scripts/live-session-store.mjs | 36 ++++-- .../skills/impeccable/scripts/live-status.mjs | 8 +- .qoder/skills/impeccable/scripts/live.mjs | 12 +- .../impeccable/scripts/load-context.mjs | 2 +- .../skills/impeccable/reference/document.md | 12 +- .rovodev/skills/impeccable/reference/live.md | 10 +- .rovodev/skills/impeccable/reference/teach.md | 4 +- .../impeccable/scripts/impeccable-paths.mjs | 105 ++++++++++++++++++ .../skills/impeccable/scripts/live-browser.js | 10 +- .../impeccable/scripts/live-complete.mjs | 8 +- .../skills/impeccable/scripts/live-inject.mjs | 13 ++- .../skills/impeccable/scripts/live-poll.mjs | 11 +- .../skills/impeccable/scripts/live-server.mjs | 49 ++++---- .../impeccable/scripts/live-session-store.mjs | 36 ++++-- .../skills/impeccable/scripts/live-status.mjs | 8 +- .rovodev/skills/impeccable/scripts/live.mjs | 12 +- .../impeccable/scripts/load-context.mjs | 2 +- .../skills/impeccable/reference/document.md | 12 +- .trae-cn/skills/impeccable/reference/live.md | 10 +- .trae-cn/skills/impeccable/reference/teach.md | 4 +- .../impeccable/scripts/impeccable-paths.mjs | 105 ++++++++++++++++++ .../skills/impeccable/scripts/live-browser.js | 10 +- .../impeccable/scripts/live-complete.mjs | 8 +- .../skills/impeccable/scripts/live-inject.mjs | 13 ++- .../skills/impeccable/scripts/live-poll.mjs | 11 +- .../skills/impeccable/scripts/live-server.mjs | 49 ++++---- .../impeccable/scripts/live-session-store.mjs | 36 ++++-- .../skills/impeccable/scripts/live-status.mjs | 8 +- .trae-cn/skills/impeccable/scripts/live.mjs | 12 +- .../impeccable/scripts/load-context.mjs | 2 +- .trae/skills/impeccable/reference/document.md | 12 +- .trae/skills/impeccable/reference/live.md | 10 +- .trae/skills/impeccable/reference/teach.md | 4 +- .../impeccable/scripts/impeccable-paths.mjs | 105 ++++++++++++++++++ .../skills/impeccable/scripts/live-browser.js | 10 +- .../impeccable/scripts/live-complete.mjs | 8 +- .../skills/impeccable/scripts/live-inject.mjs | 13 ++- .trae/skills/impeccable/scripts/live-poll.mjs | 11 +- .../skills/impeccable/scripts/live-server.mjs | 49 ++++---- .../impeccable/scripts/live-session-store.mjs | 36 ++++-- .../skills/impeccable/scripts/live-status.mjs | 8 +- .trae/skills/impeccable/scripts/live.mjs | 12 +- .../impeccable/scripts/load-context.mjs | 2 +- README.md | 4 +- docs/adr-live-variant-mode.md | 4 +- package.json | 2 +- .../skills/impeccable/reference/document.md | 12 +- plugin/skills/impeccable/reference/live.md | 10 +- plugin/skills/impeccable/reference/teach.md | 4 +- .../impeccable/scripts/impeccable-paths.mjs | 105 ++++++++++++++++++ .../skills/impeccable/scripts/live-browser.js | 10 +- .../impeccable/scripts/live-complete.mjs | 8 +- .../skills/impeccable/scripts/live-inject.mjs | 13 ++- .../skills/impeccable/scripts/live-poll.mjs | 11 +- .../skills/impeccable/scripts/live-server.mjs | 49 ++++---- .../impeccable/scripts/live-session-store.mjs | 36 ++++-- .../skills/impeccable/scripts/live-status.mjs | 8 +- plugin/skills/impeccable/scripts/live.mjs | 12 +- .../impeccable/scripts/load-context.mjs | 2 +- scripts/build.js | 2 +- scripts/lib/utils.js | 5 +- .../skills/impeccable/reference/document.md | 12 +- source/skills/impeccable/reference/live.md | 10 +- source/skills/impeccable/reference/teach.md | 4 +- .../impeccable/scripts/impeccable-paths.mjs | 105 ++++++++++++++++++ .../skills/impeccable/scripts/live-browser.js | 10 +- .../impeccable/scripts/live-complete.mjs | 8 +- .../skills/impeccable/scripts/live-inject.mjs | 13 ++- .../skills/impeccable/scripts/live-poll.mjs | 11 +- .../skills/impeccable/scripts/live-server.mjs | 49 ++++---- .../impeccable/scripts/live-session-store.mjs | 36 ++++-- .../skills/impeccable/scripts/live-status.mjs | 8 +- source/skills/impeccable/scripts/live.mjs | 12 +- .../impeccable/scripts/load-context.mjs | 2 +- tests/framework-fixtures.test.mjs | 22 +--- tests/framework-fixtures/README.md | 2 +- tests/impeccable-paths.test.mjs | 100 +++++++++++++++++ tests/live-e2e/session.mjs | 7 +- tests/live-inject.test.mjs | 50 ++++++++- tests/live-server.test.mjs | 86 +++++++++++++- tests/live-session-store.test.mjs | 45 +++++++- 196 files changed, 3115 insertions(+), 1148 deletions(-) create mode 100644 .agents/skills/impeccable/scripts/impeccable-paths.mjs create mode 100644 .claude/skills/impeccable/scripts/impeccable-paths.mjs create mode 100644 .cursor/skills/impeccable/scripts/impeccable-paths.mjs create mode 100644 .gemini/skills/impeccable/scripts/impeccable-paths.mjs create mode 100644 .github/skills/impeccable/scripts/impeccable-paths.mjs rename DESIGN.json => .impeccable/design.json (100%) create mode 100644 .kiro/skills/impeccable/scripts/impeccable-paths.mjs create mode 100644 .opencode/skills/impeccable/scripts/impeccable-paths.mjs create mode 100644 .pi/skills/impeccable/scripts/impeccable-paths.mjs create mode 100644 .qoder/skills/impeccable/scripts/impeccable-paths.mjs create mode 100644 .rovodev/skills/impeccable/scripts/impeccable-paths.mjs create mode 100644 .trae-cn/skills/impeccable/scripts/impeccable-paths.mjs create mode 100644 .trae/skills/impeccable/scripts/impeccable-paths.mjs create mode 100644 plugin/skills/impeccable/scripts/impeccable-paths.mjs create mode 100644 source/skills/impeccable/scripts/impeccable-paths.mjs create mode 100644 tests/impeccable-paths.test.mjs diff --git a/.agents/skills/impeccable/reference/document.md b/.agents/skills/impeccable/reference/document.md index 12e9a02d..ebafe4f4 100644 --- a/.agents/skills/impeccable/reference/document.md +++ b/.agents/skills/impeccable/reference/document.md @@ -237,11 +237,11 @@ Concrete, forceful guardrails. Lead each with "Do" or "Don't". Be specific: incl - **Don't** [...] ``` -### Step 4b: Write DESIGN.json sidecar (extensions only) +### Step 4b: Write .impeccable/design.json sidecar (extensions only) -The frontmatter owns token primitives (colors, typography, rounded, spacing, components). The sidecar at `DESIGN.json` carries **what Stitch's schema can't hold**: tonal ramps per color, shadow/elevation tokens, motion tokens, breakpoints, full component HTML/CSS snippets (the panel renders these into a shadow DOM), and narrative (north star, rules, do's/don'ts). It extends the frontmatter, it doesn't duplicate it. +The frontmatter owns token primitives (colors, typography, rounded, spacing, components). The sidecar at `.impeccable/design.json` carries **what Stitch's schema can't hold**: tonal ramps per color, shadow/elevation tokens, motion tokens, breakpoints, full component HTML/CSS snippets (the panel renders these into a shadow DOM), and narrative (north star, rules, do's/don'ts). It extends the frontmatter, it doesn't duplicate it. -Regenerate the sidecar whenever you regenerate DESIGN.md. If the user only asks to refresh the sidecar (e.g., from the live panel's stale-hint), preserve DESIGN.md and write only DESIGN.json. +Regenerate the sidecar whenever you regenerate root `DESIGN.md`. If the user only asks to refresh the sidecar (e.g., from the live panel's stale-hint), preserve `DESIGN.md` and write only `.impeccable/design.json`. #### Schema @@ -310,7 +310,7 @@ Aim for a tight set of **5-10 components** that best represent the visual system - **Signature components (include if distinctive):** hero CTA, featured card, filter pill, any custom pattern the user mentioned as important in PRODUCT.md. - **Skip the rest.** Utility components, form building blocks, wrapper layouts: not worth documenting unless visually distinctive. -If the project has **no component library yet** (bare landing page, new project), synthesize canonical primitives from the tokens using best-practice defaults consistent with the DESIGN.md's rules. Every DESIGN.json has *something* to render, even on day zero. +If the project has **no component library yet** (bare landing page, new project), synthesize canonical primitives from the tokens using best-practice defaults consistent with the DESIGN.md's rules. Every `.impeccable/design.json` has *something* to render, even on day zero. #### Tonal ramps @@ -331,7 +331,7 @@ Do not reword. The panel shows these as secondary collapsible context; the same ### Step 5: Confirm, refine, and refresh session cache 1. Show the user the full DESIGN.md you wrote. Briefly highlight the non-obvious creative choices (descriptive color names, atmosphere language, named rules). -2. Mention that `DESIGN.json` was also written alongside; the live panel will now render this project's actual button/input/nav primitives instead of generic approximations. +2. Mention that `.impeccable/design.json` was also written alongside; the live panel will now render this project's actual button/input/nav primitives instead of generic approximations. 3. Offer to refine any section: "Want me to revise a section, add component patterns I missed, or adjust the atmosphere language?" 4. **Refresh the session cache.** Run `node .agents/skills/impeccable/scripts/load-context.mjs` one final time so the newly-written DESIGN.md lands in conversation. Subsequent commands in this session will use the fresh version automatically without re-reading. @@ -392,7 +392,7 @@ Per-section guidance in seed mode: - **Components**: omit entirely; no components exist yet. - **Do's and Don'ts**: carry PRODUCT.md's anti-references directly plus the anti-reference named in Q5. -Seed mode writes a minimal frontmatter with `name` and `description` only; no colors, typography, rounded, spacing, or components yet. Real tokens land on the next Scan-mode run. Skip the `DESIGN.json` sidecar in seed mode for the same reason: nothing to render. +Seed mode writes a minimal frontmatter with `name` and `description` only; no colors, typography, rounded, spacing, or components yet. Real tokens land on the next Scan-mode run. Skip the `.impeccable/design.json` sidecar in seed mode for the same reason: nothing to render. ### Step 4: Confirm and refresh session cache diff --git a/.agents/skills/impeccable/reference/live.md b/.agents/skills/impeccable/reference/live.md index 3e04af54..235f7fed 100644 --- a/.agents/skills/impeccable/reference/live.md +++ b/.agents/skills/impeccable/reference/live.md @@ -53,7 +53,7 @@ LOOP: ## Recovery commands -The live helper persists an append-only journal under `.impeccable-live/sessions`. Browser checkpoints are advisory but durable; the journal is canonical. +The live helper persists an append-only journal under `.impeccable/live/sessions/`. Browser checkpoints are advisory but durable; the journal is canonical. This is local durable recovery state, not project source. Use these commands when the chat was interrupted, polling was missed, the helper restarted, or the browser reloaded: @@ -473,7 +473,7 @@ When the poll returns `exit`, proceed to cleanup. If the poll is still running a node .agents/skills/impeccable/scripts/live-server.mjs stop ``` -Stops the HTTP server and runs `live-inject.mjs --remove` to strip `localhost:…/live.js` from the HTML entry. To stop the server but keep the inject tag (for a quick restart), use `stop --keep-inject`. `config.json` persists for future sessions. +Stops the HTTP server and runs `live-inject.mjs --remove` to strip `localhost:…/live.js` from the HTML entry. To stop the server but keep the inject tag (for a quick restart), use `stop --keep-inject`. `.impeccable/live/config.json` persists as project config for future sessions. Then: - Remove any leftover variant wrappers (search for `impeccable-variants-start` markers). @@ -481,7 +481,7 @@ Then: ## First-time setup (config missing or invalid) -If `live.mjs` outputs `{ ok: false, error: "config_missing" | "config_invalid", path }`, write `config.json` at the reported path. +If `live.mjs` outputs `{ ok: false, error: "config_missing" | "config_invalid", path }`, write the live config at the reported path. By default this is `.impeccable/live/config.json`. Schema: @@ -561,7 +561,7 @@ node .agents/skills/impeccable/scripts/detect-csp.mjs Output: `{ shape, signals }` where `shape` is one of `append-arrays`, `append-string`, `middleware`, `meta-tag`, or `null`. The shape is named by *patch mechanism*, so one template covers many frameworks. -- **`null`**: no CSP; skip to writing `config.json` with `cspChecked: true`. +- **`null`**: no CSP; skip to writing `.impeccable/live/config.json` with `cspChecked: true`. - **`append-arrays`**: CSP defined as structured directive arrays. Auto-patchable. See *append-arrays* below. Covers: - Monorepo helpers with `additionalScriptSrc` / `additionalConnectSrc` options (Next.js + shared config package) - SvelteKit `kit.csp.directives` @@ -638,6 +638,6 @@ Reference outputs: ### Troubleshooting -If a user says "no" to the CSP patch at setup time and later complains that live doesn't work: their dev CSP blocks `http://localhost:8400`. Fix: delete `cspChecked` from `config.json` and re-run `live.mjs`: setup will ask again. +If a user says "no" to the CSP patch at setup time and later complains that live doesn't work: their dev CSP blocks `http://localhost:8400`. Fix: delete `cspChecked` from `.impeccable/live/config.json` and re-run `live.mjs`: setup will ask again. Then re-run `live.mjs`. diff --git a/.agents/skills/impeccable/reference/teach.md b/.agents/skills/impeccable/reference/teach.md index 66c7e2a4..6b7df209 100644 --- a/.agents/skills/impeccable/reference/teach.md +++ b/.agents/skills/impeccable/reference/teach.md @@ -2,8 +2,8 @@ Gathers design context for a project and writes two complementary files at the project root: -- **PRODUCT.md** (strategic): register, target users, product purpose, brand personality, anti-references, strategic design principles. Answers "who/what/why". -- **DESIGN.md** (visual): visual theme, color palette, typography, components, layout. Follows the [Google Stitch DESIGN.md format](https://stitch.withgoogle.com/docs/design-md/format/). Answers "how it looks". +- **PRODUCT.md** (strategic): root project file for register, target users, product purpose, brand personality, anti-references, strategic design principles. Answers "who/what/why". +- **DESIGN.md** (visual): root project file for visual theme, color palette, typography, components, layout. Follows the [Google Stitch DESIGN.md format](https://stitch.withgoogle.com/docs/design-md/format/). Answers "how it looks". Every other impeccable command reads these files before doing any work. diff --git a/.agents/skills/impeccable/scripts/impeccable-paths.mjs b/.agents/skills/impeccable/scripts/impeccable-paths.mjs new file mode 100644 index 00000000..ba852bae --- /dev/null +++ b/.agents/skills/impeccable/scripts/impeccable-paths.mjs @@ -0,0 +1,105 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +export const IMPECCABLE_DIR = '.impeccable'; +export const LIVE_DIR = 'live'; + +export function getImpeccableDir(cwd = process.cwd()) { + return path.join(cwd, IMPECCABLE_DIR); +} + +export function getDesignSidecarPath(cwd = process.cwd()) { + return path.join(getImpeccableDir(cwd), 'design.json'); +} + +export function getDesignSidecarCandidates(cwd = process.cwd(), contextDir = cwd) { + const candidates = [ + getDesignSidecarPath(cwd), + path.join(cwd, 'DESIGN.json'), + ]; + const contextLegacy = path.join(contextDir, 'DESIGN.json'); + if (!candidates.includes(contextLegacy)) candidates.push(contextLegacy); + return candidates; +} + +export function resolveDesignSidecarPath(cwd = process.cwd(), contextDir = cwd) { + return firstExisting(getDesignSidecarCandidates(cwd, contextDir)); +} + +export function getLiveDir(cwd = process.cwd()) { + return path.join(getImpeccableDir(cwd), LIVE_DIR); +} + +export function getLiveConfigPath(cwd = process.cwd()) { + return path.join(getLiveDir(cwd), 'config.json'); +} + +export function getLegacyLiveConfigPath(scriptsDir) { + return path.join(scriptsDir, 'config.json'); +} + +export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = process.env } = {}) { + if (env.IMPECCABLE_LIVE_CONFIG && env.IMPECCABLE_LIVE_CONFIG.trim()) { + const configured = env.IMPECCABLE_LIVE_CONFIG.trim(); + return path.isAbsolute(configured) ? configured : path.resolve(cwd, configured); + } + const primary = getLiveConfigPath(cwd); + if (fs.existsSync(primary)) return primary; + if (scriptsDir) { + const legacy = getLegacyLiveConfigPath(scriptsDir); + if (fs.existsSync(legacy)) return legacy; + } + return primary; +} + +export function getLiveServerPath(cwd = process.cwd()) { + return path.join(getLiveDir(cwd), 'server.json'); +} + +export function getLegacyLiveServerPath(cwd = process.cwd()) { + return path.join(cwd, '.impeccable-live.json'); +} + +export function readLiveServerInfo(cwd = process.cwd()) { + for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) { + try { + return { info: JSON.parse(fs.readFileSync(filePath, 'utf-8')), path: filePath }; + } catch { + /* try next */ + } + } + return null; +} + +export function writeLiveServerInfo(cwd = process.cwd(), info) { + const filePath = getLiveServerPath(cwd); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, JSON.stringify(info)); + return filePath; +} + +export function removeLiveServerInfo(cwd = process.cwd()) { + for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) { + try { fs.unlinkSync(filePath); } catch {} + } +} + +export function getLiveSessionsDir(cwd = process.cwd()) { + return path.join(getLiveDir(cwd), 'sessions'); +} + +export function getLegacyLiveSessionsDir(cwd = process.cwd()) { + return path.join(cwd, '.impeccable-live', 'sessions'); +} + +export function getLiveAnnotationsDir(cwd = process.cwd()) { + return path.join(getLiveDir(cwd), 'annotations'); +} + +export function getLegacyLiveAnnotationsDir(cwd = process.cwd()) { + return path.join(cwd, '.impeccable-live', 'annotations'); +} + +function firstExisting(paths) { + return paths.find((filePath) => fs.existsSync(filePath)) || null; +} diff --git a/.agents/skills/impeccable/scripts/live-browser.js b/.agents/skills/impeccable/scripts/live-browser.js index 7f1ff329..bb5332a3 100644 --- a/.agents/skills/impeccable/scripts/live-browser.js +++ b/.agents/skills/impeccable/scripts/live-browser.js @@ -3671,7 +3671,7 @@ void main() { } // --------------------------------------------------------------------------- - // Design System Panel — visualizes the project's DESIGN.json sidecar + // Design System Panel — visualizes the project's .impeccable/design.json sidecar // --------------------------------------------------------------------------- const DESIGN_PREFS_KEY = 'impeccable-live-design-panel'; @@ -3683,7 +3683,7 @@ void main() { open: false, tab: 'visual', // 'visual' | 'raw' parsed: null, // parseDesignMd output (frontmatter + body sections) - sidecar: null, // DESIGN.json v2 payload (extensions + components + narrative) + sidecar: null, // .impeccable/design.json v2 payload (extensions + components + narrative) hasMd: false, hasSidecar: false, present: null, // true/false once fetch resolves @@ -4184,7 +4184,7 @@ void main() { box.className = 'stale'; box.innerHTML = ` - DESIGN.md is newer than DESIGN.json. Run /impeccable document to refresh the sidecar. + DESIGN.md is newer than .impeccable/design.json. Run /impeccable document to refresh the sidecar. `; return box; } @@ -4192,7 +4192,7 @@ void main() { function renderParsedMdCta() { const box = document.createElement('div'); box.className = 'parsed-md-cta'; - box.innerHTML = `Basic viewThis panel reads the tokens in your DESIGN.md frontmatter. Running /impeccable document also generates a DESIGN.json sidecar with your project's actual component snippets (button, input, nav) and tonal ramps, rendered live below the tokens.`; + box.innerHTML = `Basic viewThis panel reads the tokens in your DESIGN.md frontmatter. Running /impeccable document also generates a .impeccable/design.json sidecar with your project's actual component snippets (button, input, nav) and tonal ramps, rendered live below the tokens.`; return box; } @@ -4652,7 +4652,7 @@ void main() { function cssSafe(v) { // Strip anything outside valid CSS value chars to prevent injection via - // DESIGN.json values rendered into inline style strings. + // .impeccable/design.json values rendered into inline style strings. return String(v).replace(/[<>"'`\n]/g, ''); } diff --git a/.agents/skills/impeccable/scripts/live-complete.mjs b/.agents/skills/impeccable/scripts/live-complete.mjs index ca00d86a..78155af8 100644 --- a/.agents/skills/impeccable/scripts/live-complete.mjs +++ b/.agents/skills/impeccable/scripts/live-complete.mjs @@ -4,10 +4,7 @@ */ import { createLiveSessionStore } from './live-session-store.mjs'; -import fs from 'node:fs'; -import path from 'node:path'; - -const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); +import { readLiveServerInfo } from './impeccable-paths.mjs'; function parseArgs(argv) { const out = { status: 'complete' }; @@ -50,8 +47,7 @@ export async function completeCli() { } function readServerInfo() { - try { return JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); } - catch { return null; } + return readLiveServerInfo(process.cwd())?.info || null; } async function completeThroughServer(info, args) { diff --git a/.agents/skills/impeccable/scripts/live-inject.mjs b/.agents/skills/impeccable/scripts/live-inject.mjs index 1d2ae12f..555c3a36 100644 --- a/.agents/skills/impeccable/scripts/live-inject.mjs +++ b/.agents/skills/impeccable/scripts/live-inject.mjs @@ -2,23 +2,24 @@ * CLI helper: insert/remove the live variant mode script tag in the project's * main HTML entry point. * - * On first live run, the agent generates `config.json` in this script's - * directory with the project's insertion target (framework-specific). On + * On first live run, the agent generates `.impeccable/live/config.json` + * with the project's insertion target (framework-specific). On * every subsequent run, this script handles insert/remove deterministically * with zero LLM involvement. * * Usage: * node live-inject.mjs --port PORT # Insert the live script tag * node live-inject.mjs --remove # Remove the live script tag - * node live-inject.mjs --check # Check whether config.json exists + * node live-inject.mjs --check # Check whether live config exists */ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { resolveLiveConfigPath } from './impeccable-paths.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const CONFIG_PATH = process.env.IMPECCABLE_LIVE_CONFIG || path.join(__dirname, 'config.json'); +const CONFIG_PATH = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname }); const MARKER_OPEN_TEXT = 'impeccable-live-start'; const MARKER_CLOSE_TEXT = 'impeccable-live-end'; @@ -39,12 +40,12 @@ export async function injectCli() { console.log(`Usage: node live-inject.mjs [options] Insert or remove the live mode script tag in the project's HTML entry point. -Reads configuration from config.json (in this same directory). +Reads configuration from .impeccable/live/config.json. Modes: --port PORT Insert script tag pointing at http://localhost:PORT/live.js --remove Remove the script tag (if present) - --check Print whether config.json exists and its content + --check Print whether .impeccable/live/config.json exists and its content Output (JSON): { ok, file, inserted|removed, config? }`); diff --git a/.agents/skills/impeccable/scripts/live-poll.mjs b/.agents/skills/impeccable/scripts/live-poll.mjs index 9a3f07ae..83a9912e 100644 --- a/.agents/skills/impeccable/scripts/live-poll.mjs +++ b/.agents/skills/impeccable/scripts/live-poll.mjs @@ -9,11 +9,10 @@ */ import { execFileSync } from 'node:child_process'; -import fs from 'node:fs'; import path from 'node:path'; -import os from 'node:os'; import { fileURLToPath } from 'node:url'; import { completionTypeForAcceptResult } from './live-completion.mjs'; +import { readLiveServerInfo } from './impeccable-paths.mjs'; // Node's built-in fetch (undici under the hood) enforces a 300s headers // timeout that can't be lowered per-request. We cap each request below @@ -21,15 +20,13 @@ import { completionTypeForAcceptResult } from './live-completion.mjs'; // depending on the standalone undici package. const PER_REQUEST_TIMEOUT_MS = 270_000; -const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); - function readServerInfo() { - try { - return JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); - } catch { + const record = readLiveServerInfo(process.cwd()); + if (!record) { console.error('No running live server found. Start one with: npx impeccable live'); process.exit(1); } + return record.info; } export function buildPollReplyPayload(token, { id, type, message, file, data }) { diff --git a/.agents/skills/impeccable/scripts/live-server.mjs b/.agents/skills/impeccable/scripts/live-server.mjs index e78a0657..53d8f21c 100644 --- a/.agents/skills/impeccable/scripts/live-server.mjs +++ b/.agents/skills/impeccable/scripts/live-server.mjs @@ -23,14 +23,19 @@ import { fileURLToPath } from 'node:url'; import { parseDesignMd } from './design-parser.mjs'; import { resolveContextDir } from './load-context.mjs'; import { createLiveSessionStore } from './live-session-store.mjs'; +import { + getDesignSidecarPath, + getLiveAnnotationsDir, + readLiveServerInfo, + removeLiveServerInfo, + resolveDesignSidecarPath, + writeLiveServerInfo, +} from './impeccable-paths.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -// PID file in the project root so both the server and agent can find it -// predictably (os.tmpdir() varies across platforms). -const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); -// PRODUCT.md / DESIGN.md / DESIGN.json live wherever load-context.mjs resolves. -// Keeps live-server in sync with the loader when users keep the docs in -// .agents/context/, docs/, or a path set via IMPECCABLE_CONTEXT_DIR. +// PRODUCT.md / DESIGN.md live wherever load-context.mjs resolves. The generated +// DESIGN sidecar is project-local at .impeccable/design.json, with legacy +// DESIGN.json fallback for existing projects. const CONTEXT_DIR = resolveContextDir(process.cwd()); const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s @@ -411,13 +416,13 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { } // --- Design system (unified v2 response) + raw --- - // /design-system.json returns both parsed DESIGN.md and DESIGN.json + // /design-system.json returns both parsed DESIGN.md and .impeccable/design.json // sidecar when present. Panel merges them: // { present, parsed, sidecar, hasMd, hasSidecar, // mdNewerThanJson, parseError?, sidecarError? } // - parsed: output of parseDesignMd (frontmatter // + six canonical sections) when DESIGN.md exists. - // - sidecar: DESIGN.json contents when present. + // - sidecar: .impeccable/design.json contents when present. // Expected shape: schemaVersion 2, carrying // extensions + components + narrative. // /design-system/raw returns DESIGN.md markdown verbatim @@ -426,7 +431,7 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } const mdPath = path.join(CONTEXT_DIR, 'DESIGN.md'); - const jsonPath = path.join(CONTEXT_DIR, 'DESIGN.json'); + const jsonPath = resolveDesignSidecarPath(process.cwd(), CONTEXT_DIR) || getDesignSidecarPath(process.cwd()); const mdStat = statOrNull(mdPath); const jsonStat = statOrNull(jsonPath); @@ -462,7 +467,7 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { try { response.sidecar = JSON.parse(fs.readFileSync(jsonPath, 'utf-8')); } catch (err) { - response.sidecarError = 'Failed to parse DESIGN.json: ' + err.message; + response.sidecarError = 'Failed to parse .impeccable/design.json: ' + err.message; } } @@ -673,7 +678,7 @@ function handlePollPost(req, res) { let httpServer = null; function shutdown() { - try { fs.unlinkSync(LIVE_PID_FILE); } catch {} + removeLiveServerInfo(process.cwd()); if (state.leaseTimer) clearTimeout(state.leaseTimer); state.leaseTimer = null; if (state.sessionDir) { @@ -725,7 +730,7 @@ Endpoints: if (args.includes('stop')) { const keepInject = args.includes('--keep-inject'); try { - const info = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); + const { info } = readLiveServerInfo(process.cwd()) || {}; const res = await fetch(`http://localhost:${info.port}/stop?token=${info.token}`); if (res.ok) console.log(`Stopped live server on port ${info.port}.`); } catch { @@ -776,7 +781,7 @@ if (args.includes('--background')) { const deadline = Date.now() + 10_000; while (Date.now() < deadline) { try { - const info = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); + const { info } = readLiveServerInfo(process.cwd()) || {}; if (info.pid !== process.pid) { // Output JSON so the agent can read port + token from stdout. console.log(JSON.stringify(info)); @@ -790,14 +795,18 @@ if (args.includes('--background')) { } // Check for existing session -try { - const existing = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); - try { process.kill(existing.pid, 0); +const existingRecord = readLiveServerInfo(process.cwd()); +if (existingRecord?.info) { + const existing = existingRecord.info; + try { + process.kill(existing.pid, 0); console.error(`Live server already running on port ${existing.port} (pid ${existing.pid}).`); console.error('Stop it first with: node ' + path.basename(fileURLToPath(import.meta.url)) + ' stop'); process.exit(1); - } catch { fs.unlinkSync(LIVE_PID_FILE); } -} catch {} + } catch { + try { fs.unlinkSync(existingRecord.path); } catch {} + } +} state.token = randomUUID(); state.sessionStore = createLiveSessionStore({ cwd: process.cwd() }); @@ -807,7 +816,7 @@ state.port = portArg ? parseInt(portArg.split('=')[1], 10) : await findOpenPort( // Annotation screenshots live in the project root so the agent's Read tool // doesn't trip a per-file permission prompt. Sessioned by token so concurrent // projects (or quick restarts) don't collide. -const annotRoot = path.join(process.cwd(), '.impeccable-live', 'annotations'); +const annotRoot = getLiveAnnotationsDir(process.cwd()); fs.mkdirSync(annotRoot, { recursive: true }); state.sessionDir = fs.mkdtempSync(path.join(annotRoot, 'session-')); @@ -815,7 +824,7 @@ const { detectScript, sessionPath, livePath } = loadBrowserScripts(); httpServer = http.createServer(createRequestHandler({ detectScript, sessionPath, livePath })); httpServer.listen(state.port, '127.0.0.1', () => { - fs.writeFileSync(LIVE_PID_FILE, JSON.stringify({ pid: process.pid, port: state.port, token: state.token })); + writeLiveServerInfo(process.cwd(), { pid: process.pid, port: state.port, token: state.token }); const url = `http://localhost:${state.port}`; console.log(`\nImpeccable live server running on ${url}`); console.log(`Token: ${state.token}\n`); diff --git a/.agents/skills/impeccable/scripts/live-session-store.mjs b/.agents/skills/impeccable/scripts/live-session-store.mjs index cc7744df..37711168 100644 --- a/.agents/skills/impeccable/scripts/live-session-store.mjs +++ b/.agents/skills/impeccable/scripts/live-session-store.mjs @@ -1,30 +1,43 @@ import fs from 'node:fs'; import path from 'node:path'; +import { getLegacyLiveSessionsDir, getLiveSessionsDir } from './impeccable-paths.mjs'; -const LIVE_DIR = '.impeccable-live'; -const SESSIONS_DIR = 'sessions'; const COMPLETED_PHASES = new Set(['completed', 'discarded']); export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {}) { - const rootDir = path.join(cwd, LIVE_DIR, SESSIONS_DIR); + 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 = getJournalPath(rootDir, id); + const journalPath = getReadableJournalPath(id); const rebuilt = rebuildSnapshotFromJournal(journalPath, id); snapshotCache.set(id, rebuilt); return rebuilt; } + function getReadableJournalPath(id) { + const primary = getJournalPath(rootDir, id); + if (fs.existsSync(primary)) return primary; + const legacy = getJournalPath(legacyRootDir, id); + if (fs.existsSync(legacy)) return legacy; + return primary; + } + return { rootDir, + legacyRootDir, appendEvent(event) { const normalized = normalizeEvent(event, sessionId); const journalPath = getJournalPath(rootDir, normalized.id); const snapshotPath = getSnapshotPath(rootDir, normalized.id); + const legacyJournalPath = getJournalPath(legacyRootDir, normalized.id); + if (!fs.existsSync(journalPath) && fs.existsSync(legacyJournalPath)) { + fs.copyFileSync(legacyJournalPath, journalPath); + } const prior = loadCachedOrRebuild(normalized.id); const seq = prior.nextSeq; const entry = { @@ -42,7 +55,7 @@ export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {}) }, getSnapshot(id = sessionId, opts = {}) { if (!id) throw new Error('session id required'); - const journalPath = getJournalPath(rootDir, id); + const journalPath = getReadableJournalPath(id); const snapshotPath = getSnapshotPath(rootDir, id); const rebuilt = rebuildSnapshotFromJournal(journalPath, id); snapshotCache.set(id, rebuilt); @@ -51,10 +64,15 @@ export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {}) return rebuilt.snapshot; }, listActiveSessions() { - if (!fs.existsSync(rootDir)) return []; - return fs.readdirSync(rootDir) - .filter((name) => name.endsWith('.jsonl')) - .map((name) => name.slice(0, -'.jsonl'.length)) + const ids = new Set(); + for (const dir of [legacyRootDir, rootDir]) { + if (!fs.existsSync(dir)) continue; + for (const name of fs.readdirSync(dir)) { + if (name.endsWith('.jsonl')) ids.add(name.slice(0, -'.jsonl'.length)); + } + } + return [...ids] + .sort() .map((id) => this.getSnapshot(id)) .filter(Boolean); }, diff --git a/.agents/skills/impeccable/scripts/live-status.mjs b/.agents/skills/impeccable/scripts/live-status.mjs index 1b85357c..dce1fbca 100644 --- a/.agents/skills/impeccable/scripts/live-status.mjs +++ b/.agents/skills/impeccable/scripts/live-status.mjs @@ -3,15 +3,11 @@ * Print durable recovery status for Impeccable live sessions. */ -import fs from 'node:fs'; -import path from 'node:path'; import { createLiveSessionStore } from './live-session-store.mjs'; - -const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); +import { readLiveServerInfo } from './impeccable-paths.mjs'; function readServerInfo() { - try { return JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); } - catch { return null; } + return readLiveServerInfo(process.cwd())?.info || null; } async function fetchServerStatus(info) { diff --git a/.agents/skills/impeccable/scripts/live.mjs b/.agents/skills/impeccable/scripts/live.mjs index befbdb8e..cafb0eca 100644 --- a/.agents/skills/impeccable/scripts/live.mjs +++ b/.agents/skills/impeccable/scripts/live.mjs @@ -2,10 +2,10 @@ * CLI entry point: prepare everything needed to enter the live variant poll loop. * * Does (all in one command): - * 1. Check config.json (returns config_missing if first-ever run) + * 1. Check .impeccable/live/config.json (returns config_missing if first-ever run) * 2. Start the live server in the background (or reuse a running one) * 3. Inject the browser script tag into the project's entry file - * 4. Read .impeccable.md for design context (if present) + * 4. Read PRODUCT.md / DESIGN.md for project context * 5. Print a single JSON blob with everything the agent needs * * After this, the agent's only remaining steps are: @@ -23,9 +23,9 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { loadContext } from './load-context.mjs'; import { resolveFiles } from './live-inject.mjs'; +import { readLiveServerInfo } from './impeccable-paths.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); async function liveCli() { const args = process.argv.slice(2); @@ -34,10 +34,10 @@ async function liveCli() { console.log(`Usage: node live.mjs Prepare everything for live variant mode in a single command: - - Checks scripts/config.json (required, created once per project) + - Checks .impeccable/live/config.json (required, created once per project) - Starts (or reuses) the live server in the background - Injects the browser script tag - - Reads .impeccable.md for design context + - Reads PRODUCT.md / DESIGN.md for project context On success, prints a JSON blob with: { ok, serverPort, serverToken, pageFile, hasContext, context } @@ -223,7 +223,7 @@ function safeParse(out) { function ensureServerRunning() { // Try to reuse an existing server try { - const existing = JSON.parse(fs.readFileSync(PID_FILE, 'utf-8')); + const existing = readLiveServerInfo(process.cwd())?.info; if (existing && existing.pid) { try { process.kill(existing.pid, 0); // throws if dead diff --git a/.agents/skills/impeccable/scripts/load-context.mjs b/.agents/skills/impeccable/scripts/load-context.mjs index dca23c1f..dc340bf1 100644 --- a/.agents/skills/impeccable/scripts/load-context.mjs +++ b/.agents/skills/impeccable/scripts/load-context.mjs @@ -39,7 +39,7 @@ const LEGACY_NAMES = ['.impeccable.md']; const FALLBACK_DIRS = ['.agents/context', 'docs']; /** - * Resolve the directory that holds PRODUCT.md / DESIGN.md / DESIGN.json for + * Resolve the directory that holds PRODUCT.md / DESIGN.md for * this project. Exported so other scripts (e.g. live-server.mjs) can read the * design files from the same location the loader uses. */ diff --git a/.claude/skills/impeccable/reference/document.md b/.claude/skills/impeccable/reference/document.md index 44f123be..a091ec98 100644 --- a/.claude/skills/impeccable/reference/document.md +++ b/.claude/skills/impeccable/reference/document.md @@ -237,11 +237,11 @@ Concrete, forceful guardrails. Lead each with "Do" or "Don't". Be specific: incl - **Don't** [...] ``` -### Step 4b: Write DESIGN.json sidecar (extensions only) +### Step 4b: Write .impeccable/design.json sidecar (extensions only) -The frontmatter owns token primitives (colors, typography, rounded, spacing, components). The sidecar at `DESIGN.json` carries **what Stitch's schema can't hold**: tonal ramps per color, shadow/elevation tokens, motion tokens, breakpoints, full component HTML/CSS snippets (the panel renders these into a shadow DOM), and narrative (north star, rules, do's/don'ts). It extends the frontmatter, it doesn't duplicate it. +The frontmatter owns token primitives (colors, typography, rounded, spacing, components). The sidecar at `.impeccable/design.json` carries **what Stitch's schema can't hold**: tonal ramps per color, shadow/elevation tokens, motion tokens, breakpoints, full component HTML/CSS snippets (the panel renders these into a shadow DOM), and narrative (north star, rules, do's/don'ts). It extends the frontmatter, it doesn't duplicate it. -Regenerate the sidecar whenever you regenerate DESIGN.md. If the user only asks to refresh the sidecar (e.g., from the live panel's stale-hint), preserve DESIGN.md and write only DESIGN.json. +Regenerate the sidecar whenever you regenerate root `DESIGN.md`. If the user only asks to refresh the sidecar (e.g., from the live panel's stale-hint), preserve `DESIGN.md` and write only `.impeccable/design.json`. #### Schema @@ -310,7 +310,7 @@ Aim for a tight set of **5-10 components** that best represent the visual system - **Signature components (include if distinctive):** hero CTA, featured card, filter pill, any custom pattern the user mentioned as important in PRODUCT.md. - **Skip the rest.** Utility components, form building blocks, wrapper layouts: not worth documenting unless visually distinctive. -If the project has **no component library yet** (bare landing page, new project), synthesize canonical primitives from the tokens using best-practice defaults consistent with the DESIGN.md's rules. Every DESIGN.json has *something* to render, even on day zero. +If the project has **no component library yet** (bare landing page, new project), synthesize canonical primitives from the tokens using best-practice defaults consistent with the DESIGN.md's rules. Every `.impeccable/design.json` has *something* to render, even on day zero. #### Tonal ramps @@ -331,7 +331,7 @@ Do not reword. The panel shows these as secondary collapsible context; the same ### Step 5: Confirm, refine, and refresh session cache 1. Show the user the full DESIGN.md you wrote. Briefly highlight the non-obvious creative choices (descriptive color names, atmosphere language, named rules). -2. Mention that `DESIGN.json` was also written alongside; the live panel will now render this project's actual button/input/nav primitives instead of generic approximations. +2. Mention that `.impeccable/design.json` was also written alongside; the live panel will now render this project's actual button/input/nav primitives instead of generic approximations. 3. Offer to refine any section: "Want me to revise a section, add component patterns I missed, or adjust the atmosphere language?" 4. **Refresh the session cache.** Run `node .claude/skills/impeccable/scripts/load-context.mjs` one final time so the newly-written DESIGN.md lands in conversation. Subsequent commands in this session will use the fresh version automatically without re-reading. @@ -392,7 +392,7 @@ Per-section guidance in seed mode: - **Components**: omit entirely; no components exist yet. - **Do's and Don'ts**: carry PRODUCT.md's anti-references directly plus the anti-reference named in Q5. -Seed mode writes a minimal frontmatter with `name` and `description` only; no colors, typography, rounded, spacing, or components yet. Real tokens land on the next Scan-mode run. Skip the `DESIGN.json` sidecar in seed mode for the same reason: nothing to render. +Seed mode writes a minimal frontmatter with `name` and `description` only; no colors, typography, rounded, spacing, or components yet. Real tokens land on the next Scan-mode run. Skip the `.impeccable/design.json` sidecar in seed mode for the same reason: nothing to render. ### Step 4: Confirm and refresh session cache diff --git a/.claude/skills/impeccable/reference/live.md b/.claude/skills/impeccable/reference/live.md index 1dbe9b70..ee92d234 100644 --- a/.claude/skills/impeccable/reference/live.md +++ b/.claude/skills/impeccable/reference/live.md @@ -53,7 +53,7 @@ LOOP: ## Recovery commands -The live helper persists an append-only journal under `.impeccable-live/sessions`. Browser checkpoints are advisory but durable; the journal is canonical. +The live helper persists an append-only journal under `.impeccable/live/sessions/`. Browser checkpoints are advisory but durable; the journal is canonical. This is local durable recovery state, not project source. Use these commands when the chat was interrupted, polling was missed, the helper restarted, or the browser reloaded: @@ -473,7 +473,7 @@ When the poll returns `exit`, proceed to cleanup. If the poll is still running a node .claude/skills/impeccable/scripts/live-server.mjs stop ``` -Stops the HTTP server and runs `live-inject.mjs --remove` to strip `localhost:…/live.js` from the HTML entry. To stop the server but keep the inject tag (for a quick restart), use `stop --keep-inject`. `config.json` persists for future sessions. +Stops the HTTP server and runs `live-inject.mjs --remove` to strip `localhost:…/live.js` from the HTML entry. To stop the server but keep the inject tag (for a quick restart), use `stop --keep-inject`. `.impeccable/live/config.json` persists as project config for future sessions. Then: - Remove any leftover variant wrappers (search for `impeccable-variants-start` markers). @@ -481,7 +481,7 @@ Then: ## First-time setup (config missing or invalid) -If `live.mjs` outputs `{ ok: false, error: "config_missing" | "config_invalid", path }`, write `config.json` at the reported path. +If `live.mjs` outputs `{ ok: false, error: "config_missing" | "config_invalid", path }`, write the live config at the reported path. By default this is `.impeccable/live/config.json`. Schema: @@ -561,7 +561,7 @@ node .claude/skills/impeccable/scripts/detect-csp.mjs Output: `{ shape, signals }` where `shape` is one of `append-arrays`, `append-string`, `middleware`, `meta-tag`, or `null`. The shape is named by *patch mechanism*, so one template covers many frameworks. -- **`null`**: no CSP; skip to writing `config.json` with `cspChecked: true`. +- **`null`**: no CSP; skip to writing `.impeccable/live/config.json` with `cspChecked: true`. - **`append-arrays`**: CSP defined as structured directive arrays. Auto-patchable. See *append-arrays* below. Covers: - Monorepo helpers with `additionalScriptSrc` / `additionalConnectSrc` options (Next.js + shared config package) - SvelteKit `kit.csp.directives` @@ -638,6 +638,6 @@ Reference outputs: ### Troubleshooting -If a user says "no" to the CSP patch at setup time and later complains that live doesn't work: their dev CSP blocks `http://localhost:8400`. Fix: delete `cspChecked` from `config.json` and re-run `live.mjs`: setup will ask again. +If a user says "no" to the CSP patch at setup time and later complains that live doesn't work: their dev CSP blocks `http://localhost:8400`. Fix: delete `cspChecked` from `.impeccable/live/config.json` and re-run `live.mjs`: setup will ask again. Then re-run `live.mjs`. diff --git a/.claude/skills/impeccable/reference/teach.md b/.claude/skills/impeccable/reference/teach.md index 2aeeb9d7..34a18905 100644 --- a/.claude/skills/impeccable/reference/teach.md +++ b/.claude/skills/impeccable/reference/teach.md @@ -2,8 +2,8 @@ Gathers design context for a project and writes two complementary files at the project root: -- **PRODUCT.md** (strategic): register, target users, product purpose, brand personality, anti-references, strategic design principles. Answers "who/what/why". -- **DESIGN.md** (visual): visual theme, color palette, typography, components, layout. Follows the [Google Stitch DESIGN.md format](https://stitch.withgoogle.com/docs/design-md/format/). Answers "how it looks". +- **PRODUCT.md** (strategic): root project file for register, target users, product purpose, brand personality, anti-references, strategic design principles. Answers "who/what/why". +- **DESIGN.md** (visual): root project file for visual theme, color palette, typography, components, layout. Follows the [Google Stitch DESIGN.md format](https://stitch.withgoogle.com/docs/design-md/format/). Answers "how it looks". Every other impeccable command reads these files before doing any work. diff --git a/.claude/skills/impeccable/scripts/impeccable-paths.mjs b/.claude/skills/impeccable/scripts/impeccable-paths.mjs new file mode 100644 index 00000000..ba852bae --- /dev/null +++ b/.claude/skills/impeccable/scripts/impeccable-paths.mjs @@ -0,0 +1,105 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +export const IMPECCABLE_DIR = '.impeccable'; +export const LIVE_DIR = 'live'; + +export function getImpeccableDir(cwd = process.cwd()) { + return path.join(cwd, IMPECCABLE_DIR); +} + +export function getDesignSidecarPath(cwd = process.cwd()) { + return path.join(getImpeccableDir(cwd), 'design.json'); +} + +export function getDesignSidecarCandidates(cwd = process.cwd(), contextDir = cwd) { + const candidates = [ + getDesignSidecarPath(cwd), + path.join(cwd, 'DESIGN.json'), + ]; + const contextLegacy = path.join(contextDir, 'DESIGN.json'); + if (!candidates.includes(contextLegacy)) candidates.push(contextLegacy); + return candidates; +} + +export function resolveDesignSidecarPath(cwd = process.cwd(), contextDir = cwd) { + return firstExisting(getDesignSidecarCandidates(cwd, contextDir)); +} + +export function getLiveDir(cwd = process.cwd()) { + return path.join(getImpeccableDir(cwd), LIVE_DIR); +} + +export function getLiveConfigPath(cwd = process.cwd()) { + return path.join(getLiveDir(cwd), 'config.json'); +} + +export function getLegacyLiveConfigPath(scriptsDir) { + return path.join(scriptsDir, 'config.json'); +} + +export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = process.env } = {}) { + if (env.IMPECCABLE_LIVE_CONFIG && env.IMPECCABLE_LIVE_CONFIG.trim()) { + const configured = env.IMPECCABLE_LIVE_CONFIG.trim(); + return path.isAbsolute(configured) ? configured : path.resolve(cwd, configured); + } + const primary = getLiveConfigPath(cwd); + if (fs.existsSync(primary)) return primary; + if (scriptsDir) { + const legacy = getLegacyLiveConfigPath(scriptsDir); + if (fs.existsSync(legacy)) return legacy; + } + return primary; +} + +export function getLiveServerPath(cwd = process.cwd()) { + return path.join(getLiveDir(cwd), 'server.json'); +} + +export function getLegacyLiveServerPath(cwd = process.cwd()) { + return path.join(cwd, '.impeccable-live.json'); +} + +export function readLiveServerInfo(cwd = process.cwd()) { + for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) { + try { + return { info: JSON.parse(fs.readFileSync(filePath, 'utf-8')), path: filePath }; + } catch { + /* try next */ + } + } + return null; +} + +export function writeLiveServerInfo(cwd = process.cwd(), info) { + const filePath = getLiveServerPath(cwd); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, JSON.stringify(info)); + return filePath; +} + +export function removeLiveServerInfo(cwd = process.cwd()) { + for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) { + try { fs.unlinkSync(filePath); } catch {} + } +} + +export function getLiveSessionsDir(cwd = process.cwd()) { + return path.join(getLiveDir(cwd), 'sessions'); +} + +export function getLegacyLiveSessionsDir(cwd = process.cwd()) { + return path.join(cwd, '.impeccable-live', 'sessions'); +} + +export function getLiveAnnotationsDir(cwd = process.cwd()) { + return path.join(getLiveDir(cwd), 'annotations'); +} + +export function getLegacyLiveAnnotationsDir(cwd = process.cwd()) { + return path.join(cwd, '.impeccable-live', 'annotations'); +} + +function firstExisting(paths) { + return paths.find((filePath) => fs.existsSync(filePath)) || null; +} diff --git a/.claude/skills/impeccable/scripts/live-browser.js b/.claude/skills/impeccable/scripts/live-browser.js index 7f1ff329..bb5332a3 100644 --- a/.claude/skills/impeccable/scripts/live-browser.js +++ b/.claude/skills/impeccable/scripts/live-browser.js @@ -3671,7 +3671,7 @@ void main() { } // --------------------------------------------------------------------------- - // Design System Panel — visualizes the project's DESIGN.json sidecar + // Design System Panel — visualizes the project's .impeccable/design.json sidecar // --------------------------------------------------------------------------- const DESIGN_PREFS_KEY = 'impeccable-live-design-panel'; @@ -3683,7 +3683,7 @@ void main() { open: false, tab: 'visual', // 'visual' | 'raw' parsed: null, // parseDesignMd output (frontmatter + body sections) - sidecar: null, // DESIGN.json v2 payload (extensions + components + narrative) + sidecar: null, // .impeccable/design.json v2 payload (extensions + components + narrative) hasMd: false, hasSidecar: false, present: null, // true/false once fetch resolves @@ -4184,7 +4184,7 @@ void main() { box.className = 'stale'; box.innerHTML = ` - DESIGN.md is newer than DESIGN.json. Run /impeccable document to refresh the sidecar. + DESIGN.md is newer than .impeccable/design.json. Run /impeccable document to refresh the sidecar. `; return box; } @@ -4192,7 +4192,7 @@ void main() { function renderParsedMdCta() { const box = document.createElement('div'); box.className = 'parsed-md-cta'; - box.innerHTML = `Basic viewThis panel reads the tokens in your DESIGN.md frontmatter. Running /impeccable document also generates a DESIGN.json sidecar with your project's actual component snippets (button, input, nav) and tonal ramps, rendered live below the tokens.`; + box.innerHTML = `Basic viewThis panel reads the tokens in your DESIGN.md frontmatter. Running /impeccable document also generates a .impeccable/design.json sidecar with your project's actual component snippets (button, input, nav) and tonal ramps, rendered live below the tokens.`; return box; } @@ -4652,7 +4652,7 @@ void main() { function cssSafe(v) { // Strip anything outside valid CSS value chars to prevent injection via - // DESIGN.json values rendered into inline style strings. + // .impeccable/design.json values rendered into inline style strings. return String(v).replace(/[<>"'`\n]/g, ''); } diff --git a/.claude/skills/impeccable/scripts/live-complete.mjs b/.claude/skills/impeccable/scripts/live-complete.mjs index ca00d86a..78155af8 100644 --- a/.claude/skills/impeccable/scripts/live-complete.mjs +++ b/.claude/skills/impeccable/scripts/live-complete.mjs @@ -4,10 +4,7 @@ */ import { createLiveSessionStore } from './live-session-store.mjs'; -import fs from 'node:fs'; -import path from 'node:path'; - -const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); +import { readLiveServerInfo } from './impeccable-paths.mjs'; function parseArgs(argv) { const out = { status: 'complete' }; @@ -50,8 +47,7 @@ export async function completeCli() { } function readServerInfo() { - try { return JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); } - catch { return null; } + return readLiveServerInfo(process.cwd())?.info || null; } async function completeThroughServer(info, args) { diff --git a/.claude/skills/impeccable/scripts/live-inject.mjs b/.claude/skills/impeccable/scripts/live-inject.mjs index 1d2ae12f..555c3a36 100644 --- a/.claude/skills/impeccable/scripts/live-inject.mjs +++ b/.claude/skills/impeccable/scripts/live-inject.mjs @@ -2,23 +2,24 @@ * CLI helper: insert/remove the live variant mode script tag in the project's * main HTML entry point. * - * On first live run, the agent generates `config.json` in this script's - * directory with the project's insertion target (framework-specific). On + * On first live run, the agent generates `.impeccable/live/config.json` + * with the project's insertion target (framework-specific). On * every subsequent run, this script handles insert/remove deterministically * with zero LLM involvement. * * Usage: * node live-inject.mjs --port PORT # Insert the live script tag * node live-inject.mjs --remove # Remove the live script tag - * node live-inject.mjs --check # Check whether config.json exists + * node live-inject.mjs --check # Check whether live config exists */ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { resolveLiveConfigPath } from './impeccable-paths.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const CONFIG_PATH = process.env.IMPECCABLE_LIVE_CONFIG || path.join(__dirname, 'config.json'); +const CONFIG_PATH = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname }); const MARKER_OPEN_TEXT = 'impeccable-live-start'; const MARKER_CLOSE_TEXT = 'impeccable-live-end'; @@ -39,12 +40,12 @@ export async function injectCli() { console.log(`Usage: node live-inject.mjs [options] Insert or remove the live mode script tag in the project's HTML entry point. -Reads configuration from config.json (in this same directory). +Reads configuration from .impeccable/live/config.json. Modes: --port PORT Insert script tag pointing at http://localhost:PORT/live.js --remove Remove the script tag (if present) - --check Print whether config.json exists and its content + --check Print whether .impeccable/live/config.json exists and its content Output (JSON): { ok, file, inserted|removed, config? }`); diff --git a/.claude/skills/impeccable/scripts/live-poll.mjs b/.claude/skills/impeccable/scripts/live-poll.mjs index 9a3f07ae..83a9912e 100644 --- a/.claude/skills/impeccable/scripts/live-poll.mjs +++ b/.claude/skills/impeccable/scripts/live-poll.mjs @@ -9,11 +9,10 @@ */ import { execFileSync } from 'node:child_process'; -import fs from 'node:fs'; import path from 'node:path'; -import os from 'node:os'; import { fileURLToPath } from 'node:url'; import { completionTypeForAcceptResult } from './live-completion.mjs'; +import { readLiveServerInfo } from './impeccable-paths.mjs'; // Node's built-in fetch (undici under the hood) enforces a 300s headers // timeout that can't be lowered per-request. We cap each request below @@ -21,15 +20,13 @@ import { completionTypeForAcceptResult } from './live-completion.mjs'; // depending on the standalone undici package. const PER_REQUEST_TIMEOUT_MS = 270_000; -const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); - function readServerInfo() { - try { - return JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); - } catch { + const record = readLiveServerInfo(process.cwd()); + if (!record) { console.error('No running live server found. Start one with: npx impeccable live'); process.exit(1); } + return record.info; } export function buildPollReplyPayload(token, { id, type, message, file, data }) { diff --git a/.claude/skills/impeccable/scripts/live-server.mjs b/.claude/skills/impeccable/scripts/live-server.mjs index e78a0657..53d8f21c 100644 --- a/.claude/skills/impeccable/scripts/live-server.mjs +++ b/.claude/skills/impeccable/scripts/live-server.mjs @@ -23,14 +23,19 @@ import { fileURLToPath } from 'node:url'; import { parseDesignMd } from './design-parser.mjs'; import { resolveContextDir } from './load-context.mjs'; import { createLiveSessionStore } from './live-session-store.mjs'; +import { + getDesignSidecarPath, + getLiveAnnotationsDir, + readLiveServerInfo, + removeLiveServerInfo, + resolveDesignSidecarPath, + writeLiveServerInfo, +} from './impeccable-paths.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -// PID file in the project root so both the server and agent can find it -// predictably (os.tmpdir() varies across platforms). -const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); -// PRODUCT.md / DESIGN.md / DESIGN.json live wherever load-context.mjs resolves. -// Keeps live-server in sync with the loader when users keep the docs in -// .agents/context/, docs/, or a path set via IMPECCABLE_CONTEXT_DIR. +// PRODUCT.md / DESIGN.md live wherever load-context.mjs resolves. The generated +// DESIGN sidecar is project-local at .impeccable/design.json, with legacy +// DESIGN.json fallback for existing projects. const CONTEXT_DIR = resolveContextDir(process.cwd()); const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s @@ -411,13 +416,13 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { } // --- Design system (unified v2 response) + raw --- - // /design-system.json returns both parsed DESIGN.md and DESIGN.json + // /design-system.json returns both parsed DESIGN.md and .impeccable/design.json // sidecar when present. Panel merges them: // { present, parsed, sidecar, hasMd, hasSidecar, // mdNewerThanJson, parseError?, sidecarError? } // - parsed: output of parseDesignMd (frontmatter // + six canonical sections) when DESIGN.md exists. - // - sidecar: DESIGN.json contents when present. + // - sidecar: .impeccable/design.json contents when present. // Expected shape: schemaVersion 2, carrying // extensions + components + narrative. // /design-system/raw returns DESIGN.md markdown verbatim @@ -426,7 +431,7 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } const mdPath = path.join(CONTEXT_DIR, 'DESIGN.md'); - const jsonPath = path.join(CONTEXT_DIR, 'DESIGN.json'); + const jsonPath = resolveDesignSidecarPath(process.cwd(), CONTEXT_DIR) || getDesignSidecarPath(process.cwd()); const mdStat = statOrNull(mdPath); const jsonStat = statOrNull(jsonPath); @@ -462,7 +467,7 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { try { response.sidecar = JSON.parse(fs.readFileSync(jsonPath, 'utf-8')); } catch (err) { - response.sidecarError = 'Failed to parse DESIGN.json: ' + err.message; + response.sidecarError = 'Failed to parse .impeccable/design.json: ' + err.message; } } @@ -673,7 +678,7 @@ function handlePollPost(req, res) { let httpServer = null; function shutdown() { - try { fs.unlinkSync(LIVE_PID_FILE); } catch {} + removeLiveServerInfo(process.cwd()); if (state.leaseTimer) clearTimeout(state.leaseTimer); state.leaseTimer = null; if (state.sessionDir) { @@ -725,7 +730,7 @@ Endpoints: if (args.includes('stop')) { const keepInject = args.includes('--keep-inject'); try { - const info = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); + const { info } = readLiveServerInfo(process.cwd()) || {}; const res = await fetch(`http://localhost:${info.port}/stop?token=${info.token}`); if (res.ok) console.log(`Stopped live server on port ${info.port}.`); } catch { @@ -776,7 +781,7 @@ if (args.includes('--background')) { const deadline = Date.now() + 10_000; while (Date.now() < deadline) { try { - const info = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); + const { info } = readLiveServerInfo(process.cwd()) || {}; if (info.pid !== process.pid) { // Output JSON so the agent can read port + token from stdout. console.log(JSON.stringify(info)); @@ -790,14 +795,18 @@ if (args.includes('--background')) { } // Check for existing session -try { - const existing = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); - try { process.kill(existing.pid, 0); +const existingRecord = readLiveServerInfo(process.cwd()); +if (existingRecord?.info) { + const existing = existingRecord.info; + try { + process.kill(existing.pid, 0); console.error(`Live server already running on port ${existing.port} (pid ${existing.pid}).`); console.error('Stop it first with: node ' + path.basename(fileURLToPath(import.meta.url)) + ' stop'); process.exit(1); - } catch { fs.unlinkSync(LIVE_PID_FILE); } -} catch {} + } catch { + try { fs.unlinkSync(existingRecord.path); } catch {} + } +} state.token = randomUUID(); state.sessionStore = createLiveSessionStore({ cwd: process.cwd() }); @@ -807,7 +816,7 @@ state.port = portArg ? parseInt(portArg.split('=')[1], 10) : await findOpenPort( // Annotation screenshots live in the project root so the agent's Read tool // doesn't trip a per-file permission prompt. Sessioned by token so concurrent // projects (or quick restarts) don't collide. -const annotRoot = path.join(process.cwd(), '.impeccable-live', 'annotations'); +const annotRoot = getLiveAnnotationsDir(process.cwd()); fs.mkdirSync(annotRoot, { recursive: true }); state.sessionDir = fs.mkdtempSync(path.join(annotRoot, 'session-')); @@ -815,7 +824,7 @@ const { detectScript, sessionPath, livePath } = loadBrowserScripts(); httpServer = http.createServer(createRequestHandler({ detectScript, sessionPath, livePath })); httpServer.listen(state.port, '127.0.0.1', () => { - fs.writeFileSync(LIVE_PID_FILE, JSON.stringify({ pid: process.pid, port: state.port, token: state.token })); + writeLiveServerInfo(process.cwd(), { pid: process.pid, port: state.port, token: state.token }); const url = `http://localhost:${state.port}`; console.log(`\nImpeccable live server running on ${url}`); console.log(`Token: ${state.token}\n`); diff --git a/.claude/skills/impeccable/scripts/live-session-store.mjs b/.claude/skills/impeccable/scripts/live-session-store.mjs index cc7744df..37711168 100644 --- a/.claude/skills/impeccable/scripts/live-session-store.mjs +++ b/.claude/skills/impeccable/scripts/live-session-store.mjs @@ -1,30 +1,43 @@ import fs from 'node:fs'; import path from 'node:path'; +import { getLegacyLiveSessionsDir, getLiveSessionsDir } from './impeccable-paths.mjs'; -const LIVE_DIR = '.impeccable-live'; -const SESSIONS_DIR = 'sessions'; const COMPLETED_PHASES = new Set(['completed', 'discarded']); export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {}) { - const rootDir = path.join(cwd, LIVE_DIR, SESSIONS_DIR); + 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 = getJournalPath(rootDir, id); + const journalPath = getReadableJournalPath(id); const rebuilt = rebuildSnapshotFromJournal(journalPath, id); snapshotCache.set(id, rebuilt); return rebuilt; } + function getReadableJournalPath(id) { + const primary = getJournalPath(rootDir, id); + if (fs.existsSync(primary)) return primary; + const legacy = getJournalPath(legacyRootDir, id); + if (fs.existsSync(legacy)) return legacy; + return primary; + } + return { rootDir, + legacyRootDir, appendEvent(event) { const normalized = normalizeEvent(event, sessionId); const journalPath = getJournalPath(rootDir, normalized.id); const snapshotPath = getSnapshotPath(rootDir, normalized.id); + const legacyJournalPath = getJournalPath(legacyRootDir, normalized.id); + if (!fs.existsSync(journalPath) && fs.existsSync(legacyJournalPath)) { + fs.copyFileSync(legacyJournalPath, journalPath); + } const prior = loadCachedOrRebuild(normalized.id); const seq = prior.nextSeq; const entry = { @@ -42,7 +55,7 @@ export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {}) }, getSnapshot(id = sessionId, opts = {}) { if (!id) throw new Error('session id required'); - const journalPath = getJournalPath(rootDir, id); + const journalPath = getReadableJournalPath(id); const snapshotPath = getSnapshotPath(rootDir, id); const rebuilt = rebuildSnapshotFromJournal(journalPath, id); snapshotCache.set(id, rebuilt); @@ -51,10 +64,15 @@ export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {}) return rebuilt.snapshot; }, listActiveSessions() { - if (!fs.existsSync(rootDir)) return []; - return fs.readdirSync(rootDir) - .filter((name) => name.endsWith('.jsonl')) - .map((name) => name.slice(0, -'.jsonl'.length)) + const ids = new Set(); + for (const dir of [legacyRootDir, rootDir]) { + if (!fs.existsSync(dir)) continue; + for (const name of fs.readdirSync(dir)) { + if (name.endsWith('.jsonl')) ids.add(name.slice(0, -'.jsonl'.length)); + } + } + return [...ids] + .sort() .map((id) => this.getSnapshot(id)) .filter(Boolean); }, diff --git a/.claude/skills/impeccable/scripts/live-status.mjs b/.claude/skills/impeccable/scripts/live-status.mjs index 1b85357c..dce1fbca 100644 --- a/.claude/skills/impeccable/scripts/live-status.mjs +++ b/.claude/skills/impeccable/scripts/live-status.mjs @@ -3,15 +3,11 @@ * Print durable recovery status for Impeccable live sessions. */ -import fs from 'node:fs'; -import path from 'node:path'; import { createLiveSessionStore } from './live-session-store.mjs'; - -const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); +import { readLiveServerInfo } from './impeccable-paths.mjs'; function readServerInfo() { - try { return JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); } - catch { return null; } + return readLiveServerInfo(process.cwd())?.info || null; } async function fetchServerStatus(info) { diff --git a/.claude/skills/impeccable/scripts/live.mjs b/.claude/skills/impeccable/scripts/live.mjs index befbdb8e..cafb0eca 100644 --- a/.claude/skills/impeccable/scripts/live.mjs +++ b/.claude/skills/impeccable/scripts/live.mjs @@ -2,10 +2,10 @@ * CLI entry point: prepare everything needed to enter the live variant poll loop. * * Does (all in one command): - * 1. Check config.json (returns config_missing if first-ever run) + * 1. Check .impeccable/live/config.json (returns config_missing if first-ever run) * 2. Start the live server in the background (or reuse a running one) * 3. Inject the browser script tag into the project's entry file - * 4. Read .impeccable.md for design context (if present) + * 4. Read PRODUCT.md / DESIGN.md for project context * 5. Print a single JSON blob with everything the agent needs * * After this, the agent's only remaining steps are: @@ -23,9 +23,9 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { loadContext } from './load-context.mjs'; import { resolveFiles } from './live-inject.mjs'; +import { readLiveServerInfo } from './impeccable-paths.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); async function liveCli() { const args = process.argv.slice(2); @@ -34,10 +34,10 @@ async function liveCli() { console.log(`Usage: node live.mjs Prepare everything for live variant mode in a single command: - - Checks scripts/config.json (required, created once per project) + - Checks .impeccable/live/config.json (required, created once per project) - Starts (or reuses) the live server in the background - Injects the browser script tag - - Reads .impeccable.md for design context + - Reads PRODUCT.md / DESIGN.md for project context On success, prints a JSON blob with: { ok, serverPort, serverToken, pageFile, hasContext, context } @@ -223,7 +223,7 @@ function safeParse(out) { function ensureServerRunning() { // Try to reuse an existing server try { - const existing = JSON.parse(fs.readFileSync(PID_FILE, 'utf-8')); + const existing = readLiveServerInfo(process.cwd())?.info; if (existing && existing.pid) { try { process.kill(existing.pid, 0); // throws if dead diff --git a/.claude/skills/impeccable/scripts/load-context.mjs b/.claude/skills/impeccable/scripts/load-context.mjs index dca23c1f..dc340bf1 100644 --- a/.claude/skills/impeccable/scripts/load-context.mjs +++ b/.claude/skills/impeccable/scripts/load-context.mjs @@ -39,7 +39,7 @@ const LEGACY_NAMES = ['.impeccable.md']; const FALLBACK_DIRS = ['.agents/context', 'docs']; /** - * Resolve the directory that holds PRODUCT.md / DESIGN.md / DESIGN.json for + * Resolve the directory that holds PRODUCT.md / DESIGN.md for * this project. Exported so other scripts (e.g. live-server.mjs) can read the * design files from the same location the loader uses. */ diff --git a/.cursor/skills/impeccable/reference/document.md b/.cursor/skills/impeccable/reference/document.md index 490c2152..254e0183 100644 --- a/.cursor/skills/impeccable/reference/document.md +++ b/.cursor/skills/impeccable/reference/document.md @@ -237,11 +237,11 @@ Concrete, forceful guardrails. Lead each with "Do" or "Don't". Be specific: incl - **Don't** [...] ``` -### Step 4b: Write DESIGN.json sidecar (extensions only) +### Step 4b: Write .impeccable/design.json sidecar (extensions only) -The frontmatter owns token primitives (colors, typography, rounded, spacing, components). The sidecar at `DESIGN.json` carries **what Stitch's schema can't hold**: tonal ramps per color, shadow/elevation tokens, motion tokens, breakpoints, full component HTML/CSS snippets (the panel renders these into a shadow DOM), and narrative (north star, rules, do's/don'ts). It extends the frontmatter, it doesn't duplicate it. +The frontmatter owns token primitives (colors, typography, rounded, spacing, components). The sidecar at `.impeccable/design.json` carries **what Stitch's schema can't hold**: tonal ramps per color, shadow/elevation tokens, motion tokens, breakpoints, full component HTML/CSS snippets (the panel renders these into a shadow DOM), and narrative (north star, rules, do's/don'ts). It extends the frontmatter, it doesn't duplicate it. -Regenerate the sidecar whenever you regenerate DESIGN.md. If the user only asks to refresh the sidecar (e.g., from the live panel's stale-hint), preserve DESIGN.md and write only DESIGN.json. +Regenerate the sidecar whenever you regenerate root `DESIGN.md`. If the user only asks to refresh the sidecar (e.g., from the live panel's stale-hint), preserve `DESIGN.md` and write only `.impeccable/design.json`. #### Schema @@ -310,7 +310,7 @@ Aim for a tight set of **5-10 components** that best represent the visual system - **Signature components (include if distinctive):** hero CTA, featured card, filter pill, any custom pattern the user mentioned as important in PRODUCT.md. - **Skip the rest.** Utility components, form building blocks, wrapper layouts: not worth documenting unless visually distinctive. -If the project has **no component library yet** (bare landing page, new project), synthesize canonical primitives from the tokens using best-practice defaults consistent with the DESIGN.md's rules. Every DESIGN.json has *something* to render, even on day zero. +If the project has **no component library yet** (bare landing page, new project), synthesize canonical primitives from the tokens using best-practice defaults consistent with the DESIGN.md's rules. Every `.impeccable/design.json` has *something* to render, even on day zero. #### Tonal ramps @@ -331,7 +331,7 @@ Do not reword. The panel shows these as secondary collapsible context; the same ### Step 5: Confirm, refine, and refresh session cache 1. Show the user the full DESIGN.md you wrote. Briefly highlight the non-obvious creative choices (descriptive color names, atmosphere language, named rules). -2. Mention that `DESIGN.json` was also written alongside; the live panel will now render this project's actual button/input/nav primitives instead of generic approximations. +2. Mention that `.impeccable/design.json` was also written alongside; the live panel will now render this project's actual button/input/nav primitives instead of generic approximations. 3. Offer to refine any section: "Want me to revise a section, add component patterns I missed, or adjust the atmosphere language?" 4. **Refresh the session cache.** Run `node .cursor/skills/impeccable/scripts/load-context.mjs` one final time so the newly-written DESIGN.md lands in conversation. Subsequent commands in this session will use the fresh version automatically without re-reading. @@ -392,7 +392,7 @@ Per-section guidance in seed mode: - **Components**: omit entirely; no components exist yet. - **Do's and Don'ts**: carry PRODUCT.md's anti-references directly plus the anti-reference named in Q5. -Seed mode writes a minimal frontmatter with `name` and `description` only; no colors, typography, rounded, spacing, or components yet. Real tokens land on the next Scan-mode run. Skip the `DESIGN.json` sidecar in seed mode for the same reason: nothing to render. +Seed mode writes a minimal frontmatter with `name` and `description` only; no colors, typography, rounded, spacing, or components yet. Real tokens land on the next Scan-mode run. Skip the `.impeccable/design.json` sidecar in seed mode for the same reason: nothing to render. ### Step 4: Confirm and refresh session cache diff --git a/.cursor/skills/impeccable/reference/live.md b/.cursor/skills/impeccable/reference/live.md index 3a593f12..07665cd6 100644 --- a/.cursor/skills/impeccable/reference/live.md +++ b/.cursor/skills/impeccable/reference/live.md @@ -53,7 +53,7 @@ LOOP: ## Recovery commands -The live helper persists an append-only journal under `.impeccable-live/sessions`. Browser checkpoints are advisory but durable; the journal is canonical. +The live helper persists an append-only journal under `.impeccable/live/sessions/`. Browser checkpoints are advisory but durable; the journal is canonical. This is local durable recovery state, not project source. Use these commands when the chat was interrupted, polling was missed, the helper restarted, or the browser reloaded: @@ -473,7 +473,7 @@ When the poll returns `exit`, proceed to cleanup. If the poll is still running a node .cursor/skills/impeccable/scripts/live-server.mjs stop ``` -Stops the HTTP server and runs `live-inject.mjs --remove` to strip `localhost:…/live.js` from the HTML entry. To stop the server but keep the inject tag (for a quick restart), use `stop --keep-inject`. `config.json` persists for future sessions. +Stops the HTTP server and runs `live-inject.mjs --remove` to strip `localhost:…/live.js` from the HTML entry. To stop the server but keep the inject tag (for a quick restart), use `stop --keep-inject`. `.impeccable/live/config.json` persists as project config for future sessions. Then: - Remove any leftover variant wrappers (search for `impeccable-variants-start` markers). @@ -481,7 +481,7 @@ Then: ## First-time setup (config missing or invalid) -If `live.mjs` outputs `{ ok: false, error: "config_missing" | "config_invalid", path }`, write `config.json` at the reported path. +If `live.mjs` outputs `{ ok: false, error: "config_missing" | "config_invalid", path }`, write the live config at the reported path. By default this is `.impeccable/live/config.json`. Schema: @@ -561,7 +561,7 @@ node .cursor/skills/impeccable/scripts/detect-csp.mjs Output: `{ shape, signals }` where `shape` is one of `append-arrays`, `append-string`, `middleware`, `meta-tag`, or `null`. The shape is named by *patch mechanism*, so one template covers many frameworks. -- **`null`**: no CSP; skip to writing `config.json` with `cspChecked: true`. +- **`null`**: no CSP; skip to writing `.impeccable/live/config.json` with `cspChecked: true`. - **`append-arrays`**: CSP defined as structured directive arrays. Auto-patchable. See *append-arrays* below. Covers: - Monorepo helpers with `additionalScriptSrc` / `additionalConnectSrc` options (Next.js + shared config package) - SvelteKit `kit.csp.directives` @@ -638,6 +638,6 @@ Reference outputs: ### Troubleshooting -If a user says "no" to the CSP patch at setup time and later complains that live doesn't work: their dev CSP blocks `http://localhost:8400`. Fix: delete `cspChecked` from `config.json` and re-run `live.mjs`: setup will ask again. +If a user says "no" to the CSP patch at setup time and later complains that live doesn't work: their dev CSP blocks `http://localhost:8400`. Fix: delete `cspChecked` from `.impeccable/live/config.json` and re-run `live.mjs`: setup will ask again. Then re-run `live.mjs`. diff --git a/.cursor/skills/impeccable/reference/teach.md b/.cursor/skills/impeccable/reference/teach.md index b40385ab..c5d09b9f 100644 --- a/.cursor/skills/impeccable/reference/teach.md +++ b/.cursor/skills/impeccable/reference/teach.md @@ -2,8 +2,8 @@ Gathers design context for a project and writes two complementary files at the project root: -- **PRODUCT.md** (strategic): register, target users, product purpose, brand personality, anti-references, strategic design principles. Answers "who/what/why". -- **DESIGN.md** (visual): visual theme, color palette, typography, components, layout. Follows the [Google Stitch DESIGN.md format](https://stitch.withgoogle.com/docs/design-md/format/). Answers "how it looks". +- **PRODUCT.md** (strategic): root project file for register, target users, product purpose, brand personality, anti-references, strategic design principles. Answers "who/what/why". +- **DESIGN.md** (visual): root project file for visual theme, color palette, typography, components, layout. Follows the [Google Stitch DESIGN.md format](https://stitch.withgoogle.com/docs/design-md/format/). Answers "how it looks". Every other impeccable command reads these files before doing any work. diff --git a/.cursor/skills/impeccable/scripts/impeccable-paths.mjs b/.cursor/skills/impeccable/scripts/impeccable-paths.mjs new file mode 100644 index 00000000..ba852bae --- /dev/null +++ b/.cursor/skills/impeccable/scripts/impeccable-paths.mjs @@ -0,0 +1,105 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +export const IMPECCABLE_DIR = '.impeccable'; +export const LIVE_DIR = 'live'; + +export function getImpeccableDir(cwd = process.cwd()) { + return path.join(cwd, IMPECCABLE_DIR); +} + +export function getDesignSidecarPath(cwd = process.cwd()) { + return path.join(getImpeccableDir(cwd), 'design.json'); +} + +export function getDesignSidecarCandidates(cwd = process.cwd(), contextDir = cwd) { + const candidates = [ + getDesignSidecarPath(cwd), + path.join(cwd, 'DESIGN.json'), + ]; + const contextLegacy = path.join(contextDir, 'DESIGN.json'); + if (!candidates.includes(contextLegacy)) candidates.push(contextLegacy); + return candidates; +} + +export function resolveDesignSidecarPath(cwd = process.cwd(), contextDir = cwd) { + return firstExisting(getDesignSidecarCandidates(cwd, contextDir)); +} + +export function getLiveDir(cwd = process.cwd()) { + return path.join(getImpeccableDir(cwd), LIVE_DIR); +} + +export function getLiveConfigPath(cwd = process.cwd()) { + return path.join(getLiveDir(cwd), 'config.json'); +} + +export function getLegacyLiveConfigPath(scriptsDir) { + return path.join(scriptsDir, 'config.json'); +} + +export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = process.env } = {}) { + if (env.IMPECCABLE_LIVE_CONFIG && env.IMPECCABLE_LIVE_CONFIG.trim()) { + const configured = env.IMPECCABLE_LIVE_CONFIG.trim(); + return path.isAbsolute(configured) ? configured : path.resolve(cwd, configured); + } + const primary = getLiveConfigPath(cwd); + if (fs.existsSync(primary)) return primary; + if (scriptsDir) { + const legacy = getLegacyLiveConfigPath(scriptsDir); + if (fs.existsSync(legacy)) return legacy; + } + return primary; +} + +export function getLiveServerPath(cwd = process.cwd()) { + return path.join(getLiveDir(cwd), 'server.json'); +} + +export function getLegacyLiveServerPath(cwd = process.cwd()) { + return path.join(cwd, '.impeccable-live.json'); +} + +export function readLiveServerInfo(cwd = process.cwd()) { + for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) { + try { + return { info: JSON.parse(fs.readFileSync(filePath, 'utf-8')), path: filePath }; + } catch { + /* try next */ + } + } + return null; +} + +export function writeLiveServerInfo(cwd = process.cwd(), info) { + const filePath = getLiveServerPath(cwd); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, JSON.stringify(info)); + return filePath; +} + +export function removeLiveServerInfo(cwd = process.cwd()) { + for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) { + try { fs.unlinkSync(filePath); } catch {} + } +} + +export function getLiveSessionsDir(cwd = process.cwd()) { + return path.join(getLiveDir(cwd), 'sessions'); +} + +export function getLegacyLiveSessionsDir(cwd = process.cwd()) { + return path.join(cwd, '.impeccable-live', 'sessions'); +} + +export function getLiveAnnotationsDir(cwd = process.cwd()) { + return path.join(getLiveDir(cwd), 'annotations'); +} + +export function getLegacyLiveAnnotationsDir(cwd = process.cwd()) { + return path.join(cwd, '.impeccable-live', 'annotations'); +} + +function firstExisting(paths) { + return paths.find((filePath) => fs.existsSync(filePath)) || null; +} diff --git a/.cursor/skills/impeccable/scripts/live-browser.js b/.cursor/skills/impeccable/scripts/live-browser.js index 7f1ff329..bb5332a3 100644 --- a/.cursor/skills/impeccable/scripts/live-browser.js +++ b/.cursor/skills/impeccable/scripts/live-browser.js @@ -3671,7 +3671,7 @@ void main() { } // --------------------------------------------------------------------------- - // Design System Panel — visualizes the project's DESIGN.json sidecar + // Design System Panel — visualizes the project's .impeccable/design.json sidecar // --------------------------------------------------------------------------- const DESIGN_PREFS_KEY = 'impeccable-live-design-panel'; @@ -3683,7 +3683,7 @@ void main() { open: false, tab: 'visual', // 'visual' | 'raw' parsed: null, // parseDesignMd output (frontmatter + body sections) - sidecar: null, // DESIGN.json v2 payload (extensions + components + narrative) + sidecar: null, // .impeccable/design.json v2 payload (extensions + components + narrative) hasMd: false, hasSidecar: false, present: null, // true/false once fetch resolves @@ -4184,7 +4184,7 @@ void main() { box.className = 'stale'; box.innerHTML = ` - DESIGN.md is newer than DESIGN.json. Run /impeccable document to refresh the sidecar. + DESIGN.md is newer than .impeccable/design.json. Run /impeccable document to refresh the sidecar. `; return box; } @@ -4192,7 +4192,7 @@ void main() { function renderParsedMdCta() { const box = document.createElement('div'); box.className = 'parsed-md-cta'; - box.innerHTML = `Basic viewThis panel reads the tokens in your DESIGN.md frontmatter. Running /impeccable document also generates a DESIGN.json sidecar with your project's actual component snippets (button, input, nav) and tonal ramps, rendered live below the tokens.`; + box.innerHTML = `Basic viewThis panel reads the tokens in your DESIGN.md frontmatter. Running /impeccable document also generates a .impeccable/design.json sidecar with your project's actual component snippets (button, input, nav) and tonal ramps, rendered live below the tokens.`; return box; } @@ -4652,7 +4652,7 @@ void main() { function cssSafe(v) { // Strip anything outside valid CSS value chars to prevent injection via - // DESIGN.json values rendered into inline style strings. + // .impeccable/design.json values rendered into inline style strings. return String(v).replace(/[<>"'`\n]/g, ''); } diff --git a/.cursor/skills/impeccable/scripts/live-complete.mjs b/.cursor/skills/impeccable/scripts/live-complete.mjs index ca00d86a..78155af8 100644 --- a/.cursor/skills/impeccable/scripts/live-complete.mjs +++ b/.cursor/skills/impeccable/scripts/live-complete.mjs @@ -4,10 +4,7 @@ */ import { createLiveSessionStore } from './live-session-store.mjs'; -import fs from 'node:fs'; -import path from 'node:path'; - -const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); +import { readLiveServerInfo } from './impeccable-paths.mjs'; function parseArgs(argv) { const out = { status: 'complete' }; @@ -50,8 +47,7 @@ export async function completeCli() { } function readServerInfo() { - try { return JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); } - catch { return null; } + return readLiveServerInfo(process.cwd())?.info || null; } async function completeThroughServer(info, args) { diff --git a/.cursor/skills/impeccable/scripts/live-inject.mjs b/.cursor/skills/impeccable/scripts/live-inject.mjs index 1d2ae12f..555c3a36 100644 --- a/.cursor/skills/impeccable/scripts/live-inject.mjs +++ b/.cursor/skills/impeccable/scripts/live-inject.mjs @@ -2,23 +2,24 @@ * CLI helper: insert/remove the live variant mode script tag in the project's * main HTML entry point. * - * On first live run, the agent generates `config.json` in this script's - * directory with the project's insertion target (framework-specific). On + * On first live run, the agent generates `.impeccable/live/config.json` + * with the project's insertion target (framework-specific). On * every subsequent run, this script handles insert/remove deterministically * with zero LLM involvement. * * Usage: * node live-inject.mjs --port PORT # Insert the live script tag * node live-inject.mjs --remove # Remove the live script tag - * node live-inject.mjs --check # Check whether config.json exists + * node live-inject.mjs --check # Check whether live config exists */ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { resolveLiveConfigPath } from './impeccable-paths.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const CONFIG_PATH = process.env.IMPECCABLE_LIVE_CONFIG || path.join(__dirname, 'config.json'); +const CONFIG_PATH = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname }); const MARKER_OPEN_TEXT = 'impeccable-live-start'; const MARKER_CLOSE_TEXT = 'impeccable-live-end'; @@ -39,12 +40,12 @@ export async function injectCli() { console.log(`Usage: node live-inject.mjs [options] Insert or remove the live mode script tag in the project's HTML entry point. -Reads configuration from config.json (in this same directory). +Reads configuration from .impeccable/live/config.json. Modes: --port PORT Insert script tag pointing at http://localhost:PORT/live.js --remove Remove the script tag (if present) - --check Print whether config.json exists and its content + --check Print whether .impeccable/live/config.json exists and its content Output (JSON): { ok, file, inserted|removed, config? }`); diff --git a/.cursor/skills/impeccable/scripts/live-poll.mjs b/.cursor/skills/impeccable/scripts/live-poll.mjs index 9a3f07ae..83a9912e 100644 --- a/.cursor/skills/impeccable/scripts/live-poll.mjs +++ b/.cursor/skills/impeccable/scripts/live-poll.mjs @@ -9,11 +9,10 @@ */ import { execFileSync } from 'node:child_process'; -import fs from 'node:fs'; import path from 'node:path'; -import os from 'node:os'; import { fileURLToPath } from 'node:url'; import { completionTypeForAcceptResult } from './live-completion.mjs'; +import { readLiveServerInfo } from './impeccable-paths.mjs'; // Node's built-in fetch (undici under the hood) enforces a 300s headers // timeout that can't be lowered per-request. We cap each request below @@ -21,15 +20,13 @@ import { completionTypeForAcceptResult } from './live-completion.mjs'; // depending on the standalone undici package. const PER_REQUEST_TIMEOUT_MS = 270_000; -const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); - function readServerInfo() { - try { - return JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); - } catch { + const record = readLiveServerInfo(process.cwd()); + if (!record) { console.error('No running live server found. Start one with: npx impeccable live'); process.exit(1); } + return record.info; } export function buildPollReplyPayload(token, { id, type, message, file, data }) { diff --git a/.cursor/skills/impeccable/scripts/live-server.mjs b/.cursor/skills/impeccable/scripts/live-server.mjs index e78a0657..53d8f21c 100644 --- a/.cursor/skills/impeccable/scripts/live-server.mjs +++ b/.cursor/skills/impeccable/scripts/live-server.mjs @@ -23,14 +23,19 @@ import { fileURLToPath } from 'node:url'; import { parseDesignMd } from './design-parser.mjs'; import { resolveContextDir } from './load-context.mjs'; import { createLiveSessionStore } from './live-session-store.mjs'; +import { + getDesignSidecarPath, + getLiveAnnotationsDir, + readLiveServerInfo, + removeLiveServerInfo, + resolveDesignSidecarPath, + writeLiveServerInfo, +} from './impeccable-paths.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -// PID file in the project root so both the server and agent can find it -// predictably (os.tmpdir() varies across platforms). -const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); -// PRODUCT.md / DESIGN.md / DESIGN.json live wherever load-context.mjs resolves. -// Keeps live-server in sync with the loader when users keep the docs in -// .agents/context/, docs/, or a path set via IMPECCABLE_CONTEXT_DIR. +// PRODUCT.md / DESIGN.md live wherever load-context.mjs resolves. The generated +// DESIGN sidecar is project-local at .impeccable/design.json, with legacy +// DESIGN.json fallback for existing projects. const CONTEXT_DIR = resolveContextDir(process.cwd()); const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s @@ -411,13 +416,13 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { } // --- Design system (unified v2 response) + raw --- - // /design-system.json returns both parsed DESIGN.md and DESIGN.json + // /design-system.json returns both parsed DESIGN.md and .impeccable/design.json // sidecar when present. Panel merges them: // { present, parsed, sidecar, hasMd, hasSidecar, // mdNewerThanJson, parseError?, sidecarError? } // - parsed: output of parseDesignMd (frontmatter // + six canonical sections) when DESIGN.md exists. - // - sidecar: DESIGN.json contents when present. + // - sidecar: .impeccable/design.json contents when present. // Expected shape: schemaVersion 2, carrying // extensions + components + narrative. // /design-system/raw returns DESIGN.md markdown verbatim @@ -426,7 +431,7 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } const mdPath = path.join(CONTEXT_DIR, 'DESIGN.md'); - const jsonPath = path.join(CONTEXT_DIR, 'DESIGN.json'); + const jsonPath = resolveDesignSidecarPath(process.cwd(), CONTEXT_DIR) || getDesignSidecarPath(process.cwd()); const mdStat = statOrNull(mdPath); const jsonStat = statOrNull(jsonPath); @@ -462,7 +467,7 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { try { response.sidecar = JSON.parse(fs.readFileSync(jsonPath, 'utf-8')); } catch (err) { - response.sidecarError = 'Failed to parse DESIGN.json: ' + err.message; + response.sidecarError = 'Failed to parse .impeccable/design.json: ' + err.message; } } @@ -673,7 +678,7 @@ function handlePollPost(req, res) { let httpServer = null; function shutdown() { - try { fs.unlinkSync(LIVE_PID_FILE); } catch {} + removeLiveServerInfo(process.cwd()); if (state.leaseTimer) clearTimeout(state.leaseTimer); state.leaseTimer = null; if (state.sessionDir) { @@ -725,7 +730,7 @@ Endpoints: if (args.includes('stop')) { const keepInject = args.includes('--keep-inject'); try { - const info = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); + const { info } = readLiveServerInfo(process.cwd()) || {}; const res = await fetch(`http://localhost:${info.port}/stop?token=${info.token}`); if (res.ok) console.log(`Stopped live server on port ${info.port}.`); } catch { @@ -776,7 +781,7 @@ if (args.includes('--background')) { const deadline = Date.now() + 10_000; while (Date.now() < deadline) { try { - const info = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); + const { info } = readLiveServerInfo(process.cwd()) || {}; if (info.pid !== process.pid) { // Output JSON so the agent can read port + token from stdout. console.log(JSON.stringify(info)); @@ -790,14 +795,18 @@ if (args.includes('--background')) { } // Check for existing session -try { - const existing = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); - try { process.kill(existing.pid, 0); +const existingRecord = readLiveServerInfo(process.cwd()); +if (existingRecord?.info) { + const existing = existingRecord.info; + try { + process.kill(existing.pid, 0); console.error(`Live server already running on port ${existing.port} (pid ${existing.pid}).`); console.error('Stop it first with: node ' + path.basename(fileURLToPath(import.meta.url)) + ' stop'); process.exit(1); - } catch { fs.unlinkSync(LIVE_PID_FILE); } -} catch {} + } catch { + try { fs.unlinkSync(existingRecord.path); } catch {} + } +} state.token = randomUUID(); state.sessionStore = createLiveSessionStore({ cwd: process.cwd() }); @@ -807,7 +816,7 @@ state.port = portArg ? parseInt(portArg.split('=')[1], 10) : await findOpenPort( // Annotation screenshots live in the project root so the agent's Read tool // doesn't trip a per-file permission prompt. Sessioned by token so concurrent // projects (or quick restarts) don't collide. -const annotRoot = path.join(process.cwd(), '.impeccable-live', 'annotations'); +const annotRoot = getLiveAnnotationsDir(process.cwd()); fs.mkdirSync(annotRoot, { recursive: true }); state.sessionDir = fs.mkdtempSync(path.join(annotRoot, 'session-')); @@ -815,7 +824,7 @@ const { detectScript, sessionPath, livePath } = loadBrowserScripts(); httpServer = http.createServer(createRequestHandler({ detectScript, sessionPath, livePath })); httpServer.listen(state.port, '127.0.0.1', () => { - fs.writeFileSync(LIVE_PID_FILE, JSON.stringify({ pid: process.pid, port: state.port, token: state.token })); + writeLiveServerInfo(process.cwd(), { pid: process.pid, port: state.port, token: state.token }); const url = `http://localhost:${state.port}`; console.log(`\nImpeccable live server running on ${url}`); console.log(`Token: ${state.token}\n`); diff --git a/.cursor/skills/impeccable/scripts/live-session-store.mjs b/.cursor/skills/impeccable/scripts/live-session-store.mjs index cc7744df..37711168 100644 --- a/.cursor/skills/impeccable/scripts/live-session-store.mjs +++ b/.cursor/skills/impeccable/scripts/live-session-store.mjs @@ -1,30 +1,43 @@ import fs from 'node:fs'; import path from 'node:path'; +import { getLegacyLiveSessionsDir, getLiveSessionsDir } from './impeccable-paths.mjs'; -const LIVE_DIR = '.impeccable-live'; -const SESSIONS_DIR = 'sessions'; const COMPLETED_PHASES = new Set(['completed', 'discarded']); export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {}) { - const rootDir = path.join(cwd, LIVE_DIR, SESSIONS_DIR); + 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 = getJournalPath(rootDir, id); + const journalPath = getReadableJournalPath(id); const rebuilt = rebuildSnapshotFromJournal(journalPath, id); snapshotCache.set(id, rebuilt); return rebuilt; } + function getReadableJournalPath(id) { + const primary = getJournalPath(rootDir, id); + if (fs.existsSync(primary)) return primary; + const legacy = getJournalPath(legacyRootDir, id); + if (fs.existsSync(legacy)) return legacy; + return primary; + } + return { rootDir, + legacyRootDir, appendEvent(event) { const normalized = normalizeEvent(event, sessionId); const journalPath = getJournalPath(rootDir, normalized.id); const snapshotPath = getSnapshotPath(rootDir, normalized.id); + const legacyJournalPath = getJournalPath(legacyRootDir, normalized.id); + if (!fs.existsSync(journalPath) && fs.existsSync(legacyJournalPath)) { + fs.copyFileSync(legacyJournalPath, journalPath); + } const prior = loadCachedOrRebuild(normalized.id); const seq = prior.nextSeq; const entry = { @@ -42,7 +55,7 @@ export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {}) }, getSnapshot(id = sessionId, opts = {}) { if (!id) throw new Error('session id required'); - const journalPath = getJournalPath(rootDir, id); + const journalPath = getReadableJournalPath(id); const snapshotPath = getSnapshotPath(rootDir, id); const rebuilt = rebuildSnapshotFromJournal(journalPath, id); snapshotCache.set(id, rebuilt); @@ -51,10 +64,15 @@ export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {}) return rebuilt.snapshot; }, listActiveSessions() { - if (!fs.existsSync(rootDir)) return []; - return fs.readdirSync(rootDir) - .filter((name) => name.endsWith('.jsonl')) - .map((name) => name.slice(0, -'.jsonl'.length)) + const ids = new Set(); + for (const dir of [legacyRootDir, rootDir]) { + if (!fs.existsSync(dir)) continue; + for (const name of fs.readdirSync(dir)) { + if (name.endsWith('.jsonl')) ids.add(name.slice(0, -'.jsonl'.length)); + } + } + return [...ids] + .sort() .map((id) => this.getSnapshot(id)) .filter(Boolean); }, diff --git a/.cursor/skills/impeccable/scripts/live-status.mjs b/.cursor/skills/impeccable/scripts/live-status.mjs index 1b85357c..dce1fbca 100644 --- a/.cursor/skills/impeccable/scripts/live-status.mjs +++ b/.cursor/skills/impeccable/scripts/live-status.mjs @@ -3,15 +3,11 @@ * Print durable recovery status for Impeccable live sessions. */ -import fs from 'node:fs'; -import path from 'node:path'; import { createLiveSessionStore } from './live-session-store.mjs'; - -const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); +import { readLiveServerInfo } from './impeccable-paths.mjs'; function readServerInfo() { - try { return JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); } - catch { return null; } + return readLiveServerInfo(process.cwd())?.info || null; } async function fetchServerStatus(info) { diff --git a/.cursor/skills/impeccable/scripts/live.mjs b/.cursor/skills/impeccable/scripts/live.mjs index befbdb8e..cafb0eca 100644 --- a/.cursor/skills/impeccable/scripts/live.mjs +++ b/.cursor/skills/impeccable/scripts/live.mjs @@ -2,10 +2,10 @@ * CLI entry point: prepare everything needed to enter the live variant poll loop. * * Does (all in one command): - * 1. Check config.json (returns config_missing if first-ever run) + * 1. Check .impeccable/live/config.json (returns config_missing if first-ever run) * 2. Start the live server in the background (or reuse a running one) * 3. Inject the browser script tag into the project's entry file - * 4. Read .impeccable.md for design context (if present) + * 4. Read PRODUCT.md / DESIGN.md for project context * 5. Print a single JSON blob with everything the agent needs * * After this, the agent's only remaining steps are: @@ -23,9 +23,9 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { loadContext } from './load-context.mjs'; import { resolveFiles } from './live-inject.mjs'; +import { readLiveServerInfo } from './impeccable-paths.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); async function liveCli() { const args = process.argv.slice(2); @@ -34,10 +34,10 @@ async function liveCli() { console.log(`Usage: node live.mjs Prepare everything for live variant mode in a single command: - - Checks scripts/config.json (required, created once per project) + - Checks .impeccable/live/config.json (required, created once per project) - Starts (or reuses) the live server in the background - Injects the browser script tag - - Reads .impeccable.md for design context + - Reads PRODUCT.md / DESIGN.md for project context On success, prints a JSON blob with: { ok, serverPort, serverToken, pageFile, hasContext, context } @@ -223,7 +223,7 @@ function safeParse(out) { function ensureServerRunning() { // Try to reuse an existing server try { - const existing = JSON.parse(fs.readFileSync(PID_FILE, 'utf-8')); + const existing = readLiveServerInfo(process.cwd())?.info; if (existing && existing.pid) { try { process.kill(existing.pid, 0); // throws if dead diff --git a/.cursor/skills/impeccable/scripts/load-context.mjs b/.cursor/skills/impeccable/scripts/load-context.mjs index dca23c1f..dc340bf1 100644 --- a/.cursor/skills/impeccable/scripts/load-context.mjs +++ b/.cursor/skills/impeccable/scripts/load-context.mjs @@ -39,7 +39,7 @@ const LEGACY_NAMES = ['.impeccable.md']; const FALLBACK_DIRS = ['.agents/context', 'docs']; /** - * Resolve the directory that holds PRODUCT.md / DESIGN.md / DESIGN.json for + * Resolve the directory that holds PRODUCT.md / DESIGN.md for * this project. Exported so other scripts (e.g. live-server.mjs) can read the * design files from the same location the loader uses. */ diff --git a/.gemini/skills/impeccable/reference/document.md b/.gemini/skills/impeccable/reference/document.md index c0a3c8c1..9dbd3043 100644 --- a/.gemini/skills/impeccable/reference/document.md +++ b/.gemini/skills/impeccable/reference/document.md @@ -237,11 +237,11 @@ Concrete, forceful guardrails. Lead each with "Do" or "Don't". Be specific: incl - **Don't** [...] ``` -### Step 4b: Write DESIGN.json sidecar (extensions only) +### Step 4b: Write .impeccable/design.json sidecar (extensions only) -The frontmatter owns token primitives (colors, typography, rounded, spacing, components). The sidecar at `DESIGN.json` carries **what Stitch's schema can't hold**: tonal ramps per color, shadow/elevation tokens, motion tokens, breakpoints, full component HTML/CSS snippets (the panel renders these into a shadow DOM), and narrative (north star, rules, do's/don'ts). It extends the frontmatter, it doesn't duplicate it. +The frontmatter owns token primitives (colors, typography, rounded, spacing, components). The sidecar at `.impeccable/design.json` carries **what Stitch's schema can't hold**: tonal ramps per color, shadow/elevation tokens, motion tokens, breakpoints, full component HTML/CSS snippets (the panel renders these into a shadow DOM), and narrative (north star, rules, do's/don'ts). It extends the frontmatter, it doesn't duplicate it. -Regenerate the sidecar whenever you regenerate DESIGN.md. If the user only asks to refresh the sidecar (e.g., from the live panel's stale-hint), preserve DESIGN.md and write only DESIGN.json. +Regenerate the sidecar whenever you regenerate root `DESIGN.md`. If the user only asks to refresh the sidecar (e.g., from the live panel's stale-hint), preserve `DESIGN.md` and write only `.impeccable/design.json`. #### Schema @@ -310,7 +310,7 @@ Aim for a tight set of **5-10 components** that best represent the visual system - **Signature components (include if distinctive):** hero CTA, featured card, filter pill, any custom pattern the user mentioned as important in PRODUCT.md. - **Skip the rest.** Utility components, form building blocks, wrapper layouts: not worth documenting unless visually distinctive. -If the project has **no component library yet** (bare landing page, new project), synthesize canonical primitives from the tokens using best-practice defaults consistent with the DESIGN.md's rules. Every DESIGN.json has *something* to render, even on day zero. +If the project has **no component library yet** (bare landing page, new project), synthesize canonical primitives from the tokens using best-practice defaults consistent with the DESIGN.md's rules. Every `.impeccable/design.json` has *something* to render, even on day zero. #### Tonal ramps @@ -331,7 +331,7 @@ Do not reword. The panel shows these as secondary collapsible context; the same ### Step 5: Confirm, refine, and refresh session cache 1. Show the user the full DESIGN.md you wrote. Briefly highlight the non-obvious creative choices (descriptive color names, atmosphere language, named rules). -2. Mention that `DESIGN.json` was also written alongside; the live panel will now render this project's actual button/input/nav primitives instead of generic approximations. +2. Mention that `.impeccable/design.json` was also written alongside; the live panel will now render this project's actual button/input/nav primitives instead of generic approximations. 3. Offer to refine any section: "Want me to revise a section, add component patterns I missed, or adjust the atmosphere language?" 4. **Refresh the session cache.** Run `node .gemini/skills/impeccable/scripts/load-context.mjs` one final time so the newly-written DESIGN.md lands in conversation. Subsequent commands in this session will use the fresh version automatically without re-reading. @@ -392,7 +392,7 @@ Per-section guidance in seed mode: - **Components**: omit entirely; no components exist yet. - **Do's and Don'ts**: carry PRODUCT.md's anti-references directly plus the anti-reference named in Q5. -Seed mode writes a minimal frontmatter with `name` and `description` only; no colors, typography, rounded, spacing, or components yet. Real tokens land on the next Scan-mode run. Skip the `DESIGN.json` sidecar in seed mode for the same reason: nothing to render. +Seed mode writes a minimal frontmatter with `name` and `description` only; no colors, typography, rounded, spacing, or components yet. Real tokens land on the next Scan-mode run. Skip the `.impeccable/design.json` sidecar in seed mode for the same reason: nothing to render. ### Step 4: Confirm and refresh session cache diff --git a/.gemini/skills/impeccable/reference/live.md b/.gemini/skills/impeccable/reference/live.md index 6bc45783..5c880b4a 100644 --- a/.gemini/skills/impeccable/reference/live.md +++ b/.gemini/skills/impeccable/reference/live.md @@ -53,7 +53,7 @@ LOOP: ## Recovery commands -The live helper persists an append-only journal under `.impeccable-live/sessions`. Browser checkpoints are advisory but durable; the journal is canonical. +The live helper persists an append-only journal under `.impeccable/live/sessions/`. Browser checkpoints are advisory but durable; the journal is canonical. This is local durable recovery state, not project source. Use these commands when the chat was interrupted, polling was missed, the helper restarted, or the browser reloaded: @@ -473,7 +473,7 @@ When the poll returns `exit`, proceed to cleanup. If the poll is still running a node .gemini/skills/impeccable/scripts/live-server.mjs stop ``` -Stops the HTTP server and runs `live-inject.mjs --remove` to strip `localhost:…/live.js` from the HTML entry. To stop the server but keep the inject tag (for a quick restart), use `stop --keep-inject`. `config.json` persists for future sessions. +Stops the HTTP server and runs `live-inject.mjs --remove` to strip `localhost:…/live.js` from the HTML entry. To stop the server but keep the inject tag (for a quick restart), use `stop --keep-inject`. `.impeccable/live/config.json` persists as project config for future sessions. Then: - Remove any leftover variant wrappers (search for `impeccable-variants-start` markers). @@ -481,7 +481,7 @@ Then: ## First-time setup (config missing or invalid) -If `live.mjs` outputs `{ ok: false, error: "config_missing" | "config_invalid", path }`, write `config.json` at the reported path. +If `live.mjs` outputs `{ ok: false, error: "config_missing" | "config_invalid", path }`, write the live config at the reported path. By default this is `.impeccable/live/config.json`. Schema: @@ -561,7 +561,7 @@ node .gemini/skills/impeccable/scripts/detect-csp.mjs Output: `{ shape, signals }` where `shape` is one of `append-arrays`, `append-string`, `middleware`, `meta-tag`, or `null`. The shape is named by *patch mechanism*, so one template covers many frameworks. -- **`null`**: no CSP; skip to writing `config.json` with `cspChecked: true`. +- **`null`**: no CSP; skip to writing `.impeccable/live/config.json` with `cspChecked: true`. - **`append-arrays`**: CSP defined as structured directive arrays. Auto-patchable. See *append-arrays* below. Covers: - Monorepo helpers with `additionalScriptSrc` / `additionalConnectSrc` options (Next.js + shared config package) - SvelteKit `kit.csp.directives` @@ -638,6 +638,6 @@ Reference outputs: ### Troubleshooting -If a user says "no" to the CSP patch at setup time and later complains that live doesn't work: their dev CSP blocks `http://localhost:8400`. Fix: delete `cspChecked` from `config.json` and re-run `live.mjs`: setup will ask again. +If a user says "no" to the CSP patch at setup time and later complains that live doesn't work: their dev CSP blocks `http://localhost:8400`. Fix: delete `cspChecked` from `.impeccable/live/config.json` and re-run `live.mjs`: setup will ask again. Then re-run `live.mjs`. diff --git a/.gemini/skills/impeccable/reference/teach.md b/.gemini/skills/impeccable/reference/teach.md index 78ccd9fd..4857a9b1 100644 --- a/.gemini/skills/impeccable/reference/teach.md +++ b/.gemini/skills/impeccable/reference/teach.md @@ -2,8 +2,8 @@ Gathers design context for a project and writes two complementary files at the project root: -- **PRODUCT.md** (strategic): register, target users, product purpose, brand personality, anti-references, strategic design principles. Answers "who/what/why". -- **DESIGN.md** (visual): visual theme, color palette, typography, components, layout. Follows the [Google Stitch DESIGN.md format](https://stitch.withgoogle.com/docs/design-md/format/). Answers "how it looks". +- **PRODUCT.md** (strategic): root project file for register, target users, product purpose, brand personality, anti-references, strategic design principles. Answers "who/what/why". +- **DESIGN.md** (visual): root project file for visual theme, color palette, typography, components, layout. Follows the [Google Stitch DESIGN.md format](https://stitch.withgoogle.com/docs/design-md/format/). Answers "how it looks". Every other impeccable command reads these files before doing any work. diff --git a/.gemini/skills/impeccable/scripts/impeccable-paths.mjs b/.gemini/skills/impeccable/scripts/impeccable-paths.mjs new file mode 100644 index 00000000..ba852bae --- /dev/null +++ b/.gemini/skills/impeccable/scripts/impeccable-paths.mjs @@ -0,0 +1,105 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +export const IMPECCABLE_DIR = '.impeccable'; +export const LIVE_DIR = 'live'; + +export function getImpeccableDir(cwd = process.cwd()) { + return path.join(cwd, IMPECCABLE_DIR); +} + +export function getDesignSidecarPath(cwd = process.cwd()) { + return path.join(getImpeccableDir(cwd), 'design.json'); +} + +export function getDesignSidecarCandidates(cwd = process.cwd(), contextDir = cwd) { + const candidates = [ + getDesignSidecarPath(cwd), + path.join(cwd, 'DESIGN.json'), + ]; + const contextLegacy = path.join(contextDir, 'DESIGN.json'); + if (!candidates.includes(contextLegacy)) candidates.push(contextLegacy); + return candidates; +} + +export function resolveDesignSidecarPath(cwd = process.cwd(), contextDir = cwd) { + return firstExisting(getDesignSidecarCandidates(cwd, contextDir)); +} + +export function getLiveDir(cwd = process.cwd()) { + return path.join(getImpeccableDir(cwd), LIVE_DIR); +} + +export function getLiveConfigPath(cwd = process.cwd()) { + return path.join(getLiveDir(cwd), 'config.json'); +} + +export function getLegacyLiveConfigPath(scriptsDir) { + return path.join(scriptsDir, 'config.json'); +} + +export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = process.env } = {}) { + if (env.IMPECCABLE_LIVE_CONFIG && env.IMPECCABLE_LIVE_CONFIG.trim()) { + const configured = env.IMPECCABLE_LIVE_CONFIG.trim(); + return path.isAbsolute(configured) ? configured : path.resolve(cwd, configured); + } + const primary = getLiveConfigPath(cwd); + if (fs.existsSync(primary)) return primary; + if (scriptsDir) { + const legacy = getLegacyLiveConfigPath(scriptsDir); + if (fs.existsSync(legacy)) return legacy; + } + return primary; +} + +export function getLiveServerPath(cwd = process.cwd()) { + return path.join(getLiveDir(cwd), 'server.json'); +} + +export function getLegacyLiveServerPath(cwd = process.cwd()) { + return path.join(cwd, '.impeccable-live.json'); +} + +export function readLiveServerInfo(cwd = process.cwd()) { + for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) { + try { + return { info: JSON.parse(fs.readFileSync(filePath, 'utf-8')), path: filePath }; + } catch { + /* try next */ + } + } + return null; +} + +export function writeLiveServerInfo(cwd = process.cwd(), info) { + const filePath = getLiveServerPath(cwd); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, JSON.stringify(info)); + return filePath; +} + +export function removeLiveServerInfo(cwd = process.cwd()) { + for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) { + try { fs.unlinkSync(filePath); } catch {} + } +} + +export function getLiveSessionsDir(cwd = process.cwd()) { + return path.join(getLiveDir(cwd), 'sessions'); +} + +export function getLegacyLiveSessionsDir(cwd = process.cwd()) { + return path.join(cwd, '.impeccable-live', 'sessions'); +} + +export function getLiveAnnotationsDir(cwd = process.cwd()) { + return path.join(getLiveDir(cwd), 'annotations'); +} + +export function getLegacyLiveAnnotationsDir(cwd = process.cwd()) { + return path.join(cwd, '.impeccable-live', 'annotations'); +} + +function firstExisting(paths) { + return paths.find((filePath) => fs.existsSync(filePath)) || null; +} diff --git a/.gemini/skills/impeccable/scripts/live-browser.js b/.gemini/skills/impeccable/scripts/live-browser.js index 7f1ff329..bb5332a3 100644 --- a/.gemini/skills/impeccable/scripts/live-browser.js +++ b/.gemini/skills/impeccable/scripts/live-browser.js @@ -3671,7 +3671,7 @@ void main() { } // --------------------------------------------------------------------------- - // Design System Panel — visualizes the project's DESIGN.json sidecar + // Design System Panel — visualizes the project's .impeccable/design.json sidecar // --------------------------------------------------------------------------- const DESIGN_PREFS_KEY = 'impeccable-live-design-panel'; @@ -3683,7 +3683,7 @@ void main() { open: false, tab: 'visual', // 'visual' | 'raw' parsed: null, // parseDesignMd output (frontmatter + body sections) - sidecar: null, // DESIGN.json v2 payload (extensions + components + narrative) + sidecar: null, // .impeccable/design.json v2 payload (extensions + components + narrative) hasMd: false, hasSidecar: false, present: null, // true/false once fetch resolves @@ -4184,7 +4184,7 @@ void main() { box.className = 'stale'; box.innerHTML = ` - DESIGN.md is newer than DESIGN.json. Run /impeccable document to refresh the sidecar. + DESIGN.md is newer than .impeccable/design.json. Run /impeccable document to refresh the sidecar. `; return box; } @@ -4192,7 +4192,7 @@ void main() { function renderParsedMdCta() { const box = document.createElement('div'); box.className = 'parsed-md-cta'; - box.innerHTML = `Basic viewThis panel reads the tokens in your DESIGN.md frontmatter. Running /impeccable document also generates a DESIGN.json sidecar with your project's actual component snippets (button, input, nav) and tonal ramps, rendered live below the tokens.`; + box.innerHTML = `Basic viewThis panel reads the tokens in your DESIGN.md frontmatter. Running /impeccable document also generates a .impeccable/design.json sidecar with your project's actual component snippets (button, input, nav) and tonal ramps, rendered live below the tokens.`; return box; } @@ -4652,7 +4652,7 @@ void main() { function cssSafe(v) { // Strip anything outside valid CSS value chars to prevent injection via - // DESIGN.json values rendered into inline style strings. + // .impeccable/design.json values rendered into inline style strings. return String(v).replace(/[<>"'`\n]/g, ''); } diff --git a/.gemini/skills/impeccable/scripts/live-complete.mjs b/.gemini/skills/impeccable/scripts/live-complete.mjs index ca00d86a..78155af8 100644 --- a/.gemini/skills/impeccable/scripts/live-complete.mjs +++ b/.gemini/skills/impeccable/scripts/live-complete.mjs @@ -4,10 +4,7 @@ */ import { createLiveSessionStore } from './live-session-store.mjs'; -import fs from 'node:fs'; -import path from 'node:path'; - -const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); +import { readLiveServerInfo } from './impeccable-paths.mjs'; function parseArgs(argv) { const out = { status: 'complete' }; @@ -50,8 +47,7 @@ export async function completeCli() { } function readServerInfo() { - try { return JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); } - catch { return null; } + return readLiveServerInfo(process.cwd())?.info || null; } async function completeThroughServer(info, args) { diff --git a/.gemini/skills/impeccable/scripts/live-inject.mjs b/.gemini/skills/impeccable/scripts/live-inject.mjs index 1d2ae12f..555c3a36 100644 --- a/.gemini/skills/impeccable/scripts/live-inject.mjs +++ b/.gemini/skills/impeccable/scripts/live-inject.mjs @@ -2,23 +2,24 @@ * CLI helper: insert/remove the live variant mode script tag in the project's * main HTML entry point. * - * On first live run, the agent generates `config.json` in this script's - * directory with the project's insertion target (framework-specific). On + * On first live run, the agent generates `.impeccable/live/config.json` + * with the project's insertion target (framework-specific). On * every subsequent run, this script handles insert/remove deterministically * with zero LLM involvement. * * Usage: * node live-inject.mjs --port PORT # Insert the live script tag * node live-inject.mjs --remove # Remove the live script tag - * node live-inject.mjs --check # Check whether config.json exists + * node live-inject.mjs --check # Check whether live config exists */ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { resolveLiveConfigPath } from './impeccable-paths.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const CONFIG_PATH = process.env.IMPECCABLE_LIVE_CONFIG || path.join(__dirname, 'config.json'); +const CONFIG_PATH = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname }); const MARKER_OPEN_TEXT = 'impeccable-live-start'; const MARKER_CLOSE_TEXT = 'impeccable-live-end'; @@ -39,12 +40,12 @@ export async function injectCli() { console.log(`Usage: node live-inject.mjs [options] Insert or remove the live mode script tag in the project's HTML entry point. -Reads configuration from config.json (in this same directory). +Reads configuration from .impeccable/live/config.json. Modes: --port PORT Insert script tag pointing at http://localhost:PORT/live.js --remove Remove the script tag (if present) - --check Print whether config.json exists and its content + --check Print whether .impeccable/live/config.json exists and its content Output (JSON): { ok, file, inserted|removed, config? }`); diff --git a/.gemini/skills/impeccable/scripts/live-poll.mjs b/.gemini/skills/impeccable/scripts/live-poll.mjs index 9a3f07ae..83a9912e 100644 --- a/.gemini/skills/impeccable/scripts/live-poll.mjs +++ b/.gemini/skills/impeccable/scripts/live-poll.mjs @@ -9,11 +9,10 @@ */ import { execFileSync } from 'node:child_process'; -import fs from 'node:fs'; import path from 'node:path'; -import os from 'node:os'; import { fileURLToPath } from 'node:url'; import { completionTypeForAcceptResult } from './live-completion.mjs'; +import { readLiveServerInfo } from './impeccable-paths.mjs'; // Node's built-in fetch (undici under the hood) enforces a 300s headers // timeout that can't be lowered per-request. We cap each request below @@ -21,15 +20,13 @@ import { completionTypeForAcceptResult } from './live-completion.mjs'; // depending on the standalone undici package. const PER_REQUEST_TIMEOUT_MS = 270_000; -const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); - function readServerInfo() { - try { - return JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); - } catch { + const record = readLiveServerInfo(process.cwd()); + if (!record) { console.error('No running live server found. Start one with: npx impeccable live'); process.exit(1); } + return record.info; } export function buildPollReplyPayload(token, { id, type, message, file, data }) { diff --git a/.gemini/skills/impeccable/scripts/live-server.mjs b/.gemini/skills/impeccable/scripts/live-server.mjs index e78a0657..53d8f21c 100644 --- a/.gemini/skills/impeccable/scripts/live-server.mjs +++ b/.gemini/skills/impeccable/scripts/live-server.mjs @@ -23,14 +23,19 @@ import { fileURLToPath } from 'node:url'; import { parseDesignMd } from './design-parser.mjs'; import { resolveContextDir } from './load-context.mjs'; import { createLiveSessionStore } from './live-session-store.mjs'; +import { + getDesignSidecarPath, + getLiveAnnotationsDir, + readLiveServerInfo, + removeLiveServerInfo, + resolveDesignSidecarPath, + writeLiveServerInfo, +} from './impeccable-paths.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -// PID file in the project root so both the server and agent can find it -// predictably (os.tmpdir() varies across platforms). -const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); -// PRODUCT.md / DESIGN.md / DESIGN.json live wherever load-context.mjs resolves. -// Keeps live-server in sync with the loader when users keep the docs in -// .agents/context/, docs/, or a path set via IMPECCABLE_CONTEXT_DIR. +// PRODUCT.md / DESIGN.md live wherever load-context.mjs resolves. The generated +// DESIGN sidecar is project-local at .impeccable/design.json, with legacy +// DESIGN.json fallback for existing projects. const CONTEXT_DIR = resolveContextDir(process.cwd()); const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s @@ -411,13 +416,13 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { } // --- Design system (unified v2 response) + raw --- - // /design-system.json returns both parsed DESIGN.md and DESIGN.json + // /design-system.json returns both parsed DESIGN.md and .impeccable/design.json // sidecar when present. Panel merges them: // { present, parsed, sidecar, hasMd, hasSidecar, // mdNewerThanJson, parseError?, sidecarError? } // - parsed: output of parseDesignMd (frontmatter // + six canonical sections) when DESIGN.md exists. - // - sidecar: DESIGN.json contents when present. + // - sidecar: .impeccable/design.json contents when present. // Expected shape: schemaVersion 2, carrying // extensions + components + narrative. // /design-system/raw returns DESIGN.md markdown verbatim @@ -426,7 +431,7 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } const mdPath = path.join(CONTEXT_DIR, 'DESIGN.md'); - const jsonPath = path.join(CONTEXT_DIR, 'DESIGN.json'); + const jsonPath = resolveDesignSidecarPath(process.cwd(), CONTEXT_DIR) || getDesignSidecarPath(process.cwd()); const mdStat = statOrNull(mdPath); const jsonStat = statOrNull(jsonPath); @@ -462,7 +467,7 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { try { response.sidecar = JSON.parse(fs.readFileSync(jsonPath, 'utf-8')); } catch (err) { - response.sidecarError = 'Failed to parse DESIGN.json: ' + err.message; + response.sidecarError = 'Failed to parse .impeccable/design.json: ' + err.message; } } @@ -673,7 +678,7 @@ function handlePollPost(req, res) { let httpServer = null; function shutdown() { - try { fs.unlinkSync(LIVE_PID_FILE); } catch {} + removeLiveServerInfo(process.cwd()); if (state.leaseTimer) clearTimeout(state.leaseTimer); state.leaseTimer = null; if (state.sessionDir) { @@ -725,7 +730,7 @@ Endpoints: if (args.includes('stop')) { const keepInject = args.includes('--keep-inject'); try { - const info = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); + const { info } = readLiveServerInfo(process.cwd()) || {}; const res = await fetch(`http://localhost:${info.port}/stop?token=${info.token}`); if (res.ok) console.log(`Stopped live server on port ${info.port}.`); } catch { @@ -776,7 +781,7 @@ if (args.includes('--background')) { const deadline = Date.now() + 10_000; while (Date.now() < deadline) { try { - const info = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); + const { info } = readLiveServerInfo(process.cwd()) || {}; if (info.pid !== process.pid) { // Output JSON so the agent can read port + token from stdout. console.log(JSON.stringify(info)); @@ -790,14 +795,18 @@ if (args.includes('--background')) { } // Check for existing session -try { - const existing = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); - try { process.kill(existing.pid, 0); +const existingRecord = readLiveServerInfo(process.cwd()); +if (existingRecord?.info) { + const existing = existingRecord.info; + try { + process.kill(existing.pid, 0); console.error(`Live server already running on port ${existing.port} (pid ${existing.pid}).`); console.error('Stop it first with: node ' + path.basename(fileURLToPath(import.meta.url)) + ' stop'); process.exit(1); - } catch { fs.unlinkSync(LIVE_PID_FILE); } -} catch {} + } catch { + try { fs.unlinkSync(existingRecord.path); } catch {} + } +} state.token = randomUUID(); state.sessionStore = createLiveSessionStore({ cwd: process.cwd() }); @@ -807,7 +816,7 @@ state.port = portArg ? parseInt(portArg.split('=')[1], 10) : await findOpenPort( // Annotation screenshots live in the project root so the agent's Read tool // doesn't trip a per-file permission prompt. Sessioned by token so concurrent // projects (or quick restarts) don't collide. -const annotRoot = path.join(process.cwd(), '.impeccable-live', 'annotations'); +const annotRoot = getLiveAnnotationsDir(process.cwd()); fs.mkdirSync(annotRoot, { recursive: true }); state.sessionDir = fs.mkdtempSync(path.join(annotRoot, 'session-')); @@ -815,7 +824,7 @@ const { detectScript, sessionPath, livePath } = loadBrowserScripts(); httpServer = http.createServer(createRequestHandler({ detectScript, sessionPath, livePath })); httpServer.listen(state.port, '127.0.0.1', () => { - fs.writeFileSync(LIVE_PID_FILE, JSON.stringify({ pid: process.pid, port: state.port, token: state.token })); + writeLiveServerInfo(process.cwd(), { pid: process.pid, port: state.port, token: state.token }); const url = `http://localhost:${state.port}`; console.log(`\nImpeccable live server running on ${url}`); console.log(`Token: ${state.token}\n`); diff --git a/.gemini/skills/impeccable/scripts/live-session-store.mjs b/.gemini/skills/impeccable/scripts/live-session-store.mjs index cc7744df..37711168 100644 --- a/.gemini/skills/impeccable/scripts/live-session-store.mjs +++ b/.gemini/skills/impeccable/scripts/live-session-store.mjs @@ -1,30 +1,43 @@ import fs from 'node:fs'; import path from 'node:path'; +import { getLegacyLiveSessionsDir, getLiveSessionsDir } from './impeccable-paths.mjs'; -const LIVE_DIR = '.impeccable-live'; -const SESSIONS_DIR = 'sessions'; const COMPLETED_PHASES = new Set(['completed', 'discarded']); export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {}) { - const rootDir = path.join(cwd, LIVE_DIR, SESSIONS_DIR); + 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 = getJournalPath(rootDir, id); + const journalPath = getReadableJournalPath(id); const rebuilt = rebuildSnapshotFromJournal(journalPath, id); snapshotCache.set(id, rebuilt); return rebuilt; } + function getReadableJournalPath(id) { + const primary = getJournalPath(rootDir, id); + if (fs.existsSync(primary)) return primary; + const legacy = getJournalPath(legacyRootDir, id); + if (fs.existsSync(legacy)) return legacy; + return primary; + } + return { rootDir, + legacyRootDir, appendEvent(event) { const normalized = normalizeEvent(event, sessionId); const journalPath = getJournalPath(rootDir, normalized.id); const snapshotPath = getSnapshotPath(rootDir, normalized.id); + const legacyJournalPath = getJournalPath(legacyRootDir, normalized.id); + if (!fs.existsSync(journalPath) && fs.existsSync(legacyJournalPath)) { + fs.copyFileSync(legacyJournalPath, journalPath); + } const prior = loadCachedOrRebuild(normalized.id); const seq = prior.nextSeq; const entry = { @@ -42,7 +55,7 @@ export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {}) }, getSnapshot(id = sessionId, opts = {}) { if (!id) throw new Error('session id required'); - const journalPath = getJournalPath(rootDir, id); + const journalPath = getReadableJournalPath(id); const snapshotPath = getSnapshotPath(rootDir, id); const rebuilt = rebuildSnapshotFromJournal(journalPath, id); snapshotCache.set(id, rebuilt); @@ -51,10 +64,15 @@ export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {}) return rebuilt.snapshot; }, listActiveSessions() { - if (!fs.existsSync(rootDir)) return []; - return fs.readdirSync(rootDir) - .filter((name) => name.endsWith('.jsonl')) - .map((name) => name.slice(0, -'.jsonl'.length)) + const ids = new Set(); + for (const dir of [legacyRootDir, rootDir]) { + if (!fs.existsSync(dir)) continue; + for (const name of fs.readdirSync(dir)) { + if (name.endsWith('.jsonl')) ids.add(name.slice(0, -'.jsonl'.length)); + } + } + return [...ids] + .sort() .map((id) => this.getSnapshot(id)) .filter(Boolean); }, diff --git a/.gemini/skills/impeccable/scripts/live-status.mjs b/.gemini/skills/impeccable/scripts/live-status.mjs index 1b85357c..dce1fbca 100644 --- a/.gemini/skills/impeccable/scripts/live-status.mjs +++ b/.gemini/skills/impeccable/scripts/live-status.mjs @@ -3,15 +3,11 @@ * Print durable recovery status for Impeccable live sessions. */ -import fs from 'node:fs'; -import path from 'node:path'; import { createLiveSessionStore } from './live-session-store.mjs'; - -const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); +import { readLiveServerInfo } from './impeccable-paths.mjs'; function readServerInfo() { - try { return JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); } - catch { return null; } + return readLiveServerInfo(process.cwd())?.info || null; } async function fetchServerStatus(info) { diff --git a/.gemini/skills/impeccable/scripts/live.mjs b/.gemini/skills/impeccable/scripts/live.mjs index befbdb8e..cafb0eca 100644 --- a/.gemini/skills/impeccable/scripts/live.mjs +++ b/.gemini/skills/impeccable/scripts/live.mjs @@ -2,10 +2,10 @@ * CLI entry point: prepare everything needed to enter the live variant poll loop. * * Does (all in one command): - * 1. Check config.json (returns config_missing if first-ever run) + * 1. Check .impeccable/live/config.json (returns config_missing if first-ever run) * 2. Start the live server in the background (or reuse a running one) * 3. Inject the browser script tag into the project's entry file - * 4. Read .impeccable.md for design context (if present) + * 4. Read PRODUCT.md / DESIGN.md for project context * 5. Print a single JSON blob with everything the agent needs * * After this, the agent's only remaining steps are: @@ -23,9 +23,9 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { loadContext } from './load-context.mjs'; import { resolveFiles } from './live-inject.mjs'; +import { readLiveServerInfo } from './impeccable-paths.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); async function liveCli() { const args = process.argv.slice(2); @@ -34,10 +34,10 @@ async function liveCli() { console.log(`Usage: node live.mjs Prepare everything for live variant mode in a single command: - - Checks scripts/config.json (required, created once per project) + - Checks .impeccable/live/config.json (required, created once per project) - Starts (or reuses) the live server in the background - Injects the browser script tag - - Reads .impeccable.md for design context + - Reads PRODUCT.md / DESIGN.md for project context On success, prints a JSON blob with: { ok, serverPort, serverToken, pageFile, hasContext, context } @@ -223,7 +223,7 @@ function safeParse(out) { function ensureServerRunning() { // Try to reuse an existing server try { - const existing = JSON.parse(fs.readFileSync(PID_FILE, 'utf-8')); + const existing = readLiveServerInfo(process.cwd())?.info; if (existing && existing.pid) { try { process.kill(existing.pid, 0); // throws if dead diff --git a/.gemini/skills/impeccable/scripts/load-context.mjs b/.gemini/skills/impeccable/scripts/load-context.mjs index dca23c1f..dc340bf1 100644 --- a/.gemini/skills/impeccable/scripts/load-context.mjs +++ b/.gemini/skills/impeccable/scripts/load-context.mjs @@ -39,7 +39,7 @@ const LEGACY_NAMES = ['.impeccable.md']; const FALLBACK_DIRS = ['.agents/context', 'docs']; /** - * Resolve the directory that holds PRODUCT.md / DESIGN.md / DESIGN.json for + * Resolve the directory that holds PRODUCT.md / DESIGN.md for * this project. Exported so other scripts (e.g. live-server.mjs) can read the * design files from the same location the loader uses. */ diff --git a/.github/skills/impeccable/reference/document.md b/.github/skills/impeccable/reference/document.md index 39afbd2b..d2753327 100644 --- a/.github/skills/impeccable/reference/document.md +++ b/.github/skills/impeccable/reference/document.md @@ -237,11 +237,11 @@ Concrete, forceful guardrails. Lead each with "Do" or "Don't". Be specific: incl - **Don't** [...] ``` -### Step 4b: Write DESIGN.json sidecar (extensions only) +### Step 4b: Write .impeccable/design.json sidecar (extensions only) -The frontmatter owns token primitives (colors, typography, rounded, spacing, components). The sidecar at `DESIGN.json` carries **what Stitch's schema can't hold**: tonal ramps per color, shadow/elevation tokens, motion tokens, breakpoints, full component HTML/CSS snippets (the panel renders these into a shadow DOM), and narrative (north star, rules, do's/don'ts). It extends the frontmatter, it doesn't duplicate it. +The frontmatter owns token primitives (colors, typography, rounded, spacing, components). The sidecar at `.impeccable/design.json` carries **what Stitch's schema can't hold**: tonal ramps per color, shadow/elevation tokens, motion tokens, breakpoints, full component HTML/CSS snippets (the panel renders these into a shadow DOM), and narrative (north star, rules, do's/don'ts). It extends the frontmatter, it doesn't duplicate it. -Regenerate the sidecar whenever you regenerate DESIGN.md. If the user only asks to refresh the sidecar (e.g., from the live panel's stale-hint), preserve DESIGN.md and write only DESIGN.json. +Regenerate the sidecar whenever you regenerate root `DESIGN.md`. If the user only asks to refresh the sidecar (e.g., from the live panel's stale-hint), preserve `DESIGN.md` and write only `.impeccable/design.json`. #### Schema @@ -310,7 +310,7 @@ Aim for a tight set of **5-10 components** that best represent the visual system - **Signature components (include if distinctive):** hero CTA, featured card, filter pill, any custom pattern the user mentioned as important in PRODUCT.md. - **Skip the rest.** Utility components, form building blocks, wrapper layouts: not worth documenting unless visually distinctive. -If the project has **no component library yet** (bare landing page, new project), synthesize canonical primitives from the tokens using best-practice defaults consistent with the DESIGN.md's rules. Every DESIGN.json has *something* to render, even on day zero. +If the project has **no component library yet** (bare landing page, new project), synthesize canonical primitives from the tokens using best-practice defaults consistent with the DESIGN.md's rules. Every `.impeccable/design.json` has *something* to render, even on day zero. #### Tonal ramps @@ -331,7 +331,7 @@ Do not reword. The panel shows these as secondary collapsible context; the same ### Step 5: Confirm, refine, and refresh session cache 1. Show the user the full DESIGN.md you wrote. Briefly highlight the non-obvious creative choices (descriptive color names, atmosphere language, named rules). -2. Mention that `DESIGN.json` was also written alongside; the live panel will now render this project's actual button/input/nav primitives instead of generic approximations. +2. Mention that `.impeccable/design.json` was also written alongside; the live panel will now render this project's actual button/input/nav primitives instead of generic approximations. 3. Offer to refine any section: "Want me to revise a section, add component patterns I missed, or adjust the atmosphere language?" 4. **Refresh the session cache.** Run `node .github/skills/impeccable/scripts/load-context.mjs` one final time so the newly-written DESIGN.md lands in conversation. Subsequent commands in this session will use the fresh version automatically without re-reading. @@ -392,7 +392,7 @@ Per-section guidance in seed mode: - **Components**: omit entirely; no components exist yet. - **Do's and Don'ts**: carry PRODUCT.md's anti-references directly plus the anti-reference named in Q5. -Seed mode writes a minimal frontmatter with `name` and `description` only; no colors, typography, rounded, spacing, or components yet. Real tokens land on the next Scan-mode run. Skip the `DESIGN.json` sidecar in seed mode for the same reason: nothing to render. +Seed mode writes a minimal frontmatter with `name` and `description` only; no colors, typography, rounded, spacing, or components yet. Real tokens land on the next Scan-mode run. Skip the `.impeccable/design.json` sidecar in seed mode for the same reason: nothing to render. ### Step 4: Confirm and refresh session cache diff --git a/.github/skills/impeccable/reference/live.md b/.github/skills/impeccable/reference/live.md index cc72f7ca..30f5eb3f 100644 --- a/.github/skills/impeccable/reference/live.md +++ b/.github/skills/impeccable/reference/live.md @@ -53,7 +53,7 @@ LOOP: ## Recovery commands -The live helper persists an append-only journal under `.impeccable-live/sessions`. Browser checkpoints are advisory but durable; the journal is canonical. +The live helper persists an append-only journal under `.impeccable/live/sessions/`. Browser checkpoints are advisory but durable; the journal is canonical. This is local durable recovery state, not project source. Use these commands when the chat was interrupted, polling was missed, the helper restarted, or the browser reloaded: @@ -473,7 +473,7 @@ When the poll returns `exit`, proceed to cleanup. If the poll is still running a node .github/skills/impeccable/scripts/live-server.mjs stop ``` -Stops the HTTP server and runs `live-inject.mjs --remove` to strip `localhost:…/live.js` from the HTML entry. To stop the server but keep the inject tag (for a quick restart), use `stop --keep-inject`. `config.json` persists for future sessions. +Stops the HTTP server and runs `live-inject.mjs --remove` to strip `localhost:…/live.js` from the HTML entry. To stop the server but keep the inject tag (for a quick restart), use `stop --keep-inject`. `.impeccable/live/config.json` persists as project config for future sessions. Then: - Remove any leftover variant wrappers (search for `impeccable-variants-start` markers). @@ -481,7 +481,7 @@ Then: ## First-time setup (config missing or invalid) -If `live.mjs` outputs `{ ok: false, error: "config_missing" | "config_invalid", path }`, write `config.json` at the reported path. +If `live.mjs` outputs `{ ok: false, error: "config_missing" | "config_invalid", path }`, write the live config at the reported path. By default this is `.impeccable/live/config.json`. Schema: @@ -561,7 +561,7 @@ node .github/skills/impeccable/scripts/detect-csp.mjs Output: `{ shape, signals }` where `shape` is one of `append-arrays`, `append-string`, `middleware`, `meta-tag`, or `null`. The shape is named by *patch mechanism*, so one template covers many frameworks. -- **`null`**: no CSP; skip to writing `config.json` with `cspChecked: true`. +- **`null`**: no CSP; skip to writing `.impeccable/live/config.json` with `cspChecked: true`. - **`append-arrays`**: CSP defined as structured directive arrays. Auto-patchable. See *append-arrays* below. Covers: - Monorepo helpers with `additionalScriptSrc` / `additionalConnectSrc` options (Next.js + shared config package) - SvelteKit `kit.csp.directives` @@ -638,6 +638,6 @@ Reference outputs: ### Troubleshooting -If a user says "no" to the CSP patch at setup time and later complains that live doesn't work: their dev CSP blocks `http://localhost:8400`. Fix: delete `cspChecked` from `config.json` and re-run `live.mjs`: setup will ask again. +If a user says "no" to the CSP patch at setup time and later complains that live doesn't work: their dev CSP blocks `http://localhost:8400`. Fix: delete `cspChecked` from `.impeccable/live/config.json` and re-run `live.mjs`: setup will ask again. Then re-run `live.mjs`. diff --git a/.github/skills/impeccable/reference/teach.md b/.github/skills/impeccable/reference/teach.md index 724c6a98..003e436e 100644 --- a/.github/skills/impeccable/reference/teach.md +++ b/.github/skills/impeccable/reference/teach.md @@ -2,8 +2,8 @@ Gathers design context for a project and writes two complementary files at the project root: -- **PRODUCT.md** (strategic): register, target users, product purpose, brand personality, anti-references, strategic design principles. Answers "who/what/why". -- **DESIGN.md** (visual): visual theme, color palette, typography, components, layout. Follows the [Google Stitch DESIGN.md format](https://stitch.withgoogle.com/docs/design-md/format/). Answers "how it looks". +- **PRODUCT.md** (strategic): root project file for register, target users, product purpose, brand personality, anti-references, strategic design principles. Answers "who/what/why". +- **DESIGN.md** (visual): root project file for visual theme, color palette, typography, components, layout. Follows the [Google Stitch DESIGN.md format](https://stitch.withgoogle.com/docs/design-md/format/). Answers "how it looks". Every other impeccable command reads these files before doing any work. diff --git a/.github/skills/impeccable/scripts/impeccable-paths.mjs b/.github/skills/impeccable/scripts/impeccable-paths.mjs new file mode 100644 index 00000000..ba852bae --- /dev/null +++ b/.github/skills/impeccable/scripts/impeccable-paths.mjs @@ -0,0 +1,105 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +export const IMPECCABLE_DIR = '.impeccable'; +export const LIVE_DIR = 'live'; + +export function getImpeccableDir(cwd = process.cwd()) { + return path.join(cwd, IMPECCABLE_DIR); +} + +export function getDesignSidecarPath(cwd = process.cwd()) { + return path.join(getImpeccableDir(cwd), 'design.json'); +} + +export function getDesignSidecarCandidates(cwd = process.cwd(), contextDir = cwd) { + const candidates = [ + getDesignSidecarPath(cwd), + path.join(cwd, 'DESIGN.json'), + ]; + const contextLegacy = path.join(contextDir, 'DESIGN.json'); + if (!candidates.includes(contextLegacy)) candidates.push(contextLegacy); + return candidates; +} + +export function resolveDesignSidecarPath(cwd = process.cwd(), contextDir = cwd) { + return firstExisting(getDesignSidecarCandidates(cwd, contextDir)); +} + +export function getLiveDir(cwd = process.cwd()) { + return path.join(getImpeccableDir(cwd), LIVE_DIR); +} + +export function getLiveConfigPath(cwd = process.cwd()) { + return path.join(getLiveDir(cwd), 'config.json'); +} + +export function getLegacyLiveConfigPath(scriptsDir) { + return path.join(scriptsDir, 'config.json'); +} + +export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = process.env } = {}) { + if (env.IMPECCABLE_LIVE_CONFIG && env.IMPECCABLE_LIVE_CONFIG.trim()) { + const configured = env.IMPECCABLE_LIVE_CONFIG.trim(); + return path.isAbsolute(configured) ? configured : path.resolve(cwd, configured); + } + const primary = getLiveConfigPath(cwd); + if (fs.existsSync(primary)) return primary; + if (scriptsDir) { + const legacy = getLegacyLiveConfigPath(scriptsDir); + if (fs.existsSync(legacy)) return legacy; + } + return primary; +} + +export function getLiveServerPath(cwd = process.cwd()) { + return path.join(getLiveDir(cwd), 'server.json'); +} + +export function getLegacyLiveServerPath(cwd = process.cwd()) { + return path.join(cwd, '.impeccable-live.json'); +} + +export function readLiveServerInfo(cwd = process.cwd()) { + for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) { + try { + return { info: JSON.parse(fs.readFileSync(filePath, 'utf-8')), path: filePath }; + } catch { + /* try next */ + } + } + return null; +} + +export function writeLiveServerInfo(cwd = process.cwd(), info) { + const filePath = getLiveServerPath(cwd); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, JSON.stringify(info)); + return filePath; +} + +export function removeLiveServerInfo(cwd = process.cwd()) { + for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) { + try { fs.unlinkSync(filePath); } catch {} + } +} + +export function getLiveSessionsDir(cwd = process.cwd()) { + return path.join(getLiveDir(cwd), 'sessions'); +} + +export function getLegacyLiveSessionsDir(cwd = process.cwd()) { + return path.join(cwd, '.impeccable-live', 'sessions'); +} + +export function getLiveAnnotationsDir(cwd = process.cwd()) { + return path.join(getLiveDir(cwd), 'annotations'); +} + +export function getLegacyLiveAnnotationsDir(cwd = process.cwd()) { + return path.join(cwd, '.impeccable-live', 'annotations'); +} + +function firstExisting(paths) { + return paths.find((filePath) => fs.existsSync(filePath)) || null; +} diff --git a/.github/skills/impeccable/scripts/live-browser.js b/.github/skills/impeccable/scripts/live-browser.js index 7f1ff329..bb5332a3 100644 --- a/.github/skills/impeccable/scripts/live-browser.js +++ b/.github/skills/impeccable/scripts/live-browser.js @@ -3671,7 +3671,7 @@ void main() { } // --------------------------------------------------------------------------- - // Design System Panel — visualizes the project's DESIGN.json sidecar + // Design System Panel — visualizes the project's .impeccable/design.json sidecar // --------------------------------------------------------------------------- const DESIGN_PREFS_KEY = 'impeccable-live-design-panel'; @@ -3683,7 +3683,7 @@ void main() { open: false, tab: 'visual', // 'visual' | 'raw' parsed: null, // parseDesignMd output (frontmatter + body sections) - sidecar: null, // DESIGN.json v2 payload (extensions + components + narrative) + sidecar: null, // .impeccable/design.json v2 payload (extensions + components + narrative) hasMd: false, hasSidecar: false, present: null, // true/false once fetch resolves @@ -4184,7 +4184,7 @@ void main() { box.className = 'stale'; box.innerHTML = ` - DESIGN.md is newer than DESIGN.json. Run /impeccable document to refresh the sidecar. + DESIGN.md is newer than .impeccable/design.json. Run /impeccable document to refresh the sidecar. `; return box; } @@ -4192,7 +4192,7 @@ void main() { function renderParsedMdCta() { const box = document.createElement('div'); box.className = 'parsed-md-cta'; - box.innerHTML = `Basic viewThis panel reads the tokens in your DESIGN.md frontmatter. Running /impeccable document also generates a DESIGN.json sidecar with your project's actual component snippets (button, input, nav) and tonal ramps, rendered live below the tokens.`; + box.innerHTML = `Basic viewThis panel reads the tokens in your DESIGN.md frontmatter. Running /impeccable document also generates a .impeccable/design.json sidecar with your project's actual component snippets (button, input, nav) and tonal ramps, rendered live below the tokens.`; return box; } @@ -4652,7 +4652,7 @@ void main() { function cssSafe(v) { // Strip anything outside valid CSS value chars to prevent injection via - // DESIGN.json values rendered into inline style strings. + // .impeccable/design.json values rendered into inline style strings. return String(v).replace(/[<>"'`\n]/g, ''); } diff --git a/.github/skills/impeccable/scripts/live-complete.mjs b/.github/skills/impeccable/scripts/live-complete.mjs index ca00d86a..78155af8 100644 --- a/.github/skills/impeccable/scripts/live-complete.mjs +++ b/.github/skills/impeccable/scripts/live-complete.mjs @@ -4,10 +4,7 @@ */ import { createLiveSessionStore } from './live-session-store.mjs'; -import fs from 'node:fs'; -import path from 'node:path'; - -const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); +import { readLiveServerInfo } from './impeccable-paths.mjs'; function parseArgs(argv) { const out = { status: 'complete' }; @@ -50,8 +47,7 @@ export async function completeCli() { } function readServerInfo() { - try { return JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); } - catch { return null; } + return readLiveServerInfo(process.cwd())?.info || null; } async function completeThroughServer(info, args) { diff --git a/.github/skills/impeccable/scripts/live-inject.mjs b/.github/skills/impeccable/scripts/live-inject.mjs index 1d2ae12f..555c3a36 100644 --- a/.github/skills/impeccable/scripts/live-inject.mjs +++ b/.github/skills/impeccable/scripts/live-inject.mjs @@ -2,23 +2,24 @@ * CLI helper: insert/remove the live variant mode script tag in the project's * main HTML entry point. * - * On first live run, the agent generates `config.json` in this script's - * directory with the project's insertion target (framework-specific). On + * On first live run, the agent generates `.impeccable/live/config.json` + * with the project's insertion target (framework-specific). On * every subsequent run, this script handles insert/remove deterministically * with zero LLM involvement. * * Usage: * node live-inject.mjs --port PORT # Insert the live script tag * node live-inject.mjs --remove # Remove the live script tag - * node live-inject.mjs --check # Check whether config.json exists + * node live-inject.mjs --check # Check whether live config exists */ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { resolveLiveConfigPath } from './impeccable-paths.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const CONFIG_PATH = process.env.IMPECCABLE_LIVE_CONFIG || path.join(__dirname, 'config.json'); +const CONFIG_PATH = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname }); const MARKER_OPEN_TEXT = 'impeccable-live-start'; const MARKER_CLOSE_TEXT = 'impeccable-live-end'; @@ -39,12 +40,12 @@ export async function injectCli() { console.log(`Usage: node live-inject.mjs [options] Insert or remove the live mode script tag in the project's HTML entry point. -Reads configuration from config.json (in this same directory). +Reads configuration from .impeccable/live/config.json. Modes: --port PORT Insert script tag pointing at http://localhost:PORT/live.js --remove Remove the script tag (if present) - --check Print whether config.json exists and its content + --check Print whether .impeccable/live/config.json exists and its content Output (JSON): { ok, file, inserted|removed, config? }`); diff --git a/.github/skills/impeccable/scripts/live-poll.mjs b/.github/skills/impeccable/scripts/live-poll.mjs index 9a3f07ae..83a9912e 100644 --- a/.github/skills/impeccable/scripts/live-poll.mjs +++ b/.github/skills/impeccable/scripts/live-poll.mjs @@ -9,11 +9,10 @@ */ import { execFileSync } from 'node:child_process'; -import fs from 'node:fs'; import path from 'node:path'; -import os from 'node:os'; import { fileURLToPath } from 'node:url'; import { completionTypeForAcceptResult } from './live-completion.mjs'; +import { readLiveServerInfo } from './impeccable-paths.mjs'; // Node's built-in fetch (undici under the hood) enforces a 300s headers // timeout that can't be lowered per-request. We cap each request below @@ -21,15 +20,13 @@ import { completionTypeForAcceptResult } from './live-completion.mjs'; // depending on the standalone undici package. const PER_REQUEST_TIMEOUT_MS = 270_000; -const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); - function readServerInfo() { - try { - return JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); - } catch { + const record = readLiveServerInfo(process.cwd()); + if (!record) { console.error('No running live server found. Start one with: npx impeccable live'); process.exit(1); } + return record.info; } export function buildPollReplyPayload(token, { id, type, message, file, data }) { diff --git a/.github/skills/impeccable/scripts/live-server.mjs b/.github/skills/impeccable/scripts/live-server.mjs index e78a0657..53d8f21c 100644 --- a/.github/skills/impeccable/scripts/live-server.mjs +++ b/.github/skills/impeccable/scripts/live-server.mjs @@ -23,14 +23,19 @@ import { fileURLToPath } from 'node:url'; import { parseDesignMd } from './design-parser.mjs'; import { resolveContextDir } from './load-context.mjs'; import { createLiveSessionStore } from './live-session-store.mjs'; +import { + getDesignSidecarPath, + getLiveAnnotationsDir, + readLiveServerInfo, + removeLiveServerInfo, + resolveDesignSidecarPath, + writeLiveServerInfo, +} from './impeccable-paths.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -// PID file in the project root so both the server and agent can find it -// predictably (os.tmpdir() varies across platforms). -const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); -// PRODUCT.md / DESIGN.md / DESIGN.json live wherever load-context.mjs resolves. -// Keeps live-server in sync with the loader when users keep the docs in -// .agents/context/, docs/, or a path set via IMPECCABLE_CONTEXT_DIR. +// PRODUCT.md / DESIGN.md live wherever load-context.mjs resolves. The generated +// DESIGN sidecar is project-local at .impeccable/design.json, with legacy +// DESIGN.json fallback for existing projects. const CONTEXT_DIR = resolveContextDir(process.cwd()); const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s @@ -411,13 +416,13 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { } // --- Design system (unified v2 response) + raw --- - // /design-system.json returns both parsed DESIGN.md and DESIGN.json + // /design-system.json returns both parsed DESIGN.md and .impeccable/design.json // sidecar when present. Panel merges them: // { present, parsed, sidecar, hasMd, hasSidecar, // mdNewerThanJson, parseError?, sidecarError? } // - parsed: output of parseDesignMd (frontmatter // + six canonical sections) when DESIGN.md exists. - // - sidecar: DESIGN.json contents when present. + // - sidecar: .impeccable/design.json contents when present. // Expected shape: schemaVersion 2, carrying // extensions + components + narrative. // /design-system/raw returns DESIGN.md markdown verbatim @@ -426,7 +431,7 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } const mdPath = path.join(CONTEXT_DIR, 'DESIGN.md'); - const jsonPath = path.join(CONTEXT_DIR, 'DESIGN.json'); + const jsonPath = resolveDesignSidecarPath(process.cwd(), CONTEXT_DIR) || getDesignSidecarPath(process.cwd()); const mdStat = statOrNull(mdPath); const jsonStat = statOrNull(jsonPath); @@ -462,7 +467,7 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { try { response.sidecar = JSON.parse(fs.readFileSync(jsonPath, 'utf-8')); } catch (err) { - response.sidecarError = 'Failed to parse DESIGN.json: ' + err.message; + response.sidecarError = 'Failed to parse .impeccable/design.json: ' + err.message; } } @@ -673,7 +678,7 @@ function handlePollPost(req, res) { let httpServer = null; function shutdown() { - try { fs.unlinkSync(LIVE_PID_FILE); } catch {} + removeLiveServerInfo(process.cwd()); if (state.leaseTimer) clearTimeout(state.leaseTimer); state.leaseTimer = null; if (state.sessionDir) { @@ -725,7 +730,7 @@ Endpoints: if (args.includes('stop')) { const keepInject = args.includes('--keep-inject'); try { - const info = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); + const { info } = readLiveServerInfo(process.cwd()) || {}; const res = await fetch(`http://localhost:${info.port}/stop?token=${info.token}`); if (res.ok) console.log(`Stopped live server on port ${info.port}.`); } catch { @@ -776,7 +781,7 @@ if (args.includes('--background')) { const deadline = Date.now() + 10_000; while (Date.now() < deadline) { try { - const info = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); + const { info } = readLiveServerInfo(process.cwd()) || {}; if (info.pid !== process.pid) { // Output JSON so the agent can read port + token from stdout. console.log(JSON.stringify(info)); @@ -790,14 +795,18 @@ if (args.includes('--background')) { } // Check for existing session -try { - const existing = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); - try { process.kill(existing.pid, 0); +const existingRecord = readLiveServerInfo(process.cwd()); +if (existingRecord?.info) { + const existing = existingRecord.info; + try { + process.kill(existing.pid, 0); console.error(`Live server already running on port ${existing.port} (pid ${existing.pid}).`); console.error('Stop it first with: node ' + path.basename(fileURLToPath(import.meta.url)) + ' stop'); process.exit(1); - } catch { fs.unlinkSync(LIVE_PID_FILE); } -} catch {} + } catch { + try { fs.unlinkSync(existingRecord.path); } catch {} + } +} state.token = randomUUID(); state.sessionStore = createLiveSessionStore({ cwd: process.cwd() }); @@ -807,7 +816,7 @@ state.port = portArg ? parseInt(portArg.split('=')[1], 10) : await findOpenPort( // Annotation screenshots live in the project root so the agent's Read tool // doesn't trip a per-file permission prompt. Sessioned by token so concurrent // projects (or quick restarts) don't collide. -const annotRoot = path.join(process.cwd(), '.impeccable-live', 'annotations'); +const annotRoot = getLiveAnnotationsDir(process.cwd()); fs.mkdirSync(annotRoot, { recursive: true }); state.sessionDir = fs.mkdtempSync(path.join(annotRoot, 'session-')); @@ -815,7 +824,7 @@ const { detectScript, sessionPath, livePath } = loadBrowserScripts(); httpServer = http.createServer(createRequestHandler({ detectScript, sessionPath, livePath })); httpServer.listen(state.port, '127.0.0.1', () => { - fs.writeFileSync(LIVE_PID_FILE, JSON.stringify({ pid: process.pid, port: state.port, token: state.token })); + writeLiveServerInfo(process.cwd(), { pid: process.pid, port: state.port, token: state.token }); const url = `http://localhost:${state.port}`; console.log(`\nImpeccable live server running on ${url}`); console.log(`Token: ${state.token}\n`); diff --git a/.github/skills/impeccable/scripts/live-session-store.mjs b/.github/skills/impeccable/scripts/live-session-store.mjs index cc7744df..37711168 100644 --- a/.github/skills/impeccable/scripts/live-session-store.mjs +++ b/.github/skills/impeccable/scripts/live-session-store.mjs @@ -1,30 +1,43 @@ import fs from 'node:fs'; import path from 'node:path'; +import { getLegacyLiveSessionsDir, getLiveSessionsDir } from './impeccable-paths.mjs'; -const LIVE_DIR = '.impeccable-live'; -const SESSIONS_DIR = 'sessions'; const COMPLETED_PHASES = new Set(['completed', 'discarded']); export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {}) { - const rootDir = path.join(cwd, LIVE_DIR, SESSIONS_DIR); + 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 = getJournalPath(rootDir, id); + const journalPath = getReadableJournalPath(id); const rebuilt = rebuildSnapshotFromJournal(journalPath, id); snapshotCache.set(id, rebuilt); return rebuilt; } + function getReadableJournalPath(id) { + const primary = getJournalPath(rootDir, id); + if (fs.existsSync(primary)) return primary; + const legacy = getJournalPath(legacyRootDir, id); + if (fs.existsSync(legacy)) return legacy; + return primary; + } + return { rootDir, + legacyRootDir, appendEvent(event) { const normalized = normalizeEvent(event, sessionId); const journalPath = getJournalPath(rootDir, normalized.id); const snapshotPath = getSnapshotPath(rootDir, normalized.id); + const legacyJournalPath = getJournalPath(legacyRootDir, normalized.id); + if (!fs.existsSync(journalPath) && fs.existsSync(legacyJournalPath)) { + fs.copyFileSync(legacyJournalPath, journalPath); + } const prior = loadCachedOrRebuild(normalized.id); const seq = prior.nextSeq; const entry = { @@ -42,7 +55,7 @@ export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {}) }, getSnapshot(id = sessionId, opts = {}) { if (!id) throw new Error('session id required'); - const journalPath = getJournalPath(rootDir, id); + const journalPath = getReadableJournalPath(id); const snapshotPath = getSnapshotPath(rootDir, id); const rebuilt = rebuildSnapshotFromJournal(journalPath, id); snapshotCache.set(id, rebuilt); @@ -51,10 +64,15 @@ export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {}) return rebuilt.snapshot; }, listActiveSessions() { - if (!fs.existsSync(rootDir)) return []; - return fs.readdirSync(rootDir) - .filter((name) => name.endsWith('.jsonl')) - .map((name) => name.slice(0, -'.jsonl'.length)) + const ids = new Set(); + for (const dir of [legacyRootDir, rootDir]) { + if (!fs.existsSync(dir)) continue; + for (const name of fs.readdirSync(dir)) { + if (name.endsWith('.jsonl')) ids.add(name.slice(0, -'.jsonl'.length)); + } + } + return [...ids] + .sort() .map((id) => this.getSnapshot(id)) .filter(Boolean); }, diff --git a/.github/skills/impeccable/scripts/live-status.mjs b/.github/skills/impeccable/scripts/live-status.mjs index 1b85357c..dce1fbca 100644 --- a/.github/skills/impeccable/scripts/live-status.mjs +++ b/.github/skills/impeccable/scripts/live-status.mjs @@ -3,15 +3,11 @@ * Print durable recovery status for Impeccable live sessions. */ -import fs from 'node:fs'; -import path from 'node:path'; import { createLiveSessionStore } from './live-session-store.mjs'; - -const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); +import { readLiveServerInfo } from './impeccable-paths.mjs'; function readServerInfo() { - try { return JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); } - catch { return null; } + return readLiveServerInfo(process.cwd())?.info || null; } async function fetchServerStatus(info) { diff --git a/.github/skills/impeccable/scripts/live.mjs b/.github/skills/impeccable/scripts/live.mjs index befbdb8e..cafb0eca 100644 --- a/.github/skills/impeccable/scripts/live.mjs +++ b/.github/skills/impeccable/scripts/live.mjs @@ -2,10 +2,10 @@ * CLI entry point: prepare everything needed to enter the live variant poll loop. * * Does (all in one command): - * 1. Check config.json (returns config_missing if first-ever run) + * 1. Check .impeccable/live/config.json (returns config_missing if first-ever run) * 2. Start the live server in the background (or reuse a running one) * 3. Inject the browser script tag into the project's entry file - * 4. Read .impeccable.md for design context (if present) + * 4. Read PRODUCT.md / DESIGN.md for project context * 5. Print a single JSON blob with everything the agent needs * * After this, the agent's only remaining steps are: @@ -23,9 +23,9 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { loadContext } from './load-context.mjs'; import { resolveFiles } from './live-inject.mjs'; +import { readLiveServerInfo } from './impeccable-paths.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); async function liveCli() { const args = process.argv.slice(2); @@ -34,10 +34,10 @@ async function liveCli() { console.log(`Usage: node live.mjs Prepare everything for live variant mode in a single command: - - Checks scripts/config.json (required, created once per project) + - Checks .impeccable/live/config.json (required, created once per project) - Starts (or reuses) the live server in the background - Injects the browser script tag - - Reads .impeccable.md for design context + - Reads PRODUCT.md / DESIGN.md for project context On success, prints a JSON blob with: { ok, serverPort, serverToken, pageFile, hasContext, context } @@ -223,7 +223,7 @@ function safeParse(out) { function ensureServerRunning() { // Try to reuse an existing server try { - const existing = JSON.parse(fs.readFileSync(PID_FILE, 'utf-8')); + const existing = readLiveServerInfo(process.cwd())?.info; if (existing && existing.pid) { try { process.kill(existing.pid, 0); // throws if dead diff --git a/.github/skills/impeccable/scripts/load-context.mjs b/.github/skills/impeccable/scripts/load-context.mjs index dca23c1f..dc340bf1 100644 --- a/.github/skills/impeccable/scripts/load-context.mjs +++ b/.github/skills/impeccable/scripts/load-context.mjs @@ -39,7 +39,7 @@ const LEGACY_NAMES = ['.impeccable.md']; const FALLBACK_DIRS = ['.agents/context', 'docs']; /** - * Resolve the directory that holds PRODUCT.md / DESIGN.md / DESIGN.json for + * Resolve the directory that holds PRODUCT.md / DESIGN.md for * this project. Exported so other scripts (e.g. live-server.mjs) can read the * design files from the same location the loader uses. */ diff --git a/.gitignore b/.gitignore index d895f500..9755513f 100644 --- a/.gitignore +++ b/.gitignore @@ -37,12 +37,20 @@ Thumbs.db # Cloudflare .wrangler/ -# Live mode session file + annotation screenshots (cleaned up on server stop) +# Impeccable-owned project files are split: generated sidecars/config may be +# tracked, but runtime recovery state and local assets should stay local. +.impeccable/live/server.json +.impeccable/live/sessions/ +.impeccable/live/annotations/ +.impeccable/live/cache/ +.impeccable/history/ + +# Legacy live mode session file + annotation screenshots .impeccable-live.json .impeccable-live/ -# Per-project live mode injection config (generated once per project by the -# skill; wiped on skill update, regenerated on next live run) +# Legacy per-project live mode injection config. New installs use +# .impeccable/live/config.json in the project root instead. **/skills/impeccable/scripts/config.json # Extension build artifacts diff --git a/DESIGN.json b/.impeccable/design.json similarity index 100% rename from DESIGN.json rename to .impeccable/design.json diff --git a/.kiro/skills/impeccable/reference/document.md b/.kiro/skills/impeccable/reference/document.md index a1630271..f9f04356 100644 --- a/.kiro/skills/impeccable/reference/document.md +++ b/.kiro/skills/impeccable/reference/document.md @@ -237,11 +237,11 @@ Concrete, forceful guardrails. Lead each with "Do" or "Don't". Be specific: incl - **Don't** [...] ``` -### Step 4b: Write DESIGN.json sidecar (extensions only) +### Step 4b: Write .impeccable/design.json sidecar (extensions only) -The frontmatter owns token primitives (colors, typography, rounded, spacing, components). The sidecar at `DESIGN.json` carries **what Stitch's schema can't hold**: tonal ramps per color, shadow/elevation tokens, motion tokens, breakpoints, full component HTML/CSS snippets (the panel renders these into a shadow DOM), and narrative (north star, rules, do's/don'ts). It extends the frontmatter, it doesn't duplicate it. +The frontmatter owns token primitives (colors, typography, rounded, spacing, components). The sidecar at `.impeccable/design.json` carries **what Stitch's schema can't hold**: tonal ramps per color, shadow/elevation tokens, motion tokens, breakpoints, full component HTML/CSS snippets (the panel renders these into a shadow DOM), and narrative (north star, rules, do's/don'ts). It extends the frontmatter, it doesn't duplicate it. -Regenerate the sidecar whenever you regenerate DESIGN.md. If the user only asks to refresh the sidecar (e.g., from the live panel's stale-hint), preserve DESIGN.md and write only DESIGN.json. +Regenerate the sidecar whenever you regenerate root `DESIGN.md`. If the user only asks to refresh the sidecar (e.g., from the live panel's stale-hint), preserve `DESIGN.md` and write only `.impeccable/design.json`. #### Schema @@ -310,7 +310,7 @@ Aim for a tight set of **5-10 components** that best represent the visual system - **Signature components (include if distinctive):** hero CTA, featured card, filter pill, any custom pattern the user mentioned as important in PRODUCT.md. - **Skip the rest.** Utility components, form building blocks, wrapper layouts: not worth documenting unless visually distinctive. -If the project has **no component library yet** (bare landing page, new project), synthesize canonical primitives from the tokens using best-practice defaults consistent with the DESIGN.md's rules. Every DESIGN.json has *something* to render, even on day zero. +If the project has **no component library yet** (bare landing page, new project), synthesize canonical primitives from the tokens using best-practice defaults consistent with the DESIGN.md's rules. Every `.impeccable/design.json` has *something* to render, even on day zero. #### Tonal ramps @@ -331,7 +331,7 @@ Do not reword. The panel shows these as secondary collapsible context; the same ### Step 5: Confirm, refine, and refresh session cache 1. Show the user the full DESIGN.md you wrote. Briefly highlight the non-obvious creative choices (descriptive color names, atmosphere language, named rules). -2. Mention that `DESIGN.json` was also written alongside; the live panel will now render this project's actual button/input/nav primitives instead of generic approximations. +2. Mention that `.impeccable/design.json` was also written alongside; the live panel will now render this project's actual button/input/nav primitives instead of generic approximations. 3. Offer to refine any section: "Want me to revise a section, add component patterns I missed, or adjust the atmosphere language?" 4. **Refresh the session cache.** Run `node .kiro/skills/impeccable/scripts/load-context.mjs` one final time so the newly-written DESIGN.md lands in conversation. Subsequent commands in this session will use the fresh version automatically without re-reading. @@ -392,7 +392,7 @@ Per-section guidance in seed mode: - **Components**: omit entirely; no components exist yet. - **Do's and Don'ts**: carry PRODUCT.md's anti-references directly plus the anti-reference named in Q5. -Seed mode writes a minimal frontmatter with `name` and `description` only; no colors, typography, rounded, spacing, or components yet. Real tokens land on the next Scan-mode run. Skip the `DESIGN.json` sidecar in seed mode for the same reason: nothing to render. +Seed mode writes a minimal frontmatter with `name` and `description` only; no colors, typography, rounded, spacing, or components yet. Real tokens land on the next Scan-mode run. Skip the `.impeccable/design.json` sidecar in seed mode for the same reason: nothing to render. ### Step 4: Confirm and refresh session cache diff --git a/.kiro/skills/impeccable/reference/live.md b/.kiro/skills/impeccable/reference/live.md index 5aafef23..0eda4b36 100644 --- a/.kiro/skills/impeccable/reference/live.md +++ b/.kiro/skills/impeccable/reference/live.md @@ -53,7 +53,7 @@ LOOP: ## Recovery commands -The live helper persists an append-only journal under `.impeccable-live/sessions`. Browser checkpoints are advisory but durable; the journal is canonical. +The live helper persists an append-only journal under `.impeccable/live/sessions/`. Browser checkpoints are advisory but durable; the journal is canonical. This is local durable recovery state, not project source. Use these commands when the chat was interrupted, polling was missed, the helper restarted, or the browser reloaded: @@ -473,7 +473,7 @@ When the poll returns `exit`, proceed to cleanup. If the poll is still running a node .kiro/skills/impeccable/scripts/live-server.mjs stop ``` -Stops the HTTP server and runs `live-inject.mjs --remove` to strip `localhost:…/live.js` from the HTML entry. To stop the server but keep the inject tag (for a quick restart), use `stop --keep-inject`. `config.json` persists for future sessions. +Stops the HTTP server and runs `live-inject.mjs --remove` to strip `localhost:…/live.js` from the HTML entry. To stop the server but keep the inject tag (for a quick restart), use `stop --keep-inject`. `.impeccable/live/config.json` persists as project config for future sessions. Then: - Remove any leftover variant wrappers (search for `impeccable-variants-start` markers). @@ -481,7 +481,7 @@ Then: ## First-time setup (config missing or invalid) -If `live.mjs` outputs `{ ok: false, error: "config_missing" | "config_invalid", path }`, write `config.json` at the reported path. +If `live.mjs` outputs `{ ok: false, error: "config_missing" | "config_invalid", path }`, write the live config at the reported path. By default this is `.impeccable/live/config.json`. Schema: @@ -561,7 +561,7 @@ node .kiro/skills/impeccable/scripts/detect-csp.mjs Output: `{ shape, signals }` where `shape` is one of `append-arrays`, `append-string`, `middleware`, `meta-tag`, or `null`. The shape is named by *patch mechanism*, so one template covers many frameworks. -- **`null`**: no CSP; skip to writing `config.json` with `cspChecked: true`. +- **`null`**: no CSP; skip to writing `.impeccable/live/config.json` with `cspChecked: true`. - **`append-arrays`**: CSP defined as structured directive arrays. Auto-patchable. See *append-arrays* below. Covers: - Monorepo helpers with `additionalScriptSrc` / `additionalConnectSrc` options (Next.js + shared config package) - SvelteKit `kit.csp.directives` @@ -638,6 +638,6 @@ Reference outputs: ### Troubleshooting -If a user says "no" to the CSP patch at setup time and later complains that live doesn't work: their dev CSP blocks `http://localhost:8400`. Fix: delete `cspChecked` from `config.json` and re-run `live.mjs`: setup will ask again. +If a user says "no" to the CSP patch at setup time and later complains that live doesn't work: their dev CSP blocks `http://localhost:8400`. Fix: delete `cspChecked` from `.impeccable/live/config.json` and re-run `live.mjs`: setup will ask again. Then re-run `live.mjs`. diff --git a/.kiro/skills/impeccable/reference/teach.md b/.kiro/skills/impeccable/reference/teach.md index b17d6615..81949a89 100644 --- a/.kiro/skills/impeccable/reference/teach.md +++ b/.kiro/skills/impeccable/reference/teach.md @@ -2,8 +2,8 @@ Gathers design context for a project and writes two complementary files at the project root: -- **PRODUCT.md** (strategic): register, target users, product purpose, brand personality, anti-references, strategic design principles. Answers "who/what/why". -- **DESIGN.md** (visual): visual theme, color palette, typography, components, layout. Follows the [Google Stitch DESIGN.md format](https://stitch.withgoogle.com/docs/design-md/format/). Answers "how it looks". +- **PRODUCT.md** (strategic): root project file for register, target users, product purpose, brand personality, anti-references, strategic design principles. Answers "who/what/why". +- **DESIGN.md** (visual): root project file for visual theme, color palette, typography, components, layout. Follows the [Google Stitch DESIGN.md format](https://stitch.withgoogle.com/docs/design-md/format/). Answers "how it looks". Every other impeccable command reads these files before doing any work. diff --git a/.kiro/skills/impeccable/scripts/impeccable-paths.mjs b/.kiro/skills/impeccable/scripts/impeccable-paths.mjs new file mode 100644 index 00000000..ba852bae --- /dev/null +++ b/.kiro/skills/impeccable/scripts/impeccable-paths.mjs @@ -0,0 +1,105 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +export const IMPECCABLE_DIR = '.impeccable'; +export const LIVE_DIR = 'live'; + +export function getImpeccableDir(cwd = process.cwd()) { + return path.join(cwd, IMPECCABLE_DIR); +} + +export function getDesignSidecarPath(cwd = process.cwd()) { + return path.join(getImpeccableDir(cwd), 'design.json'); +} + +export function getDesignSidecarCandidates(cwd = process.cwd(), contextDir = cwd) { + const candidates = [ + getDesignSidecarPath(cwd), + path.join(cwd, 'DESIGN.json'), + ]; + const contextLegacy = path.join(contextDir, 'DESIGN.json'); + if (!candidates.includes(contextLegacy)) candidates.push(contextLegacy); + return candidates; +} + +export function resolveDesignSidecarPath(cwd = process.cwd(), contextDir = cwd) { + return firstExisting(getDesignSidecarCandidates(cwd, contextDir)); +} + +export function getLiveDir(cwd = process.cwd()) { + return path.join(getImpeccableDir(cwd), LIVE_DIR); +} + +export function getLiveConfigPath(cwd = process.cwd()) { + return path.join(getLiveDir(cwd), 'config.json'); +} + +export function getLegacyLiveConfigPath(scriptsDir) { + return path.join(scriptsDir, 'config.json'); +} + +export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = process.env } = {}) { + if (env.IMPECCABLE_LIVE_CONFIG && env.IMPECCABLE_LIVE_CONFIG.trim()) { + const configured = env.IMPECCABLE_LIVE_CONFIG.trim(); + return path.isAbsolute(configured) ? configured : path.resolve(cwd, configured); + } + const primary = getLiveConfigPath(cwd); + if (fs.existsSync(primary)) return primary; + if (scriptsDir) { + const legacy = getLegacyLiveConfigPath(scriptsDir); + if (fs.existsSync(legacy)) return legacy; + } + return primary; +} + +export function getLiveServerPath(cwd = process.cwd()) { + return path.join(getLiveDir(cwd), 'server.json'); +} + +export function getLegacyLiveServerPath(cwd = process.cwd()) { + return path.join(cwd, '.impeccable-live.json'); +} + +export function readLiveServerInfo(cwd = process.cwd()) { + for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) { + try { + return { info: JSON.parse(fs.readFileSync(filePath, 'utf-8')), path: filePath }; + } catch { + /* try next */ + } + } + return null; +} + +export function writeLiveServerInfo(cwd = process.cwd(), info) { + const filePath = getLiveServerPath(cwd); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, JSON.stringify(info)); + return filePath; +} + +export function removeLiveServerInfo(cwd = process.cwd()) { + for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) { + try { fs.unlinkSync(filePath); } catch {} + } +} + +export function getLiveSessionsDir(cwd = process.cwd()) { + return path.join(getLiveDir(cwd), 'sessions'); +} + +export function getLegacyLiveSessionsDir(cwd = process.cwd()) { + return path.join(cwd, '.impeccable-live', 'sessions'); +} + +export function getLiveAnnotationsDir(cwd = process.cwd()) { + return path.join(getLiveDir(cwd), 'annotations'); +} + +export function getLegacyLiveAnnotationsDir(cwd = process.cwd()) { + return path.join(cwd, '.impeccable-live', 'annotations'); +} + +function firstExisting(paths) { + return paths.find((filePath) => fs.existsSync(filePath)) || null; +} diff --git a/.kiro/skills/impeccable/scripts/live-browser.js b/.kiro/skills/impeccable/scripts/live-browser.js index 7f1ff329..bb5332a3 100644 --- a/.kiro/skills/impeccable/scripts/live-browser.js +++ b/.kiro/skills/impeccable/scripts/live-browser.js @@ -3671,7 +3671,7 @@ void main() { } // --------------------------------------------------------------------------- - // Design System Panel — visualizes the project's DESIGN.json sidecar + // Design System Panel — visualizes the project's .impeccable/design.json sidecar // --------------------------------------------------------------------------- const DESIGN_PREFS_KEY = 'impeccable-live-design-panel'; @@ -3683,7 +3683,7 @@ void main() { open: false, tab: 'visual', // 'visual' | 'raw' parsed: null, // parseDesignMd output (frontmatter + body sections) - sidecar: null, // DESIGN.json v2 payload (extensions + components + narrative) + sidecar: null, // .impeccable/design.json v2 payload (extensions + components + narrative) hasMd: false, hasSidecar: false, present: null, // true/false once fetch resolves @@ -4184,7 +4184,7 @@ void main() { box.className = 'stale'; box.innerHTML = ` - DESIGN.md is newer than DESIGN.json. Run /impeccable document to refresh the sidecar. + DESIGN.md is newer than .impeccable/design.json. Run /impeccable document to refresh the sidecar. `; return box; } @@ -4192,7 +4192,7 @@ void main() { function renderParsedMdCta() { const box = document.createElement('div'); box.className = 'parsed-md-cta'; - box.innerHTML = `Basic viewThis panel reads the tokens in your DESIGN.md frontmatter. Running /impeccable document also generates a DESIGN.json sidecar with your project's actual component snippets (button, input, nav) and tonal ramps, rendered live below the tokens.`; + box.innerHTML = `Basic viewThis panel reads the tokens in your DESIGN.md frontmatter. Running /impeccable document also generates a .impeccable/design.json sidecar with your project's actual component snippets (button, input, nav) and tonal ramps, rendered live below the tokens.`; return box; } @@ -4652,7 +4652,7 @@ void main() { function cssSafe(v) { // Strip anything outside valid CSS value chars to prevent injection via - // DESIGN.json values rendered into inline style strings. + // .impeccable/design.json values rendered into inline style strings. return String(v).replace(/[<>"'`\n]/g, ''); } diff --git a/.kiro/skills/impeccable/scripts/live-complete.mjs b/.kiro/skills/impeccable/scripts/live-complete.mjs index ca00d86a..78155af8 100644 --- a/.kiro/skills/impeccable/scripts/live-complete.mjs +++ b/.kiro/skills/impeccable/scripts/live-complete.mjs @@ -4,10 +4,7 @@ */ import { createLiveSessionStore } from './live-session-store.mjs'; -import fs from 'node:fs'; -import path from 'node:path'; - -const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); +import { readLiveServerInfo } from './impeccable-paths.mjs'; function parseArgs(argv) { const out = { status: 'complete' }; @@ -50,8 +47,7 @@ export async function completeCli() { } function readServerInfo() { - try { return JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); } - catch { return null; } + return readLiveServerInfo(process.cwd())?.info || null; } async function completeThroughServer(info, args) { diff --git a/.kiro/skills/impeccable/scripts/live-inject.mjs b/.kiro/skills/impeccable/scripts/live-inject.mjs index 1d2ae12f..555c3a36 100644 --- a/.kiro/skills/impeccable/scripts/live-inject.mjs +++ b/.kiro/skills/impeccable/scripts/live-inject.mjs @@ -2,23 +2,24 @@ * CLI helper: insert/remove the live variant mode script tag in the project's * main HTML entry point. * - * On first live run, the agent generates `config.json` in this script's - * directory with the project's insertion target (framework-specific). On + * On first live run, the agent generates `.impeccable/live/config.json` + * with the project's insertion target (framework-specific). On * every subsequent run, this script handles insert/remove deterministically * with zero LLM involvement. * * Usage: * node live-inject.mjs --port PORT # Insert the live script tag * node live-inject.mjs --remove # Remove the live script tag - * node live-inject.mjs --check # Check whether config.json exists + * node live-inject.mjs --check # Check whether live config exists */ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { resolveLiveConfigPath } from './impeccable-paths.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const CONFIG_PATH = process.env.IMPECCABLE_LIVE_CONFIG || path.join(__dirname, 'config.json'); +const CONFIG_PATH = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname }); const MARKER_OPEN_TEXT = 'impeccable-live-start'; const MARKER_CLOSE_TEXT = 'impeccable-live-end'; @@ -39,12 +40,12 @@ export async function injectCli() { console.log(`Usage: node live-inject.mjs [options] Insert or remove the live mode script tag in the project's HTML entry point. -Reads configuration from config.json (in this same directory). +Reads configuration from .impeccable/live/config.json. Modes: --port PORT Insert script tag pointing at http://localhost:PORT/live.js --remove Remove the script tag (if present) - --check Print whether config.json exists and its content + --check Print whether .impeccable/live/config.json exists and its content Output (JSON): { ok, file, inserted|removed, config? }`); diff --git a/.kiro/skills/impeccable/scripts/live-poll.mjs b/.kiro/skills/impeccable/scripts/live-poll.mjs index 9a3f07ae..83a9912e 100644 --- a/.kiro/skills/impeccable/scripts/live-poll.mjs +++ b/.kiro/skills/impeccable/scripts/live-poll.mjs @@ -9,11 +9,10 @@ */ import { execFileSync } from 'node:child_process'; -import fs from 'node:fs'; import path from 'node:path'; -import os from 'node:os'; import { fileURLToPath } from 'node:url'; import { completionTypeForAcceptResult } from './live-completion.mjs'; +import { readLiveServerInfo } from './impeccable-paths.mjs'; // Node's built-in fetch (undici under the hood) enforces a 300s headers // timeout that can't be lowered per-request. We cap each request below @@ -21,15 +20,13 @@ import { completionTypeForAcceptResult } from './live-completion.mjs'; // depending on the standalone undici package. const PER_REQUEST_TIMEOUT_MS = 270_000; -const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); - function readServerInfo() { - try { - return JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); - } catch { + const record = readLiveServerInfo(process.cwd()); + if (!record) { console.error('No running live server found. Start one with: npx impeccable live'); process.exit(1); } + return record.info; } export function buildPollReplyPayload(token, { id, type, message, file, data }) { diff --git a/.kiro/skills/impeccable/scripts/live-server.mjs b/.kiro/skills/impeccable/scripts/live-server.mjs index e78a0657..53d8f21c 100644 --- a/.kiro/skills/impeccable/scripts/live-server.mjs +++ b/.kiro/skills/impeccable/scripts/live-server.mjs @@ -23,14 +23,19 @@ import { fileURLToPath } from 'node:url'; import { parseDesignMd } from './design-parser.mjs'; import { resolveContextDir } from './load-context.mjs'; import { createLiveSessionStore } from './live-session-store.mjs'; +import { + getDesignSidecarPath, + getLiveAnnotationsDir, + readLiveServerInfo, + removeLiveServerInfo, + resolveDesignSidecarPath, + writeLiveServerInfo, +} from './impeccable-paths.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -// PID file in the project root so both the server and agent can find it -// predictably (os.tmpdir() varies across platforms). -const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); -// PRODUCT.md / DESIGN.md / DESIGN.json live wherever load-context.mjs resolves. -// Keeps live-server in sync with the loader when users keep the docs in -// .agents/context/, docs/, or a path set via IMPECCABLE_CONTEXT_DIR. +// PRODUCT.md / DESIGN.md live wherever load-context.mjs resolves. The generated +// DESIGN sidecar is project-local at .impeccable/design.json, with legacy +// DESIGN.json fallback for existing projects. const CONTEXT_DIR = resolveContextDir(process.cwd()); const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s @@ -411,13 +416,13 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { } // --- Design system (unified v2 response) + raw --- - // /design-system.json returns both parsed DESIGN.md and DESIGN.json + // /design-system.json returns both parsed DESIGN.md and .impeccable/design.json // sidecar when present. Panel merges them: // { present, parsed, sidecar, hasMd, hasSidecar, // mdNewerThanJson, parseError?, sidecarError? } // - parsed: output of parseDesignMd (frontmatter // + six canonical sections) when DESIGN.md exists. - // - sidecar: DESIGN.json contents when present. + // - sidecar: .impeccable/design.json contents when present. // Expected shape: schemaVersion 2, carrying // extensions + components + narrative. // /design-system/raw returns DESIGN.md markdown verbatim @@ -426,7 +431,7 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } const mdPath = path.join(CONTEXT_DIR, 'DESIGN.md'); - const jsonPath = path.join(CONTEXT_DIR, 'DESIGN.json'); + const jsonPath = resolveDesignSidecarPath(process.cwd(), CONTEXT_DIR) || getDesignSidecarPath(process.cwd()); const mdStat = statOrNull(mdPath); const jsonStat = statOrNull(jsonPath); @@ -462,7 +467,7 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { try { response.sidecar = JSON.parse(fs.readFileSync(jsonPath, 'utf-8')); } catch (err) { - response.sidecarError = 'Failed to parse DESIGN.json: ' + err.message; + response.sidecarError = 'Failed to parse .impeccable/design.json: ' + err.message; } } @@ -673,7 +678,7 @@ function handlePollPost(req, res) { let httpServer = null; function shutdown() { - try { fs.unlinkSync(LIVE_PID_FILE); } catch {} + removeLiveServerInfo(process.cwd()); if (state.leaseTimer) clearTimeout(state.leaseTimer); state.leaseTimer = null; if (state.sessionDir) { @@ -725,7 +730,7 @@ Endpoints: if (args.includes('stop')) { const keepInject = args.includes('--keep-inject'); try { - const info = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); + const { info } = readLiveServerInfo(process.cwd()) || {}; const res = await fetch(`http://localhost:${info.port}/stop?token=${info.token}`); if (res.ok) console.log(`Stopped live server on port ${info.port}.`); } catch { @@ -776,7 +781,7 @@ if (args.includes('--background')) { const deadline = Date.now() + 10_000; while (Date.now() < deadline) { try { - const info = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); + const { info } = readLiveServerInfo(process.cwd()) || {}; if (info.pid !== process.pid) { // Output JSON so the agent can read port + token from stdout. console.log(JSON.stringify(info)); @@ -790,14 +795,18 @@ if (args.includes('--background')) { } // Check for existing session -try { - const existing = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); - try { process.kill(existing.pid, 0); +const existingRecord = readLiveServerInfo(process.cwd()); +if (existingRecord?.info) { + const existing = existingRecord.info; + try { + process.kill(existing.pid, 0); console.error(`Live server already running on port ${existing.port} (pid ${existing.pid}).`); console.error('Stop it first with: node ' + path.basename(fileURLToPath(import.meta.url)) + ' stop'); process.exit(1); - } catch { fs.unlinkSync(LIVE_PID_FILE); } -} catch {} + } catch { + try { fs.unlinkSync(existingRecord.path); } catch {} + } +} state.token = randomUUID(); state.sessionStore = createLiveSessionStore({ cwd: process.cwd() }); @@ -807,7 +816,7 @@ state.port = portArg ? parseInt(portArg.split('=')[1], 10) : await findOpenPort( // Annotation screenshots live in the project root so the agent's Read tool // doesn't trip a per-file permission prompt. Sessioned by token so concurrent // projects (or quick restarts) don't collide. -const annotRoot = path.join(process.cwd(), '.impeccable-live', 'annotations'); +const annotRoot = getLiveAnnotationsDir(process.cwd()); fs.mkdirSync(annotRoot, { recursive: true }); state.sessionDir = fs.mkdtempSync(path.join(annotRoot, 'session-')); @@ -815,7 +824,7 @@ const { detectScript, sessionPath, livePath } = loadBrowserScripts(); httpServer = http.createServer(createRequestHandler({ detectScript, sessionPath, livePath })); httpServer.listen(state.port, '127.0.0.1', () => { - fs.writeFileSync(LIVE_PID_FILE, JSON.stringify({ pid: process.pid, port: state.port, token: state.token })); + writeLiveServerInfo(process.cwd(), { pid: process.pid, port: state.port, token: state.token }); const url = `http://localhost:${state.port}`; console.log(`\nImpeccable live server running on ${url}`); console.log(`Token: ${state.token}\n`); diff --git a/.kiro/skills/impeccable/scripts/live-session-store.mjs b/.kiro/skills/impeccable/scripts/live-session-store.mjs index cc7744df..37711168 100644 --- a/.kiro/skills/impeccable/scripts/live-session-store.mjs +++ b/.kiro/skills/impeccable/scripts/live-session-store.mjs @@ -1,30 +1,43 @@ import fs from 'node:fs'; import path from 'node:path'; +import { getLegacyLiveSessionsDir, getLiveSessionsDir } from './impeccable-paths.mjs'; -const LIVE_DIR = '.impeccable-live'; -const SESSIONS_DIR = 'sessions'; const COMPLETED_PHASES = new Set(['completed', 'discarded']); export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {}) { - const rootDir = path.join(cwd, LIVE_DIR, SESSIONS_DIR); + 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 = getJournalPath(rootDir, id); + const journalPath = getReadableJournalPath(id); const rebuilt = rebuildSnapshotFromJournal(journalPath, id); snapshotCache.set(id, rebuilt); return rebuilt; } + function getReadableJournalPath(id) { + const primary = getJournalPath(rootDir, id); + if (fs.existsSync(primary)) return primary; + const legacy = getJournalPath(legacyRootDir, id); + if (fs.existsSync(legacy)) return legacy; + return primary; + } + return { rootDir, + legacyRootDir, appendEvent(event) { const normalized = normalizeEvent(event, sessionId); const journalPath = getJournalPath(rootDir, normalized.id); const snapshotPath = getSnapshotPath(rootDir, normalized.id); + const legacyJournalPath = getJournalPath(legacyRootDir, normalized.id); + if (!fs.existsSync(journalPath) && fs.existsSync(legacyJournalPath)) { + fs.copyFileSync(legacyJournalPath, journalPath); + } const prior = loadCachedOrRebuild(normalized.id); const seq = prior.nextSeq; const entry = { @@ -42,7 +55,7 @@ export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {}) }, getSnapshot(id = sessionId, opts = {}) { if (!id) throw new Error('session id required'); - const journalPath = getJournalPath(rootDir, id); + const journalPath = getReadableJournalPath(id); const snapshotPath = getSnapshotPath(rootDir, id); const rebuilt = rebuildSnapshotFromJournal(journalPath, id); snapshotCache.set(id, rebuilt); @@ -51,10 +64,15 @@ export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {}) return rebuilt.snapshot; }, listActiveSessions() { - if (!fs.existsSync(rootDir)) return []; - return fs.readdirSync(rootDir) - .filter((name) => name.endsWith('.jsonl')) - .map((name) => name.slice(0, -'.jsonl'.length)) + const ids = new Set(); + for (const dir of [legacyRootDir, rootDir]) { + if (!fs.existsSync(dir)) continue; + for (const name of fs.readdirSync(dir)) { + if (name.endsWith('.jsonl')) ids.add(name.slice(0, -'.jsonl'.length)); + } + } + return [...ids] + .sort() .map((id) => this.getSnapshot(id)) .filter(Boolean); }, diff --git a/.kiro/skills/impeccable/scripts/live-status.mjs b/.kiro/skills/impeccable/scripts/live-status.mjs index 1b85357c..dce1fbca 100644 --- a/.kiro/skills/impeccable/scripts/live-status.mjs +++ b/.kiro/skills/impeccable/scripts/live-status.mjs @@ -3,15 +3,11 @@ * Print durable recovery status for Impeccable live sessions. */ -import fs from 'node:fs'; -import path from 'node:path'; import { createLiveSessionStore } from './live-session-store.mjs'; - -const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); +import { readLiveServerInfo } from './impeccable-paths.mjs'; function readServerInfo() { - try { return JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); } - catch { return null; } + return readLiveServerInfo(process.cwd())?.info || null; } async function fetchServerStatus(info) { diff --git a/.kiro/skills/impeccable/scripts/live.mjs b/.kiro/skills/impeccable/scripts/live.mjs index befbdb8e..cafb0eca 100644 --- a/.kiro/skills/impeccable/scripts/live.mjs +++ b/.kiro/skills/impeccable/scripts/live.mjs @@ -2,10 +2,10 @@ * CLI entry point: prepare everything needed to enter the live variant poll loop. * * Does (all in one command): - * 1. Check config.json (returns config_missing if first-ever run) + * 1. Check .impeccable/live/config.json (returns config_missing if first-ever run) * 2. Start the live server in the background (or reuse a running one) * 3. Inject the browser script tag into the project's entry file - * 4. Read .impeccable.md for design context (if present) + * 4. Read PRODUCT.md / DESIGN.md for project context * 5. Print a single JSON blob with everything the agent needs * * After this, the agent's only remaining steps are: @@ -23,9 +23,9 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { loadContext } from './load-context.mjs'; import { resolveFiles } from './live-inject.mjs'; +import { readLiveServerInfo } from './impeccable-paths.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); async function liveCli() { const args = process.argv.slice(2); @@ -34,10 +34,10 @@ async function liveCli() { console.log(`Usage: node live.mjs Prepare everything for live variant mode in a single command: - - Checks scripts/config.json (required, created once per project) + - Checks .impeccable/live/config.json (required, created once per project) - Starts (or reuses) the live server in the background - Injects the browser script tag - - Reads .impeccable.md for design context + - Reads PRODUCT.md / DESIGN.md for project context On success, prints a JSON blob with: { ok, serverPort, serverToken, pageFile, hasContext, context } @@ -223,7 +223,7 @@ function safeParse(out) { function ensureServerRunning() { // Try to reuse an existing server try { - const existing = JSON.parse(fs.readFileSync(PID_FILE, 'utf-8')); + const existing = readLiveServerInfo(process.cwd())?.info; if (existing && existing.pid) { try { process.kill(existing.pid, 0); // throws if dead diff --git a/.kiro/skills/impeccable/scripts/load-context.mjs b/.kiro/skills/impeccable/scripts/load-context.mjs index dca23c1f..dc340bf1 100644 --- a/.kiro/skills/impeccable/scripts/load-context.mjs +++ b/.kiro/skills/impeccable/scripts/load-context.mjs @@ -39,7 +39,7 @@ const LEGACY_NAMES = ['.impeccable.md']; const FALLBACK_DIRS = ['.agents/context', 'docs']; /** - * Resolve the directory that holds PRODUCT.md / DESIGN.md / DESIGN.json for + * Resolve the directory that holds PRODUCT.md / DESIGN.md for * this project. Exported so other scripts (e.g. live-server.mjs) can read the * design files from the same location the loader uses. */ diff --git a/.opencode/skills/impeccable/reference/document.md b/.opencode/skills/impeccable/reference/document.md index 1787c669..ad09f87c 100644 --- a/.opencode/skills/impeccable/reference/document.md +++ b/.opencode/skills/impeccable/reference/document.md @@ -237,11 +237,11 @@ Concrete, forceful guardrails. Lead each with "Do" or "Don't". Be specific: incl - **Don't** [...] ``` -### Step 4b: Write DESIGN.json sidecar (extensions only) +### Step 4b: Write .impeccable/design.json sidecar (extensions only) -The frontmatter owns token primitives (colors, typography, rounded, spacing, components). The sidecar at `DESIGN.json` carries **what Stitch's schema can't hold**: tonal ramps per color, shadow/elevation tokens, motion tokens, breakpoints, full component HTML/CSS snippets (the panel renders these into a shadow DOM), and narrative (north star, rules, do's/don'ts). It extends the frontmatter, it doesn't duplicate it. +The frontmatter owns token primitives (colors, typography, rounded, spacing, components). The sidecar at `.impeccable/design.json` carries **what Stitch's schema can't hold**: tonal ramps per color, shadow/elevation tokens, motion tokens, breakpoints, full component HTML/CSS snippets (the panel renders these into a shadow DOM), and narrative (north star, rules, do's/don'ts). It extends the frontmatter, it doesn't duplicate it. -Regenerate the sidecar whenever you regenerate DESIGN.md. If the user only asks to refresh the sidecar (e.g., from the live panel's stale-hint), preserve DESIGN.md and write only DESIGN.json. +Regenerate the sidecar whenever you regenerate root `DESIGN.md`. If the user only asks to refresh the sidecar (e.g., from the live panel's stale-hint), preserve `DESIGN.md` and write only `.impeccable/design.json`. #### Schema @@ -310,7 +310,7 @@ Aim for a tight set of **5-10 components** that best represent the visual system - **Signature components (include if distinctive):** hero CTA, featured card, filter pill, any custom pattern the user mentioned as important in PRODUCT.md. - **Skip the rest.** Utility components, form building blocks, wrapper layouts: not worth documenting unless visually distinctive. -If the project has **no component library yet** (bare landing page, new project), synthesize canonical primitives from the tokens using best-practice defaults consistent with the DESIGN.md's rules. Every DESIGN.json has *something* to render, even on day zero. +If the project has **no component library yet** (bare landing page, new project), synthesize canonical primitives from the tokens using best-practice defaults consistent with the DESIGN.md's rules. Every `.impeccable/design.json` has *something* to render, even on day zero. #### Tonal ramps @@ -331,7 +331,7 @@ Do not reword. The panel shows these as secondary collapsible context; the same ### Step 5: Confirm, refine, and refresh session cache 1. Show the user the full DESIGN.md you wrote. Briefly highlight the non-obvious creative choices (descriptive color names, atmosphere language, named rules). -2. Mention that `DESIGN.json` was also written alongside; the live panel will now render this project's actual button/input/nav primitives instead of generic approximations. +2. Mention that `.impeccable/design.json` was also written alongside; the live panel will now render this project's actual button/input/nav primitives instead of generic approximations. 3. Offer to refine any section: "Want me to revise a section, add component patterns I missed, or adjust the atmosphere language?" 4. **Refresh the session cache.** Run `node .opencode/skills/impeccable/scripts/load-context.mjs` one final time so the newly-written DESIGN.md lands in conversation. Subsequent commands in this session will use the fresh version automatically without re-reading. @@ -392,7 +392,7 @@ Per-section guidance in seed mode: - **Components**: omit entirely; no components exist yet. - **Do's and Don'ts**: carry PRODUCT.md's anti-references directly plus the anti-reference named in Q5. -Seed mode writes a minimal frontmatter with `name` and `description` only; no colors, typography, rounded, spacing, or components yet. Real tokens land on the next Scan-mode run. Skip the `DESIGN.json` sidecar in seed mode for the same reason: nothing to render. +Seed mode writes a minimal frontmatter with `name` and `description` only; no colors, typography, rounded, spacing, or components yet. Real tokens land on the next Scan-mode run. Skip the `.impeccable/design.json` sidecar in seed mode for the same reason: nothing to render. ### Step 4: Confirm and refresh session cache diff --git a/.opencode/skills/impeccable/reference/live.md b/.opencode/skills/impeccable/reference/live.md index 4247bead..64c97063 100644 --- a/.opencode/skills/impeccable/reference/live.md +++ b/.opencode/skills/impeccable/reference/live.md @@ -53,7 +53,7 @@ LOOP: ## Recovery commands -The live helper persists an append-only journal under `.impeccable-live/sessions`. Browser checkpoints are advisory but durable; the journal is canonical. +The live helper persists an append-only journal under `.impeccable/live/sessions/`. Browser checkpoints are advisory but durable; the journal is canonical. This is local durable recovery state, not project source. Use these commands when the chat was interrupted, polling was missed, the helper restarted, or the browser reloaded: @@ -473,7 +473,7 @@ When the poll returns `exit`, proceed to cleanup. If the poll is still running a node .opencode/skills/impeccable/scripts/live-server.mjs stop ``` -Stops the HTTP server and runs `live-inject.mjs --remove` to strip `localhost:…/live.js` from the HTML entry. To stop the server but keep the inject tag (for a quick restart), use `stop --keep-inject`. `config.json` persists for future sessions. +Stops the HTTP server and runs `live-inject.mjs --remove` to strip `localhost:…/live.js` from the HTML entry. To stop the server but keep the inject tag (for a quick restart), use `stop --keep-inject`. `.impeccable/live/config.json` persists as project config for future sessions. Then: - Remove any leftover variant wrappers (search for `impeccable-variants-start` markers). @@ -481,7 +481,7 @@ Then: ## First-time setup (config missing or invalid) -If `live.mjs` outputs `{ ok: false, error: "config_missing" | "config_invalid", path }`, write `config.json` at the reported path. +If `live.mjs` outputs `{ ok: false, error: "config_missing" | "config_invalid", path }`, write the live config at the reported path. By default this is `.impeccable/live/config.json`. Schema: @@ -561,7 +561,7 @@ node .opencode/skills/impeccable/scripts/detect-csp.mjs Output: `{ shape, signals }` where `shape` is one of `append-arrays`, `append-string`, `middleware`, `meta-tag`, or `null`. The shape is named by *patch mechanism*, so one template covers many frameworks. -- **`null`**: no CSP; skip to writing `config.json` with `cspChecked: true`. +- **`null`**: no CSP; skip to writing `.impeccable/live/config.json` with `cspChecked: true`. - **`append-arrays`**: CSP defined as structured directive arrays. Auto-patchable. See *append-arrays* below. Covers: - Monorepo helpers with `additionalScriptSrc` / `additionalConnectSrc` options (Next.js + shared config package) - SvelteKit `kit.csp.directives` @@ -638,6 +638,6 @@ Reference outputs: ### Troubleshooting -If a user says "no" to the CSP patch at setup time and later complains that live doesn't work: their dev CSP blocks `http://localhost:8400`. Fix: delete `cspChecked` from `config.json` and re-run `live.mjs`: setup will ask again. +If a user says "no" to the CSP patch at setup time and later complains that live doesn't work: their dev CSP blocks `http://localhost:8400`. Fix: delete `cspChecked` from `.impeccable/live/config.json` and re-run `live.mjs`: setup will ask again. Then re-run `live.mjs`. diff --git a/.opencode/skills/impeccable/reference/teach.md b/.opencode/skills/impeccable/reference/teach.md index c3f3294b..93c2a74f 100644 --- a/.opencode/skills/impeccable/reference/teach.md +++ b/.opencode/skills/impeccable/reference/teach.md @@ -2,8 +2,8 @@ Gathers design context for a project and writes two complementary files at the project root: -- **PRODUCT.md** (strategic): register, target users, product purpose, brand personality, anti-references, strategic design principles. Answers "who/what/why". -- **DESIGN.md** (visual): visual theme, color palette, typography, components, layout. Follows the [Google Stitch DESIGN.md format](https://stitch.withgoogle.com/docs/design-md/format/). Answers "how it looks". +- **PRODUCT.md** (strategic): root project file for register, target users, product purpose, brand personality, anti-references, strategic design principles. Answers "who/what/why". +- **DESIGN.md** (visual): root project file for visual theme, color palette, typography, components, layout. Follows the [Google Stitch DESIGN.md format](https://stitch.withgoogle.com/docs/design-md/format/). Answers "how it looks". Every other impeccable command reads these files before doing any work. diff --git a/.opencode/skills/impeccable/scripts/impeccable-paths.mjs b/.opencode/skills/impeccable/scripts/impeccable-paths.mjs new file mode 100644 index 00000000..ba852bae --- /dev/null +++ b/.opencode/skills/impeccable/scripts/impeccable-paths.mjs @@ -0,0 +1,105 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +export const IMPECCABLE_DIR = '.impeccable'; +export const LIVE_DIR = 'live'; + +export function getImpeccableDir(cwd = process.cwd()) { + return path.join(cwd, IMPECCABLE_DIR); +} + +export function getDesignSidecarPath(cwd = process.cwd()) { + return path.join(getImpeccableDir(cwd), 'design.json'); +} + +export function getDesignSidecarCandidates(cwd = process.cwd(), contextDir = cwd) { + const candidates = [ + getDesignSidecarPath(cwd), + path.join(cwd, 'DESIGN.json'), + ]; + const contextLegacy = path.join(contextDir, 'DESIGN.json'); + if (!candidates.includes(contextLegacy)) candidates.push(contextLegacy); + return candidates; +} + +export function resolveDesignSidecarPath(cwd = process.cwd(), contextDir = cwd) { + return firstExisting(getDesignSidecarCandidates(cwd, contextDir)); +} + +export function getLiveDir(cwd = process.cwd()) { + return path.join(getImpeccableDir(cwd), LIVE_DIR); +} + +export function getLiveConfigPath(cwd = process.cwd()) { + return path.join(getLiveDir(cwd), 'config.json'); +} + +export function getLegacyLiveConfigPath(scriptsDir) { + return path.join(scriptsDir, 'config.json'); +} + +export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = process.env } = {}) { + if (env.IMPECCABLE_LIVE_CONFIG && env.IMPECCABLE_LIVE_CONFIG.trim()) { + const configured = env.IMPECCABLE_LIVE_CONFIG.trim(); + return path.isAbsolute(configured) ? configured : path.resolve(cwd, configured); + } + const primary = getLiveConfigPath(cwd); + if (fs.existsSync(primary)) return primary; + if (scriptsDir) { + const legacy = getLegacyLiveConfigPath(scriptsDir); + if (fs.existsSync(legacy)) return legacy; + } + return primary; +} + +export function getLiveServerPath(cwd = process.cwd()) { + return path.join(getLiveDir(cwd), 'server.json'); +} + +export function getLegacyLiveServerPath(cwd = process.cwd()) { + return path.join(cwd, '.impeccable-live.json'); +} + +export function readLiveServerInfo(cwd = process.cwd()) { + for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) { + try { + return { info: JSON.parse(fs.readFileSync(filePath, 'utf-8')), path: filePath }; + } catch { + /* try next */ + } + } + return null; +} + +export function writeLiveServerInfo(cwd = process.cwd(), info) { + const filePath = getLiveServerPath(cwd); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, JSON.stringify(info)); + return filePath; +} + +export function removeLiveServerInfo(cwd = process.cwd()) { + for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) { + try { fs.unlinkSync(filePath); } catch {} + } +} + +export function getLiveSessionsDir(cwd = process.cwd()) { + return path.join(getLiveDir(cwd), 'sessions'); +} + +export function getLegacyLiveSessionsDir(cwd = process.cwd()) { + return path.join(cwd, '.impeccable-live', 'sessions'); +} + +export function getLiveAnnotationsDir(cwd = process.cwd()) { + return path.join(getLiveDir(cwd), 'annotations'); +} + +export function getLegacyLiveAnnotationsDir(cwd = process.cwd()) { + return path.join(cwd, '.impeccable-live', 'annotations'); +} + +function firstExisting(paths) { + return paths.find((filePath) => fs.existsSync(filePath)) || null; +} diff --git a/.opencode/skills/impeccable/scripts/live-browser.js b/.opencode/skills/impeccable/scripts/live-browser.js index 7f1ff329..bb5332a3 100644 --- a/.opencode/skills/impeccable/scripts/live-browser.js +++ b/.opencode/skills/impeccable/scripts/live-browser.js @@ -3671,7 +3671,7 @@ void main() { } // --------------------------------------------------------------------------- - // Design System Panel — visualizes the project's DESIGN.json sidecar + // Design System Panel — visualizes the project's .impeccable/design.json sidecar // --------------------------------------------------------------------------- const DESIGN_PREFS_KEY = 'impeccable-live-design-panel'; @@ -3683,7 +3683,7 @@ void main() { open: false, tab: 'visual', // 'visual' | 'raw' parsed: null, // parseDesignMd output (frontmatter + body sections) - sidecar: null, // DESIGN.json v2 payload (extensions + components + narrative) + sidecar: null, // .impeccable/design.json v2 payload (extensions + components + narrative) hasMd: false, hasSidecar: false, present: null, // true/false once fetch resolves @@ -4184,7 +4184,7 @@ void main() { box.className = 'stale'; box.innerHTML = ` - DESIGN.md is newer than DESIGN.json. Run /impeccable document to refresh the sidecar. + DESIGN.md is newer than .impeccable/design.json. Run /impeccable document to refresh the sidecar. `; return box; } @@ -4192,7 +4192,7 @@ void main() { function renderParsedMdCta() { const box = document.createElement('div'); box.className = 'parsed-md-cta'; - box.innerHTML = `Basic viewThis panel reads the tokens in your DESIGN.md frontmatter. Running /impeccable document also generates a DESIGN.json sidecar with your project's actual component snippets (button, input, nav) and tonal ramps, rendered live below the tokens.`; + box.innerHTML = `Basic viewThis panel reads the tokens in your DESIGN.md frontmatter. Running /impeccable document also generates a .impeccable/design.json sidecar with your project's actual component snippets (button, input, nav) and tonal ramps, rendered live below the tokens.`; return box; } @@ -4652,7 +4652,7 @@ void main() { function cssSafe(v) { // Strip anything outside valid CSS value chars to prevent injection via - // DESIGN.json values rendered into inline style strings. + // .impeccable/design.json values rendered into inline style strings. return String(v).replace(/[<>"'`\n]/g, ''); } diff --git a/.opencode/skills/impeccable/scripts/live-complete.mjs b/.opencode/skills/impeccable/scripts/live-complete.mjs index ca00d86a..78155af8 100644 --- a/.opencode/skills/impeccable/scripts/live-complete.mjs +++ b/.opencode/skills/impeccable/scripts/live-complete.mjs @@ -4,10 +4,7 @@ */ import { createLiveSessionStore } from './live-session-store.mjs'; -import fs from 'node:fs'; -import path from 'node:path'; - -const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); +import { readLiveServerInfo } from './impeccable-paths.mjs'; function parseArgs(argv) { const out = { status: 'complete' }; @@ -50,8 +47,7 @@ export async function completeCli() { } function readServerInfo() { - try { return JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); } - catch { return null; } + return readLiveServerInfo(process.cwd())?.info || null; } async function completeThroughServer(info, args) { diff --git a/.opencode/skills/impeccable/scripts/live-inject.mjs b/.opencode/skills/impeccable/scripts/live-inject.mjs index 1d2ae12f..555c3a36 100644 --- a/.opencode/skills/impeccable/scripts/live-inject.mjs +++ b/.opencode/skills/impeccable/scripts/live-inject.mjs @@ -2,23 +2,24 @@ * CLI helper: insert/remove the live variant mode script tag in the project's * main HTML entry point. * - * On first live run, the agent generates `config.json` in this script's - * directory with the project's insertion target (framework-specific). On + * On first live run, the agent generates `.impeccable/live/config.json` + * with the project's insertion target (framework-specific). On * every subsequent run, this script handles insert/remove deterministically * with zero LLM involvement. * * Usage: * node live-inject.mjs --port PORT # Insert the live script tag * node live-inject.mjs --remove # Remove the live script tag - * node live-inject.mjs --check # Check whether config.json exists + * node live-inject.mjs --check # Check whether live config exists */ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { resolveLiveConfigPath } from './impeccable-paths.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const CONFIG_PATH = process.env.IMPECCABLE_LIVE_CONFIG || path.join(__dirname, 'config.json'); +const CONFIG_PATH = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname }); const MARKER_OPEN_TEXT = 'impeccable-live-start'; const MARKER_CLOSE_TEXT = 'impeccable-live-end'; @@ -39,12 +40,12 @@ export async function injectCli() { console.log(`Usage: node live-inject.mjs [options] Insert or remove the live mode script tag in the project's HTML entry point. -Reads configuration from config.json (in this same directory). +Reads configuration from .impeccable/live/config.json. Modes: --port PORT Insert script tag pointing at http://localhost:PORT/live.js --remove Remove the script tag (if present) - --check Print whether config.json exists and its content + --check Print whether .impeccable/live/config.json exists and its content Output (JSON): { ok, file, inserted|removed, config? }`); diff --git a/.opencode/skills/impeccable/scripts/live-poll.mjs b/.opencode/skills/impeccable/scripts/live-poll.mjs index 9a3f07ae..83a9912e 100644 --- a/.opencode/skills/impeccable/scripts/live-poll.mjs +++ b/.opencode/skills/impeccable/scripts/live-poll.mjs @@ -9,11 +9,10 @@ */ import { execFileSync } from 'node:child_process'; -import fs from 'node:fs'; import path from 'node:path'; -import os from 'node:os'; import { fileURLToPath } from 'node:url'; import { completionTypeForAcceptResult } from './live-completion.mjs'; +import { readLiveServerInfo } from './impeccable-paths.mjs'; // Node's built-in fetch (undici under the hood) enforces a 300s headers // timeout that can't be lowered per-request. We cap each request below @@ -21,15 +20,13 @@ import { completionTypeForAcceptResult } from './live-completion.mjs'; // depending on the standalone undici package. const PER_REQUEST_TIMEOUT_MS = 270_000; -const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); - function readServerInfo() { - try { - return JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); - } catch { + const record = readLiveServerInfo(process.cwd()); + if (!record) { console.error('No running live server found. Start one with: npx impeccable live'); process.exit(1); } + return record.info; } export function buildPollReplyPayload(token, { id, type, message, file, data }) { diff --git a/.opencode/skills/impeccable/scripts/live-server.mjs b/.opencode/skills/impeccable/scripts/live-server.mjs index e78a0657..53d8f21c 100644 --- a/.opencode/skills/impeccable/scripts/live-server.mjs +++ b/.opencode/skills/impeccable/scripts/live-server.mjs @@ -23,14 +23,19 @@ import { fileURLToPath } from 'node:url'; import { parseDesignMd } from './design-parser.mjs'; import { resolveContextDir } from './load-context.mjs'; import { createLiveSessionStore } from './live-session-store.mjs'; +import { + getDesignSidecarPath, + getLiveAnnotationsDir, + readLiveServerInfo, + removeLiveServerInfo, + resolveDesignSidecarPath, + writeLiveServerInfo, +} from './impeccable-paths.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -// PID file in the project root so both the server and agent can find it -// predictably (os.tmpdir() varies across platforms). -const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); -// PRODUCT.md / DESIGN.md / DESIGN.json live wherever load-context.mjs resolves. -// Keeps live-server in sync with the loader when users keep the docs in -// .agents/context/, docs/, or a path set via IMPECCABLE_CONTEXT_DIR. +// PRODUCT.md / DESIGN.md live wherever load-context.mjs resolves. The generated +// DESIGN sidecar is project-local at .impeccable/design.json, with legacy +// DESIGN.json fallback for existing projects. const CONTEXT_DIR = resolveContextDir(process.cwd()); const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s @@ -411,13 +416,13 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { } // --- Design system (unified v2 response) + raw --- - // /design-system.json returns both parsed DESIGN.md and DESIGN.json + // /design-system.json returns both parsed DESIGN.md and .impeccable/design.json // sidecar when present. Panel merges them: // { present, parsed, sidecar, hasMd, hasSidecar, // mdNewerThanJson, parseError?, sidecarError? } // - parsed: output of parseDesignMd (frontmatter // + six canonical sections) when DESIGN.md exists. - // - sidecar: DESIGN.json contents when present. + // - sidecar: .impeccable/design.json contents when present. // Expected shape: schemaVersion 2, carrying // extensions + components + narrative. // /design-system/raw returns DESIGN.md markdown verbatim @@ -426,7 +431,7 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } const mdPath = path.join(CONTEXT_DIR, 'DESIGN.md'); - const jsonPath = path.join(CONTEXT_DIR, 'DESIGN.json'); + const jsonPath = resolveDesignSidecarPath(process.cwd(), CONTEXT_DIR) || getDesignSidecarPath(process.cwd()); const mdStat = statOrNull(mdPath); const jsonStat = statOrNull(jsonPath); @@ -462,7 +467,7 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { try { response.sidecar = JSON.parse(fs.readFileSync(jsonPath, 'utf-8')); } catch (err) { - response.sidecarError = 'Failed to parse DESIGN.json: ' + err.message; + response.sidecarError = 'Failed to parse .impeccable/design.json: ' + err.message; } } @@ -673,7 +678,7 @@ function handlePollPost(req, res) { let httpServer = null; function shutdown() { - try { fs.unlinkSync(LIVE_PID_FILE); } catch {} + removeLiveServerInfo(process.cwd()); if (state.leaseTimer) clearTimeout(state.leaseTimer); state.leaseTimer = null; if (state.sessionDir) { @@ -725,7 +730,7 @@ Endpoints: if (args.includes('stop')) { const keepInject = args.includes('--keep-inject'); try { - const info = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); + const { info } = readLiveServerInfo(process.cwd()) || {}; const res = await fetch(`http://localhost:${info.port}/stop?token=${info.token}`); if (res.ok) console.log(`Stopped live server on port ${info.port}.`); } catch { @@ -776,7 +781,7 @@ if (args.includes('--background')) { const deadline = Date.now() + 10_000; while (Date.now() < deadline) { try { - const info = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); + const { info } = readLiveServerInfo(process.cwd()) || {}; if (info.pid !== process.pid) { // Output JSON so the agent can read port + token from stdout. console.log(JSON.stringify(info)); @@ -790,14 +795,18 @@ if (args.includes('--background')) { } // Check for existing session -try { - const existing = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); - try { process.kill(existing.pid, 0); +const existingRecord = readLiveServerInfo(process.cwd()); +if (existingRecord?.info) { + const existing = existingRecord.info; + try { + process.kill(existing.pid, 0); console.error(`Live server already running on port ${existing.port} (pid ${existing.pid}).`); console.error('Stop it first with: node ' + path.basename(fileURLToPath(import.meta.url)) + ' stop'); process.exit(1); - } catch { fs.unlinkSync(LIVE_PID_FILE); } -} catch {} + } catch { + try { fs.unlinkSync(existingRecord.path); } catch {} + } +} state.token = randomUUID(); state.sessionStore = createLiveSessionStore({ cwd: process.cwd() }); @@ -807,7 +816,7 @@ state.port = portArg ? parseInt(portArg.split('=')[1], 10) : await findOpenPort( // Annotation screenshots live in the project root so the agent's Read tool // doesn't trip a per-file permission prompt. Sessioned by token so concurrent // projects (or quick restarts) don't collide. -const annotRoot = path.join(process.cwd(), '.impeccable-live', 'annotations'); +const annotRoot = getLiveAnnotationsDir(process.cwd()); fs.mkdirSync(annotRoot, { recursive: true }); state.sessionDir = fs.mkdtempSync(path.join(annotRoot, 'session-')); @@ -815,7 +824,7 @@ const { detectScript, sessionPath, livePath } = loadBrowserScripts(); httpServer = http.createServer(createRequestHandler({ detectScript, sessionPath, livePath })); httpServer.listen(state.port, '127.0.0.1', () => { - fs.writeFileSync(LIVE_PID_FILE, JSON.stringify({ pid: process.pid, port: state.port, token: state.token })); + writeLiveServerInfo(process.cwd(), { pid: process.pid, port: state.port, token: state.token }); const url = `http://localhost:${state.port}`; console.log(`\nImpeccable live server running on ${url}`); console.log(`Token: ${state.token}\n`); diff --git a/.opencode/skills/impeccable/scripts/live-session-store.mjs b/.opencode/skills/impeccable/scripts/live-session-store.mjs index cc7744df..37711168 100644 --- a/.opencode/skills/impeccable/scripts/live-session-store.mjs +++ b/.opencode/skills/impeccable/scripts/live-session-store.mjs @@ -1,30 +1,43 @@ import fs from 'node:fs'; import path from 'node:path'; +import { getLegacyLiveSessionsDir, getLiveSessionsDir } from './impeccable-paths.mjs'; -const LIVE_DIR = '.impeccable-live'; -const SESSIONS_DIR = 'sessions'; const COMPLETED_PHASES = new Set(['completed', 'discarded']); export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {}) { - const rootDir = path.join(cwd, LIVE_DIR, SESSIONS_DIR); + 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 = getJournalPath(rootDir, id); + const journalPath = getReadableJournalPath(id); const rebuilt = rebuildSnapshotFromJournal(journalPath, id); snapshotCache.set(id, rebuilt); return rebuilt; } + function getReadableJournalPath(id) { + const primary = getJournalPath(rootDir, id); + if (fs.existsSync(primary)) return primary; + const legacy = getJournalPath(legacyRootDir, id); + if (fs.existsSync(legacy)) return legacy; + return primary; + } + return { rootDir, + legacyRootDir, appendEvent(event) { const normalized = normalizeEvent(event, sessionId); const journalPath = getJournalPath(rootDir, normalized.id); const snapshotPath = getSnapshotPath(rootDir, normalized.id); + const legacyJournalPath = getJournalPath(legacyRootDir, normalized.id); + if (!fs.existsSync(journalPath) && fs.existsSync(legacyJournalPath)) { + fs.copyFileSync(legacyJournalPath, journalPath); + } const prior = loadCachedOrRebuild(normalized.id); const seq = prior.nextSeq; const entry = { @@ -42,7 +55,7 @@ export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {}) }, getSnapshot(id = sessionId, opts = {}) { if (!id) throw new Error('session id required'); - const journalPath = getJournalPath(rootDir, id); + const journalPath = getReadableJournalPath(id); const snapshotPath = getSnapshotPath(rootDir, id); const rebuilt = rebuildSnapshotFromJournal(journalPath, id); snapshotCache.set(id, rebuilt); @@ -51,10 +64,15 @@ export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {}) return rebuilt.snapshot; }, listActiveSessions() { - if (!fs.existsSync(rootDir)) return []; - return fs.readdirSync(rootDir) - .filter((name) => name.endsWith('.jsonl')) - .map((name) => name.slice(0, -'.jsonl'.length)) + const ids = new Set(); + for (const dir of [legacyRootDir, rootDir]) { + if (!fs.existsSync(dir)) continue; + for (const name of fs.readdirSync(dir)) { + if (name.endsWith('.jsonl')) ids.add(name.slice(0, -'.jsonl'.length)); + } + } + return [...ids] + .sort() .map((id) => this.getSnapshot(id)) .filter(Boolean); }, diff --git a/.opencode/skills/impeccable/scripts/live-status.mjs b/.opencode/skills/impeccable/scripts/live-status.mjs index 1b85357c..dce1fbca 100644 --- a/.opencode/skills/impeccable/scripts/live-status.mjs +++ b/.opencode/skills/impeccable/scripts/live-status.mjs @@ -3,15 +3,11 @@ * Print durable recovery status for Impeccable live sessions. */ -import fs from 'node:fs'; -import path from 'node:path'; import { createLiveSessionStore } from './live-session-store.mjs'; - -const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); +import { readLiveServerInfo } from './impeccable-paths.mjs'; function readServerInfo() { - try { return JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); } - catch { return null; } + return readLiveServerInfo(process.cwd())?.info || null; } async function fetchServerStatus(info) { diff --git a/.opencode/skills/impeccable/scripts/live.mjs b/.opencode/skills/impeccable/scripts/live.mjs index befbdb8e..cafb0eca 100644 --- a/.opencode/skills/impeccable/scripts/live.mjs +++ b/.opencode/skills/impeccable/scripts/live.mjs @@ -2,10 +2,10 @@ * CLI entry point: prepare everything needed to enter the live variant poll loop. * * Does (all in one command): - * 1. Check config.json (returns config_missing if first-ever run) + * 1. Check .impeccable/live/config.json (returns config_missing if first-ever run) * 2. Start the live server in the background (or reuse a running one) * 3. Inject the browser script tag into the project's entry file - * 4. Read .impeccable.md for design context (if present) + * 4. Read PRODUCT.md / DESIGN.md for project context * 5. Print a single JSON blob with everything the agent needs * * After this, the agent's only remaining steps are: @@ -23,9 +23,9 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { loadContext } from './load-context.mjs'; import { resolveFiles } from './live-inject.mjs'; +import { readLiveServerInfo } from './impeccable-paths.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); async function liveCli() { const args = process.argv.slice(2); @@ -34,10 +34,10 @@ async function liveCli() { console.log(`Usage: node live.mjs Prepare everything for live variant mode in a single command: - - Checks scripts/config.json (required, created once per project) + - Checks .impeccable/live/config.json (required, created once per project) - Starts (or reuses) the live server in the background - Injects the browser script tag - - Reads .impeccable.md for design context + - Reads PRODUCT.md / DESIGN.md for project context On success, prints a JSON blob with: { ok, serverPort, serverToken, pageFile, hasContext, context } @@ -223,7 +223,7 @@ function safeParse(out) { function ensureServerRunning() { // Try to reuse an existing server try { - const existing = JSON.parse(fs.readFileSync(PID_FILE, 'utf-8')); + const existing = readLiveServerInfo(process.cwd())?.info; if (existing && existing.pid) { try { process.kill(existing.pid, 0); // throws if dead diff --git a/.opencode/skills/impeccable/scripts/load-context.mjs b/.opencode/skills/impeccable/scripts/load-context.mjs index dca23c1f..dc340bf1 100644 --- a/.opencode/skills/impeccable/scripts/load-context.mjs +++ b/.opencode/skills/impeccable/scripts/load-context.mjs @@ -39,7 +39,7 @@ const LEGACY_NAMES = ['.impeccable.md']; const FALLBACK_DIRS = ['.agents/context', 'docs']; /** - * Resolve the directory that holds PRODUCT.md / DESIGN.md / DESIGN.json for + * Resolve the directory that holds PRODUCT.md / DESIGN.md for * this project. Exported so other scripts (e.g. live-server.mjs) can read the * design files from the same location the loader uses. */ diff --git a/.pi/skills/impeccable/reference/document.md b/.pi/skills/impeccable/reference/document.md index 7430f2bf..e5488c07 100644 --- a/.pi/skills/impeccable/reference/document.md +++ b/.pi/skills/impeccable/reference/document.md @@ -237,11 +237,11 @@ Concrete, forceful guardrails. Lead each with "Do" or "Don't". Be specific: incl - **Don't** [...] ``` -### Step 4b: Write DESIGN.json sidecar (extensions only) +### Step 4b: Write .impeccable/design.json sidecar (extensions only) -The frontmatter owns token primitives (colors, typography, rounded, spacing, components). The sidecar at `DESIGN.json` carries **what Stitch's schema can't hold**: tonal ramps per color, shadow/elevation tokens, motion tokens, breakpoints, full component HTML/CSS snippets (the panel renders these into a shadow DOM), and narrative (north star, rules, do's/don'ts). It extends the frontmatter, it doesn't duplicate it. +The frontmatter owns token primitives (colors, typography, rounded, spacing, components). The sidecar at `.impeccable/design.json` carries **what Stitch's schema can't hold**: tonal ramps per color, shadow/elevation tokens, motion tokens, breakpoints, full component HTML/CSS snippets (the panel renders these into a shadow DOM), and narrative (north star, rules, do's/don'ts). It extends the frontmatter, it doesn't duplicate it. -Regenerate the sidecar whenever you regenerate DESIGN.md. If the user only asks to refresh the sidecar (e.g., from the live panel's stale-hint), preserve DESIGN.md and write only DESIGN.json. +Regenerate the sidecar whenever you regenerate root `DESIGN.md`. If the user only asks to refresh the sidecar (e.g., from the live panel's stale-hint), preserve `DESIGN.md` and write only `.impeccable/design.json`. #### Schema @@ -310,7 +310,7 @@ Aim for a tight set of **5-10 components** that best represent the visual system - **Signature components (include if distinctive):** hero CTA, featured card, filter pill, any custom pattern the user mentioned as important in PRODUCT.md. - **Skip the rest.** Utility components, form building blocks, wrapper layouts: not worth documenting unless visually distinctive. -If the project has **no component library yet** (bare landing page, new project), synthesize canonical primitives from the tokens using best-practice defaults consistent with the DESIGN.md's rules. Every DESIGN.json has *something* to render, even on day zero. +If the project has **no component library yet** (bare landing page, new project), synthesize canonical primitives from the tokens using best-practice defaults consistent with the DESIGN.md's rules. Every `.impeccable/design.json` has *something* to render, even on day zero. #### Tonal ramps @@ -331,7 +331,7 @@ Do not reword. The panel shows these as secondary collapsible context; the same ### Step 5: Confirm, refine, and refresh session cache 1. Show the user the full DESIGN.md you wrote. Briefly highlight the non-obvious creative choices (descriptive color names, atmosphere language, named rules). -2. Mention that `DESIGN.json` was also written alongside; the live panel will now render this project's actual button/input/nav primitives instead of generic approximations. +2. Mention that `.impeccable/design.json` was also written alongside; the live panel will now render this project's actual button/input/nav primitives instead of generic approximations. 3. Offer to refine any section: "Want me to revise a section, add component patterns I missed, or adjust the atmosphere language?" 4. **Refresh the session cache.** Run `node .pi/skills/impeccable/scripts/load-context.mjs` one final time so the newly-written DESIGN.md lands in conversation. Subsequent commands in this session will use the fresh version automatically without re-reading. @@ -392,7 +392,7 @@ Per-section guidance in seed mode: - **Components**: omit entirely; no components exist yet. - **Do's and Don'ts**: carry PRODUCT.md's anti-references directly plus the anti-reference named in Q5. -Seed mode writes a minimal frontmatter with `name` and `description` only; no colors, typography, rounded, spacing, or components yet. Real tokens land on the next Scan-mode run. Skip the `DESIGN.json` sidecar in seed mode for the same reason: nothing to render. +Seed mode writes a minimal frontmatter with `name` and `description` only; no colors, typography, rounded, spacing, or components yet. Real tokens land on the next Scan-mode run. Skip the `.impeccable/design.json` sidecar in seed mode for the same reason: nothing to render. ### Step 4: Confirm and refresh session cache diff --git a/.pi/skills/impeccable/reference/live.md b/.pi/skills/impeccable/reference/live.md index 940db3c1..502de06d 100644 --- a/.pi/skills/impeccable/reference/live.md +++ b/.pi/skills/impeccable/reference/live.md @@ -53,7 +53,7 @@ LOOP: ## Recovery commands -The live helper persists an append-only journal under `.impeccable-live/sessions`. Browser checkpoints are advisory but durable; the journal is canonical. +The live helper persists an append-only journal under `.impeccable/live/sessions/`. Browser checkpoints are advisory but durable; the journal is canonical. This is local durable recovery state, not project source. Use these commands when the chat was interrupted, polling was missed, the helper restarted, or the browser reloaded: @@ -473,7 +473,7 @@ When the poll returns `exit`, proceed to cleanup. If the poll is still running a node .pi/skills/impeccable/scripts/live-server.mjs stop ``` -Stops the HTTP server and runs `live-inject.mjs --remove` to strip `localhost:…/live.js` from the HTML entry. To stop the server but keep the inject tag (for a quick restart), use `stop --keep-inject`. `config.json` persists for future sessions. +Stops the HTTP server and runs `live-inject.mjs --remove` to strip `localhost:…/live.js` from the HTML entry. To stop the server but keep the inject tag (for a quick restart), use `stop --keep-inject`. `.impeccable/live/config.json` persists as project config for future sessions. Then: - Remove any leftover variant wrappers (search for `impeccable-variants-start` markers). @@ -481,7 +481,7 @@ Then: ## First-time setup (config missing or invalid) -If `live.mjs` outputs `{ ok: false, error: "config_missing" | "config_invalid", path }`, write `config.json` at the reported path. +If `live.mjs` outputs `{ ok: false, error: "config_missing" | "config_invalid", path }`, write the live config at the reported path. By default this is `.impeccable/live/config.json`. Schema: @@ -561,7 +561,7 @@ node .pi/skills/impeccable/scripts/detect-csp.mjs Output: `{ shape, signals }` where `shape` is one of `append-arrays`, `append-string`, `middleware`, `meta-tag`, or `null`. The shape is named by *patch mechanism*, so one template covers many frameworks. -- **`null`**: no CSP; skip to writing `config.json` with `cspChecked: true`. +- **`null`**: no CSP; skip to writing `.impeccable/live/config.json` with `cspChecked: true`. - **`append-arrays`**: CSP defined as structured directive arrays. Auto-patchable. See *append-arrays* below. Covers: - Monorepo helpers with `additionalScriptSrc` / `additionalConnectSrc` options (Next.js + shared config package) - SvelteKit `kit.csp.directives` @@ -638,6 +638,6 @@ Reference outputs: ### Troubleshooting -If a user says "no" to the CSP patch at setup time and later complains that live doesn't work: their dev CSP blocks `http://localhost:8400`. Fix: delete `cspChecked` from `config.json` and re-run `live.mjs`: setup will ask again. +If a user says "no" to the CSP patch at setup time and later complains that live doesn't work: their dev CSP blocks `http://localhost:8400`. Fix: delete `cspChecked` from `.impeccable/live/config.json` and re-run `live.mjs`: setup will ask again. Then re-run `live.mjs`. diff --git a/.pi/skills/impeccable/reference/teach.md b/.pi/skills/impeccable/reference/teach.md index 9d14cfdd..9da9cffd 100644 --- a/.pi/skills/impeccable/reference/teach.md +++ b/.pi/skills/impeccable/reference/teach.md @@ -2,8 +2,8 @@ Gathers design context for a project and writes two complementary files at the project root: -- **PRODUCT.md** (strategic): register, target users, product purpose, brand personality, anti-references, strategic design principles. Answers "who/what/why". -- **DESIGN.md** (visual): visual theme, color palette, typography, components, layout. Follows the [Google Stitch DESIGN.md format](https://stitch.withgoogle.com/docs/design-md/format/). Answers "how it looks". +- **PRODUCT.md** (strategic): root project file for register, target users, product purpose, brand personality, anti-references, strategic design principles. Answers "who/what/why". +- **DESIGN.md** (visual): root project file for visual theme, color palette, typography, components, layout. Follows the [Google Stitch DESIGN.md format](https://stitch.withgoogle.com/docs/design-md/format/). Answers "how it looks". Every other impeccable command reads these files before doing any work. diff --git a/.pi/skills/impeccable/scripts/impeccable-paths.mjs b/.pi/skills/impeccable/scripts/impeccable-paths.mjs new file mode 100644 index 00000000..ba852bae --- /dev/null +++ b/.pi/skills/impeccable/scripts/impeccable-paths.mjs @@ -0,0 +1,105 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +export const IMPECCABLE_DIR = '.impeccable'; +export const LIVE_DIR = 'live'; + +export function getImpeccableDir(cwd = process.cwd()) { + return path.join(cwd, IMPECCABLE_DIR); +} + +export function getDesignSidecarPath(cwd = process.cwd()) { + return path.join(getImpeccableDir(cwd), 'design.json'); +} + +export function getDesignSidecarCandidates(cwd = process.cwd(), contextDir = cwd) { + const candidates = [ + getDesignSidecarPath(cwd), + path.join(cwd, 'DESIGN.json'), + ]; + const contextLegacy = path.join(contextDir, 'DESIGN.json'); + if (!candidates.includes(contextLegacy)) candidates.push(contextLegacy); + return candidates; +} + +export function resolveDesignSidecarPath(cwd = process.cwd(), contextDir = cwd) { + return firstExisting(getDesignSidecarCandidates(cwd, contextDir)); +} + +export function getLiveDir(cwd = process.cwd()) { + return path.join(getImpeccableDir(cwd), LIVE_DIR); +} + +export function getLiveConfigPath(cwd = process.cwd()) { + return path.join(getLiveDir(cwd), 'config.json'); +} + +export function getLegacyLiveConfigPath(scriptsDir) { + return path.join(scriptsDir, 'config.json'); +} + +export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = process.env } = {}) { + if (env.IMPECCABLE_LIVE_CONFIG && env.IMPECCABLE_LIVE_CONFIG.trim()) { + const configured = env.IMPECCABLE_LIVE_CONFIG.trim(); + return path.isAbsolute(configured) ? configured : path.resolve(cwd, configured); + } + const primary = getLiveConfigPath(cwd); + if (fs.existsSync(primary)) return primary; + if (scriptsDir) { + const legacy = getLegacyLiveConfigPath(scriptsDir); + if (fs.existsSync(legacy)) return legacy; + } + return primary; +} + +export function getLiveServerPath(cwd = process.cwd()) { + return path.join(getLiveDir(cwd), 'server.json'); +} + +export function getLegacyLiveServerPath(cwd = process.cwd()) { + return path.join(cwd, '.impeccable-live.json'); +} + +export function readLiveServerInfo(cwd = process.cwd()) { + for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) { + try { + return { info: JSON.parse(fs.readFileSync(filePath, 'utf-8')), path: filePath }; + } catch { + /* try next */ + } + } + return null; +} + +export function writeLiveServerInfo(cwd = process.cwd(), info) { + const filePath = getLiveServerPath(cwd); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, JSON.stringify(info)); + return filePath; +} + +export function removeLiveServerInfo(cwd = process.cwd()) { + for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) { + try { fs.unlinkSync(filePath); } catch {} + } +} + +export function getLiveSessionsDir(cwd = process.cwd()) { + return path.join(getLiveDir(cwd), 'sessions'); +} + +export function getLegacyLiveSessionsDir(cwd = process.cwd()) { + return path.join(cwd, '.impeccable-live', 'sessions'); +} + +export function getLiveAnnotationsDir(cwd = process.cwd()) { + return path.join(getLiveDir(cwd), 'annotations'); +} + +export function getLegacyLiveAnnotationsDir(cwd = process.cwd()) { + return path.join(cwd, '.impeccable-live', 'annotations'); +} + +function firstExisting(paths) { + return paths.find((filePath) => fs.existsSync(filePath)) || null; +} diff --git a/.pi/skills/impeccable/scripts/live-browser.js b/.pi/skills/impeccable/scripts/live-browser.js index 7f1ff329..bb5332a3 100644 --- a/.pi/skills/impeccable/scripts/live-browser.js +++ b/.pi/skills/impeccable/scripts/live-browser.js @@ -3671,7 +3671,7 @@ void main() { } // --------------------------------------------------------------------------- - // Design System Panel — visualizes the project's DESIGN.json sidecar + // Design System Panel — visualizes the project's .impeccable/design.json sidecar // --------------------------------------------------------------------------- const DESIGN_PREFS_KEY = 'impeccable-live-design-panel'; @@ -3683,7 +3683,7 @@ void main() { open: false, tab: 'visual', // 'visual' | 'raw' parsed: null, // parseDesignMd output (frontmatter + body sections) - sidecar: null, // DESIGN.json v2 payload (extensions + components + narrative) + sidecar: null, // .impeccable/design.json v2 payload (extensions + components + narrative) hasMd: false, hasSidecar: false, present: null, // true/false once fetch resolves @@ -4184,7 +4184,7 @@ void main() { box.className = 'stale'; box.innerHTML = ` - DESIGN.md is newer than DESIGN.json. Run /impeccable document to refresh the sidecar. + DESIGN.md is newer than .impeccable/design.json. Run /impeccable document to refresh the sidecar. `; return box; } @@ -4192,7 +4192,7 @@ void main() { function renderParsedMdCta() { const box = document.createElement('div'); box.className = 'parsed-md-cta'; - box.innerHTML = `Basic viewThis panel reads the tokens in your DESIGN.md frontmatter. Running /impeccable document also generates a DESIGN.json sidecar with your project's actual component snippets (button, input, nav) and tonal ramps, rendered live below the tokens.`; + box.innerHTML = `Basic viewThis panel reads the tokens in your DESIGN.md frontmatter. Running /impeccable document also generates a .impeccable/design.json sidecar with your project's actual component snippets (button, input, nav) and tonal ramps, rendered live below the tokens.`; return box; } @@ -4652,7 +4652,7 @@ void main() { function cssSafe(v) { // Strip anything outside valid CSS value chars to prevent injection via - // DESIGN.json values rendered into inline style strings. + // .impeccable/design.json values rendered into inline style strings. return String(v).replace(/[<>"'`\n]/g, ''); } diff --git a/.pi/skills/impeccable/scripts/live-complete.mjs b/.pi/skills/impeccable/scripts/live-complete.mjs index ca00d86a..78155af8 100644 --- a/.pi/skills/impeccable/scripts/live-complete.mjs +++ b/.pi/skills/impeccable/scripts/live-complete.mjs @@ -4,10 +4,7 @@ */ import { createLiveSessionStore } from './live-session-store.mjs'; -import fs from 'node:fs'; -import path from 'node:path'; - -const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); +import { readLiveServerInfo } from './impeccable-paths.mjs'; function parseArgs(argv) { const out = { status: 'complete' }; @@ -50,8 +47,7 @@ export async function completeCli() { } function readServerInfo() { - try { return JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); } - catch { return null; } + return readLiveServerInfo(process.cwd())?.info || null; } async function completeThroughServer(info, args) { diff --git a/.pi/skills/impeccable/scripts/live-inject.mjs b/.pi/skills/impeccable/scripts/live-inject.mjs index 1d2ae12f..555c3a36 100644 --- a/.pi/skills/impeccable/scripts/live-inject.mjs +++ b/.pi/skills/impeccable/scripts/live-inject.mjs @@ -2,23 +2,24 @@ * CLI helper: insert/remove the live variant mode script tag in the project's * main HTML entry point. * - * On first live run, the agent generates `config.json` in this script's - * directory with the project's insertion target (framework-specific). On + * On first live run, the agent generates `.impeccable/live/config.json` + * with the project's insertion target (framework-specific). On * every subsequent run, this script handles insert/remove deterministically * with zero LLM involvement. * * Usage: * node live-inject.mjs --port PORT # Insert the live script tag * node live-inject.mjs --remove # Remove the live script tag - * node live-inject.mjs --check # Check whether config.json exists + * node live-inject.mjs --check # Check whether live config exists */ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { resolveLiveConfigPath } from './impeccable-paths.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const CONFIG_PATH = process.env.IMPECCABLE_LIVE_CONFIG || path.join(__dirname, 'config.json'); +const CONFIG_PATH = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname }); const MARKER_OPEN_TEXT = 'impeccable-live-start'; const MARKER_CLOSE_TEXT = 'impeccable-live-end'; @@ -39,12 +40,12 @@ export async function injectCli() { console.log(`Usage: node live-inject.mjs [options] Insert or remove the live mode script tag in the project's HTML entry point. -Reads configuration from config.json (in this same directory). +Reads configuration from .impeccable/live/config.json. Modes: --port PORT Insert script tag pointing at http://localhost:PORT/live.js --remove Remove the script tag (if present) - --check Print whether config.json exists and its content + --check Print whether .impeccable/live/config.json exists and its content Output (JSON): { ok, file, inserted|removed, config? }`); diff --git a/.pi/skills/impeccable/scripts/live-poll.mjs b/.pi/skills/impeccable/scripts/live-poll.mjs index 9a3f07ae..83a9912e 100644 --- a/.pi/skills/impeccable/scripts/live-poll.mjs +++ b/.pi/skills/impeccable/scripts/live-poll.mjs @@ -9,11 +9,10 @@ */ import { execFileSync } from 'node:child_process'; -import fs from 'node:fs'; import path from 'node:path'; -import os from 'node:os'; import { fileURLToPath } from 'node:url'; import { completionTypeForAcceptResult } from './live-completion.mjs'; +import { readLiveServerInfo } from './impeccable-paths.mjs'; // Node's built-in fetch (undici under the hood) enforces a 300s headers // timeout that can't be lowered per-request. We cap each request below @@ -21,15 +20,13 @@ import { completionTypeForAcceptResult } from './live-completion.mjs'; // depending on the standalone undici package. const PER_REQUEST_TIMEOUT_MS = 270_000; -const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); - function readServerInfo() { - try { - return JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); - } catch { + const record = readLiveServerInfo(process.cwd()); + if (!record) { console.error('No running live server found. Start one with: npx impeccable live'); process.exit(1); } + return record.info; } export function buildPollReplyPayload(token, { id, type, message, file, data }) { diff --git a/.pi/skills/impeccable/scripts/live-server.mjs b/.pi/skills/impeccable/scripts/live-server.mjs index e78a0657..53d8f21c 100644 --- a/.pi/skills/impeccable/scripts/live-server.mjs +++ b/.pi/skills/impeccable/scripts/live-server.mjs @@ -23,14 +23,19 @@ import { fileURLToPath } from 'node:url'; import { parseDesignMd } from './design-parser.mjs'; import { resolveContextDir } from './load-context.mjs'; import { createLiveSessionStore } from './live-session-store.mjs'; +import { + getDesignSidecarPath, + getLiveAnnotationsDir, + readLiveServerInfo, + removeLiveServerInfo, + resolveDesignSidecarPath, + writeLiveServerInfo, +} from './impeccable-paths.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -// PID file in the project root so both the server and agent can find it -// predictably (os.tmpdir() varies across platforms). -const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); -// PRODUCT.md / DESIGN.md / DESIGN.json live wherever load-context.mjs resolves. -// Keeps live-server in sync with the loader when users keep the docs in -// .agents/context/, docs/, or a path set via IMPECCABLE_CONTEXT_DIR. +// PRODUCT.md / DESIGN.md live wherever load-context.mjs resolves. The generated +// DESIGN sidecar is project-local at .impeccable/design.json, with legacy +// DESIGN.json fallback for existing projects. const CONTEXT_DIR = resolveContextDir(process.cwd()); const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s @@ -411,13 +416,13 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { } // --- Design system (unified v2 response) + raw --- - // /design-system.json returns both parsed DESIGN.md and DESIGN.json + // /design-system.json returns both parsed DESIGN.md and .impeccable/design.json // sidecar when present. Panel merges them: // { present, parsed, sidecar, hasMd, hasSidecar, // mdNewerThanJson, parseError?, sidecarError? } // - parsed: output of parseDesignMd (frontmatter // + six canonical sections) when DESIGN.md exists. - // - sidecar: DESIGN.json contents when present. + // - sidecar: .impeccable/design.json contents when present. // Expected shape: schemaVersion 2, carrying // extensions + components + narrative. // /design-system/raw returns DESIGN.md markdown verbatim @@ -426,7 +431,7 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } const mdPath = path.join(CONTEXT_DIR, 'DESIGN.md'); - const jsonPath = path.join(CONTEXT_DIR, 'DESIGN.json'); + const jsonPath = resolveDesignSidecarPath(process.cwd(), CONTEXT_DIR) || getDesignSidecarPath(process.cwd()); const mdStat = statOrNull(mdPath); const jsonStat = statOrNull(jsonPath); @@ -462,7 +467,7 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { try { response.sidecar = JSON.parse(fs.readFileSync(jsonPath, 'utf-8')); } catch (err) { - response.sidecarError = 'Failed to parse DESIGN.json: ' + err.message; + response.sidecarError = 'Failed to parse .impeccable/design.json: ' + err.message; } } @@ -673,7 +678,7 @@ function handlePollPost(req, res) { let httpServer = null; function shutdown() { - try { fs.unlinkSync(LIVE_PID_FILE); } catch {} + removeLiveServerInfo(process.cwd()); if (state.leaseTimer) clearTimeout(state.leaseTimer); state.leaseTimer = null; if (state.sessionDir) { @@ -725,7 +730,7 @@ Endpoints: if (args.includes('stop')) { const keepInject = args.includes('--keep-inject'); try { - const info = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); + const { info } = readLiveServerInfo(process.cwd()) || {}; const res = await fetch(`http://localhost:${info.port}/stop?token=${info.token}`); if (res.ok) console.log(`Stopped live server on port ${info.port}.`); } catch { @@ -776,7 +781,7 @@ if (args.includes('--background')) { const deadline = Date.now() + 10_000; while (Date.now() < deadline) { try { - const info = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); + const { info } = readLiveServerInfo(process.cwd()) || {}; if (info.pid !== process.pid) { // Output JSON so the agent can read port + token from stdout. console.log(JSON.stringify(info)); @@ -790,14 +795,18 @@ if (args.includes('--background')) { } // Check for existing session -try { - const existing = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); - try { process.kill(existing.pid, 0); +const existingRecord = readLiveServerInfo(process.cwd()); +if (existingRecord?.info) { + const existing = existingRecord.info; + try { + process.kill(existing.pid, 0); console.error(`Live server already running on port ${existing.port} (pid ${existing.pid}).`); console.error('Stop it first with: node ' + path.basename(fileURLToPath(import.meta.url)) + ' stop'); process.exit(1); - } catch { fs.unlinkSync(LIVE_PID_FILE); } -} catch {} + } catch { + try { fs.unlinkSync(existingRecord.path); } catch {} + } +} state.token = randomUUID(); state.sessionStore = createLiveSessionStore({ cwd: process.cwd() }); @@ -807,7 +816,7 @@ state.port = portArg ? parseInt(portArg.split('=')[1], 10) : await findOpenPort( // Annotation screenshots live in the project root so the agent's Read tool // doesn't trip a per-file permission prompt. Sessioned by token so concurrent // projects (or quick restarts) don't collide. -const annotRoot = path.join(process.cwd(), '.impeccable-live', 'annotations'); +const annotRoot = getLiveAnnotationsDir(process.cwd()); fs.mkdirSync(annotRoot, { recursive: true }); state.sessionDir = fs.mkdtempSync(path.join(annotRoot, 'session-')); @@ -815,7 +824,7 @@ const { detectScript, sessionPath, livePath } = loadBrowserScripts(); httpServer = http.createServer(createRequestHandler({ detectScript, sessionPath, livePath })); httpServer.listen(state.port, '127.0.0.1', () => { - fs.writeFileSync(LIVE_PID_FILE, JSON.stringify({ pid: process.pid, port: state.port, token: state.token })); + writeLiveServerInfo(process.cwd(), { pid: process.pid, port: state.port, token: state.token }); const url = `http://localhost:${state.port}`; console.log(`\nImpeccable live server running on ${url}`); console.log(`Token: ${state.token}\n`); diff --git a/.pi/skills/impeccable/scripts/live-session-store.mjs b/.pi/skills/impeccable/scripts/live-session-store.mjs index cc7744df..37711168 100644 --- a/.pi/skills/impeccable/scripts/live-session-store.mjs +++ b/.pi/skills/impeccable/scripts/live-session-store.mjs @@ -1,30 +1,43 @@ import fs from 'node:fs'; import path from 'node:path'; +import { getLegacyLiveSessionsDir, getLiveSessionsDir } from './impeccable-paths.mjs'; -const LIVE_DIR = '.impeccable-live'; -const SESSIONS_DIR = 'sessions'; const COMPLETED_PHASES = new Set(['completed', 'discarded']); export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {}) { - const rootDir = path.join(cwd, LIVE_DIR, SESSIONS_DIR); + 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 = getJournalPath(rootDir, id); + const journalPath = getReadableJournalPath(id); const rebuilt = rebuildSnapshotFromJournal(journalPath, id); snapshotCache.set(id, rebuilt); return rebuilt; } + function getReadableJournalPath(id) { + const primary = getJournalPath(rootDir, id); + if (fs.existsSync(primary)) return primary; + const legacy = getJournalPath(legacyRootDir, id); + if (fs.existsSync(legacy)) return legacy; + return primary; + } + return { rootDir, + legacyRootDir, appendEvent(event) { const normalized = normalizeEvent(event, sessionId); const journalPath = getJournalPath(rootDir, normalized.id); const snapshotPath = getSnapshotPath(rootDir, normalized.id); + const legacyJournalPath = getJournalPath(legacyRootDir, normalized.id); + if (!fs.existsSync(journalPath) && fs.existsSync(legacyJournalPath)) { + fs.copyFileSync(legacyJournalPath, journalPath); + } const prior = loadCachedOrRebuild(normalized.id); const seq = prior.nextSeq; const entry = { @@ -42,7 +55,7 @@ export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {}) }, getSnapshot(id = sessionId, opts = {}) { if (!id) throw new Error('session id required'); - const journalPath = getJournalPath(rootDir, id); + const journalPath = getReadableJournalPath(id); const snapshotPath = getSnapshotPath(rootDir, id); const rebuilt = rebuildSnapshotFromJournal(journalPath, id); snapshotCache.set(id, rebuilt); @@ -51,10 +64,15 @@ export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {}) return rebuilt.snapshot; }, listActiveSessions() { - if (!fs.existsSync(rootDir)) return []; - return fs.readdirSync(rootDir) - .filter((name) => name.endsWith('.jsonl')) - .map((name) => name.slice(0, -'.jsonl'.length)) + const ids = new Set(); + for (const dir of [legacyRootDir, rootDir]) { + if (!fs.existsSync(dir)) continue; + for (const name of fs.readdirSync(dir)) { + if (name.endsWith('.jsonl')) ids.add(name.slice(0, -'.jsonl'.length)); + } + } + return [...ids] + .sort() .map((id) => this.getSnapshot(id)) .filter(Boolean); }, diff --git a/.pi/skills/impeccable/scripts/live-status.mjs b/.pi/skills/impeccable/scripts/live-status.mjs index 1b85357c..dce1fbca 100644 --- a/.pi/skills/impeccable/scripts/live-status.mjs +++ b/.pi/skills/impeccable/scripts/live-status.mjs @@ -3,15 +3,11 @@ * Print durable recovery status for Impeccable live sessions. */ -import fs from 'node:fs'; -import path from 'node:path'; import { createLiveSessionStore } from './live-session-store.mjs'; - -const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); +import { readLiveServerInfo } from './impeccable-paths.mjs'; function readServerInfo() { - try { return JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); } - catch { return null; } + return readLiveServerInfo(process.cwd())?.info || null; } async function fetchServerStatus(info) { diff --git a/.pi/skills/impeccable/scripts/live.mjs b/.pi/skills/impeccable/scripts/live.mjs index befbdb8e..cafb0eca 100644 --- a/.pi/skills/impeccable/scripts/live.mjs +++ b/.pi/skills/impeccable/scripts/live.mjs @@ -2,10 +2,10 @@ * CLI entry point: prepare everything needed to enter the live variant poll loop. * * Does (all in one command): - * 1. Check config.json (returns config_missing if first-ever run) + * 1. Check .impeccable/live/config.json (returns config_missing if first-ever run) * 2. Start the live server in the background (or reuse a running one) * 3. Inject the browser script tag into the project's entry file - * 4. Read .impeccable.md for design context (if present) + * 4. Read PRODUCT.md / DESIGN.md for project context * 5. Print a single JSON blob with everything the agent needs * * After this, the agent's only remaining steps are: @@ -23,9 +23,9 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { loadContext } from './load-context.mjs'; import { resolveFiles } from './live-inject.mjs'; +import { readLiveServerInfo } from './impeccable-paths.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); async function liveCli() { const args = process.argv.slice(2); @@ -34,10 +34,10 @@ async function liveCli() { console.log(`Usage: node live.mjs Prepare everything for live variant mode in a single command: - - Checks scripts/config.json (required, created once per project) + - Checks .impeccable/live/config.json (required, created once per project) - Starts (or reuses) the live server in the background - Injects the browser script tag - - Reads .impeccable.md for design context + - Reads PRODUCT.md / DESIGN.md for project context On success, prints a JSON blob with: { ok, serverPort, serverToken, pageFile, hasContext, context } @@ -223,7 +223,7 @@ function safeParse(out) { function ensureServerRunning() { // Try to reuse an existing server try { - const existing = JSON.parse(fs.readFileSync(PID_FILE, 'utf-8')); + const existing = readLiveServerInfo(process.cwd())?.info; if (existing && existing.pid) { try { process.kill(existing.pid, 0); // throws if dead diff --git a/.pi/skills/impeccable/scripts/load-context.mjs b/.pi/skills/impeccable/scripts/load-context.mjs index dca23c1f..dc340bf1 100644 --- a/.pi/skills/impeccable/scripts/load-context.mjs +++ b/.pi/skills/impeccable/scripts/load-context.mjs @@ -39,7 +39,7 @@ const LEGACY_NAMES = ['.impeccable.md']; const FALLBACK_DIRS = ['.agents/context', 'docs']; /** - * Resolve the directory that holds PRODUCT.md / DESIGN.md / DESIGN.json for + * Resolve the directory that holds PRODUCT.md / DESIGN.md for * this project. Exported so other scripts (e.g. live-server.mjs) can read the * design files from the same location the loader uses. */ diff --git a/.qoder/skills/impeccable/reference/document.md b/.qoder/skills/impeccable/reference/document.md index 0e36d02d..2722dc87 100644 --- a/.qoder/skills/impeccable/reference/document.md +++ b/.qoder/skills/impeccable/reference/document.md @@ -237,11 +237,11 @@ Concrete, forceful guardrails. Lead each with "Do" or "Don't". Be specific: incl - **Don't** [...] ``` -### Step 4b: Write DESIGN.json sidecar (extensions only) +### Step 4b: Write .impeccable/design.json sidecar (extensions only) -The frontmatter owns token primitives (colors, typography, rounded, spacing, components). The sidecar at `DESIGN.json` carries **what Stitch's schema can't hold**: tonal ramps per color, shadow/elevation tokens, motion tokens, breakpoints, full component HTML/CSS snippets (the panel renders these into a shadow DOM), and narrative (north star, rules, do's/don'ts). It extends the frontmatter, it doesn't duplicate it. +The frontmatter owns token primitives (colors, typography, rounded, spacing, components). The sidecar at `.impeccable/design.json` carries **what Stitch's schema can't hold**: tonal ramps per color, shadow/elevation tokens, motion tokens, breakpoints, full component HTML/CSS snippets (the panel renders these into a shadow DOM), and narrative (north star, rules, do's/don'ts). It extends the frontmatter, it doesn't duplicate it. -Regenerate the sidecar whenever you regenerate DESIGN.md. If the user only asks to refresh the sidecar (e.g., from the live panel's stale-hint), preserve DESIGN.md and write only DESIGN.json. +Regenerate the sidecar whenever you regenerate root `DESIGN.md`. If the user only asks to refresh the sidecar (e.g., from the live panel's stale-hint), preserve `DESIGN.md` and write only `.impeccable/design.json`. #### Schema @@ -310,7 +310,7 @@ Aim for a tight set of **5-10 components** that best represent the visual system - **Signature components (include if distinctive):** hero CTA, featured card, filter pill, any custom pattern the user mentioned as important in PRODUCT.md. - **Skip the rest.** Utility components, form building blocks, wrapper layouts: not worth documenting unless visually distinctive. -If the project has **no component library yet** (bare landing page, new project), synthesize canonical primitives from the tokens using best-practice defaults consistent with the DESIGN.md's rules. Every DESIGN.json has *something* to render, even on day zero. +If the project has **no component library yet** (bare landing page, new project), synthesize canonical primitives from the tokens using best-practice defaults consistent with the DESIGN.md's rules. Every `.impeccable/design.json` has *something* to render, even on day zero. #### Tonal ramps @@ -331,7 +331,7 @@ Do not reword. The panel shows these as secondary collapsible context; the same ### Step 5: Confirm, refine, and refresh session cache 1. Show the user the full DESIGN.md you wrote. Briefly highlight the non-obvious creative choices (descriptive color names, atmosphere language, named rules). -2. Mention that `DESIGN.json` was also written alongside; the live panel will now render this project's actual button/input/nav primitives instead of generic approximations. +2. Mention that `.impeccable/design.json` was also written alongside; the live panel will now render this project's actual button/input/nav primitives instead of generic approximations. 3. Offer to refine any section: "Want me to revise a section, add component patterns I missed, or adjust the atmosphere language?" 4. **Refresh the session cache.** Run `node .qoder/skills/impeccable/scripts/load-context.mjs` one final time so the newly-written DESIGN.md lands in conversation. Subsequent commands in this session will use the fresh version automatically without re-reading. @@ -392,7 +392,7 @@ Per-section guidance in seed mode: - **Components**: omit entirely; no components exist yet. - **Do's and Don'ts**: carry PRODUCT.md's anti-references directly plus the anti-reference named in Q5. -Seed mode writes a minimal frontmatter with `name` and `description` only; no colors, typography, rounded, spacing, or components yet. Real tokens land on the next Scan-mode run. Skip the `DESIGN.json` sidecar in seed mode for the same reason: nothing to render. +Seed mode writes a minimal frontmatter with `name` and `description` only; no colors, typography, rounded, spacing, or components yet. Real tokens land on the next Scan-mode run. Skip the `.impeccable/design.json` sidecar in seed mode for the same reason: nothing to render. ### Step 4: Confirm and refresh session cache diff --git a/.qoder/skills/impeccable/reference/live.md b/.qoder/skills/impeccable/reference/live.md index 3202cc9c..83d65291 100644 --- a/.qoder/skills/impeccable/reference/live.md +++ b/.qoder/skills/impeccable/reference/live.md @@ -53,7 +53,7 @@ LOOP: ## Recovery commands -The live helper persists an append-only journal under `.impeccable-live/sessions`. Browser checkpoints are advisory but durable; the journal is canonical. +The live helper persists an append-only journal under `.impeccable/live/sessions/`. Browser checkpoints are advisory but durable; the journal is canonical. This is local durable recovery state, not project source. Use these commands when the chat was interrupted, polling was missed, the helper restarted, or the browser reloaded: @@ -473,7 +473,7 @@ When the poll returns `exit`, proceed to cleanup. If the poll is still running a node .qoder/skills/impeccable/scripts/live-server.mjs stop ``` -Stops the HTTP server and runs `live-inject.mjs --remove` to strip `localhost:…/live.js` from the HTML entry. To stop the server but keep the inject tag (for a quick restart), use `stop --keep-inject`. `config.json` persists for future sessions. +Stops the HTTP server and runs `live-inject.mjs --remove` to strip `localhost:…/live.js` from the HTML entry. To stop the server but keep the inject tag (for a quick restart), use `stop --keep-inject`. `.impeccable/live/config.json` persists as project config for future sessions. Then: - Remove any leftover variant wrappers (search for `impeccable-variants-start` markers). @@ -481,7 +481,7 @@ Then: ## First-time setup (config missing or invalid) -If `live.mjs` outputs `{ ok: false, error: "config_missing" | "config_invalid", path }`, write `config.json` at the reported path. +If `live.mjs` outputs `{ ok: false, error: "config_missing" | "config_invalid", path }`, write the live config at the reported path. By default this is `.impeccable/live/config.json`. Schema: @@ -561,7 +561,7 @@ node .qoder/skills/impeccable/scripts/detect-csp.mjs Output: `{ shape, signals }` where `shape` is one of `append-arrays`, `append-string`, `middleware`, `meta-tag`, or `null`. The shape is named by *patch mechanism*, so one template covers many frameworks. -- **`null`**: no CSP; skip to writing `config.json` with `cspChecked: true`. +- **`null`**: no CSP; skip to writing `.impeccable/live/config.json` with `cspChecked: true`. - **`append-arrays`**: CSP defined as structured directive arrays. Auto-patchable. See *append-arrays* below. Covers: - Monorepo helpers with `additionalScriptSrc` / `additionalConnectSrc` options (Next.js + shared config package) - SvelteKit `kit.csp.directives` @@ -638,6 +638,6 @@ Reference outputs: ### Troubleshooting -If a user says "no" to the CSP patch at setup time and later complains that live doesn't work: their dev CSP blocks `http://localhost:8400`. Fix: delete `cspChecked` from `config.json` and re-run `live.mjs`: setup will ask again. +If a user says "no" to the CSP patch at setup time and later complains that live doesn't work: their dev CSP blocks `http://localhost:8400`. Fix: delete `cspChecked` from `.impeccable/live/config.json` and re-run `live.mjs`: setup will ask again. Then re-run `live.mjs`. diff --git a/.qoder/skills/impeccable/reference/teach.md b/.qoder/skills/impeccable/reference/teach.md index e9f657e4..2fa563e9 100644 --- a/.qoder/skills/impeccable/reference/teach.md +++ b/.qoder/skills/impeccable/reference/teach.md @@ -2,8 +2,8 @@ Gathers design context for a project and writes two complementary files at the project root: -- **PRODUCT.md** (strategic): register, target users, product purpose, brand personality, anti-references, strategic design principles. Answers "who/what/why". -- **DESIGN.md** (visual): visual theme, color palette, typography, components, layout. Follows the [Google Stitch DESIGN.md format](https://stitch.withgoogle.com/docs/design-md/format/). Answers "how it looks". +- **PRODUCT.md** (strategic): root project file for register, target users, product purpose, brand personality, anti-references, strategic design principles. Answers "who/what/why". +- **DESIGN.md** (visual): root project file for visual theme, color palette, typography, components, layout. Follows the [Google Stitch DESIGN.md format](https://stitch.withgoogle.com/docs/design-md/format/). Answers "how it looks". Every other impeccable command reads these files before doing any work. diff --git a/.qoder/skills/impeccable/scripts/impeccable-paths.mjs b/.qoder/skills/impeccable/scripts/impeccable-paths.mjs new file mode 100644 index 00000000..ba852bae --- /dev/null +++ b/.qoder/skills/impeccable/scripts/impeccable-paths.mjs @@ -0,0 +1,105 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +export const IMPECCABLE_DIR = '.impeccable'; +export const LIVE_DIR = 'live'; + +export function getImpeccableDir(cwd = process.cwd()) { + return path.join(cwd, IMPECCABLE_DIR); +} + +export function getDesignSidecarPath(cwd = process.cwd()) { + return path.join(getImpeccableDir(cwd), 'design.json'); +} + +export function getDesignSidecarCandidates(cwd = process.cwd(), contextDir = cwd) { + const candidates = [ + getDesignSidecarPath(cwd), + path.join(cwd, 'DESIGN.json'), + ]; + const contextLegacy = path.join(contextDir, 'DESIGN.json'); + if (!candidates.includes(contextLegacy)) candidates.push(contextLegacy); + return candidates; +} + +export function resolveDesignSidecarPath(cwd = process.cwd(), contextDir = cwd) { + return firstExisting(getDesignSidecarCandidates(cwd, contextDir)); +} + +export function getLiveDir(cwd = process.cwd()) { + return path.join(getImpeccableDir(cwd), LIVE_DIR); +} + +export function getLiveConfigPath(cwd = process.cwd()) { + return path.join(getLiveDir(cwd), 'config.json'); +} + +export function getLegacyLiveConfigPath(scriptsDir) { + return path.join(scriptsDir, 'config.json'); +} + +export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = process.env } = {}) { + if (env.IMPECCABLE_LIVE_CONFIG && env.IMPECCABLE_LIVE_CONFIG.trim()) { + const configured = env.IMPECCABLE_LIVE_CONFIG.trim(); + return path.isAbsolute(configured) ? configured : path.resolve(cwd, configured); + } + const primary = getLiveConfigPath(cwd); + if (fs.existsSync(primary)) return primary; + if (scriptsDir) { + const legacy = getLegacyLiveConfigPath(scriptsDir); + if (fs.existsSync(legacy)) return legacy; + } + return primary; +} + +export function getLiveServerPath(cwd = process.cwd()) { + return path.join(getLiveDir(cwd), 'server.json'); +} + +export function getLegacyLiveServerPath(cwd = process.cwd()) { + return path.join(cwd, '.impeccable-live.json'); +} + +export function readLiveServerInfo(cwd = process.cwd()) { + for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) { + try { + return { info: JSON.parse(fs.readFileSync(filePath, 'utf-8')), path: filePath }; + } catch { + /* try next */ + } + } + return null; +} + +export function writeLiveServerInfo(cwd = process.cwd(), info) { + const filePath = getLiveServerPath(cwd); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, JSON.stringify(info)); + return filePath; +} + +export function removeLiveServerInfo(cwd = process.cwd()) { + for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) { + try { fs.unlinkSync(filePath); } catch {} + } +} + +export function getLiveSessionsDir(cwd = process.cwd()) { + return path.join(getLiveDir(cwd), 'sessions'); +} + +export function getLegacyLiveSessionsDir(cwd = process.cwd()) { + return path.join(cwd, '.impeccable-live', 'sessions'); +} + +export function getLiveAnnotationsDir(cwd = process.cwd()) { + return path.join(getLiveDir(cwd), 'annotations'); +} + +export function getLegacyLiveAnnotationsDir(cwd = process.cwd()) { + return path.join(cwd, '.impeccable-live', 'annotations'); +} + +function firstExisting(paths) { + return paths.find((filePath) => fs.existsSync(filePath)) || null; +} diff --git a/.qoder/skills/impeccable/scripts/live-browser.js b/.qoder/skills/impeccable/scripts/live-browser.js index 7f1ff329..bb5332a3 100644 --- a/.qoder/skills/impeccable/scripts/live-browser.js +++ b/.qoder/skills/impeccable/scripts/live-browser.js @@ -3671,7 +3671,7 @@ void main() { } // --------------------------------------------------------------------------- - // Design System Panel — visualizes the project's DESIGN.json sidecar + // Design System Panel — visualizes the project's .impeccable/design.json sidecar // --------------------------------------------------------------------------- const DESIGN_PREFS_KEY = 'impeccable-live-design-panel'; @@ -3683,7 +3683,7 @@ void main() { open: false, tab: 'visual', // 'visual' | 'raw' parsed: null, // parseDesignMd output (frontmatter + body sections) - sidecar: null, // DESIGN.json v2 payload (extensions + components + narrative) + sidecar: null, // .impeccable/design.json v2 payload (extensions + components + narrative) hasMd: false, hasSidecar: false, present: null, // true/false once fetch resolves @@ -4184,7 +4184,7 @@ void main() { box.className = 'stale'; box.innerHTML = ` - DESIGN.md is newer than DESIGN.json. Run /impeccable document to refresh the sidecar. + DESIGN.md is newer than .impeccable/design.json. Run /impeccable document to refresh the sidecar. `; return box; } @@ -4192,7 +4192,7 @@ void main() { function renderParsedMdCta() { const box = document.createElement('div'); box.className = 'parsed-md-cta'; - box.innerHTML = `Basic viewThis panel reads the tokens in your DESIGN.md frontmatter. Running /impeccable document also generates a DESIGN.json sidecar with your project's actual component snippets (button, input, nav) and tonal ramps, rendered live below the tokens.`; + box.innerHTML = `Basic viewThis panel reads the tokens in your DESIGN.md frontmatter. Running /impeccable document also generates a .impeccable/design.json sidecar with your project's actual component snippets (button, input, nav) and tonal ramps, rendered live below the tokens.`; return box; } @@ -4652,7 +4652,7 @@ void main() { function cssSafe(v) { // Strip anything outside valid CSS value chars to prevent injection via - // DESIGN.json values rendered into inline style strings. + // .impeccable/design.json values rendered into inline style strings. return String(v).replace(/[<>"'`\n]/g, ''); } diff --git a/.qoder/skills/impeccable/scripts/live-complete.mjs b/.qoder/skills/impeccable/scripts/live-complete.mjs index ca00d86a..78155af8 100644 --- a/.qoder/skills/impeccable/scripts/live-complete.mjs +++ b/.qoder/skills/impeccable/scripts/live-complete.mjs @@ -4,10 +4,7 @@ */ import { createLiveSessionStore } from './live-session-store.mjs'; -import fs from 'node:fs'; -import path from 'node:path'; - -const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); +import { readLiveServerInfo } from './impeccable-paths.mjs'; function parseArgs(argv) { const out = { status: 'complete' }; @@ -50,8 +47,7 @@ export async function completeCli() { } function readServerInfo() { - try { return JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); } - catch { return null; } + return readLiveServerInfo(process.cwd())?.info || null; } async function completeThroughServer(info, args) { diff --git a/.qoder/skills/impeccable/scripts/live-inject.mjs b/.qoder/skills/impeccable/scripts/live-inject.mjs index 1d2ae12f..555c3a36 100644 --- a/.qoder/skills/impeccable/scripts/live-inject.mjs +++ b/.qoder/skills/impeccable/scripts/live-inject.mjs @@ -2,23 +2,24 @@ * CLI helper: insert/remove the live variant mode script tag in the project's * main HTML entry point. * - * On first live run, the agent generates `config.json` in this script's - * directory with the project's insertion target (framework-specific). On + * On first live run, the agent generates `.impeccable/live/config.json` + * with the project's insertion target (framework-specific). On * every subsequent run, this script handles insert/remove deterministically * with zero LLM involvement. * * Usage: * node live-inject.mjs --port PORT # Insert the live script tag * node live-inject.mjs --remove # Remove the live script tag - * node live-inject.mjs --check # Check whether config.json exists + * node live-inject.mjs --check # Check whether live config exists */ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { resolveLiveConfigPath } from './impeccable-paths.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const CONFIG_PATH = process.env.IMPECCABLE_LIVE_CONFIG || path.join(__dirname, 'config.json'); +const CONFIG_PATH = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname }); const MARKER_OPEN_TEXT = 'impeccable-live-start'; const MARKER_CLOSE_TEXT = 'impeccable-live-end'; @@ -39,12 +40,12 @@ export async function injectCli() { console.log(`Usage: node live-inject.mjs [options] Insert or remove the live mode script tag in the project's HTML entry point. -Reads configuration from config.json (in this same directory). +Reads configuration from .impeccable/live/config.json. Modes: --port PORT Insert script tag pointing at http://localhost:PORT/live.js --remove Remove the script tag (if present) - --check Print whether config.json exists and its content + --check Print whether .impeccable/live/config.json exists and its content Output (JSON): { ok, file, inserted|removed, config? }`); diff --git a/.qoder/skills/impeccable/scripts/live-poll.mjs b/.qoder/skills/impeccable/scripts/live-poll.mjs index 9a3f07ae..83a9912e 100644 --- a/.qoder/skills/impeccable/scripts/live-poll.mjs +++ b/.qoder/skills/impeccable/scripts/live-poll.mjs @@ -9,11 +9,10 @@ */ import { execFileSync } from 'node:child_process'; -import fs from 'node:fs'; import path from 'node:path'; -import os from 'node:os'; import { fileURLToPath } from 'node:url'; import { completionTypeForAcceptResult } from './live-completion.mjs'; +import { readLiveServerInfo } from './impeccable-paths.mjs'; // Node's built-in fetch (undici under the hood) enforces a 300s headers // timeout that can't be lowered per-request. We cap each request below @@ -21,15 +20,13 @@ import { completionTypeForAcceptResult } from './live-completion.mjs'; // depending on the standalone undici package. const PER_REQUEST_TIMEOUT_MS = 270_000; -const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); - function readServerInfo() { - try { - return JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); - } catch { + const record = readLiveServerInfo(process.cwd()); + if (!record) { console.error('No running live server found. Start one with: npx impeccable live'); process.exit(1); } + return record.info; } export function buildPollReplyPayload(token, { id, type, message, file, data }) { diff --git a/.qoder/skills/impeccable/scripts/live-server.mjs b/.qoder/skills/impeccable/scripts/live-server.mjs index e78a0657..53d8f21c 100644 --- a/.qoder/skills/impeccable/scripts/live-server.mjs +++ b/.qoder/skills/impeccable/scripts/live-server.mjs @@ -23,14 +23,19 @@ import { fileURLToPath } from 'node:url'; import { parseDesignMd } from './design-parser.mjs'; import { resolveContextDir } from './load-context.mjs'; import { createLiveSessionStore } from './live-session-store.mjs'; +import { + getDesignSidecarPath, + getLiveAnnotationsDir, + readLiveServerInfo, + removeLiveServerInfo, + resolveDesignSidecarPath, + writeLiveServerInfo, +} from './impeccable-paths.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -// PID file in the project root so both the server and agent can find it -// predictably (os.tmpdir() varies across platforms). -const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); -// PRODUCT.md / DESIGN.md / DESIGN.json live wherever load-context.mjs resolves. -// Keeps live-server in sync with the loader when users keep the docs in -// .agents/context/, docs/, or a path set via IMPECCABLE_CONTEXT_DIR. +// PRODUCT.md / DESIGN.md live wherever load-context.mjs resolves. The generated +// DESIGN sidecar is project-local at .impeccable/design.json, with legacy +// DESIGN.json fallback for existing projects. const CONTEXT_DIR = resolveContextDir(process.cwd()); const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s @@ -411,13 +416,13 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { } // --- Design system (unified v2 response) + raw --- - // /design-system.json returns both parsed DESIGN.md and DESIGN.json + // /design-system.json returns both parsed DESIGN.md and .impeccable/design.json // sidecar when present. Panel merges them: // { present, parsed, sidecar, hasMd, hasSidecar, // mdNewerThanJson, parseError?, sidecarError? } // - parsed: output of parseDesignMd (frontmatter // + six canonical sections) when DESIGN.md exists. - // - sidecar: DESIGN.json contents when present. + // - sidecar: .impeccable/design.json contents when present. // Expected shape: schemaVersion 2, carrying // extensions + components + narrative. // /design-system/raw returns DESIGN.md markdown verbatim @@ -426,7 +431,7 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } const mdPath = path.join(CONTEXT_DIR, 'DESIGN.md'); - const jsonPath = path.join(CONTEXT_DIR, 'DESIGN.json'); + const jsonPath = resolveDesignSidecarPath(process.cwd(), CONTEXT_DIR) || getDesignSidecarPath(process.cwd()); const mdStat = statOrNull(mdPath); const jsonStat = statOrNull(jsonPath); @@ -462,7 +467,7 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { try { response.sidecar = JSON.parse(fs.readFileSync(jsonPath, 'utf-8')); } catch (err) { - response.sidecarError = 'Failed to parse DESIGN.json: ' + err.message; + response.sidecarError = 'Failed to parse .impeccable/design.json: ' + err.message; } } @@ -673,7 +678,7 @@ function handlePollPost(req, res) { let httpServer = null; function shutdown() { - try { fs.unlinkSync(LIVE_PID_FILE); } catch {} + removeLiveServerInfo(process.cwd()); if (state.leaseTimer) clearTimeout(state.leaseTimer); state.leaseTimer = null; if (state.sessionDir) { @@ -725,7 +730,7 @@ Endpoints: if (args.includes('stop')) { const keepInject = args.includes('--keep-inject'); try { - const info = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); + const { info } = readLiveServerInfo(process.cwd()) || {}; const res = await fetch(`http://localhost:${info.port}/stop?token=${info.token}`); if (res.ok) console.log(`Stopped live server on port ${info.port}.`); } catch { @@ -776,7 +781,7 @@ if (args.includes('--background')) { const deadline = Date.now() + 10_000; while (Date.now() < deadline) { try { - const info = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); + const { info } = readLiveServerInfo(process.cwd()) || {}; if (info.pid !== process.pid) { // Output JSON so the agent can read port + token from stdout. console.log(JSON.stringify(info)); @@ -790,14 +795,18 @@ if (args.includes('--background')) { } // Check for existing session -try { - const existing = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); - try { process.kill(existing.pid, 0); +const existingRecord = readLiveServerInfo(process.cwd()); +if (existingRecord?.info) { + const existing = existingRecord.info; + try { + process.kill(existing.pid, 0); console.error(`Live server already running on port ${existing.port} (pid ${existing.pid}).`); console.error('Stop it first with: node ' + path.basename(fileURLToPath(import.meta.url)) + ' stop'); process.exit(1); - } catch { fs.unlinkSync(LIVE_PID_FILE); } -} catch {} + } catch { + try { fs.unlinkSync(existingRecord.path); } catch {} + } +} state.token = randomUUID(); state.sessionStore = createLiveSessionStore({ cwd: process.cwd() }); @@ -807,7 +816,7 @@ state.port = portArg ? parseInt(portArg.split('=')[1], 10) : await findOpenPort( // Annotation screenshots live in the project root so the agent's Read tool // doesn't trip a per-file permission prompt. Sessioned by token so concurrent // projects (or quick restarts) don't collide. -const annotRoot = path.join(process.cwd(), '.impeccable-live', 'annotations'); +const annotRoot = getLiveAnnotationsDir(process.cwd()); fs.mkdirSync(annotRoot, { recursive: true }); state.sessionDir = fs.mkdtempSync(path.join(annotRoot, 'session-')); @@ -815,7 +824,7 @@ const { detectScript, sessionPath, livePath } = loadBrowserScripts(); httpServer = http.createServer(createRequestHandler({ detectScript, sessionPath, livePath })); httpServer.listen(state.port, '127.0.0.1', () => { - fs.writeFileSync(LIVE_PID_FILE, JSON.stringify({ pid: process.pid, port: state.port, token: state.token })); + writeLiveServerInfo(process.cwd(), { pid: process.pid, port: state.port, token: state.token }); const url = `http://localhost:${state.port}`; console.log(`\nImpeccable live server running on ${url}`); console.log(`Token: ${state.token}\n`); diff --git a/.qoder/skills/impeccable/scripts/live-session-store.mjs b/.qoder/skills/impeccable/scripts/live-session-store.mjs index cc7744df..37711168 100644 --- a/.qoder/skills/impeccable/scripts/live-session-store.mjs +++ b/.qoder/skills/impeccable/scripts/live-session-store.mjs @@ -1,30 +1,43 @@ import fs from 'node:fs'; import path from 'node:path'; +import { getLegacyLiveSessionsDir, getLiveSessionsDir } from './impeccable-paths.mjs'; -const LIVE_DIR = '.impeccable-live'; -const SESSIONS_DIR = 'sessions'; const COMPLETED_PHASES = new Set(['completed', 'discarded']); export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {}) { - const rootDir = path.join(cwd, LIVE_DIR, SESSIONS_DIR); + 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 = getJournalPath(rootDir, id); + const journalPath = getReadableJournalPath(id); const rebuilt = rebuildSnapshotFromJournal(journalPath, id); snapshotCache.set(id, rebuilt); return rebuilt; } + function getReadableJournalPath(id) { + const primary = getJournalPath(rootDir, id); + if (fs.existsSync(primary)) return primary; + const legacy = getJournalPath(legacyRootDir, id); + if (fs.existsSync(legacy)) return legacy; + return primary; + } + return { rootDir, + legacyRootDir, appendEvent(event) { const normalized = normalizeEvent(event, sessionId); const journalPath = getJournalPath(rootDir, normalized.id); const snapshotPath = getSnapshotPath(rootDir, normalized.id); + const legacyJournalPath = getJournalPath(legacyRootDir, normalized.id); + if (!fs.existsSync(journalPath) && fs.existsSync(legacyJournalPath)) { + fs.copyFileSync(legacyJournalPath, journalPath); + } const prior = loadCachedOrRebuild(normalized.id); const seq = prior.nextSeq; const entry = { @@ -42,7 +55,7 @@ export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {}) }, getSnapshot(id = sessionId, opts = {}) { if (!id) throw new Error('session id required'); - const journalPath = getJournalPath(rootDir, id); + const journalPath = getReadableJournalPath(id); const snapshotPath = getSnapshotPath(rootDir, id); const rebuilt = rebuildSnapshotFromJournal(journalPath, id); snapshotCache.set(id, rebuilt); @@ -51,10 +64,15 @@ export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {}) return rebuilt.snapshot; }, listActiveSessions() { - if (!fs.existsSync(rootDir)) return []; - return fs.readdirSync(rootDir) - .filter((name) => name.endsWith('.jsonl')) - .map((name) => name.slice(0, -'.jsonl'.length)) + const ids = new Set(); + for (const dir of [legacyRootDir, rootDir]) { + if (!fs.existsSync(dir)) continue; + for (const name of fs.readdirSync(dir)) { + if (name.endsWith('.jsonl')) ids.add(name.slice(0, -'.jsonl'.length)); + } + } + return [...ids] + .sort() .map((id) => this.getSnapshot(id)) .filter(Boolean); }, diff --git a/.qoder/skills/impeccable/scripts/live-status.mjs b/.qoder/skills/impeccable/scripts/live-status.mjs index 1b85357c..dce1fbca 100644 --- a/.qoder/skills/impeccable/scripts/live-status.mjs +++ b/.qoder/skills/impeccable/scripts/live-status.mjs @@ -3,15 +3,11 @@ * Print durable recovery status for Impeccable live sessions. */ -import fs from 'node:fs'; -import path from 'node:path'; import { createLiveSessionStore } from './live-session-store.mjs'; - -const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); +import { readLiveServerInfo } from './impeccable-paths.mjs'; function readServerInfo() { - try { return JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); } - catch { return null; } + return readLiveServerInfo(process.cwd())?.info || null; } async function fetchServerStatus(info) { diff --git a/.qoder/skills/impeccable/scripts/live.mjs b/.qoder/skills/impeccable/scripts/live.mjs index befbdb8e..cafb0eca 100644 --- a/.qoder/skills/impeccable/scripts/live.mjs +++ b/.qoder/skills/impeccable/scripts/live.mjs @@ -2,10 +2,10 @@ * CLI entry point: prepare everything needed to enter the live variant poll loop. * * Does (all in one command): - * 1. Check config.json (returns config_missing if first-ever run) + * 1. Check .impeccable/live/config.json (returns config_missing if first-ever run) * 2. Start the live server in the background (or reuse a running one) * 3. Inject the browser script tag into the project's entry file - * 4. Read .impeccable.md for design context (if present) + * 4. Read PRODUCT.md / DESIGN.md for project context * 5. Print a single JSON blob with everything the agent needs * * After this, the agent's only remaining steps are: @@ -23,9 +23,9 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { loadContext } from './load-context.mjs'; import { resolveFiles } from './live-inject.mjs'; +import { readLiveServerInfo } from './impeccable-paths.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); async function liveCli() { const args = process.argv.slice(2); @@ -34,10 +34,10 @@ async function liveCli() { console.log(`Usage: node live.mjs Prepare everything for live variant mode in a single command: - - Checks scripts/config.json (required, created once per project) + - Checks .impeccable/live/config.json (required, created once per project) - Starts (or reuses) the live server in the background - Injects the browser script tag - - Reads .impeccable.md for design context + - Reads PRODUCT.md / DESIGN.md for project context On success, prints a JSON blob with: { ok, serverPort, serverToken, pageFile, hasContext, context } @@ -223,7 +223,7 @@ function safeParse(out) { function ensureServerRunning() { // Try to reuse an existing server try { - const existing = JSON.parse(fs.readFileSync(PID_FILE, 'utf-8')); + const existing = readLiveServerInfo(process.cwd())?.info; if (existing && existing.pid) { try { process.kill(existing.pid, 0); // throws if dead diff --git a/.qoder/skills/impeccable/scripts/load-context.mjs b/.qoder/skills/impeccable/scripts/load-context.mjs index dca23c1f..dc340bf1 100644 --- a/.qoder/skills/impeccable/scripts/load-context.mjs +++ b/.qoder/skills/impeccable/scripts/load-context.mjs @@ -39,7 +39,7 @@ const LEGACY_NAMES = ['.impeccable.md']; const FALLBACK_DIRS = ['.agents/context', 'docs']; /** - * Resolve the directory that holds PRODUCT.md / DESIGN.md / DESIGN.json for + * Resolve the directory that holds PRODUCT.md / DESIGN.md for * this project. Exported so other scripts (e.g. live-server.mjs) can read the * design files from the same location the loader uses. */ diff --git a/.rovodev/skills/impeccable/reference/document.md b/.rovodev/skills/impeccable/reference/document.md index 9c7a339f..16111d61 100644 --- a/.rovodev/skills/impeccable/reference/document.md +++ b/.rovodev/skills/impeccable/reference/document.md @@ -237,11 +237,11 @@ Concrete, forceful guardrails. Lead each with "Do" or "Don't". Be specific: incl - **Don't** [...] ``` -### Step 4b: Write DESIGN.json sidecar (extensions only) +### Step 4b: Write .impeccable/design.json sidecar (extensions only) -The frontmatter owns token primitives (colors, typography, rounded, spacing, components). The sidecar at `DESIGN.json` carries **what Stitch's schema can't hold**: tonal ramps per color, shadow/elevation tokens, motion tokens, breakpoints, full component HTML/CSS snippets (the panel renders these into a shadow DOM), and narrative (north star, rules, do's/don'ts). It extends the frontmatter, it doesn't duplicate it. +The frontmatter owns token primitives (colors, typography, rounded, spacing, components). The sidecar at `.impeccable/design.json` carries **what Stitch's schema can't hold**: tonal ramps per color, shadow/elevation tokens, motion tokens, breakpoints, full component HTML/CSS snippets (the panel renders these into a shadow DOM), and narrative (north star, rules, do's/don'ts). It extends the frontmatter, it doesn't duplicate it. -Regenerate the sidecar whenever you regenerate DESIGN.md. If the user only asks to refresh the sidecar (e.g., from the live panel's stale-hint), preserve DESIGN.md and write only DESIGN.json. +Regenerate the sidecar whenever you regenerate root `DESIGN.md`. If the user only asks to refresh the sidecar (e.g., from the live panel's stale-hint), preserve `DESIGN.md` and write only `.impeccable/design.json`. #### Schema @@ -310,7 +310,7 @@ Aim for a tight set of **5-10 components** that best represent the visual system - **Signature components (include if distinctive):** hero CTA, featured card, filter pill, any custom pattern the user mentioned as important in PRODUCT.md. - **Skip the rest.** Utility components, form building blocks, wrapper layouts: not worth documenting unless visually distinctive. -If the project has **no component library yet** (bare landing page, new project), synthesize canonical primitives from the tokens using best-practice defaults consistent with the DESIGN.md's rules. Every DESIGN.json has *something* to render, even on day zero. +If the project has **no component library yet** (bare landing page, new project), synthesize canonical primitives from the tokens using best-practice defaults consistent with the DESIGN.md's rules. Every `.impeccable/design.json` has *something* to render, even on day zero. #### Tonal ramps @@ -331,7 +331,7 @@ Do not reword. The panel shows these as secondary collapsible context; the same ### Step 5: Confirm, refine, and refresh session cache 1. Show the user the full DESIGN.md you wrote. Briefly highlight the non-obvious creative choices (descriptive color names, atmosphere language, named rules). -2. Mention that `DESIGN.json` was also written alongside; the live panel will now render this project's actual button/input/nav primitives instead of generic approximations. +2. Mention that `.impeccable/design.json` was also written alongside; the live panel will now render this project's actual button/input/nav primitives instead of generic approximations. 3. Offer to refine any section: "Want me to revise a section, add component patterns I missed, or adjust the atmosphere language?" 4. **Refresh the session cache.** Run `node .rovodev/skills/impeccable/scripts/load-context.mjs` one final time so the newly-written DESIGN.md lands in conversation. Subsequent commands in this session will use the fresh version automatically without re-reading. @@ -392,7 +392,7 @@ Per-section guidance in seed mode: - **Components**: omit entirely; no components exist yet. - **Do's and Don'ts**: carry PRODUCT.md's anti-references directly plus the anti-reference named in Q5. -Seed mode writes a minimal frontmatter with `name` and `description` only; no colors, typography, rounded, spacing, or components yet. Real tokens land on the next Scan-mode run. Skip the `DESIGN.json` sidecar in seed mode for the same reason: nothing to render. +Seed mode writes a minimal frontmatter with `name` and `description` only; no colors, typography, rounded, spacing, or components yet. Real tokens land on the next Scan-mode run. Skip the `.impeccable/design.json` sidecar in seed mode for the same reason: nothing to render. ### Step 4: Confirm and refresh session cache diff --git a/.rovodev/skills/impeccable/reference/live.md b/.rovodev/skills/impeccable/reference/live.md index 75e97d03..25e9ad5c 100644 --- a/.rovodev/skills/impeccable/reference/live.md +++ b/.rovodev/skills/impeccable/reference/live.md @@ -53,7 +53,7 @@ LOOP: ## Recovery commands -The live helper persists an append-only journal under `.impeccable-live/sessions`. Browser checkpoints are advisory but durable; the journal is canonical. +The live helper persists an append-only journal under `.impeccable/live/sessions/`. Browser checkpoints are advisory but durable; the journal is canonical. This is local durable recovery state, not project source. Use these commands when the chat was interrupted, polling was missed, the helper restarted, or the browser reloaded: @@ -473,7 +473,7 @@ When the poll returns `exit`, proceed to cleanup. If the poll is still running a node .rovodev/skills/impeccable/scripts/live-server.mjs stop ``` -Stops the HTTP server and runs `live-inject.mjs --remove` to strip `localhost:…/live.js` from the HTML entry. To stop the server but keep the inject tag (for a quick restart), use `stop --keep-inject`. `config.json` persists for future sessions. +Stops the HTTP server and runs `live-inject.mjs --remove` to strip `localhost:…/live.js` from the HTML entry. To stop the server but keep the inject tag (for a quick restart), use `stop --keep-inject`. `.impeccable/live/config.json` persists as project config for future sessions. Then: - Remove any leftover variant wrappers (search for `impeccable-variants-start` markers). @@ -481,7 +481,7 @@ Then: ## First-time setup (config missing or invalid) -If `live.mjs` outputs `{ ok: false, error: "config_missing" | "config_invalid", path }`, write `config.json` at the reported path. +If `live.mjs` outputs `{ ok: false, error: "config_missing" | "config_invalid", path }`, write the live config at the reported path. By default this is `.impeccable/live/config.json`. Schema: @@ -561,7 +561,7 @@ node .rovodev/skills/impeccable/scripts/detect-csp.mjs Output: `{ shape, signals }` where `shape` is one of `append-arrays`, `append-string`, `middleware`, `meta-tag`, or `null`. The shape is named by *patch mechanism*, so one template covers many frameworks. -- **`null`**: no CSP; skip to writing `config.json` with `cspChecked: true`. +- **`null`**: no CSP; skip to writing `.impeccable/live/config.json` with `cspChecked: true`. - **`append-arrays`**: CSP defined as structured directive arrays. Auto-patchable. See *append-arrays* below. Covers: - Monorepo helpers with `additionalScriptSrc` / `additionalConnectSrc` options (Next.js + shared config package) - SvelteKit `kit.csp.directives` @@ -638,6 +638,6 @@ Reference outputs: ### Troubleshooting -If a user says "no" to the CSP patch at setup time and later complains that live doesn't work: their dev CSP blocks `http://localhost:8400`. Fix: delete `cspChecked` from `config.json` and re-run `live.mjs`: setup will ask again. +If a user says "no" to the CSP patch at setup time and later complains that live doesn't work: their dev CSP blocks `http://localhost:8400`. Fix: delete `cspChecked` from `.impeccable/live/config.json` and re-run `live.mjs`: setup will ask again. Then re-run `live.mjs`. diff --git a/.rovodev/skills/impeccable/reference/teach.md b/.rovodev/skills/impeccable/reference/teach.md index bca7c3f3..4b45f28d 100644 --- a/.rovodev/skills/impeccable/reference/teach.md +++ b/.rovodev/skills/impeccable/reference/teach.md @@ -2,8 +2,8 @@ Gathers design context for a project and writes two complementary files at the project root: -- **PRODUCT.md** (strategic): register, target users, product purpose, brand personality, anti-references, strategic design principles. Answers "who/what/why". -- **DESIGN.md** (visual): visual theme, color palette, typography, components, layout. Follows the [Google Stitch DESIGN.md format](https://stitch.withgoogle.com/docs/design-md/format/). Answers "how it looks". +- **PRODUCT.md** (strategic): root project file for register, target users, product purpose, brand personality, anti-references, strategic design principles. Answers "who/what/why". +- **DESIGN.md** (visual): root project file for visual theme, color palette, typography, components, layout. Follows the [Google Stitch DESIGN.md format](https://stitch.withgoogle.com/docs/design-md/format/). Answers "how it looks". Every other impeccable command reads these files before doing any work. diff --git a/.rovodev/skills/impeccable/scripts/impeccable-paths.mjs b/.rovodev/skills/impeccable/scripts/impeccable-paths.mjs new file mode 100644 index 00000000..ba852bae --- /dev/null +++ b/.rovodev/skills/impeccable/scripts/impeccable-paths.mjs @@ -0,0 +1,105 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +export const IMPECCABLE_DIR = '.impeccable'; +export const LIVE_DIR = 'live'; + +export function getImpeccableDir(cwd = process.cwd()) { + return path.join(cwd, IMPECCABLE_DIR); +} + +export function getDesignSidecarPath(cwd = process.cwd()) { + return path.join(getImpeccableDir(cwd), 'design.json'); +} + +export function getDesignSidecarCandidates(cwd = process.cwd(), contextDir = cwd) { + const candidates = [ + getDesignSidecarPath(cwd), + path.join(cwd, 'DESIGN.json'), + ]; + const contextLegacy = path.join(contextDir, 'DESIGN.json'); + if (!candidates.includes(contextLegacy)) candidates.push(contextLegacy); + return candidates; +} + +export function resolveDesignSidecarPath(cwd = process.cwd(), contextDir = cwd) { + return firstExisting(getDesignSidecarCandidates(cwd, contextDir)); +} + +export function getLiveDir(cwd = process.cwd()) { + return path.join(getImpeccableDir(cwd), LIVE_DIR); +} + +export function getLiveConfigPath(cwd = process.cwd()) { + return path.join(getLiveDir(cwd), 'config.json'); +} + +export function getLegacyLiveConfigPath(scriptsDir) { + return path.join(scriptsDir, 'config.json'); +} + +export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = process.env } = {}) { + if (env.IMPECCABLE_LIVE_CONFIG && env.IMPECCABLE_LIVE_CONFIG.trim()) { + const configured = env.IMPECCABLE_LIVE_CONFIG.trim(); + return path.isAbsolute(configured) ? configured : path.resolve(cwd, configured); + } + const primary = getLiveConfigPath(cwd); + if (fs.existsSync(primary)) return primary; + if (scriptsDir) { + const legacy = getLegacyLiveConfigPath(scriptsDir); + if (fs.existsSync(legacy)) return legacy; + } + return primary; +} + +export function getLiveServerPath(cwd = process.cwd()) { + return path.join(getLiveDir(cwd), 'server.json'); +} + +export function getLegacyLiveServerPath(cwd = process.cwd()) { + return path.join(cwd, '.impeccable-live.json'); +} + +export function readLiveServerInfo(cwd = process.cwd()) { + for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) { + try { + return { info: JSON.parse(fs.readFileSync(filePath, 'utf-8')), path: filePath }; + } catch { + /* try next */ + } + } + return null; +} + +export function writeLiveServerInfo(cwd = process.cwd(), info) { + const filePath = getLiveServerPath(cwd); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, JSON.stringify(info)); + return filePath; +} + +export function removeLiveServerInfo(cwd = process.cwd()) { + for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) { + try { fs.unlinkSync(filePath); } catch {} + } +} + +export function getLiveSessionsDir(cwd = process.cwd()) { + return path.join(getLiveDir(cwd), 'sessions'); +} + +export function getLegacyLiveSessionsDir(cwd = process.cwd()) { + return path.join(cwd, '.impeccable-live', 'sessions'); +} + +export function getLiveAnnotationsDir(cwd = process.cwd()) { + return path.join(getLiveDir(cwd), 'annotations'); +} + +export function getLegacyLiveAnnotationsDir(cwd = process.cwd()) { + return path.join(cwd, '.impeccable-live', 'annotations'); +} + +function firstExisting(paths) { + return paths.find((filePath) => fs.existsSync(filePath)) || null; +} diff --git a/.rovodev/skills/impeccable/scripts/live-browser.js b/.rovodev/skills/impeccable/scripts/live-browser.js index 7f1ff329..bb5332a3 100644 --- a/.rovodev/skills/impeccable/scripts/live-browser.js +++ b/.rovodev/skills/impeccable/scripts/live-browser.js @@ -3671,7 +3671,7 @@ void main() { } // --------------------------------------------------------------------------- - // Design System Panel — visualizes the project's DESIGN.json sidecar + // Design System Panel — visualizes the project's .impeccable/design.json sidecar // --------------------------------------------------------------------------- const DESIGN_PREFS_KEY = 'impeccable-live-design-panel'; @@ -3683,7 +3683,7 @@ void main() { open: false, tab: 'visual', // 'visual' | 'raw' parsed: null, // parseDesignMd output (frontmatter + body sections) - sidecar: null, // DESIGN.json v2 payload (extensions + components + narrative) + sidecar: null, // .impeccable/design.json v2 payload (extensions + components + narrative) hasMd: false, hasSidecar: false, present: null, // true/false once fetch resolves @@ -4184,7 +4184,7 @@ void main() { box.className = 'stale'; box.innerHTML = ` - DESIGN.md is newer than DESIGN.json. Run /impeccable document to refresh the sidecar. + DESIGN.md is newer than .impeccable/design.json. Run /impeccable document to refresh the sidecar. `; return box; } @@ -4192,7 +4192,7 @@ void main() { function renderParsedMdCta() { const box = document.createElement('div'); box.className = 'parsed-md-cta'; - box.innerHTML = `Basic viewThis panel reads the tokens in your DESIGN.md frontmatter. Running /impeccable document also generates a DESIGN.json sidecar with your project's actual component snippets (button, input, nav) and tonal ramps, rendered live below the tokens.`; + box.innerHTML = `Basic viewThis panel reads the tokens in your DESIGN.md frontmatter. Running /impeccable document also generates a .impeccable/design.json sidecar with your project's actual component snippets (button, input, nav) and tonal ramps, rendered live below the tokens.`; return box; } @@ -4652,7 +4652,7 @@ void main() { function cssSafe(v) { // Strip anything outside valid CSS value chars to prevent injection via - // DESIGN.json values rendered into inline style strings. + // .impeccable/design.json values rendered into inline style strings. return String(v).replace(/[<>"'`\n]/g, ''); } diff --git a/.rovodev/skills/impeccable/scripts/live-complete.mjs b/.rovodev/skills/impeccable/scripts/live-complete.mjs index ca00d86a..78155af8 100644 --- a/.rovodev/skills/impeccable/scripts/live-complete.mjs +++ b/.rovodev/skills/impeccable/scripts/live-complete.mjs @@ -4,10 +4,7 @@ */ import { createLiveSessionStore } from './live-session-store.mjs'; -import fs from 'node:fs'; -import path from 'node:path'; - -const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); +import { readLiveServerInfo } from './impeccable-paths.mjs'; function parseArgs(argv) { const out = { status: 'complete' }; @@ -50,8 +47,7 @@ export async function completeCli() { } function readServerInfo() { - try { return JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); } - catch { return null; } + return readLiveServerInfo(process.cwd())?.info || null; } async function completeThroughServer(info, args) { diff --git a/.rovodev/skills/impeccable/scripts/live-inject.mjs b/.rovodev/skills/impeccable/scripts/live-inject.mjs index 1d2ae12f..555c3a36 100644 --- a/.rovodev/skills/impeccable/scripts/live-inject.mjs +++ b/.rovodev/skills/impeccable/scripts/live-inject.mjs @@ -2,23 +2,24 @@ * CLI helper: insert/remove the live variant mode script tag in the project's * main HTML entry point. * - * On first live run, the agent generates `config.json` in this script's - * directory with the project's insertion target (framework-specific). On + * On first live run, the agent generates `.impeccable/live/config.json` + * with the project's insertion target (framework-specific). On * every subsequent run, this script handles insert/remove deterministically * with zero LLM involvement. * * Usage: * node live-inject.mjs --port PORT # Insert the live script tag * node live-inject.mjs --remove # Remove the live script tag - * node live-inject.mjs --check # Check whether config.json exists + * node live-inject.mjs --check # Check whether live config exists */ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { resolveLiveConfigPath } from './impeccable-paths.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const CONFIG_PATH = process.env.IMPECCABLE_LIVE_CONFIG || path.join(__dirname, 'config.json'); +const CONFIG_PATH = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname }); const MARKER_OPEN_TEXT = 'impeccable-live-start'; const MARKER_CLOSE_TEXT = 'impeccable-live-end'; @@ -39,12 +40,12 @@ export async function injectCli() { console.log(`Usage: node live-inject.mjs [options] Insert or remove the live mode script tag in the project's HTML entry point. -Reads configuration from config.json (in this same directory). +Reads configuration from .impeccable/live/config.json. Modes: --port PORT Insert script tag pointing at http://localhost:PORT/live.js --remove Remove the script tag (if present) - --check Print whether config.json exists and its content + --check Print whether .impeccable/live/config.json exists and its content Output (JSON): { ok, file, inserted|removed, config? }`); diff --git a/.rovodev/skills/impeccable/scripts/live-poll.mjs b/.rovodev/skills/impeccable/scripts/live-poll.mjs index 9a3f07ae..83a9912e 100644 --- a/.rovodev/skills/impeccable/scripts/live-poll.mjs +++ b/.rovodev/skills/impeccable/scripts/live-poll.mjs @@ -9,11 +9,10 @@ */ import { execFileSync } from 'node:child_process'; -import fs from 'node:fs'; import path from 'node:path'; -import os from 'node:os'; import { fileURLToPath } from 'node:url'; import { completionTypeForAcceptResult } from './live-completion.mjs'; +import { readLiveServerInfo } from './impeccable-paths.mjs'; // Node's built-in fetch (undici under the hood) enforces a 300s headers // timeout that can't be lowered per-request. We cap each request below @@ -21,15 +20,13 @@ import { completionTypeForAcceptResult } from './live-completion.mjs'; // depending on the standalone undici package. const PER_REQUEST_TIMEOUT_MS = 270_000; -const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); - function readServerInfo() { - try { - return JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); - } catch { + const record = readLiveServerInfo(process.cwd()); + if (!record) { console.error('No running live server found. Start one with: npx impeccable live'); process.exit(1); } + return record.info; } export function buildPollReplyPayload(token, { id, type, message, file, data }) { diff --git a/.rovodev/skills/impeccable/scripts/live-server.mjs b/.rovodev/skills/impeccable/scripts/live-server.mjs index e78a0657..53d8f21c 100644 --- a/.rovodev/skills/impeccable/scripts/live-server.mjs +++ b/.rovodev/skills/impeccable/scripts/live-server.mjs @@ -23,14 +23,19 @@ import { fileURLToPath } from 'node:url'; import { parseDesignMd } from './design-parser.mjs'; import { resolveContextDir } from './load-context.mjs'; import { createLiveSessionStore } from './live-session-store.mjs'; +import { + getDesignSidecarPath, + getLiveAnnotationsDir, + readLiveServerInfo, + removeLiveServerInfo, + resolveDesignSidecarPath, + writeLiveServerInfo, +} from './impeccable-paths.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -// PID file in the project root so both the server and agent can find it -// predictably (os.tmpdir() varies across platforms). -const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); -// PRODUCT.md / DESIGN.md / DESIGN.json live wherever load-context.mjs resolves. -// Keeps live-server in sync with the loader when users keep the docs in -// .agents/context/, docs/, or a path set via IMPECCABLE_CONTEXT_DIR. +// PRODUCT.md / DESIGN.md live wherever load-context.mjs resolves. The generated +// DESIGN sidecar is project-local at .impeccable/design.json, with legacy +// DESIGN.json fallback for existing projects. const CONTEXT_DIR = resolveContextDir(process.cwd()); const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s @@ -411,13 +416,13 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { } // --- Design system (unified v2 response) + raw --- - // /design-system.json returns both parsed DESIGN.md and DESIGN.json + // /design-system.json returns both parsed DESIGN.md and .impeccable/design.json // sidecar when present. Panel merges them: // { present, parsed, sidecar, hasMd, hasSidecar, // mdNewerThanJson, parseError?, sidecarError? } // - parsed: output of parseDesignMd (frontmatter // + six canonical sections) when DESIGN.md exists. - // - sidecar: DESIGN.json contents when present. + // - sidecar: .impeccable/design.json contents when present. // Expected shape: schemaVersion 2, carrying // extensions + components + narrative. // /design-system/raw returns DESIGN.md markdown verbatim @@ -426,7 +431,7 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } const mdPath = path.join(CONTEXT_DIR, 'DESIGN.md'); - const jsonPath = path.join(CONTEXT_DIR, 'DESIGN.json'); + const jsonPath = resolveDesignSidecarPath(process.cwd(), CONTEXT_DIR) || getDesignSidecarPath(process.cwd()); const mdStat = statOrNull(mdPath); const jsonStat = statOrNull(jsonPath); @@ -462,7 +467,7 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { try { response.sidecar = JSON.parse(fs.readFileSync(jsonPath, 'utf-8')); } catch (err) { - response.sidecarError = 'Failed to parse DESIGN.json: ' + err.message; + response.sidecarError = 'Failed to parse .impeccable/design.json: ' + err.message; } } @@ -673,7 +678,7 @@ function handlePollPost(req, res) { let httpServer = null; function shutdown() { - try { fs.unlinkSync(LIVE_PID_FILE); } catch {} + removeLiveServerInfo(process.cwd()); if (state.leaseTimer) clearTimeout(state.leaseTimer); state.leaseTimer = null; if (state.sessionDir) { @@ -725,7 +730,7 @@ Endpoints: if (args.includes('stop')) { const keepInject = args.includes('--keep-inject'); try { - const info = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); + const { info } = readLiveServerInfo(process.cwd()) || {}; const res = await fetch(`http://localhost:${info.port}/stop?token=${info.token}`); if (res.ok) console.log(`Stopped live server on port ${info.port}.`); } catch { @@ -776,7 +781,7 @@ if (args.includes('--background')) { const deadline = Date.now() + 10_000; while (Date.now() < deadline) { try { - const info = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); + const { info } = readLiveServerInfo(process.cwd()) || {}; if (info.pid !== process.pid) { // Output JSON so the agent can read port + token from stdout. console.log(JSON.stringify(info)); @@ -790,14 +795,18 @@ if (args.includes('--background')) { } // Check for existing session -try { - const existing = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); - try { process.kill(existing.pid, 0); +const existingRecord = readLiveServerInfo(process.cwd()); +if (existingRecord?.info) { + const existing = existingRecord.info; + try { + process.kill(existing.pid, 0); console.error(`Live server already running on port ${existing.port} (pid ${existing.pid}).`); console.error('Stop it first with: node ' + path.basename(fileURLToPath(import.meta.url)) + ' stop'); process.exit(1); - } catch { fs.unlinkSync(LIVE_PID_FILE); } -} catch {} + } catch { + try { fs.unlinkSync(existingRecord.path); } catch {} + } +} state.token = randomUUID(); state.sessionStore = createLiveSessionStore({ cwd: process.cwd() }); @@ -807,7 +816,7 @@ state.port = portArg ? parseInt(portArg.split('=')[1], 10) : await findOpenPort( // Annotation screenshots live in the project root so the agent's Read tool // doesn't trip a per-file permission prompt. Sessioned by token so concurrent // projects (or quick restarts) don't collide. -const annotRoot = path.join(process.cwd(), '.impeccable-live', 'annotations'); +const annotRoot = getLiveAnnotationsDir(process.cwd()); fs.mkdirSync(annotRoot, { recursive: true }); state.sessionDir = fs.mkdtempSync(path.join(annotRoot, 'session-')); @@ -815,7 +824,7 @@ const { detectScript, sessionPath, livePath } = loadBrowserScripts(); httpServer = http.createServer(createRequestHandler({ detectScript, sessionPath, livePath })); httpServer.listen(state.port, '127.0.0.1', () => { - fs.writeFileSync(LIVE_PID_FILE, JSON.stringify({ pid: process.pid, port: state.port, token: state.token })); + writeLiveServerInfo(process.cwd(), { pid: process.pid, port: state.port, token: state.token }); const url = `http://localhost:${state.port}`; console.log(`\nImpeccable live server running on ${url}`); console.log(`Token: ${state.token}\n`); diff --git a/.rovodev/skills/impeccable/scripts/live-session-store.mjs b/.rovodev/skills/impeccable/scripts/live-session-store.mjs index cc7744df..37711168 100644 --- a/.rovodev/skills/impeccable/scripts/live-session-store.mjs +++ b/.rovodev/skills/impeccable/scripts/live-session-store.mjs @@ -1,30 +1,43 @@ import fs from 'node:fs'; import path from 'node:path'; +import { getLegacyLiveSessionsDir, getLiveSessionsDir } from './impeccable-paths.mjs'; -const LIVE_DIR = '.impeccable-live'; -const SESSIONS_DIR = 'sessions'; const COMPLETED_PHASES = new Set(['completed', 'discarded']); export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {}) { - const rootDir = path.join(cwd, LIVE_DIR, SESSIONS_DIR); + 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 = getJournalPath(rootDir, id); + const journalPath = getReadableJournalPath(id); const rebuilt = rebuildSnapshotFromJournal(journalPath, id); snapshotCache.set(id, rebuilt); return rebuilt; } + function getReadableJournalPath(id) { + const primary = getJournalPath(rootDir, id); + if (fs.existsSync(primary)) return primary; + const legacy = getJournalPath(legacyRootDir, id); + if (fs.existsSync(legacy)) return legacy; + return primary; + } + return { rootDir, + legacyRootDir, appendEvent(event) { const normalized = normalizeEvent(event, sessionId); const journalPath = getJournalPath(rootDir, normalized.id); const snapshotPath = getSnapshotPath(rootDir, normalized.id); + const legacyJournalPath = getJournalPath(legacyRootDir, normalized.id); + if (!fs.existsSync(journalPath) && fs.existsSync(legacyJournalPath)) { + fs.copyFileSync(legacyJournalPath, journalPath); + } const prior = loadCachedOrRebuild(normalized.id); const seq = prior.nextSeq; const entry = { @@ -42,7 +55,7 @@ export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {}) }, getSnapshot(id = sessionId, opts = {}) { if (!id) throw new Error('session id required'); - const journalPath = getJournalPath(rootDir, id); + const journalPath = getReadableJournalPath(id); const snapshotPath = getSnapshotPath(rootDir, id); const rebuilt = rebuildSnapshotFromJournal(journalPath, id); snapshotCache.set(id, rebuilt); @@ -51,10 +64,15 @@ export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {}) return rebuilt.snapshot; }, listActiveSessions() { - if (!fs.existsSync(rootDir)) return []; - return fs.readdirSync(rootDir) - .filter((name) => name.endsWith('.jsonl')) - .map((name) => name.slice(0, -'.jsonl'.length)) + const ids = new Set(); + for (const dir of [legacyRootDir, rootDir]) { + if (!fs.existsSync(dir)) continue; + for (const name of fs.readdirSync(dir)) { + if (name.endsWith('.jsonl')) ids.add(name.slice(0, -'.jsonl'.length)); + } + } + return [...ids] + .sort() .map((id) => this.getSnapshot(id)) .filter(Boolean); }, diff --git a/.rovodev/skills/impeccable/scripts/live-status.mjs b/.rovodev/skills/impeccable/scripts/live-status.mjs index 1b85357c..dce1fbca 100644 --- a/.rovodev/skills/impeccable/scripts/live-status.mjs +++ b/.rovodev/skills/impeccable/scripts/live-status.mjs @@ -3,15 +3,11 @@ * Print durable recovery status for Impeccable live sessions. */ -import fs from 'node:fs'; -import path from 'node:path'; import { createLiveSessionStore } from './live-session-store.mjs'; - -const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); +import { readLiveServerInfo } from './impeccable-paths.mjs'; function readServerInfo() { - try { return JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); } - catch { return null; } + return readLiveServerInfo(process.cwd())?.info || null; } async function fetchServerStatus(info) { diff --git a/.rovodev/skills/impeccable/scripts/live.mjs b/.rovodev/skills/impeccable/scripts/live.mjs index befbdb8e..cafb0eca 100644 --- a/.rovodev/skills/impeccable/scripts/live.mjs +++ b/.rovodev/skills/impeccable/scripts/live.mjs @@ -2,10 +2,10 @@ * CLI entry point: prepare everything needed to enter the live variant poll loop. * * Does (all in one command): - * 1. Check config.json (returns config_missing if first-ever run) + * 1. Check .impeccable/live/config.json (returns config_missing if first-ever run) * 2. Start the live server in the background (or reuse a running one) * 3. Inject the browser script tag into the project's entry file - * 4. Read .impeccable.md for design context (if present) + * 4. Read PRODUCT.md / DESIGN.md for project context * 5. Print a single JSON blob with everything the agent needs * * After this, the agent's only remaining steps are: @@ -23,9 +23,9 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { loadContext } from './load-context.mjs'; import { resolveFiles } from './live-inject.mjs'; +import { readLiveServerInfo } from './impeccable-paths.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); async function liveCli() { const args = process.argv.slice(2); @@ -34,10 +34,10 @@ async function liveCli() { console.log(`Usage: node live.mjs Prepare everything for live variant mode in a single command: - - Checks scripts/config.json (required, created once per project) + - Checks .impeccable/live/config.json (required, created once per project) - Starts (or reuses) the live server in the background - Injects the browser script tag - - Reads .impeccable.md for design context + - Reads PRODUCT.md / DESIGN.md for project context On success, prints a JSON blob with: { ok, serverPort, serverToken, pageFile, hasContext, context } @@ -223,7 +223,7 @@ function safeParse(out) { function ensureServerRunning() { // Try to reuse an existing server try { - const existing = JSON.parse(fs.readFileSync(PID_FILE, 'utf-8')); + const existing = readLiveServerInfo(process.cwd())?.info; if (existing && existing.pid) { try { process.kill(existing.pid, 0); // throws if dead diff --git a/.rovodev/skills/impeccable/scripts/load-context.mjs b/.rovodev/skills/impeccable/scripts/load-context.mjs index dca23c1f..dc340bf1 100644 --- a/.rovodev/skills/impeccable/scripts/load-context.mjs +++ b/.rovodev/skills/impeccable/scripts/load-context.mjs @@ -39,7 +39,7 @@ const LEGACY_NAMES = ['.impeccable.md']; const FALLBACK_DIRS = ['.agents/context', 'docs']; /** - * Resolve the directory that holds PRODUCT.md / DESIGN.md / DESIGN.json for + * Resolve the directory that holds PRODUCT.md / DESIGN.md for * this project. Exported so other scripts (e.g. live-server.mjs) can read the * design files from the same location the loader uses. */ diff --git a/.trae-cn/skills/impeccable/reference/document.md b/.trae-cn/skills/impeccable/reference/document.md index 58c45cfc..3125b37a 100644 --- a/.trae-cn/skills/impeccable/reference/document.md +++ b/.trae-cn/skills/impeccable/reference/document.md @@ -237,11 +237,11 @@ Concrete, forceful guardrails. Lead each with "Do" or "Don't". Be specific: incl - **Don't** [...] ``` -### Step 4b: Write DESIGN.json sidecar (extensions only) +### Step 4b: Write .impeccable/design.json sidecar (extensions only) -The frontmatter owns token primitives (colors, typography, rounded, spacing, components). The sidecar at `DESIGN.json` carries **what Stitch's schema can't hold**: tonal ramps per color, shadow/elevation tokens, motion tokens, breakpoints, full component HTML/CSS snippets (the panel renders these into a shadow DOM), and narrative (north star, rules, do's/don'ts). It extends the frontmatter, it doesn't duplicate it. +The frontmatter owns token primitives (colors, typography, rounded, spacing, components). The sidecar at `.impeccable/design.json` carries **what Stitch's schema can't hold**: tonal ramps per color, shadow/elevation tokens, motion tokens, breakpoints, full component HTML/CSS snippets (the panel renders these into a shadow DOM), and narrative (north star, rules, do's/don'ts). It extends the frontmatter, it doesn't duplicate it. -Regenerate the sidecar whenever you regenerate DESIGN.md. If the user only asks to refresh the sidecar (e.g., from the live panel's stale-hint), preserve DESIGN.md and write only DESIGN.json. +Regenerate the sidecar whenever you regenerate root `DESIGN.md`. If the user only asks to refresh the sidecar (e.g., from the live panel's stale-hint), preserve `DESIGN.md` and write only `.impeccable/design.json`. #### Schema @@ -310,7 +310,7 @@ Aim for a tight set of **5-10 components** that best represent the visual system - **Signature components (include if distinctive):** hero CTA, featured card, filter pill, any custom pattern the user mentioned as important in PRODUCT.md. - **Skip the rest.** Utility components, form building blocks, wrapper layouts: not worth documenting unless visually distinctive. -If the project has **no component library yet** (bare landing page, new project), synthesize canonical primitives from the tokens using best-practice defaults consistent with the DESIGN.md's rules. Every DESIGN.json has *something* to render, even on day zero. +If the project has **no component library yet** (bare landing page, new project), synthesize canonical primitives from the tokens using best-practice defaults consistent with the DESIGN.md's rules. Every `.impeccable/design.json` has *something* to render, even on day zero. #### Tonal ramps @@ -331,7 +331,7 @@ Do not reword. The panel shows these as secondary collapsible context; the same ### Step 5: Confirm, refine, and refresh session cache 1. Show the user the full DESIGN.md you wrote. Briefly highlight the non-obvious creative choices (descriptive color names, atmosphere language, named rules). -2. Mention that `DESIGN.json` was also written alongside; the live panel will now render this project's actual button/input/nav primitives instead of generic approximations. +2. Mention that `.impeccable/design.json` was also written alongside; the live panel will now render this project's actual button/input/nav primitives instead of generic approximations. 3. Offer to refine any section: "Want me to revise a section, add component patterns I missed, or adjust the atmosphere language?" 4. **Refresh the session cache.** Run `node .trae-cn/skills/impeccable/scripts/load-context.mjs` one final time so the newly-written DESIGN.md lands in conversation. Subsequent commands in this session will use the fresh version automatically without re-reading. @@ -392,7 +392,7 @@ Per-section guidance in seed mode: - **Components**: omit entirely; no components exist yet. - **Do's and Don'ts**: carry PRODUCT.md's anti-references directly plus the anti-reference named in Q5. -Seed mode writes a minimal frontmatter with `name` and `description` only; no colors, typography, rounded, spacing, or components yet. Real tokens land on the next Scan-mode run. Skip the `DESIGN.json` sidecar in seed mode for the same reason: nothing to render. +Seed mode writes a minimal frontmatter with `name` and `description` only; no colors, typography, rounded, spacing, or components yet. Real tokens land on the next Scan-mode run. Skip the `.impeccable/design.json` sidecar in seed mode for the same reason: nothing to render. ### Step 4: Confirm and refresh session cache diff --git a/.trae-cn/skills/impeccable/reference/live.md b/.trae-cn/skills/impeccable/reference/live.md index 69f214a6..540b0934 100644 --- a/.trae-cn/skills/impeccable/reference/live.md +++ b/.trae-cn/skills/impeccable/reference/live.md @@ -53,7 +53,7 @@ LOOP: ## Recovery commands -The live helper persists an append-only journal under `.impeccable-live/sessions`. Browser checkpoints are advisory but durable; the journal is canonical. +The live helper persists an append-only journal under `.impeccable/live/sessions/`. Browser checkpoints are advisory but durable; the journal is canonical. This is local durable recovery state, not project source. Use these commands when the chat was interrupted, polling was missed, the helper restarted, or the browser reloaded: @@ -473,7 +473,7 @@ When the poll returns `exit`, proceed to cleanup. If the poll is still running a node .trae-cn/skills/impeccable/scripts/live-server.mjs stop ``` -Stops the HTTP server and runs `live-inject.mjs --remove` to strip `localhost:…/live.js` from the HTML entry. To stop the server but keep the inject tag (for a quick restart), use `stop --keep-inject`. `config.json` persists for future sessions. +Stops the HTTP server and runs `live-inject.mjs --remove` to strip `localhost:…/live.js` from the HTML entry. To stop the server but keep the inject tag (for a quick restart), use `stop --keep-inject`. `.impeccable/live/config.json` persists as project config for future sessions. Then: - Remove any leftover variant wrappers (search for `impeccable-variants-start` markers). @@ -481,7 +481,7 @@ Then: ## First-time setup (config missing or invalid) -If `live.mjs` outputs `{ ok: false, error: "config_missing" | "config_invalid", path }`, write `config.json` at the reported path. +If `live.mjs` outputs `{ ok: false, error: "config_missing" | "config_invalid", path }`, write the live config at the reported path. By default this is `.impeccable/live/config.json`. Schema: @@ -561,7 +561,7 @@ node .trae-cn/skills/impeccable/scripts/detect-csp.mjs Output: `{ shape, signals }` where `shape` is one of `append-arrays`, `append-string`, `middleware`, `meta-tag`, or `null`. The shape is named by *patch mechanism*, so one template covers many frameworks. -- **`null`**: no CSP; skip to writing `config.json` with `cspChecked: true`. +- **`null`**: no CSP; skip to writing `.impeccable/live/config.json` with `cspChecked: true`. - **`append-arrays`**: CSP defined as structured directive arrays. Auto-patchable. See *append-arrays* below. Covers: - Monorepo helpers with `additionalScriptSrc` / `additionalConnectSrc` options (Next.js + shared config package) - SvelteKit `kit.csp.directives` @@ -638,6 +638,6 @@ Reference outputs: ### Troubleshooting -If a user says "no" to the CSP patch at setup time and later complains that live doesn't work: their dev CSP blocks `http://localhost:8400`. Fix: delete `cspChecked` from `config.json` and re-run `live.mjs`: setup will ask again. +If a user says "no" to the CSP patch at setup time and later complains that live doesn't work: their dev CSP blocks `http://localhost:8400`. Fix: delete `cspChecked` from `.impeccable/live/config.json` and re-run `live.mjs`: setup will ask again. Then re-run `live.mjs`. diff --git a/.trae-cn/skills/impeccable/reference/teach.md b/.trae-cn/skills/impeccable/reference/teach.md index da8ea44f..7937905e 100644 --- a/.trae-cn/skills/impeccable/reference/teach.md +++ b/.trae-cn/skills/impeccable/reference/teach.md @@ -2,8 +2,8 @@ Gathers design context for a project and writes two complementary files at the project root: -- **PRODUCT.md** (strategic): register, target users, product purpose, brand personality, anti-references, strategic design principles. Answers "who/what/why". -- **DESIGN.md** (visual): visual theme, color palette, typography, components, layout. Follows the [Google Stitch DESIGN.md format](https://stitch.withgoogle.com/docs/design-md/format/). Answers "how it looks". +- **PRODUCT.md** (strategic): root project file for register, target users, product purpose, brand personality, anti-references, strategic design principles. Answers "who/what/why". +- **DESIGN.md** (visual): root project file for visual theme, color palette, typography, components, layout. Follows the [Google Stitch DESIGN.md format](https://stitch.withgoogle.com/docs/design-md/format/). Answers "how it looks". Every other impeccable command reads these files before doing any work. diff --git a/.trae-cn/skills/impeccable/scripts/impeccable-paths.mjs b/.trae-cn/skills/impeccable/scripts/impeccable-paths.mjs new file mode 100644 index 00000000..ba852bae --- /dev/null +++ b/.trae-cn/skills/impeccable/scripts/impeccable-paths.mjs @@ -0,0 +1,105 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +export const IMPECCABLE_DIR = '.impeccable'; +export const LIVE_DIR = 'live'; + +export function getImpeccableDir(cwd = process.cwd()) { + return path.join(cwd, IMPECCABLE_DIR); +} + +export function getDesignSidecarPath(cwd = process.cwd()) { + return path.join(getImpeccableDir(cwd), 'design.json'); +} + +export function getDesignSidecarCandidates(cwd = process.cwd(), contextDir = cwd) { + const candidates = [ + getDesignSidecarPath(cwd), + path.join(cwd, 'DESIGN.json'), + ]; + const contextLegacy = path.join(contextDir, 'DESIGN.json'); + if (!candidates.includes(contextLegacy)) candidates.push(contextLegacy); + return candidates; +} + +export function resolveDesignSidecarPath(cwd = process.cwd(), contextDir = cwd) { + return firstExisting(getDesignSidecarCandidates(cwd, contextDir)); +} + +export function getLiveDir(cwd = process.cwd()) { + return path.join(getImpeccableDir(cwd), LIVE_DIR); +} + +export function getLiveConfigPath(cwd = process.cwd()) { + return path.join(getLiveDir(cwd), 'config.json'); +} + +export function getLegacyLiveConfigPath(scriptsDir) { + return path.join(scriptsDir, 'config.json'); +} + +export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = process.env } = {}) { + if (env.IMPECCABLE_LIVE_CONFIG && env.IMPECCABLE_LIVE_CONFIG.trim()) { + const configured = env.IMPECCABLE_LIVE_CONFIG.trim(); + return path.isAbsolute(configured) ? configured : path.resolve(cwd, configured); + } + const primary = getLiveConfigPath(cwd); + if (fs.existsSync(primary)) return primary; + if (scriptsDir) { + const legacy = getLegacyLiveConfigPath(scriptsDir); + if (fs.existsSync(legacy)) return legacy; + } + return primary; +} + +export function getLiveServerPath(cwd = process.cwd()) { + return path.join(getLiveDir(cwd), 'server.json'); +} + +export function getLegacyLiveServerPath(cwd = process.cwd()) { + return path.join(cwd, '.impeccable-live.json'); +} + +export function readLiveServerInfo(cwd = process.cwd()) { + for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) { + try { + return { info: JSON.parse(fs.readFileSync(filePath, 'utf-8')), path: filePath }; + } catch { + /* try next */ + } + } + return null; +} + +export function writeLiveServerInfo(cwd = process.cwd(), info) { + const filePath = getLiveServerPath(cwd); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, JSON.stringify(info)); + return filePath; +} + +export function removeLiveServerInfo(cwd = process.cwd()) { + for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) { + try { fs.unlinkSync(filePath); } catch {} + } +} + +export function getLiveSessionsDir(cwd = process.cwd()) { + return path.join(getLiveDir(cwd), 'sessions'); +} + +export function getLegacyLiveSessionsDir(cwd = process.cwd()) { + return path.join(cwd, '.impeccable-live', 'sessions'); +} + +export function getLiveAnnotationsDir(cwd = process.cwd()) { + return path.join(getLiveDir(cwd), 'annotations'); +} + +export function getLegacyLiveAnnotationsDir(cwd = process.cwd()) { + return path.join(cwd, '.impeccable-live', 'annotations'); +} + +function firstExisting(paths) { + return paths.find((filePath) => fs.existsSync(filePath)) || null; +} diff --git a/.trae-cn/skills/impeccable/scripts/live-browser.js b/.trae-cn/skills/impeccable/scripts/live-browser.js index 7f1ff329..bb5332a3 100644 --- a/.trae-cn/skills/impeccable/scripts/live-browser.js +++ b/.trae-cn/skills/impeccable/scripts/live-browser.js @@ -3671,7 +3671,7 @@ void main() { } // --------------------------------------------------------------------------- - // Design System Panel — visualizes the project's DESIGN.json sidecar + // Design System Panel — visualizes the project's .impeccable/design.json sidecar // --------------------------------------------------------------------------- const DESIGN_PREFS_KEY = 'impeccable-live-design-panel'; @@ -3683,7 +3683,7 @@ void main() { open: false, tab: 'visual', // 'visual' | 'raw' parsed: null, // parseDesignMd output (frontmatter + body sections) - sidecar: null, // DESIGN.json v2 payload (extensions + components + narrative) + sidecar: null, // .impeccable/design.json v2 payload (extensions + components + narrative) hasMd: false, hasSidecar: false, present: null, // true/false once fetch resolves @@ -4184,7 +4184,7 @@ void main() { box.className = 'stale'; box.innerHTML = ` - DESIGN.md is newer than DESIGN.json. Run /impeccable document to refresh the sidecar. + DESIGN.md is newer than .impeccable/design.json. Run /impeccable document to refresh the sidecar. `; return box; } @@ -4192,7 +4192,7 @@ void main() { function renderParsedMdCta() { const box = document.createElement('div'); box.className = 'parsed-md-cta'; - box.innerHTML = `Basic viewThis panel reads the tokens in your DESIGN.md frontmatter. Running /impeccable document also generates a DESIGN.json sidecar with your project's actual component snippets (button, input, nav) and tonal ramps, rendered live below the tokens.`; + box.innerHTML = `Basic viewThis panel reads the tokens in your DESIGN.md frontmatter. Running /impeccable document also generates a .impeccable/design.json sidecar with your project's actual component snippets (button, input, nav) and tonal ramps, rendered live below the tokens.`; return box; } @@ -4652,7 +4652,7 @@ void main() { function cssSafe(v) { // Strip anything outside valid CSS value chars to prevent injection via - // DESIGN.json values rendered into inline style strings. + // .impeccable/design.json values rendered into inline style strings. return String(v).replace(/[<>"'`\n]/g, ''); } diff --git a/.trae-cn/skills/impeccable/scripts/live-complete.mjs b/.trae-cn/skills/impeccable/scripts/live-complete.mjs index ca00d86a..78155af8 100644 --- a/.trae-cn/skills/impeccable/scripts/live-complete.mjs +++ b/.trae-cn/skills/impeccable/scripts/live-complete.mjs @@ -4,10 +4,7 @@ */ import { createLiveSessionStore } from './live-session-store.mjs'; -import fs from 'node:fs'; -import path from 'node:path'; - -const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); +import { readLiveServerInfo } from './impeccable-paths.mjs'; function parseArgs(argv) { const out = { status: 'complete' }; @@ -50,8 +47,7 @@ export async function completeCli() { } function readServerInfo() { - try { return JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); } - catch { return null; } + return readLiveServerInfo(process.cwd())?.info || null; } async function completeThroughServer(info, args) { diff --git a/.trae-cn/skills/impeccable/scripts/live-inject.mjs b/.trae-cn/skills/impeccable/scripts/live-inject.mjs index 1d2ae12f..555c3a36 100644 --- a/.trae-cn/skills/impeccable/scripts/live-inject.mjs +++ b/.trae-cn/skills/impeccable/scripts/live-inject.mjs @@ -2,23 +2,24 @@ * CLI helper: insert/remove the live variant mode script tag in the project's * main HTML entry point. * - * On first live run, the agent generates `config.json` in this script's - * directory with the project's insertion target (framework-specific). On + * On first live run, the agent generates `.impeccable/live/config.json` + * with the project's insertion target (framework-specific). On * every subsequent run, this script handles insert/remove deterministically * with zero LLM involvement. * * Usage: * node live-inject.mjs --port PORT # Insert the live script tag * node live-inject.mjs --remove # Remove the live script tag - * node live-inject.mjs --check # Check whether config.json exists + * node live-inject.mjs --check # Check whether live config exists */ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { resolveLiveConfigPath } from './impeccable-paths.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const CONFIG_PATH = process.env.IMPECCABLE_LIVE_CONFIG || path.join(__dirname, 'config.json'); +const CONFIG_PATH = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname }); const MARKER_OPEN_TEXT = 'impeccable-live-start'; const MARKER_CLOSE_TEXT = 'impeccable-live-end'; @@ -39,12 +40,12 @@ export async function injectCli() { console.log(`Usage: node live-inject.mjs [options] Insert or remove the live mode script tag in the project's HTML entry point. -Reads configuration from config.json (in this same directory). +Reads configuration from .impeccable/live/config.json. Modes: --port PORT Insert script tag pointing at http://localhost:PORT/live.js --remove Remove the script tag (if present) - --check Print whether config.json exists and its content + --check Print whether .impeccable/live/config.json exists and its content Output (JSON): { ok, file, inserted|removed, config? }`); diff --git a/.trae-cn/skills/impeccable/scripts/live-poll.mjs b/.trae-cn/skills/impeccable/scripts/live-poll.mjs index 9a3f07ae..83a9912e 100644 --- a/.trae-cn/skills/impeccable/scripts/live-poll.mjs +++ b/.trae-cn/skills/impeccable/scripts/live-poll.mjs @@ -9,11 +9,10 @@ */ import { execFileSync } from 'node:child_process'; -import fs from 'node:fs'; import path from 'node:path'; -import os from 'node:os'; import { fileURLToPath } from 'node:url'; import { completionTypeForAcceptResult } from './live-completion.mjs'; +import { readLiveServerInfo } from './impeccable-paths.mjs'; // Node's built-in fetch (undici under the hood) enforces a 300s headers // timeout that can't be lowered per-request. We cap each request below @@ -21,15 +20,13 @@ import { completionTypeForAcceptResult } from './live-completion.mjs'; // depending on the standalone undici package. const PER_REQUEST_TIMEOUT_MS = 270_000; -const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); - function readServerInfo() { - try { - return JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); - } catch { + const record = readLiveServerInfo(process.cwd()); + if (!record) { console.error('No running live server found. Start one with: npx impeccable live'); process.exit(1); } + return record.info; } export function buildPollReplyPayload(token, { id, type, message, file, data }) { diff --git a/.trae-cn/skills/impeccable/scripts/live-server.mjs b/.trae-cn/skills/impeccable/scripts/live-server.mjs index e78a0657..53d8f21c 100644 --- a/.trae-cn/skills/impeccable/scripts/live-server.mjs +++ b/.trae-cn/skills/impeccable/scripts/live-server.mjs @@ -23,14 +23,19 @@ import { fileURLToPath } from 'node:url'; import { parseDesignMd } from './design-parser.mjs'; import { resolveContextDir } from './load-context.mjs'; import { createLiveSessionStore } from './live-session-store.mjs'; +import { + getDesignSidecarPath, + getLiveAnnotationsDir, + readLiveServerInfo, + removeLiveServerInfo, + resolveDesignSidecarPath, + writeLiveServerInfo, +} from './impeccable-paths.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -// PID file in the project root so both the server and agent can find it -// predictably (os.tmpdir() varies across platforms). -const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); -// PRODUCT.md / DESIGN.md / DESIGN.json live wherever load-context.mjs resolves. -// Keeps live-server in sync with the loader when users keep the docs in -// .agents/context/, docs/, or a path set via IMPECCABLE_CONTEXT_DIR. +// PRODUCT.md / DESIGN.md live wherever load-context.mjs resolves. The generated +// DESIGN sidecar is project-local at .impeccable/design.json, with legacy +// DESIGN.json fallback for existing projects. const CONTEXT_DIR = resolveContextDir(process.cwd()); const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s @@ -411,13 +416,13 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { } // --- Design system (unified v2 response) + raw --- - // /design-system.json returns both parsed DESIGN.md and DESIGN.json + // /design-system.json returns both parsed DESIGN.md and .impeccable/design.json // sidecar when present. Panel merges them: // { present, parsed, sidecar, hasMd, hasSidecar, // mdNewerThanJson, parseError?, sidecarError? } // - parsed: output of parseDesignMd (frontmatter // + six canonical sections) when DESIGN.md exists. - // - sidecar: DESIGN.json contents when present. + // - sidecar: .impeccable/design.json contents when present. // Expected shape: schemaVersion 2, carrying // extensions + components + narrative. // /design-system/raw returns DESIGN.md markdown verbatim @@ -426,7 +431,7 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } const mdPath = path.join(CONTEXT_DIR, 'DESIGN.md'); - const jsonPath = path.join(CONTEXT_DIR, 'DESIGN.json'); + const jsonPath = resolveDesignSidecarPath(process.cwd(), CONTEXT_DIR) || getDesignSidecarPath(process.cwd()); const mdStat = statOrNull(mdPath); const jsonStat = statOrNull(jsonPath); @@ -462,7 +467,7 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { try { response.sidecar = JSON.parse(fs.readFileSync(jsonPath, 'utf-8')); } catch (err) { - response.sidecarError = 'Failed to parse DESIGN.json: ' + err.message; + response.sidecarError = 'Failed to parse .impeccable/design.json: ' + err.message; } } @@ -673,7 +678,7 @@ function handlePollPost(req, res) { let httpServer = null; function shutdown() { - try { fs.unlinkSync(LIVE_PID_FILE); } catch {} + removeLiveServerInfo(process.cwd()); if (state.leaseTimer) clearTimeout(state.leaseTimer); state.leaseTimer = null; if (state.sessionDir) { @@ -725,7 +730,7 @@ Endpoints: if (args.includes('stop')) { const keepInject = args.includes('--keep-inject'); try { - const info = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); + const { info } = readLiveServerInfo(process.cwd()) || {}; const res = await fetch(`http://localhost:${info.port}/stop?token=${info.token}`); if (res.ok) console.log(`Stopped live server on port ${info.port}.`); } catch { @@ -776,7 +781,7 @@ if (args.includes('--background')) { const deadline = Date.now() + 10_000; while (Date.now() < deadline) { try { - const info = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); + const { info } = readLiveServerInfo(process.cwd()) || {}; if (info.pid !== process.pid) { // Output JSON so the agent can read port + token from stdout. console.log(JSON.stringify(info)); @@ -790,14 +795,18 @@ if (args.includes('--background')) { } // Check for existing session -try { - const existing = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); - try { process.kill(existing.pid, 0); +const existingRecord = readLiveServerInfo(process.cwd()); +if (existingRecord?.info) { + const existing = existingRecord.info; + try { + process.kill(existing.pid, 0); console.error(`Live server already running on port ${existing.port} (pid ${existing.pid}).`); console.error('Stop it first with: node ' + path.basename(fileURLToPath(import.meta.url)) + ' stop'); process.exit(1); - } catch { fs.unlinkSync(LIVE_PID_FILE); } -} catch {} + } catch { + try { fs.unlinkSync(existingRecord.path); } catch {} + } +} state.token = randomUUID(); state.sessionStore = createLiveSessionStore({ cwd: process.cwd() }); @@ -807,7 +816,7 @@ state.port = portArg ? parseInt(portArg.split('=')[1], 10) : await findOpenPort( // Annotation screenshots live in the project root so the agent's Read tool // doesn't trip a per-file permission prompt. Sessioned by token so concurrent // projects (or quick restarts) don't collide. -const annotRoot = path.join(process.cwd(), '.impeccable-live', 'annotations'); +const annotRoot = getLiveAnnotationsDir(process.cwd()); fs.mkdirSync(annotRoot, { recursive: true }); state.sessionDir = fs.mkdtempSync(path.join(annotRoot, 'session-')); @@ -815,7 +824,7 @@ const { detectScript, sessionPath, livePath } = loadBrowserScripts(); httpServer = http.createServer(createRequestHandler({ detectScript, sessionPath, livePath })); httpServer.listen(state.port, '127.0.0.1', () => { - fs.writeFileSync(LIVE_PID_FILE, JSON.stringify({ pid: process.pid, port: state.port, token: state.token })); + writeLiveServerInfo(process.cwd(), { pid: process.pid, port: state.port, token: state.token }); const url = `http://localhost:${state.port}`; console.log(`\nImpeccable live server running on ${url}`); console.log(`Token: ${state.token}\n`); diff --git a/.trae-cn/skills/impeccable/scripts/live-session-store.mjs b/.trae-cn/skills/impeccable/scripts/live-session-store.mjs index cc7744df..37711168 100644 --- a/.trae-cn/skills/impeccable/scripts/live-session-store.mjs +++ b/.trae-cn/skills/impeccable/scripts/live-session-store.mjs @@ -1,30 +1,43 @@ import fs from 'node:fs'; import path from 'node:path'; +import { getLegacyLiveSessionsDir, getLiveSessionsDir } from './impeccable-paths.mjs'; -const LIVE_DIR = '.impeccable-live'; -const SESSIONS_DIR = 'sessions'; const COMPLETED_PHASES = new Set(['completed', 'discarded']); export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {}) { - const rootDir = path.join(cwd, LIVE_DIR, SESSIONS_DIR); + 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 = getJournalPath(rootDir, id); + const journalPath = getReadableJournalPath(id); const rebuilt = rebuildSnapshotFromJournal(journalPath, id); snapshotCache.set(id, rebuilt); return rebuilt; } + function getReadableJournalPath(id) { + const primary = getJournalPath(rootDir, id); + if (fs.existsSync(primary)) return primary; + const legacy = getJournalPath(legacyRootDir, id); + if (fs.existsSync(legacy)) return legacy; + return primary; + } + return { rootDir, + legacyRootDir, appendEvent(event) { const normalized = normalizeEvent(event, sessionId); const journalPath = getJournalPath(rootDir, normalized.id); const snapshotPath = getSnapshotPath(rootDir, normalized.id); + const legacyJournalPath = getJournalPath(legacyRootDir, normalized.id); + if (!fs.existsSync(journalPath) && fs.existsSync(legacyJournalPath)) { + fs.copyFileSync(legacyJournalPath, journalPath); + } const prior = loadCachedOrRebuild(normalized.id); const seq = prior.nextSeq; const entry = { @@ -42,7 +55,7 @@ export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {}) }, getSnapshot(id = sessionId, opts = {}) { if (!id) throw new Error('session id required'); - const journalPath = getJournalPath(rootDir, id); + const journalPath = getReadableJournalPath(id); const snapshotPath = getSnapshotPath(rootDir, id); const rebuilt = rebuildSnapshotFromJournal(journalPath, id); snapshotCache.set(id, rebuilt); @@ -51,10 +64,15 @@ export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {}) return rebuilt.snapshot; }, listActiveSessions() { - if (!fs.existsSync(rootDir)) return []; - return fs.readdirSync(rootDir) - .filter((name) => name.endsWith('.jsonl')) - .map((name) => name.slice(0, -'.jsonl'.length)) + const ids = new Set(); + for (const dir of [legacyRootDir, rootDir]) { + if (!fs.existsSync(dir)) continue; + for (const name of fs.readdirSync(dir)) { + if (name.endsWith('.jsonl')) ids.add(name.slice(0, -'.jsonl'.length)); + } + } + return [...ids] + .sort() .map((id) => this.getSnapshot(id)) .filter(Boolean); }, diff --git a/.trae-cn/skills/impeccable/scripts/live-status.mjs b/.trae-cn/skills/impeccable/scripts/live-status.mjs index 1b85357c..dce1fbca 100644 --- a/.trae-cn/skills/impeccable/scripts/live-status.mjs +++ b/.trae-cn/skills/impeccable/scripts/live-status.mjs @@ -3,15 +3,11 @@ * Print durable recovery status for Impeccable live sessions. */ -import fs from 'node:fs'; -import path from 'node:path'; import { createLiveSessionStore } from './live-session-store.mjs'; - -const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); +import { readLiveServerInfo } from './impeccable-paths.mjs'; function readServerInfo() { - try { return JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); } - catch { return null; } + return readLiveServerInfo(process.cwd())?.info || null; } async function fetchServerStatus(info) { diff --git a/.trae-cn/skills/impeccable/scripts/live.mjs b/.trae-cn/skills/impeccable/scripts/live.mjs index befbdb8e..cafb0eca 100644 --- a/.trae-cn/skills/impeccable/scripts/live.mjs +++ b/.trae-cn/skills/impeccable/scripts/live.mjs @@ -2,10 +2,10 @@ * CLI entry point: prepare everything needed to enter the live variant poll loop. * * Does (all in one command): - * 1. Check config.json (returns config_missing if first-ever run) + * 1. Check .impeccable/live/config.json (returns config_missing if first-ever run) * 2. Start the live server in the background (or reuse a running one) * 3. Inject the browser script tag into the project's entry file - * 4. Read .impeccable.md for design context (if present) + * 4. Read PRODUCT.md / DESIGN.md for project context * 5. Print a single JSON blob with everything the agent needs * * After this, the agent's only remaining steps are: @@ -23,9 +23,9 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { loadContext } from './load-context.mjs'; import { resolveFiles } from './live-inject.mjs'; +import { readLiveServerInfo } from './impeccable-paths.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); async function liveCli() { const args = process.argv.slice(2); @@ -34,10 +34,10 @@ async function liveCli() { console.log(`Usage: node live.mjs Prepare everything for live variant mode in a single command: - - Checks scripts/config.json (required, created once per project) + - Checks .impeccable/live/config.json (required, created once per project) - Starts (or reuses) the live server in the background - Injects the browser script tag - - Reads .impeccable.md for design context + - Reads PRODUCT.md / DESIGN.md for project context On success, prints a JSON blob with: { ok, serverPort, serverToken, pageFile, hasContext, context } @@ -223,7 +223,7 @@ function safeParse(out) { function ensureServerRunning() { // Try to reuse an existing server try { - const existing = JSON.parse(fs.readFileSync(PID_FILE, 'utf-8')); + const existing = readLiveServerInfo(process.cwd())?.info; if (existing && existing.pid) { try { process.kill(existing.pid, 0); // throws if dead diff --git a/.trae-cn/skills/impeccable/scripts/load-context.mjs b/.trae-cn/skills/impeccable/scripts/load-context.mjs index dca23c1f..dc340bf1 100644 --- a/.trae-cn/skills/impeccable/scripts/load-context.mjs +++ b/.trae-cn/skills/impeccable/scripts/load-context.mjs @@ -39,7 +39,7 @@ const LEGACY_NAMES = ['.impeccable.md']; const FALLBACK_DIRS = ['.agents/context', 'docs']; /** - * Resolve the directory that holds PRODUCT.md / DESIGN.md / DESIGN.json for + * Resolve the directory that holds PRODUCT.md / DESIGN.md for * this project. Exported so other scripts (e.g. live-server.mjs) can read the * design files from the same location the loader uses. */ diff --git a/.trae/skills/impeccable/reference/document.md b/.trae/skills/impeccable/reference/document.md index a0123590..02ce8d6f 100644 --- a/.trae/skills/impeccable/reference/document.md +++ b/.trae/skills/impeccable/reference/document.md @@ -237,11 +237,11 @@ Concrete, forceful guardrails. Lead each with "Do" or "Don't". Be specific: incl - **Don't** [...] ``` -### Step 4b: Write DESIGN.json sidecar (extensions only) +### Step 4b: Write .impeccable/design.json sidecar (extensions only) -The frontmatter owns token primitives (colors, typography, rounded, spacing, components). The sidecar at `DESIGN.json` carries **what Stitch's schema can't hold**: tonal ramps per color, shadow/elevation tokens, motion tokens, breakpoints, full component HTML/CSS snippets (the panel renders these into a shadow DOM), and narrative (north star, rules, do's/don'ts). It extends the frontmatter, it doesn't duplicate it. +The frontmatter owns token primitives (colors, typography, rounded, spacing, components). The sidecar at `.impeccable/design.json` carries **what Stitch's schema can't hold**: tonal ramps per color, shadow/elevation tokens, motion tokens, breakpoints, full component HTML/CSS snippets (the panel renders these into a shadow DOM), and narrative (north star, rules, do's/don'ts). It extends the frontmatter, it doesn't duplicate it. -Regenerate the sidecar whenever you regenerate DESIGN.md. If the user only asks to refresh the sidecar (e.g., from the live panel's stale-hint), preserve DESIGN.md and write only DESIGN.json. +Regenerate the sidecar whenever you regenerate root `DESIGN.md`. If the user only asks to refresh the sidecar (e.g., from the live panel's stale-hint), preserve `DESIGN.md` and write only `.impeccable/design.json`. #### Schema @@ -310,7 +310,7 @@ Aim for a tight set of **5-10 components** that best represent the visual system - **Signature components (include if distinctive):** hero CTA, featured card, filter pill, any custom pattern the user mentioned as important in PRODUCT.md. - **Skip the rest.** Utility components, form building blocks, wrapper layouts: not worth documenting unless visually distinctive. -If the project has **no component library yet** (bare landing page, new project), synthesize canonical primitives from the tokens using best-practice defaults consistent with the DESIGN.md's rules. Every DESIGN.json has *something* to render, even on day zero. +If the project has **no component library yet** (bare landing page, new project), synthesize canonical primitives from the tokens using best-practice defaults consistent with the DESIGN.md's rules. Every `.impeccable/design.json` has *something* to render, even on day zero. #### Tonal ramps @@ -331,7 +331,7 @@ Do not reword. The panel shows these as secondary collapsible context; the same ### Step 5: Confirm, refine, and refresh session cache 1. Show the user the full DESIGN.md you wrote. Briefly highlight the non-obvious creative choices (descriptive color names, atmosphere language, named rules). -2. Mention that `DESIGN.json` was also written alongside; the live panel will now render this project's actual button/input/nav primitives instead of generic approximations. +2. Mention that `.impeccable/design.json` was also written alongside; the live panel will now render this project's actual button/input/nav primitives instead of generic approximations. 3. Offer to refine any section: "Want me to revise a section, add component patterns I missed, or adjust the atmosphere language?" 4. **Refresh the session cache.** Run `node .trae/skills/impeccable/scripts/load-context.mjs` one final time so the newly-written DESIGN.md lands in conversation. Subsequent commands in this session will use the fresh version automatically without re-reading. @@ -392,7 +392,7 @@ Per-section guidance in seed mode: - **Components**: omit entirely; no components exist yet. - **Do's and Don'ts**: carry PRODUCT.md's anti-references directly plus the anti-reference named in Q5. -Seed mode writes a minimal frontmatter with `name` and `description` only; no colors, typography, rounded, spacing, or components yet. Real tokens land on the next Scan-mode run. Skip the `DESIGN.json` sidecar in seed mode for the same reason: nothing to render. +Seed mode writes a minimal frontmatter with `name` and `description` only; no colors, typography, rounded, spacing, or components yet. Real tokens land on the next Scan-mode run. Skip the `.impeccable/design.json` sidecar in seed mode for the same reason: nothing to render. ### Step 4: Confirm and refresh session cache diff --git a/.trae/skills/impeccable/reference/live.md b/.trae/skills/impeccable/reference/live.md index 2cc93713..adfc355d 100644 --- a/.trae/skills/impeccable/reference/live.md +++ b/.trae/skills/impeccable/reference/live.md @@ -53,7 +53,7 @@ LOOP: ## Recovery commands -The live helper persists an append-only journal under `.impeccable-live/sessions`. Browser checkpoints are advisory but durable; the journal is canonical. +The live helper persists an append-only journal under `.impeccable/live/sessions/`. Browser checkpoints are advisory but durable; the journal is canonical. This is local durable recovery state, not project source. Use these commands when the chat was interrupted, polling was missed, the helper restarted, or the browser reloaded: @@ -473,7 +473,7 @@ When the poll returns `exit`, proceed to cleanup. If the poll is still running a node .trae/skills/impeccable/scripts/live-server.mjs stop ``` -Stops the HTTP server and runs `live-inject.mjs --remove` to strip `localhost:…/live.js` from the HTML entry. To stop the server but keep the inject tag (for a quick restart), use `stop --keep-inject`. `config.json` persists for future sessions. +Stops the HTTP server and runs `live-inject.mjs --remove` to strip `localhost:…/live.js` from the HTML entry. To stop the server but keep the inject tag (for a quick restart), use `stop --keep-inject`. `.impeccable/live/config.json` persists as project config for future sessions. Then: - Remove any leftover variant wrappers (search for `impeccable-variants-start` markers). @@ -481,7 +481,7 @@ Then: ## First-time setup (config missing or invalid) -If `live.mjs` outputs `{ ok: false, error: "config_missing" | "config_invalid", path }`, write `config.json` at the reported path. +If `live.mjs` outputs `{ ok: false, error: "config_missing" | "config_invalid", path }`, write the live config at the reported path. By default this is `.impeccable/live/config.json`. Schema: @@ -561,7 +561,7 @@ node .trae/skills/impeccable/scripts/detect-csp.mjs Output: `{ shape, signals }` where `shape` is one of `append-arrays`, `append-string`, `middleware`, `meta-tag`, or `null`. The shape is named by *patch mechanism*, so one template covers many frameworks. -- **`null`**: no CSP; skip to writing `config.json` with `cspChecked: true`. +- **`null`**: no CSP; skip to writing `.impeccable/live/config.json` with `cspChecked: true`. - **`append-arrays`**: CSP defined as structured directive arrays. Auto-patchable. See *append-arrays* below. Covers: - Monorepo helpers with `additionalScriptSrc` / `additionalConnectSrc` options (Next.js + shared config package) - SvelteKit `kit.csp.directives` @@ -638,6 +638,6 @@ Reference outputs: ### Troubleshooting -If a user says "no" to the CSP patch at setup time and later complains that live doesn't work: their dev CSP blocks `http://localhost:8400`. Fix: delete `cspChecked` from `config.json` and re-run `live.mjs`: setup will ask again. +If a user says "no" to the CSP patch at setup time and later complains that live doesn't work: their dev CSP blocks `http://localhost:8400`. Fix: delete `cspChecked` from `.impeccable/live/config.json` and re-run `live.mjs`: setup will ask again. Then re-run `live.mjs`. diff --git a/.trae/skills/impeccable/reference/teach.md b/.trae/skills/impeccable/reference/teach.md index d759ea15..ed0ceedd 100644 --- a/.trae/skills/impeccable/reference/teach.md +++ b/.trae/skills/impeccable/reference/teach.md @@ -2,8 +2,8 @@ Gathers design context for a project and writes two complementary files at the project root: -- **PRODUCT.md** (strategic): register, target users, product purpose, brand personality, anti-references, strategic design principles. Answers "who/what/why". -- **DESIGN.md** (visual): visual theme, color palette, typography, components, layout. Follows the [Google Stitch DESIGN.md format](https://stitch.withgoogle.com/docs/design-md/format/). Answers "how it looks". +- **PRODUCT.md** (strategic): root project file for register, target users, product purpose, brand personality, anti-references, strategic design principles. Answers "who/what/why". +- **DESIGN.md** (visual): root project file for visual theme, color palette, typography, components, layout. Follows the [Google Stitch DESIGN.md format](https://stitch.withgoogle.com/docs/design-md/format/). Answers "how it looks". Every other impeccable command reads these files before doing any work. diff --git a/.trae/skills/impeccable/scripts/impeccable-paths.mjs b/.trae/skills/impeccable/scripts/impeccable-paths.mjs new file mode 100644 index 00000000..ba852bae --- /dev/null +++ b/.trae/skills/impeccable/scripts/impeccable-paths.mjs @@ -0,0 +1,105 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +export const IMPECCABLE_DIR = '.impeccable'; +export const LIVE_DIR = 'live'; + +export function getImpeccableDir(cwd = process.cwd()) { + return path.join(cwd, IMPECCABLE_DIR); +} + +export function getDesignSidecarPath(cwd = process.cwd()) { + return path.join(getImpeccableDir(cwd), 'design.json'); +} + +export function getDesignSidecarCandidates(cwd = process.cwd(), contextDir = cwd) { + const candidates = [ + getDesignSidecarPath(cwd), + path.join(cwd, 'DESIGN.json'), + ]; + const contextLegacy = path.join(contextDir, 'DESIGN.json'); + if (!candidates.includes(contextLegacy)) candidates.push(contextLegacy); + return candidates; +} + +export function resolveDesignSidecarPath(cwd = process.cwd(), contextDir = cwd) { + return firstExisting(getDesignSidecarCandidates(cwd, contextDir)); +} + +export function getLiveDir(cwd = process.cwd()) { + return path.join(getImpeccableDir(cwd), LIVE_DIR); +} + +export function getLiveConfigPath(cwd = process.cwd()) { + return path.join(getLiveDir(cwd), 'config.json'); +} + +export function getLegacyLiveConfigPath(scriptsDir) { + return path.join(scriptsDir, 'config.json'); +} + +export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = process.env } = {}) { + if (env.IMPECCABLE_LIVE_CONFIG && env.IMPECCABLE_LIVE_CONFIG.trim()) { + const configured = env.IMPECCABLE_LIVE_CONFIG.trim(); + return path.isAbsolute(configured) ? configured : path.resolve(cwd, configured); + } + const primary = getLiveConfigPath(cwd); + if (fs.existsSync(primary)) return primary; + if (scriptsDir) { + const legacy = getLegacyLiveConfigPath(scriptsDir); + if (fs.existsSync(legacy)) return legacy; + } + return primary; +} + +export function getLiveServerPath(cwd = process.cwd()) { + return path.join(getLiveDir(cwd), 'server.json'); +} + +export function getLegacyLiveServerPath(cwd = process.cwd()) { + return path.join(cwd, '.impeccable-live.json'); +} + +export function readLiveServerInfo(cwd = process.cwd()) { + for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) { + try { + return { info: JSON.parse(fs.readFileSync(filePath, 'utf-8')), path: filePath }; + } catch { + /* try next */ + } + } + return null; +} + +export function writeLiveServerInfo(cwd = process.cwd(), info) { + const filePath = getLiveServerPath(cwd); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, JSON.stringify(info)); + return filePath; +} + +export function removeLiveServerInfo(cwd = process.cwd()) { + for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) { + try { fs.unlinkSync(filePath); } catch {} + } +} + +export function getLiveSessionsDir(cwd = process.cwd()) { + return path.join(getLiveDir(cwd), 'sessions'); +} + +export function getLegacyLiveSessionsDir(cwd = process.cwd()) { + return path.join(cwd, '.impeccable-live', 'sessions'); +} + +export function getLiveAnnotationsDir(cwd = process.cwd()) { + return path.join(getLiveDir(cwd), 'annotations'); +} + +export function getLegacyLiveAnnotationsDir(cwd = process.cwd()) { + return path.join(cwd, '.impeccable-live', 'annotations'); +} + +function firstExisting(paths) { + return paths.find((filePath) => fs.existsSync(filePath)) || null; +} diff --git a/.trae/skills/impeccable/scripts/live-browser.js b/.trae/skills/impeccable/scripts/live-browser.js index 7f1ff329..bb5332a3 100644 --- a/.trae/skills/impeccable/scripts/live-browser.js +++ b/.trae/skills/impeccable/scripts/live-browser.js @@ -3671,7 +3671,7 @@ void main() { } // --------------------------------------------------------------------------- - // Design System Panel — visualizes the project's DESIGN.json sidecar + // Design System Panel — visualizes the project's .impeccable/design.json sidecar // --------------------------------------------------------------------------- const DESIGN_PREFS_KEY = 'impeccable-live-design-panel'; @@ -3683,7 +3683,7 @@ void main() { open: false, tab: 'visual', // 'visual' | 'raw' parsed: null, // parseDesignMd output (frontmatter + body sections) - sidecar: null, // DESIGN.json v2 payload (extensions + components + narrative) + sidecar: null, // .impeccable/design.json v2 payload (extensions + components + narrative) hasMd: false, hasSidecar: false, present: null, // true/false once fetch resolves @@ -4184,7 +4184,7 @@ void main() { box.className = 'stale'; box.innerHTML = ` - DESIGN.md is newer than DESIGN.json. Run /impeccable document to refresh the sidecar. + DESIGN.md is newer than .impeccable/design.json. Run /impeccable document to refresh the sidecar. `; return box; } @@ -4192,7 +4192,7 @@ void main() { function renderParsedMdCta() { const box = document.createElement('div'); box.className = 'parsed-md-cta'; - box.innerHTML = `Basic viewThis panel reads the tokens in your DESIGN.md frontmatter. Running /impeccable document also generates a DESIGN.json sidecar with your project's actual component snippets (button, input, nav) and tonal ramps, rendered live below the tokens.`; + box.innerHTML = `Basic viewThis panel reads the tokens in your DESIGN.md frontmatter. Running /impeccable document also generates a .impeccable/design.json sidecar with your project's actual component snippets (button, input, nav) and tonal ramps, rendered live below the tokens.`; return box; } @@ -4652,7 +4652,7 @@ void main() { function cssSafe(v) { // Strip anything outside valid CSS value chars to prevent injection via - // DESIGN.json values rendered into inline style strings. + // .impeccable/design.json values rendered into inline style strings. return String(v).replace(/[<>"'`\n]/g, ''); } diff --git a/.trae/skills/impeccable/scripts/live-complete.mjs b/.trae/skills/impeccable/scripts/live-complete.mjs index ca00d86a..78155af8 100644 --- a/.trae/skills/impeccable/scripts/live-complete.mjs +++ b/.trae/skills/impeccable/scripts/live-complete.mjs @@ -4,10 +4,7 @@ */ import { createLiveSessionStore } from './live-session-store.mjs'; -import fs from 'node:fs'; -import path from 'node:path'; - -const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); +import { readLiveServerInfo } from './impeccable-paths.mjs'; function parseArgs(argv) { const out = { status: 'complete' }; @@ -50,8 +47,7 @@ export async function completeCli() { } function readServerInfo() { - try { return JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); } - catch { return null; } + return readLiveServerInfo(process.cwd())?.info || null; } async function completeThroughServer(info, args) { diff --git a/.trae/skills/impeccable/scripts/live-inject.mjs b/.trae/skills/impeccable/scripts/live-inject.mjs index 1d2ae12f..555c3a36 100644 --- a/.trae/skills/impeccable/scripts/live-inject.mjs +++ b/.trae/skills/impeccable/scripts/live-inject.mjs @@ -2,23 +2,24 @@ * CLI helper: insert/remove the live variant mode script tag in the project's * main HTML entry point. * - * On first live run, the agent generates `config.json` in this script's - * directory with the project's insertion target (framework-specific). On + * On first live run, the agent generates `.impeccable/live/config.json` + * with the project's insertion target (framework-specific). On * every subsequent run, this script handles insert/remove deterministically * with zero LLM involvement. * * Usage: * node live-inject.mjs --port PORT # Insert the live script tag * node live-inject.mjs --remove # Remove the live script tag - * node live-inject.mjs --check # Check whether config.json exists + * node live-inject.mjs --check # Check whether live config exists */ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { resolveLiveConfigPath } from './impeccable-paths.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const CONFIG_PATH = process.env.IMPECCABLE_LIVE_CONFIG || path.join(__dirname, 'config.json'); +const CONFIG_PATH = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname }); const MARKER_OPEN_TEXT = 'impeccable-live-start'; const MARKER_CLOSE_TEXT = 'impeccable-live-end'; @@ -39,12 +40,12 @@ export async function injectCli() { console.log(`Usage: node live-inject.mjs [options] Insert or remove the live mode script tag in the project's HTML entry point. -Reads configuration from config.json (in this same directory). +Reads configuration from .impeccable/live/config.json. Modes: --port PORT Insert script tag pointing at http://localhost:PORT/live.js --remove Remove the script tag (if present) - --check Print whether config.json exists and its content + --check Print whether .impeccable/live/config.json exists and its content Output (JSON): { ok, file, inserted|removed, config? }`); diff --git a/.trae/skills/impeccable/scripts/live-poll.mjs b/.trae/skills/impeccable/scripts/live-poll.mjs index 9a3f07ae..83a9912e 100644 --- a/.trae/skills/impeccable/scripts/live-poll.mjs +++ b/.trae/skills/impeccable/scripts/live-poll.mjs @@ -9,11 +9,10 @@ */ import { execFileSync } from 'node:child_process'; -import fs from 'node:fs'; import path from 'node:path'; -import os from 'node:os'; import { fileURLToPath } from 'node:url'; import { completionTypeForAcceptResult } from './live-completion.mjs'; +import { readLiveServerInfo } from './impeccable-paths.mjs'; // Node's built-in fetch (undici under the hood) enforces a 300s headers // timeout that can't be lowered per-request. We cap each request below @@ -21,15 +20,13 @@ import { completionTypeForAcceptResult } from './live-completion.mjs'; // depending on the standalone undici package. const PER_REQUEST_TIMEOUT_MS = 270_000; -const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); - function readServerInfo() { - try { - return JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); - } catch { + const record = readLiveServerInfo(process.cwd()); + if (!record) { console.error('No running live server found. Start one with: npx impeccable live'); process.exit(1); } + return record.info; } export function buildPollReplyPayload(token, { id, type, message, file, data }) { diff --git a/.trae/skills/impeccable/scripts/live-server.mjs b/.trae/skills/impeccable/scripts/live-server.mjs index e78a0657..53d8f21c 100644 --- a/.trae/skills/impeccable/scripts/live-server.mjs +++ b/.trae/skills/impeccable/scripts/live-server.mjs @@ -23,14 +23,19 @@ import { fileURLToPath } from 'node:url'; import { parseDesignMd } from './design-parser.mjs'; import { resolveContextDir } from './load-context.mjs'; import { createLiveSessionStore } from './live-session-store.mjs'; +import { + getDesignSidecarPath, + getLiveAnnotationsDir, + readLiveServerInfo, + removeLiveServerInfo, + resolveDesignSidecarPath, + writeLiveServerInfo, +} from './impeccable-paths.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -// PID file in the project root so both the server and agent can find it -// predictably (os.tmpdir() varies across platforms). -const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); -// PRODUCT.md / DESIGN.md / DESIGN.json live wherever load-context.mjs resolves. -// Keeps live-server in sync with the loader when users keep the docs in -// .agents/context/, docs/, or a path set via IMPECCABLE_CONTEXT_DIR. +// PRODUCT.md / DESIGN.md live wherever load-context.mjs resolves. The generated +// DESIGN sidecar is project-local at .impeccable/design.json, with legacy +// DESIGN.json fallback for existing projects. const CONTEXT_DIR = resolveContextDir(process.cwd()); const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s @@ -411,13 +416,13 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { } // --- Design system (unified v2 response) + raw --- - // /design-system.json returns both parsed DESIGN.md and DESIGN.json + // /design-system.json returns both parsed DESIGN.md and .impeccable/design.json // sidecar when present. Panel merges them: // { present, parsed, sidecar, hasMd, hasSidecar, // mdNewerThanJson, parseError?, sidecarError? } // - parsed: output of parseDesignMd (frontmatter // + six canonical sections) when DESIGN.md exists. - // - sidecar: DESIGN.json contents when present. + // - sidecar: .impeccable/design.json contents when present. // Expected shape: schemaVersion 2, carrying // extensions + components + narrative. // /design-system/raw returns DESIGN.md markdown verbatim @@ -426,7 +431,7 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } const mdPath = path.join(CONTEXT_DIR, 'DESIGN.md'); - const jsonPath = path.join(CONTEXT_DIR, 'DESIGN.json'); + const jsonPath = resolveDesignSidecarPath(process.cwd(), CONTEXT_DIR) || getDesignSidecarPath(process.cwd()); const mdStat = statOrNull(mdPath); const jsonStat = statOrNull(jsonPath); @@ -462,7 +467,7 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { try { response.sidecar = JSON.parse(fs.readFileSync(jsonPath, 'utf-8')); } catch (err) { - response.sidecarError = 'Failed to parse DESIGN.json: ' + err.message; + response.sidecarError = 'Failed to parse .impeccable/design.json: ' + err.message; } } @@ -673,7 +678,7 @@ function handlePollPost(req, res) { let httpServer = null; function shutdown() { - try { fs.unlinkSync(LIVE_PID_FILE); } catch {} + removeLiveServerInfo(process.cwd()); if (state.leaseTimer) clearTimeout(state.leaseTimer); state.leaseTimer = null; if (state.sessionDir) { @@ -725,7 +730,7 @@ Endpoints: if (args.includes('stop')) { const keepInject = args.includes('--keep-inject'); try { - const info = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); + const { info } = readLiveServerInfo(process.cwd()) || {}; const res = await fetch(`http://localhost:${info.port}/stop?token=${info.token}`); if (res.ok) console.log(`Stopped live server on port ${info.port}.`); } catch { @@ -776,7 +781,7 @@ if (args.includes('--background')) { const deadline = Date.now() + 10_000; while (Date.now() < deadline) { try { - const info = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); + const { info } = readLiveServerInfo(process.cwd()) || {}; if (info.pid !== process.pid) { // Output JSON so the agent can read port + token from stdout. console.log(JSON.stringify(info)); @@ -790,14 +795,18 @@ if (args.includes('--background')) { } // Check for existing session -try { - const existing = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); - try { process.kill(existing.pid, 0); +const existingRecord = readLiveServerInfo(process.cwd()); +if (existingRecord?.info) { + const existing = existingRecord.info; + try { + process.kill(existing.pid, 0); console.error(`Live server already running on port ${existing.port} (pid ${existing.pid}).`); console.error('Stop it first with: node ' + path.basename(fileURLToPath(import.meta.url)) + ' stop'); process.exit(1); - } catch { fs.unlinkSync(LIVE_PID_FILE); } -} catch {} + } catch { + try { fs.unlinkSync(existingRecord.path); } catch {} + } +} state.token = randomUUID(); state.sessionStore = createLiveSessionStore({ cwd: process.cwd() }); @@ -807,7 +816,7 @@ state.port = portArg ? parseInt(portArg.split('=')[1], 10) : await findOpenPort( // Annotation screenshots live in the project root so the agent's Read tool // doesn't trip a per-file permission prompt. Sessioned by token so concurrent // projects (or quick restarts) don't collide. -const annotRoot = path.join(process.cwd(), '.impeccable-live', 'annotations'); +const annotRoot = getLiveAnnotationsDir(process.cwd()); fs.mkdirSync(annotRoot, { recursive: true }); state.sessionDir = fs.mkdtempSync(path.join(annotRoot, 'session-')); @@ -815,7 +824,7 @@ const { detectScript, sessionPath, livePath } = loadBrowserScripts(); httpServer = http.createServer(createRequestHandler({ detectScript, sessionPath, livePath })); httpServer.listen(state.port, '127.0.0.1', () => { - fs.writeFileSync(LIVE_PID_FILE, JSON.stringify({ pid: process.pid, port: state.port, token: state.token })); + writeLiveServerInfo(process.cwd(), { pid: process.pid, port: state.port, token: state.token }); const url = `http://localhost:${state.port}`; console.log(`\nImpeccable live server running on ${url}`); console.log(`Token: ${state.token}\n`); diff --git a/.trae/skills/impeccable/scripts/live-session-store.mjs b/.trae/skills/impeccable/scripts/live-session-store.mjs index cc7744df..37711168 100644 --- a/.trae/skills/impeccable/scripts/live-session-store.mjs +++ b/.trae/skills/impeccable/scripts/live-session-store.mjs @@ -1,30 +1,43 @@ import fs from 'node:fs'; import path from 'node:path'; +import { getLegacyLiveSessionsDir, getLiveSessionsDir } from './impeccable-paths.mjs'; -const LIVE_DIR = '.impeccable-live'; -const SESSIONS_DIR = 'sessions'; const COMPLETED_PHASES = new Set(['completed', 'discarded']); export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {}) { - const rootDir = path.join(cwd, LIVE_DIR, SESSIONS_DIR); + 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 = getJournalPath(rootDir, id); + const journalPath = getReadableJournalPath(id); const rebuilt = rebuildSnapshotFromJournal(journalPath, id); snapshotCache.set(id, rebuilt); return rebuilt; } + function getReadableJournalPath(id) { + const primary = getJournalPath(rootDir, id); + if (fs.existsSync(primary)) return primary; + const legacy = getJournalPath(legacyRootDir, id); + if (fs.existsSync(legacy)) return legacy; + return primary; + } + return { rootDir, + legacyRootDir, appendEvent(event) { const normalized = normalizeEvent(event, sessionId); const journalPath = getJournalPath(rootDir, normalized.id); const snapshotPath = getSnapshotPath(rootDir, normalized.id); + const legacyJournalPath = getJournalPath(legacyRootDir, normalized.id); + if (!fs.existsSync(journalPath) && fs.existsSync(legacyJournalPath)) { + fs.copyFileSync(legacyJournalPath, journalPath); + } const prior = loadCachedOrRebuild(normalized.id); const seq = prior.nextSeq; const entry = { @@ -42,7 +55,7 @@ export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {}) }, getSnapshot(id = sessionId, opts = {}) { if (!id) throw new Error('session id required'); - const journalPath = getJournalPath(rootDir, id); + const journalPath = getReadableJournalPath(id); const snapshotPath = getSnapshotPath(rootDir, id); const rebuilt = rebuildSnapshotFromJournal(journalPath, id); snapshotCache.set(id, rebuilt); @@ -51,10 +64,15 @@ export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {}) return rebuilt.snapshot; }, listActiveSessions() { - if (!fs.existsSync(rootDir)) return []; - return fs.readdirSync(rootDir) - .filter((name) => name.endsWith('.jsonl')) - .map((name) => name.slice(0, -'.jsonl'.length)) + const ids = new Set(); + for (const dir of [legacyRootDir, rootDir]) { + if (!fs.existsSync(dir)) continue; + for (const name of fs.readdirSync(dir)) { + if (name.endsWith('.jsonl')) ids.add(name.slice(0, -'.jsonl'.length)); + } + } + return [...ids] + .sort() .map((id) => this.getSnapshot(id)) .filter(Boolean); }, diff --git a/.trae/skills/impeccable/scripts/live-status.mjs b/.trae/skills/impeccable/scripts/live-status.mjs index 1b85357c..dce1fbca 100644 --- a/.trae/skills/impeccable/scripts/live-status.mjs +++ b/.trae/skills/impeccable/scripts/live-status.mjs @@ -3,15 +3,11 @@ * Print durable recovery status for Impeccable live sessions. */ -import fs from 'node:fs'; -import path from 'node:path'; import { createLiveSessionStore } from './live-session-store.mjs'; - -const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); +import { readLiveServerInfo } from './impeccable-paths.mjs'; function readServerInfo() { - try { return JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); } - catch { return null; } + return readLiveServerInfo(process.cwd())?.info || null; } async function fetchServerStatus(info) { diff --git a/.trae/skills/impeccable/scripts/live.mjs b/.trae/skills/impeccable/scripts/live.mjs index befbdb8e..cafb0eca 100644 --- a/.trae/skills/impeccable/scripts/live.mjs +++ b/.trae/skills/impeccable/scripts/live.mjs @@ -2,10 +2,10 @@ * CLI entry point: prepare everything needed to enter the live variant poll loop. * * Does (all in one command): - * 1. Check config.json (returns config_missing if first-ever run) + * 1. Check .impeccable/live/config.json (returns config_missing if first-ever run) * 2. Start the live server in the background (or reuse a running one) * 3. Inject the browser script tag into the project's entry file - * 4. Read .impeccable.md for design context (if present) + * 4. Read PRODUCT.md / DESIGN.md for project context * 5. Print a single JSON blob with everything the agent needs * * After this, the agent's only remaining steps are: @@ -23,9 +23,9 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { loadContext } from './load-context.mjs'; import { resolveFiles } from './live-inject.mjs'; +import { readLiveServerInfo } from './impeccable-paths.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); async function liveCli() { const args = process.argv.slice(2); @@ -34,10 +34,10 @@ async function liveCli() { console.log(`Usage: node live.mjs Prepare everything for live variant mode in a single command: - - Checks scripts/config.json (required, created once per project) + - Checks .impeccable/live/config.json (required, created once per project) - Starts (or reuses) the live server in the background - Injects the browser script tag - - Reads .impeccable.md for design context + - Reads PRODUCT.md / DESIGN.md for project context On success, prints a JSON blob with: { ok, serverPort, serverToken, pageFile, hasContext, context } @@ -223,7 +223,7 @@ function safeParse(out) { function ensureServerRunning() { // Try to reuse an existing server try { - const existing = JSON.parse(fs.readFileSync(PID_FILE, 'utf-8')); + const existing = readLiveServerInfo(process.cwd())?.info; if (existing && existing.pid) { try { process.kill(existing.pid, 0); // throws if dead diff --git a/.trae/skills/impeccable/scripts/load-context.mjs b/.trae/skills/impeccable/scripts/load-context.mjs index dca23c1f..dc340bf1 100644 --- a/.trae/skills/impeccable/scripts/load-context.mjs +++ b/.trae/skills/impeccable/scripts/load-context.mjs @@ -39,7 +39,7 @@ const LEGACY_NAMES = ['.impeccable.md']; const FALLBACK_DIRS = ['.agents/context', 'docs']; /** - * Resolve the directory that holds PRODUCT.md / DESIGN.md / DESIGN.json for + * Resolve the directory that holds PRODUCT.md / DESIGN.md for * this project. Exported so other scripts (e.g. live-server.mjs) can read the * design files from the same location the loader uses. */ diff --git a/README.md b/README.md index 962b568b..ed5c2ee0 100644 --- a/README.md +++ b/README.md @@ -38,8 +38,8 @@ All commands are accessed through `/impeccable`: | Command | What it does | |---------|--------------| | `/impeccable craft` | Full shape-then-build flow with visual iteration | -| `/impeccable teach` | One-time setup: gather design context, write PRODUCT.md and DESIGN.md | -| `/impeccable document` | Generate DESIGN.md from existing project code | +| `/impeccable teach` | One-time setup: gather design context, write root PRODUCT.md and DESIGN.md | +| `/impeccable document` | Generate root DESIGN.md from existing project code | | `/impeccable extract` | Pull reusable components and tokens into the design system | | `/impeccable shape` | Plan UX/UI before writing code | | `/impeccable critique` | UX design review: hierarchy, clarity, emotional resonance | diff --git a/docs/adr-live-variant-mode.md b/docs/adr-live-variant-mode.md index 82c978c0..0390e2a9 100644 --- a/docs/adr-live-variant-mode.md +++ b/docs/adr-live-variant-mode.md @@ -82,7 +82,7 @@ For dev servers that don't support HMR (like Bun's static HTML import), the brow │ └── GET /stop — graceful shutdown │ │ │ │ State: session token, SSE client set, event queue, poll queue │ - │ PID file: .impeccable-live.json (project root) │ + │ Server file: .impeccable/live/server.json (project root) │ │ │ └────────────┬──────────────────────────────────┬─────────────────┘ │ GET /poll (long-poll) │ POST /poll (reply) @@ -228,7 +228,7 @@ This survives page reloads, browser close/reopen, HMR, and accidental refreshes. - **Session token**: `crypto.randomUUID()`, checked on all mutating endpoints and SSE connections. - **Localhost only**: server binds to `127.0.0.1`, not `0.0.0.0`. -- **Token in PID file**: `.impeccable-live.json` in project root. Only the user's processes can read it. +- **Token in server file**: `.impeccable/live/server.json` in project root. Only the user's processes can read it. - **Token injected into `/live.js`**: the server prepends `window.__IMPECCABLE_TOKEN__` at serve time. - **Path traversal guard**: `/source` endpoint validates the requested path is within `process.cwd()`. - **No eval/innerHTML**: all browser UI built with `createElement` and `textContent`. diff --git a/package.json b/package.json index 7cb2de0e..3d455eb0 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ "dev": "npx astro dev", "preview": "bun run build && npx astro preview", "deploy": "bun run build && wrangler pages deploy build/", - "test": "bun test tests/build.test.js tests/detect-antipatterns.test.js tests/windows-path-fix.test.js && node --test tests/detect-antipatterns-fixtures.test.mjs && node --test tests/detect-antipatterns-browser.test.mjs && node --test tests/cleanup-deprecated.test.mjs && node --test tests/live-wrap.test.mjs && node --test tests/live-accept.test.mjs && node --test tests/live-inject.test.mjs && node --test tests/live-poll.test.mjs && node --test tests/live-server.test.mjs && node --test tests/live-browser-regression.test.mjs && node --test tests/live-session-store.test.mjs && node --test tests/live-browser-session.test.mjs && node --test tests/live-browser-source.test.mjs && node --test tests/live-completion.test.mjs && node --test tests/live-recovery-commands.test.mjs && node --test tests/framework-fixtures.test.mjs", + "test": "bun test tests/build.test.js tests/detect-antipatterns.test.js tests/windows-path-fix.test.js && node --test tests/detect-antipatterns-fixtures.test.mjs && node --test tests/detect-antipatterns-browser.test.mjs && node --test tests/cleanup-deprecated.test.mjs && node --test tests/impeccable-paths.test.mjs && node --test tests/live-wrap.test.mjs && node --test tests/live-accept.test.mjs && node --test tests/live-inject.test.mjs && node --test tests/live-poll.test.mjs && node --test tests/live-server.test.mjs && node --test tests/live-browser-regression.test.mjs && node --test tests/live-session-store.test.mjs && node --test tests/live-browser-session.test.mjs && node --test tests/live-browser-source.test.mjs && node --test tests/live-completion.test.mjs && node --test tests/live-recovery-commands.test.mjs && node --test tests/framework-fixtures.test.mjs", "test:live-e2e": "node --test --test-timeout=600000 tests/live-e2e.test.mjs", "audit": "bun audit --audit-level=moderate", "prepack": "cp README.md README.repo.md && cp README.npm.md README.md", diff --git a/plugin/skills/impeccable/reference/document.md b/plugin/skills/impeccable/reference/document.md index 44f123be..a091ec98 100644 --- a/plugin/skills/impeccable/reference/document.md +++ b/plugin/skills/impeccable/reference/document.md @@ -237,11 +237,11 @@ Concrete, forceful guardrails. Lead each with "Do" or "Don't". Be specific: incl - **Don't** [...] ``` -### Step 4b: Write DESIGN.json sidecar (extensions only) +### Step 4b: Write .impeccable/design.json sidecar (extensions only) -The frontmatter owns token primitives (colors, typography, rounded, spacing, components). The sidecar at `DESIGN.json` carries **what Stitch's schema can't hold**: tonal ramps per color, shadow/elevation tokens, motion tokens, breakpoints, full component HTML/CSS snippets (the panel renders these into a shadow DOM), and narrative (north star, rules, do's/don'ts). It extends the frontmatter, it doesn't duplicate it. +The frontmatter owns token primitives (colors, typography, rounded, spacing, components). The sidecar at `.impeccable/design.json` carries **what Stitch's schema can't hold**: tonal ramps per color, shadow/elevation tokens, motion tokens, breakpoints, full component HTML/CSS snippets (the panel renders these into a shadow DOM), and narrative (north star, rules, do's/don'ts). It extends the frontmatter, it doesn't duplicate it. -Regenerate the sidecar whenever you regenerate DESIGN.md. If the user only asks to refresh the sidecar (e.g., from the live panel's stale-hint), preserve DESIGN.md and write only DESIGN.json. +Regenerate the sidecar whenever you regenerate root `DESIGN.md`. If the user only asks to refresh the sidecar (e.g., from the live panel's stale-hint), preserve `DESIGN.md` and write only `.impeccable/design.json`. #### Schema @@ -310,7 +310,7 @@ Aim for a tight set of **5-10 components** that best represent the visual system - **Signature components (include if distinctive):** hero CTA, featured card, filter pill, any custom pattern the user mentioned as important in PRODUCT.md. - **Skip the rest.** Utility components, form building blocks, wrapper layouts: not worth documenting unless visually distinctive. -If the project has **no component library yet** (bare landing page, new project), synthesize canonical primitives from the tokens using best-practice defaults consistent with the DESIGN.md's rules. Every DESIGN.json has *something* to render, even on day zero. +If the project has **no component library yet** (bare landing page, new project), synthesize canonical primitives from the tokens using best-practice defaults consistent with the DESIGN.md's rules. Every `.impeccable/design.json` has *something* to render, even on day zero. #### Tonal ramps @@ -331,7 +331,7 @@ Do not reword. The panel shows these as secondary collapsible context; the same ### Step 5: Confirm, refine, and refresh session cache 1. Show the user the full DESIGN.md you wrote. Briefly highlight the non-obvious creative choices (descriptive color names, atmosphere language, named rules). -2. Mention that `DESIGN.json` was also written alongside; the live panel will now render this project's actual button/input/nav primitives instead of generic approximations. +2. Mention that `.impeccable/design.json` was also written alongside; the live panel will now render this project's actual button/input/nav primitives instead of generic approximations. 3. Offer to refine any section: "Want me to revise a section, add component patterns I missed, or adjust the atmosphere language?" 4. **Refresh the session cache.** Run `node .claude/skills/impeccable/scripts/load-context.mjs` one final time so the newly-written DESIGN.md lands in conversation. Subsequent commands in this session will use the fresh version automatically without re-reading. @@ -392,7 +392,7 @@ Per-section guidance in seed mode: - **Components**: omit entirely; no components exist yet. - **Do's and Don'ts**: carry PRODUCT.md's anti-references directly plus the anti-reference named in Q5. -Seed mode writes a minimal frontmatter with `name` and `description` only; no colors, typography, rounded, spacing, or components yet. Real tokens land on the next Scan-mode run. Skip the `DESIGN.json` sidecar in seed mode for the same reason: nothing to render. +Seed mode writes a minimal frontmatter with `name` and `description` only; no colors, typography, rounded, spacing, or components yet. Real tokens land on the next Scan-mode run. Skip the `.impeccable/design.json` sidecar in seed mode for the same reason: nothing to render. ### Step 4: Confirm and refresh session cache diff --git a/plugin/skills/impeccable/reference/live.md b/plugin/skills/impeccable/reference/live.md index 1dbe9b70..ee92d234 100644 --- a/plugin/skills/impeccable/reference/live.md +++ b/plugin/skills/impeccable/reference/live.md @@ -53,7 +53,7 @@ LOOP: ## Recovery commands -The live helper persists an append-only journal under `.impeccable-live/sessions`. Browser checkpoints are advisory but durable; the journal is canonical. +The live helper persists an append-only journal under `.impeccable/live/sessions/`. Browser checkpoints are advisory but durable; the journal is canonical. This is local durable recovery state, not project source. Use these commands when the chat was interrupted, polling was missed, the helper restarted, or the browser reloaded: @@ -473,7 +473,7 @@ When the poll returns `exit`, proceed to cleanup. If the poll is still running a node .claude/skills/impeccable/scripts/live-server.mjs stop ``` -Stops the HTTP server and runs `live-inject.mjs --remove` to strip `localhost:…/live.js` from the HTML entry. To stop the server but keep the inject tag (for a quick restart), use `stop --keep-inject`. `config.json` persists for future sessions. +Stops the HTTP server and runs `live-inject.mjs --remove` to strip `localhost:…/live.js` from the HTML entry. To stop the server but keep the inject tag (for a quick restart), use `stop --keep-inject`. `.impeccable/live/config.json` persists as project config for future sessions. Then: - Remove any leftover variant wrappers (search for `impeccable-variants-start` markers). @@ -481,7 +481,7 @@ Then: ## First-time setup (config missing or invalid) -If `live.mjs` outputs `{ ok: false, error: "config_missing" | "config_invalid", path }`, write `config.json` at the reported path. +If `live.mjs` outputs `{ ok: false, error: "config_missing" | "config_invalid", path }`, write the live config at the reported path. By default this is `.impeccable/live/config.json`. Schema: @@ -561,7 +561,7 @@ node .claude/skills/impeccable/scripts/detect-csp.mjs Output: `{ shape, signals }` where `shape` is one of `append-arrays`, `append-string`, `middleware`, `meta-tag`, or `null`. The shape is named by *patch mechanism*, so one template covers many frameworks. -- **`null`**: no CSP; skip to writing `config.json` with `cspChecked: true`. +- **`null`**: no CSP; skip to writing `.impeccable/live/config.json` with `cspChecked: true`. - **`append-arrays`**: CSP defined as structured directive arrays. Auto-patchable. See *append-arrays* below. Covers: - Monorepo helpers with `additionalScriptSrc` / `additionalConnectSrc` options (Next.js + shared config package) - SvelteKit `kit.csp.directives` @@ -638,6 +638,6 @@ Reference outputs: ### Troubleshooting -If a user says "no" to the CSP patch at setup time and later complains that live doesn't work: their dev CSP blocks `http://localhost:8400`. Fix: delete `cspChecked` from `config.json` and re-run `live.mjs`: setup will ask again. +If a user says "no" to the CSP patch at setup time and later complains that live doesn't work: their dev CSP blocks `http://localhost:8400`. Fix: delete `cspChecked` from `.impeccable/live/config.json` and re-run `live.mjs`: setup will ask again. Then re-run `live.mjs`. diff --git a/plugin/skills/impeccable/reference/teach.md b/plugin/skills/impeccable/reference/teach.md index 2aeeb9d7..34a18905 100644 --- a/plugin/skills/impeccable/reference/teach.md +++ b/plugin/skills/impeccable/reference/teach.md @@ -2,8 +2,8 @@ Gathers design context for a project and writes two complementary files at the project root: -- **PRODUCT.md** (strategic): register, target users, product purpose, brand personality, anti-references, strategic design principles. Answers "who/what/why". -- **DESIGN.md** (visual): visual theme, color palette, typography, components, layout. Follows the [Google Stitch DESIGN.md format](https://stitch.withgoogle.com/docs/design-md/format/). Answers "how it looks". +- **PRODUCT.md** (strategic): root project file for register, target users, product purpose, brand personality, anti-references, strategic design principles. Answers "who/what/why". +- **DESIGN.md** (visual): root project file for visual theme, color palette, typography, components, layout. Follows the [Google Stitch DESIGN.md format](https://stitch.withgoogle.com/docs/design-md/format/). Answers "how it looks". Every other impeccable command reads these files before doing any work. diff --git a/plugin/skills/impeccable/scripts/impeccable-paths.mjs b/plugin/skills/impeccable/scripts/impeccable-paths.mjs new file mode 100644 index 00000000..ba852bae --- /dev/null +++ b/plugin/skills/impeccable/scripts/impeccable-paths.mjs @@ -0,0 +1,105 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +export const IMPECCABLE_DIR = '.impeccable'; +export const LIVE_DIR = 'live'; + +export function getImpeccableDir(cwd = process.cwd()) { + return path.join(cwd, IMPECCABLE_DIR); +} + +export function getDesignSidecarPath(cwd = process.cwd()) { + return path.join(getImpeccableDir(cwd), 'design.json'); +} + +export function getDesignSidecarCandidates(cwd = process.cwd(), contextDir = cwd) { + const candidates = [ + getDesignSidecarPath(cwd), + path.join(cwd, 'DESIGN.json'), + ]; + const contextLegacy = path.join(contextDir, 'DESIGN.json'); + if (!candidates.includes(contextLegacy)) candidates.push(contextLegacy); + return candidates; +} + +export function resolveDesignSidecarPath(cwd = process.cwd(), contextDir = cwd) { + return firstExisting(getDesignSidecarCandidates(cwd, contextDir)); +} + +export function getLiveDir(cwd = process.cwd()) { + return path.join(getImpeccableDir(cwd), LIVE_DIR); +} + +export function getLiveConfigPath(cwd = process.cwd()) { + return path.join(getLiveDir(cwd), 'config.json'); +} + +export function getLegacyLiveConfigPath(scriptsDir) { + return path.join(scriptsDir, 'config.json'); +} + +export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = process.env } = {}) { + if (env.IMPECCABLE_LIVE_CONFIG && env.IMPECCABLE_LIVE_CONFIG.trim()) { + const configured = env.IMPECCABLE_LIVE_CONFIG.trim(); + return path.isAbsolute(configured) ? configured : path.resolve(cwd, configured); + } + const primary = getLiveConfigPath(cwd); + if (fs.existsSync(primary)) return primary; + if (scriptsDir) { + const legacy = getLegacyLiveConfigPath(scriptsDir); + if (fs.existsSync(legacy)) return legacy; + } + return primary; +} + +export function getLiveServerPath(cwd = process.cwd()) { + return path.join(getLiveDir(cwd), 'server.json'); +} + +export function getLegacyLiveServerPath(cwd = process.cwd()) { + return path.join(cwd, '.impeccable-live.json'); +} + +export function readLiveServerInfo(cwd = process.cwd()) { + for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) { + try { + return { info: JSON.parse(fs.readFileSync(filePath, 'utf-8')), path: filePath }; + } catch { + /* try next */ + } + } + return null; +} + +export function writeLiveServerInfo(cwd = process.cwd(), info) { + const filePath = getLiveServerPath(cwd); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, JSON.stringify(info)); + return filePath; +} + +export function removeLiveServerInfo(cwd = process.cwd()) { + for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) { + try { fs.unlinkSync(filePath); } catch {} + } +} + +export function getLiveSessionsDir(cwd = process.cwd()) { + return path.join(getLiveDir(cwd), 'sessions'); +} + +export function getLegacyLiveSessionsDir(cwd = process.cwd()) { + return path.join(cwd, '.impeccable-live', 'sessions'); +} + +export function getLiveAnnotationsDir(cwd = process.cwd()) { + return path.join(getLiveDir(cwd), 'annotations'); +} + +export function getLegacyLiveAnnotationsDir(cwd = process.cwd()) { + return path.join(cwd, '.impeccable-live', 'annotations'); +} + +function firstExisting(paths) { + return paths.find((filePath) => fs.existsSync(filePath)) || null; +} diff --git a/plugin/skills/impeccable/scripts/live-browser.js b/plugin/skills/impeccable/scripts/live-browser.js index 7f1ff329..bb5332a3 100644 --- a/plugin/skills/impeccable/scripts/live-browser.js +++ b/plugin/skills/impeccable/scripts/live-browser.js @@ -3671,7 +3671,7 @@ void main() { } // --------------------------------------------------------------------------- - // Design System Panel — visualizes the project's DESIGN.json sidecar + // Design System Panel — visualizes the project's .impeccable/design.json sidecar // --------------------------------------------------------------------------- const DESIGN_PREFS_KEY = 'impeccable-live-design-panel'; @@ -3683,7 +3683,7 @@ void main() { open: false, tab: 'visual', // 'visual' | 'raw' parsed: null, // parseDesignMd output (frontmatter + body sections) - sidecar: null, // DESIGN.json v2 payload (extensions + components + narrative) + sidecar: null, // .impeccable/design.json v2 payload (extensions + components + narrative) hasMd: false, hasSidecar: false, present: null, // true/false once fetch resolves @@ -4184,7 +4184,7 @@ void main() { box.className = 'stale'; box.innerHTML = ` - DESIGN.md is newer than DESIGN.json. Run /impeccable document to refresh the sidecar. + DESIGN.md is newer than .impeccable/design.json. Run /impeccable document to refresh the sidecar. `; return box; } @@ -4192,7 +4192,7 @@ void main() { function renderParsedMdCta() { const box = document.createElement('div'); box.className = 'parsed-md-cta'; - box.innerHTML = `Basic viewThis panel reads the tokens in your DESIGN.md frontmatter. Running /impeccable document also generates a DESIGN.json sidecar with your project's actual component snippets (button, input, nav) and tonal ramps, rendered live below the tokens.`; + box.innerHTML = `Basic viewThis panel reads the tokens in your DESIGN.md frontmatter. Running /impeccable document also generates a .impeccable/design.json sidecar with your project's actual component snippets (button, input, nav) and tonal ramps, rendered live below the tokens.`; return box; } @@ -4652,7 +4652,7 @@ void main() { function cssSafe(v) { // Strip anything outside valid CSS value chars to prevent injection via - // DESIGN.json values rendered into inline style strings. + // .impeccable/design.json values rendered into inline style strings. return String(v).replace(/[<>"'`\n]/g, ''); } diff --git a/plugin/skills/impeccable/scripts/live-complete.mjs b/plugin/skills/impeccable/scripts/live-complete.mjs index ca00d86a..78155af8 100644 --- a/plugin/skills/impeccable/scripts/live-complete.mjs +++ b/plugin/skills/impeccable/scripts/live-complete.mjs @@ -4,10 +4,7 @@ */ import { createLiveSessionStore } from './live-session-store.mjs'; -import fs from 'node:fs'; -import path from 'node:path'; - -const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); +import { readLiveServerInfo } from './impeccable-paths.mjs'; function parseArgs(argv) { const out = { status: 'complete' }; @@ -50,8 +47,7 @@ export async function completeCli() { } function readServerInfo() { - try { return JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); } - catch { return null; } + return readLiveServerInfo(process.cwd())?.info || null; } async function completeThroughServer(info, args) { diff --git a/plugin/skills/impeccable/scripts/live-inject.mjs b/plugin/skills/impeccable/scripts/live-inject.mjs index 1d2ae12f..555c3a36 100644 --- a/plugin/skills/impeccable/scripts/live-inject.mjs +++ b/plugin/skills/impeccable/scripts/live-inject.mjs @@ -2,23 +2,24 @@ * CLI helper: insert/remove the live variant mode script tag in the project's * main HTML entry point. * - * On first live run, the agent generates `config.json` in this script's - * directory with the project's insertion target (framework-specific). On + * On first live run, the agent generates `.impeccable/live/config.json` + * with the project's insertion target (framework-specific). On * every subsequent run, this script handles insert/remove deterministically * with zero LLM involvement. * * Usage: * node live-inject.mjs --port PORT # Insert the live script tag * node live-inject.mjs --remove # Remove the live script tag - * node live-inject.mjs --check # Check whether config.json exists + * node live-inject.mjs --check # Check whether live config exists */ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { resolveLiveConfigPath } from './impeccable-paths.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const CONFIG_PATH = process.env.IMPECCABLE_LIVE_CONFIG || path.join(__dirname, 'config.json'); +const CONFIG_PATH = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname }); const MARKER_OPEN_TEXT = 'impeccable-live-start'; const MARKER_CLOSE_TEXT = 'impeccable-live-end'; @@ -39,12 +40,12 @@ export async function injectCli() { console.log(`Usage: node live-inject.mjs [options] Insert or remove the live mode script tag in the project's HTML entry point. -Reads configuration from config.json (in this same directory). +Reads configuration from .impeccable/live/config.json. Modes: --port PORT Insert script tag pointing at http://localhost:PORT/live.js --remove Remove the script tag (if present) - --check Print whether config.json exists and its content + --check Print whether .impeccable/live/config.json exists and its content Output (JSON): { ok, file, inserted|removed, config? }`); diff --git a/plugin/skills/impeccable/scripts/live-poll.mjs b/plugin/skills/impeccable/scripts/live-poll.mjs index 9a3f07ae..83a9912e 100644 --- a/plugin/skills/impeccable/scripts/live-poll.mjs +++ b/plugin/skills/impeccable/scripts/live-poll.mjs @@ -9,11 +9,10 @@ */ import { execFileSync } from 'node:child_process'; -import fs from 'node:fs'; import path from 'node:path'; -import os from 'node:os'; import { fileURLToPath } from 'node:url'; import { completionTypeForAcceptResult } from './live-completion.mjs'; +import { readLiveServerInfo } from './impeccable-paths.mjs'; // Node's built-in fetch (undici under the hood) enforces a 300s headers // timeout that can't be lowered per-request. We cap each request below @@ -21,15 +20,13 @@ import { completionTypeForAcceptResult } from './live-completion.mjs'; // depending on the standalone undici package. const PER_REQUEST_TIMEOUT_MS = 270_000; -const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); - function readServerInfo() { - try { - return JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); - } catch { + const record = readLiveServerInfo(process.cwd()); + if (!record) { console.error('No running live server found. Start one with: npx impeccable live'); process.exit(1); } + return record.info; } export function buildPollReplyPayload(token, { id, type, message, file, data }) { diff --git a/plugin/skills/impeccable/scripts/live-server.mjs b/plugin/skills/impeccable/scripts/live-server.mjs index e78a0657..53d8f21c 100644 --- a/plugin/skills/impeccable/scripts/live-server.mjs +++ b/plugin/skills/impeccable/scripts/live-server.mjs @@ -23,14 +23,19 @@ import { fileURLToPath } from 'node:url'; import { parseDesignMd } from './design-parser.mjs'; import { resolveContextDir } from './load-context.mjs'; import { createLiveSessionStore } from './live-session-store.mjs'; +import { + getDesignSidecarPath, + getLiveAnnotationsDir, + readLiveServerInfo, + removeLiveServerInfo, + resolveDesignSidecarPath, + writeLiveServerInfo, +} from './impeccable-paths.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -// PID file in the project root so both the server and agent can find it -// predictably (os.tmpdir() varies across platforms). -const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); -// PRODUCT.md / DESIGN.md / DESIGN.json live wherever load-context.mjs resolves. -// Keeps live-server in sync with the loader when users keep the docs in -// .agents/context/, docs/, or a path set via IMPECCABLE_CONTEXT_DIR. +// PRODUCT.md / DESIGN.md live wherever load-context.mjs resolves. The generated +// DESIGN sidecar is project-local at .impeccable/design.json, with legacy +// DESIGN.json fallback for existing projects. const CONTEXT_DIR = resolveContextDir(process.cwd()); const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s @@ -411,13 +416,13 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { } // --- Design system (unified v2 response) + raw --- - // /design-system.json returns both parsed DESIGN.md and DESIGN.json + // /design-system.json returns both parsed DESIGN.md and .impeccable/design.json // sidecar when present. Panel merges them: // { present, parsed, sidecar, hasMd, hasSidecar, // mdNewerThanJson, parseError?, sidecarError? } // - parsed: output of parseDesignMd (frontmatter // + six canonical sections) when DESIGN.md exists. - // - sidecar: DESIGN.json contents when present. + // - sidecar: .impeccable/design.json contents when present. // Expected shape: schemaVersion 2, carrying // extensions + components + narrative. // /design-system/raw returns DESIGN.md markdown verbatim @@ -426,7 +431,7 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } const mdPath = path.join(CONTEXT_DIR, 'DESIGN.md'); - const jsonPath = path.join(CONTEXT_DIR, 'DESIGN.json'); + const jsonPath = resolveDesignSidecarPath(process.cwd(), CONTEXT_DIR) || getDesignSidecarPath(process.cwd()); const mdStat = statOrNull(mdPath); const jsonStat = statOrNull(jsonPath); @@ -462,7 +467,7 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { try { response.sidecar = JSON.parse(fs.readFileSync(jsonPath, 'utf-8')); } catch (err) { - response.sidecarError = 'Failed to parse DESIGN.json: ' + err.message; + response.sidecarError = 'Failed to parse .impeccable/design.json: ' + err.message; } } @@ -673,7 +678,7 @@ function handlePollPost(req, res) { let httpServer = null; function shutdown() { - try { fs.unlinkSync(LIVE_PID_FILE); } catch {} + removeLiveServerInfo(process.cwd()); if (state.leaseTimer) clearTimeout(state.leaseTimer); state.leaseTimer = null; if (state.sessionDir) { @@ -725,7 +730,7 @@ Endpoints: if (args.includes('stop')) { const keepInject = args.includes('--keep-inject'); try { - const info = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); + const { info } = readLiveServerInfo(process.cwd()) || {}; const res = await fetch(`http://localhost:${info.port}/stop?token=${info.token}`); if (res.ok) console.log(`Stopped live server on port ${info.port}.`); } catch { @@ -776,7 +781,7 @@ if (args.includes('--background')) { const deadline = Date.now() + 10_000; while (Date.now() < deadline) { try { - const info = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); + const { info } = readLiveServerInfo(process.cwd()) || {}; if (info.pid !== process.pid) { // Output JSON so the agent can read port + token from stdout. console.log(JSON.stringify(info)); @@ -790,14 +795,18 @@ if (args.includes('--background')) { } // Check for existing session -try { - const existing = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); - try { process.kill(existing.pid, 0); +const existingRecord = readLiveServerInfo(process.cwd()); +if (existingRecord?.info) { + const existing = existingRecord.info; + try { + process.kill(existing.pid, 0); console.error(`Live server already running on port ${existing.port} (pid ${existing.pid}).`); console.error('Stop it first with: node ' + path.basename(fileURLToPath(import.meta.url)) + ' stop'); process.exit(1); - } catch { fs.unlinkSync(LIVE_PID_FILE); } -} catch {} + } catch { + try { fs.unlinkSync(existingRecord.path); } catch {} + } +} state.token = randomUUID(); state.sessionStore = createLiveSessionStore({ cwd: process.cwd() }); @@ -807,7 +816,7 @@ state.port = portArg ? parseInt(portArg.split('=')[1], 10) : await findOpenPort( // Annotation screenshots live in the project root so the agent's Read tool // doesn't trip a per-file permission prompt. Sessioned by token so concurrent // projects (or quick restarts) don't collide. -const annotRoot = path.join(process.cwd(), '.impeccable-live', 'annotations'); +const annotRoot = getLiveAnnotationsDir(process.cwd()); fs.mkdirSync(annotRoot, { recursive: true }); state.sessionDir = fs.mkdtempSync(path.join(annotRoot, 'session-')); @@ -815,7 +824,7 @@ const { detectScript, sessionPath, livePath } = loadBrowserScripts(); httpServer = http.createServer(createRequestHandler({ detectScript, sessionPath, livePath })); httpServer.listen(state.port, '127.0.0.1', () => { - fs.writeFileSync(LIVE_PID_FILE, JSON.stringify({ pid: process.pid, port: state.port, token: state.token })); + writeLiveServerInfo(process.cwd(), { pid: process.pid, port: state.port, token: state.token }); const url = `http://localhost:${state.port}`; console.log(`\nImpeccable live server running on ${url}`); console.log(`Token: ${state.token}\n`); diff --git a/plugin/skills/impeccable/scripts/live-session-store.mjs b/plugin/skills/impeccable/scripts/live-session-store.mjs index cc7744df..37711168 100644 --- a/plugin/skills/impeccable/scripts/live-session-store.mjs +++ b/plugin/skills/impeccable/scripts/live-session-store.mjs @@ -1,30 +1,43 @@ import fs from 'node:fs'; import path from 'node:path'; +import { getLegacyLiveSessionsDir, getLiveSessionsDir } from './impeccable-paths.mjs'; -const LIVE_DIR = '.impeccable-live'; -const SESSIONS_DIR = 'sessions'; const COMPLETED_PHASES = new Set(['completed', 'discarded']); export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {}) { - const rootDir = path.join(cwd, LIVE_DIR, SESSIONS_DIR); + 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 = getJournalPath(rootDir, id); + const journalPath = getReadableJournalPath(id); const rebuilt = rebuildSnapshotFromJournal(journalPath, id); snapshotCache.set(id, rebuilt); return rebuilt; } + function getReadableJournalPath(id) { + const primary = getJournalPath(rootDir, id); + if (fs.existsSync(primary)) return primary; + const legacy = getJournalPath(legacyRootDir, id); + if (fs.existsSync(legacy)) return legacy; + return primary; + } + return { rootDir, + legacyRootDir, appendEvent(event) { const normalized = normalizeEvent(event, sessionId); const journalPath = getJournalPath(rootDir, normalized.id); const snapshotPath = getSnapshotPath(rootDir, normalized.id); + const legacyJournalPath = getJournalPath(legacyRootDir, normalized.id); + if (!fs.existsSync(journalPath) && fs.existsSync(legacyJournalPath)) { + fs.copyFileSync(legacyJournalPath, journalPath); + } const prior = loadCachedOrRebuild(normalized.id); const seq = prior.nextSeq; const entry = { @@ -42,7 +55,7 @@ export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {}) }, getSnapshot(id = sessionId, opts = {}) { if (!id) throw new Error('session id required'); - const journalPath = getJournalPath(rootDir, id); + const journalPath = getReadableJournalPath(id); const snapshotPath = getSnapshotPath(rootDir, id); const rebuilt = rebuildSnapshotFromJournal(journalPath, id); snapshotCache.set(id, rebuilt); @@ -51,10 +64,15 @@ export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {}) return rebuilt.snapshot; }, listActiveSessions() { - if (!fs.existsSync(rootDir)) return []; - return fs.readdirSync(rootDir) - .filter((name) => name.endsWith('.jsonl')) - .map((name) => name.slice(0, -'.jsonl'.length)) + const ids = new Set(); + for (const dir of [legacyRootDir, rootDir]) { + if (!fs.existsSync(dir)) continue; + for (const name of fs.readdirSync(dir)) { + if (name.endsWith('.jsonl')) ids.add(name.slice(0, -'.jsonl'.length)); + } + } + return [...ids] + .sort() .map((id) => this.getSnapshot(id)) .filter(Boolean); }, diff --git a/plugin/skills/impeccable/scripts/live-status.mjs b/plugin/skills/impeccable/scripts/live-status.mjs index 1b85357c..dce1fbca 100644 --- a/plugin/skills/impeccable/scripts/live-status.mjs +++ b/plugin/skills/impeccable/scripts/live-status.mjs @@ -3,15 +3,11 @@ * Print durable recovery status for Impeccable live sessions. */ -import fs from 'node:fs'; -import path from 'node:path'; import { createLiveSessionStore } from './live-session-store.mjs'; - -const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); +import { readLiveServerInfo } from './impeccable-paths.mjs'; function readServerInfo() { - try { return JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); } - catch { return null; } + return readLiveServerInfo(process.cwd())?.info || null; } async function fetchServerStatus(info) { diff --git a/plugin/skills/impeccable/scripts/live.mjs b/plugin/skills/impeccable/scripts/live.mjs index befbdb8e..cafb0eca 100644 --- a/plugin/skills/impeccable/scripts/live.mjs +++ b/plugin/skills/impeccable/scripts/live.mjs @@ -2,10 +2,10 @@ * CLI entry point: prepare everything needed to enter the live variant poll loop. * * Does (all in one command): - * 1. Check config.json (returns config_missing if first-ever run) + * 1. Check .impeccable/live/config.json (returns config_missing if first-ever run) * 2. Start the live server in the background (or reuse a running one) * 3. Inject the browser script tag into the project's entry file - * 4. Read .impeccable.md for design context (if present) + * 4. Read PRODUCT.md / DESIGN.md for project context * 5. Print a single JSON blob with everything the agent needs * * After this, the agent's only remaining steps are: @@ -23,9 +23,9 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { loadContext } from './load-context.mjs'; import { resolveFiles } from './live-inject.mjs'; +import { readLiveServerInfo } from './impeccable-paths.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); async function liveCli() { const args = process.argv.slice(2); @@ -34,10 +34,10 @@ async function liveCli() { console.log(`Usage: node live.mjs Prepare everything for live variant mode in a single command: - - Checks scripts/config.json (required, created once per project) + - Checks .impeccable/live/config.json (required, created once per project) - Starts (or reuses) the live server in the background - Injects the browser script tag - - Reads .impeccable.md for design context + - Reads PRODUCT.md / DESIGN.md for project context On success, prints a JSON blob with: { ok, serverPort, serverToken, pageFile, hasContext, context } @@ -223,7 +223,7 @@ function safeParse(out) { function ensureServerRunning() { // Try to reuse an existing server try { - const existing = JSON.parse(fs.readFileSync(PID_FILE, 'utf-8')); + const existing = readLiveServerInfo(process.cwd())?.info; if (existing && existing.pid) { try { process.kill(existing.pid, 0); // throws if dead diff --git a/plugin/skills/impeccable/scripts/load-context.mjs b/plugin/skills/impeccable/scripts/load-context.mjs index dca23c1f..dc340bf1 100644 --- a/plugin/skills/impeccable/scripts/load-context.mjs +++ b/plugin/skills/impeccable/scripts/load-context.mjs @@ -39,7 +39,7 @@ const LEGACY_NAMES = ['.impeccable.md']; const FALLBACK_DIRS = ['.agents/context', 'docs']; /** - * Resolve the directory that holds PRODUCT.md / DESIGN.md / DESIGN.json for + * Resolve the directory that holds PRODUCT.md / DESIGN.md for * this project. Exported so other scripts (e.g. live-server.mjs) can read the * design files from the same location the loader uses. */ diff --git a/scripts/build.js b/scripts/build.js index e78fdb99..89f09c8e 100644 --- a/scripts/build.js +++ b/scripts/build.js @@ -744,7 +744,7 @@ async function build() { const skillsDest = path.join(ROOT_DIR, configDir, 'skills'); if (fs.existsSync(skillsSrc)) { - // Preserve per-project script artifacts (e.g. live-mode config.json) + // Preserve legacy per-project script artifacts (e.g. live-mode config.json) // across the rm + recopy. The build intentionally doesn't ship them, // so without this the sync destroys local state on every rebuild. const stashed = stashPerProjectArtifacts(skillsDest); diff --git a/scripts/lib/utils.js b/scripts/lib/utils.js index 7f14a8c3..515faed8 100644 --- a/scripts/lib/utils.js +++ b/scripts/lib/utils.js @@ -5,9 +5,8 @@ import path from 'path'; // belong to the consuming project, not the distributable skill. The build // excludes them from dist, and the harness-sync step preserves them across // the rm+recopy so local state isn't destroyed on every rebuild. -// - config.json: live-mode inject target list for the current project. -// Written by the agent at first /impeccable live; tied to the project's -// filesystem layout. Losing it resets the user's glob + exclusions. +// - config.json: legacy live-mode inject target list for existing projects. +// New installs write project config at .impeccable/live/config.json instead. export const PER_PROJECT_SCRIPT_ARTIFACTS = new Set(['config.json']); // Walk the harness-dir skill tree and return any per-project script diff --git a/source/skills/impeccable/reference/document.md b/source/skills/impeccable/reference/document.md index f2bcdb20..abb0a675 100644 --- a/source/skills/impeccable/reference/document.md +++ b/source/skills/impeccable/reference/document.md @@ -237,11 +237,11 @@ Concrete, forceful guardrails. Lead each with "Do" or "Don't". Be specific: incl - **Don't** [...] ``` -### Step 4b: Write DESIGN.json sidecar (extensions only) +### Step 4b: Write .impeccable/design.json sidecar (extensions only) -The frontmatter owns token primitives (colors, typography, rounded, spacing, components). The sidecar at `DESIGN.json` carries **what Stitch's schema can't hold**: tonal ramps per color, shadow/elevation tokens, motion tokens, breakpoints, full component HTML/CSS snippets (the panel renders these into a shadow DOM), and narrative (north star, rules, do's/don'ts). It extends the frontmatter, it doesn't duplicate it. +The frontmatter owns token primitives (colors, typography, rounded, spacing, components). The sidecar at `.impeccable/design.json` carries **what Stitch's schema can't hold**: tonal ramps per color, shadow/elevation tokens, motion tokens, breakpoints, full component HTML/CSS snippets (the panel renders these into a shadow DOM), and narrative (north star, rules, do's/don'ts). It extends the frontmatter, it doesn't duplicate it. -Regenerate the sidecar whenever you regenerate DESIGN.md. If the user only asks to refresh the sidecar (e.g., from the live panel's stale-hint), preserve DESIGN.md and write only DESIGN.json. +Regenerate the sidecar whenever you regenerate root `DESIGN.md`. If the user only asks to refresh the sidecar (e.g., from the live panel's stale-hint), preserve `DESIGN.md` and write only `.impeccable/design.json`. #### Schema @@ -310,7 +310,7 @@ Aim for a tight set of **5-10 components** that best represent the visual system - **Signature components (include if distinctive):** hero CTA, featured card, filter pill, any custom pattern the user mentioned as important in PRODUCT.md. - **Skip the rest.** Utility components, form building blocks, wrapper layouts: not worth documenting unless visually distinctive. -If the project has **no component library yet** (bare landing page, new project), synthesize canonical primitives from the tokens using best-practice defaults consistent with the DESIGN.md's rules. Every DESIGN.json has *something* to render, even on day zero. +If the project has **no component library yet** (bare landing page, new project), synthesize canonical primitives from the tokens using best-practice defaults consistent with the DESIGN.md's rules. Every `.impeccable/design.json` has *something* to render, even on day zero. #### Tonal ramps @@ -331,7 +331,7 @@ Do not reword. The panel shows these as secondary collapsible context; the same ### Step 5: Confirm, refine, and refresh session cache 1. Show the user the full DESIGN.md you wrote. Briefly highlight the non-obvious creative choices (descriptive color names, atmosphere language, named rules). -2. Mention that `DESIGN.json` was also written alongside; the live panel will now render this project's actual button/input/nav primitives instead of generic approximations. +2. Mention that `.impeccable/design.json` was also written alongside; the live panel will now render this project's actual button/input/nav primitives instead of generic approximations. 3. Offer to refine any section: "Want me to revise a section, add component patterns I missed, or adjust the atmosphere language?" 4. **Refresh the session cache.** Run `node {{scripts_path}}/load-context.mjs` one final time so the newly-written DESIGN.md lands in conversation. Subsequent commands in this session will use the fresh version automatically without re-reading. @@ -392,7 +392,7 @@ Per-section guidance in seed mode: - **Components**: omit entirely; no components exist yet. - **Do's and Don'ts**: carry PRODUCT.md's anti-references directly plus the anti-reference named in Q5. -Seed mode writes a minimal frontmatter with `name` and `description` only; no colors, typography, rounded, spacing, or components yet. Real tokens land on the next Scan-mode run. Skip the `DESIGN.json` sidecar in seed mode for the same reason: nothing to render. +Seed mode writes a minimal frontmatter with `name` and `description` only; no colors, typography, rounded, spacing, or components yet. Real tokens land on the next Scan-mode run. Skip the `.impeccable/design.json` sidecar in seed mode for the same reason: nothing to render. ### Step 4: Confirm and refresh session cache diff --git a/source/skills/impeccable/reference/live.md b/source/skills/impeccable/reference/live.md index 0b96dcfb..0d957a12 100644 --- a/source/skills/impeccable/reference/live.md +++ b/source/skills/impeccable/reference/live.md @@ -53,7 +53,7 @@ LOOP: ## Recovery commands -The live helper persists an append-only journal under `.impeccable-live/sessions`. Browser checkpoints are advisory but durable; the journal is canonical. +The live helper persists an append-only journal under `.impeccable/live/sessions/`. Browser checkpoints are advisory but durable; the journal is canonical. This is local durable recovery state, not project source. Use these commands when the chat was interrupted, polling was missed, the helper restarted, or the browser reloaded: @@ -473,7 +473,7 @@ When the poll returns `exit`, proceed to cleanup. If the poll is still running a node {{scripts_path}}/live-server.mjs stop ``` -Stops the HTTP server and runs `live-inject.mjs --remove` to strip `localhost:…/live.js` from the HTML entry. To stop the server but keep the inject tag (for a quick restart), use `stop --keep-inject`. `config.json` persists for future sessions. +Stops the HTTP server and runs `live-inject.mjs --remove` to strip `localhost:…/live.js` from the HTML entry. To stop the server but keep the inject tag (for a quick restart), use `stop --keep-inject`. `.impeccable/live/config.json` persists as project config for future sessions. Then: - Remove any leftover variant wrappers (search for `impeccable-variants-start` markers). @@ -481,7 +481,7 @@ Then: ## First-time setup (config missing or invalid) -If `live.mjs` outputs `{ ok: false, error: "config_missing" | "config_invalid", path }`, write `config.json` at the reported path. +If `live.mjs` outputs `{ ok: false, error: "config_missing" | "config_invalid", path }`, write the live config at the reported path. By default this is `.impeccable/live/config.json`. Schema: @@ -561,7 +561,7 @@ node {{scripts_path}}/detect-csp.mjs Output: `{ shape, signals }` where `shape` is one of `append-arrays`, `append-string`, `middleware`, `meta-tag`, or `null`. The shape is named by *patch mechanism*, so one template covers many frameworks. -- **`null`**: no CSP; skip to writing `config.json` with `cspChecked: true`. +- **`null`**: no CSP; skip to writing `.impeccable/live/config.json` with `cspChecked: true`. - **`append-arrays`**: CSP defined as structured directive arrays. Auto-patchable. See *append-arrays* below. Covers: - Monorepo helpers with `additionalScriptSrc` / `additionalConnectSrc` options (Next.js + shared config package) - SvelteKit `kit.csp.directives` @@ -638,6 +638,6 @@ Reference outputs: ### Troubleshooting -If a user says "no" to the CSP patch at setup time and later complains that live doesn't work: their dev CSP blocks `http://localhost:8400`. Fix: delete `cspChecked` from `config.json` and re-run `live.mjs`: setup will ask again. +If a user says "no" to the CSP patch at setup time and later complains that live doesn't work: their dev CSP blocks `http://localhost:8400`. Fix: delete `cspChecked` from `.impeccable/live/config.json` and re-run `live.mjs`: setup will ask again. Then re-run `live.mjs`. diff --git a/source/skills/impeccable/reference/teach.md b/source/skills/impeccable/reference/teach.md index a422f9ec..474fce7d 100644 --- a/source/skills/impeccable/reference/teach.md +++ b/source/skills/impeccable/reference/teach.md @@ -2,8 +2,8 @@ Gathers design context for a project and writes two complementary files at the project root: -- **PRODUCT.md** (strategic): register, target users, product purpose, brand personality, anti-references, strategic design principles. Answers "who/what/why". -- **DESIGN.md** (visual): visual theme, color palette, typography, components, layout. Follows the [Google Stitch DESIGN.md format](https://stitch.withgoogle.com/docs/design-md/format/). Answers "how it looks". +- **PRODUCT.md** (strategic): root project file for register, target users, product purpose, brand personality, anti-references, strategic design principles. Answers "who/what/why". +- **DESIGN.md** (visual): root project file for visual theme, color palette, typography, components, layout. Follows the [Google Stitch DESIGN.md format](https://stitch.withgoogle.com/docs/design-md/format/). Answers "how it looks". Every other impeccable command reads these files before doing any work. diff --git a/source/skills/impeccable/scripts/impeccable-paths.mjs b/source/skills/impeccable/scripts/impeccable-paths.mjs new file mode 100644 index 00000000..ba852bae --- /dev/null +++ b/source/skills/impeccable/scripts/impeccable-paths.mjs @@ -0,0 +1,105 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +export const IMPECCABLE_DIR = '.impeccable'; +export const LIVE_DIR = 'live'; + +export function getImpeccableDir(cwd = process.cwd()) { + return path.join(cwd, IMPECCABLE_DIR); +} + +export function getDesignSidecarPath(cwd = process.cwd()) { + return path.join(getImpeccableDir(cwd), 'design.json'); +} + +export function getDesignSidecarCandidates(cwd = process.cwd(), contextDir = cwd) { + const candidates = [ + getDesignSidecarPath(cwd), + path.join(cwd, 'DESIGN.json'), + ]; + const contextLegacy = path.join(contextDir, 'DESIGN.json'); + if (!candidates.includes(contextLegacy)) candidates.push(contextLegacy); + return candidates; +} + +export function resolveDesignSidecarPath(cwd = process.cwd(), contextDir = cwd) { + return firstExisting(getDesignSidecarCandidates(cwd, contextDir)); +} + +export function getLiveDir(cwd = process.cwd()) { + return path.join(getImpeccableDir(cwd), LIVE_DIR); +} + +export function getLiveConfigPath(cwd = process.cwd()) { + return path.join(getLiveDir(cwd), 'config.json'); +} + +export function getLegacyLiveConfigPath(scriptsDir) { + return path.join(scriptsDir, 'config.json'); +} + +export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = process.env } = {}) { + if (env.IMPECCABLE_LIVE_CONFIG && env.IMPECCABLE_LIVE_CONFIG.trim()) { + const configured = env.IMPECCABLE_LIVE_CONFIG.trim(); + return path.isAbsolute(configured) ? configured : path.resolve(cwd, configured); + } + const primary = getLiveConfigPath(cwd); + if (fs.existsSync(primary)) return primary; + if (scriptsDir) { + const legacy = getLegacyLiveConfigPath(scriptsDir); + if (fs.existsSync(legacy)) return legacy; + } + return primary; +} + +export function getLiveServerPath(cwd = process.cwd()) { + return path.join(getLiveDir(cwd), 'server.json'); +} + +export function getLegacyLiveServerPath(cwd = process.cwd()) { + return path.join(cwd, '.impeccable-live.json'); +} + +export function readLiveServerInfo(cwd = process.cwd()) { + for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) { + try { + return { info: JSON.parse(fs.readFileSync(filePath, 'utf-8')), path: filePath }; + } catch { + /* try next */ + } + } + return null; +} + +export function writeLiveServerInfo(cwd = process.cwd(), info) { + const filePath = getLiveServerPath(cwd); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, JSON.stringify(info)); + return filePath; +} + +export function removeLiveServerInfo(cwd = process.cwd()) { + for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) { + try { fs.unlinkSync(filePath); } catch {} + } +} + +export function getLiveSessionsDir(cwd = process.cwd()) { + return path.join(getLiveDir(cwd), 'sessions'); +} + +export function getLegacyLiveSessionsDir(cwd = process.cwd()) { + return path.join(cwd, '.impeccable-live', 'sessions'); +} + +export function getLiveAnnotationsDir(cwd = process.cwd()) { + return path.join(getLiveDir(cwd), 'annotations'); +} + +export function getLegacyLiveAnnotationsDir(cwd = process.cwd()) { + return path.join(cwd, '.impeccable-live', 'annotations'); +} + +function firstExisting(paths) { + return paths.find((filePath) => fs.existsSync(filePath)) || null; +} diff --git a/source/skills/impeccable/scripts/live-browser.js b/source/skills/impeccable/scripts/live-browser.js index 7f1ff329..bb5332a3 100644 --- a/source/skills/impeccable/scripts/live-browser.js +++ b/source/skills/impeccable/scripts/live-browser.js @@ -3671,7 +3671,7 @@ void main() { } // --------------------------------------------------------------------------- - // Design System Panel — visualizes the project's DESIGN.json sidecar + // Design System Panel — visualizes the project's .impeccable/design.json sidecar // --------------------------------------------------------------------------- const DESIGN_PREFS_KEY = 'impeccable-live-design-panel'; @@ -3683,7 +3683,7 @@ void main() { open: false, tab: 'visual', // 'visual' | 'raw' parsed: null, // parseDesignMd output (frontmatter + body sections) - sidecar: null, // DESIGN.json v2 payload (extensions + components + narrative) + sidecar: null, // .impeccable/design.json v2 payload (extensions + components + narrative) hasMd: false, hasSidecar: false, present: null, // true/false once fetch resolves @@ -4184,7 +4184,7 @@ void main() { box.className = 'stale'; box.innerHTML = ` - DESIGN.md is newer than DESIGN.json. Run /impeccable document to refresh the sidecar. + DESIGN.md is newer than .impeccable/design.json. Run /impeccable document to refresh the sidecar. `; return box; } @@ -4192,7 +4192,7 @@ void main() { function renderParsedMdCta() { const box = document.createElement('div'); box.className = 'parsed-md-cta'; - box.innerHTML = `Basic viewThis panel reads the tokens in your DESIGN.md frontmatter. Running /impeccable document also generates a DESIGN.json sidecar with your project's actual component snippets (button, input, nav) and tonal ramps, rendered live below the tokens.`; + box.innerHTML = `Basic viewThis panel reads the tokens in your DESIGN.md frontmatter. Running /impeccable document also generates a .impeccable/design.json sidecar with your project's actual component snippets (button, input, nav) and tonal ramps, rendered live below the tokens.`; return box; } @@ -4652,7 +4652,7 @@ void main() { function cssSafe(v) { // Strip anything outside valid CSS value chars to prevent injection via - // DESIGN.json values rendered into inline style strings. + // .impeccable/design.json values rendered into inline style strings. return String(v).replace(/[<>"'`\n]/g, ''); } diff --git a/source/skills/impeccable/scripts/live-complete.mjs b/source/skills/impeccable/scripts/live-complete.mjs index ca00d86a..78155af8 100644 --- a/source/skills/impeccable/scripts/live-complete.mjs +++ b/source/skills/impeccable/scripts/live-complete.mjs @@ -4,10 +4,7 @@ */ import { createLiveSessionStore } from './live-session-store.mjs'; -import fs from 'node:fs'; -import path from 'node:path'; - -const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); +import { readLiveServerInfo } from './impeccable-paths.mjs'; function parseArgs(argv) { const out = { status: 'complete' }; @@ -50,8 +47,7 @@ export async function completeCli() { } function readServerInfo() { - try { return JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); } - catch { return null; } + return readLiveServerInfo(process.cwd())?.info || null; } async function completeThroughServer(info, args) { diff --git a/source/skills/impeccable/scripts/live-inject.mjs b/source/skills/impeccable/scripts/live-inject.mjs index 1d2ae12f..555c3a36 100644 --- a/source/skills/impeccable/scripts/live-inject.mjs +++ b/source/skills/impeccable/scripts/live-inject.mjs @@ -2,23 +2,24 @@ * CLI helper: insert/remove the live variant mode script tag in the project's * main HTML entry point. * - * On first live run, the agent generates `config.json` in this script's - * directory with the project's insertion target (framework-specific). On + * On first live run, the agent generates `.impeccable/live/config.json` + * with the project's insertion target (framework-specific). On * every subsequent run, this script handles insert/remove deterministically * with zero LLM involvement. * * Usage: * node live-inject.mjs --port PORT # Insert the live script tag * node live-inject.mjs --remove # Remove the live script tag - * node live-inject.mjs --check # Check whether config.json exists + * node live-inject.mjs --check # Check whether live config exists */ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { resolveLiveConfigPath } from './impeccable-paths.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const CONFIG_PATH = process.env.IMPECCABLE_LIVE_CONFIG || path.join(__dirname, 'config.json'); +const CONFIG_PATH = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname }); const MARKER_OPEN_TEXT = 'impeccable-live-start'; const MARKER_CLOSE_TEXT = 'impeccable-live-end'; @@ -39,12 +40,12 @@ export async function injectCli() { console.log(`Usage: node live-inject.mjs [options] Insert or remove the live mode script tag in the project's HTML entry point. -Reads configuration from config.json (in this same directory). +Reads configuration from .impeccable/live/config.json. Modes: --port PORT Insert script tag pointing at http://localhost:PORT/live.js --remove Remove the script tag (if present) - --check Print whether config.json exists and its content + --check Print whether .impeccable/live/config.json exists and its content Output (JSON): { ok, file, inserted|removed, config? }`); diff --git a/source/skills/impeccable/scripts/live-poll.mjs b/source/skills/impeccable/scripts/live-poll.mjs index 9a3f07ae..83a9912e 100644 --- a/source/skills/impeccable/scripts/live-poll.mjs +++ b/source/skills/impeccable/scripts/live-poll.mjs @@ -9,11 +9,10 @@ */ import { execFileSync } from 'node:child_process'; -import fs from 'node:fs'; import path from 'node:path'; -import os from 'node:os'; import { fileURLToPath } from 'node:url'; import { completionTypeForAcceptResult } from './live-completion.mjs'; +import { readLiveServerInfo } from './impeccable-paths.mjs'; // Node's built-in fetch (undici under the hood) enforces a 300s headers // timeout that can't be lowered per-request. We cap each request below @@ -21,15 +20,13 @@ import { completionTypeForAcceptResult } from './live-completion.mjs'; // depending on the standalone undici package. const PER_REQUEST_TIMEOUT_MS = 270_000; -const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); - function readServerInfo() { - try { - return JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); - } catch { + const record = readLiveServerInfo(process.cwd()); + if (!record) { console.error('No running live server found. Start one with: npx impeccable live'); process.exit(1); } + return record.info; } export function buildPollReplyPayload(token, { id, type, message, file, data }) { diff --git a/source/skills/impeccable/scripts/live-server.mjs b/source/skills/impeccable/scripts/live-server.mjs index e78a0657..53d8f21c 100644 --- a/source/skills/impeccable/scripts/live-server.mjs +++ b/source/skills/impeccable/scripts/live-server.mjs @@ -23,14 +23,19 @@ import { fileURLToPath } from 'node:url'; import { parseDesignMd } from './design-parser.mjs'; import { resolveContextDir } from './load-context.mjs'; import { createLiveSessionStore } from './live-session-store.mjs'; +import { + getDesignSidecarPath, + getLiveAnnotationsDir, + readLiveServerInfo, + removeLiveServerInfo, + resolveDesignSidecarPath, + writeLiveServerInfo, +} from './impeccable-paths.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -// PID file in the project root so both the server and agent can find it -// predictably (os.tmpdir() varies across platforms). -const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); -// PRODUCT.md / DESIGN.md / DESIGN.json live wherever load-context.mjs resolves. -// Keeps live-server in sync with the loader when users keep the docs in -// .agents/context/, docs/, or a path set via IMPECCABLE_CONTEXT_DIR. +// PRODUCT.md / DESIGN.md live wherever load-context.mjs resolves. The generated +// DESIGN sidecar is project-local at .impeccable/design.json, with legacy +// DESIGN.json fallback for existing projects. const CONTEXT_DIR = resolveContextDir(process.cwd()); const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s @@ -411,13 +416,13 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { } // --- Design system (unified v2 response) + raw --- - // /design-system.json returns both parsed DESIGN.md and DESIGN.json + // /design-system.json returns both parsed DESIGN.md and .impeccable/design.json // sidecar when present. Panel merges them: // { present, parsed, sidecar, hasMd, hasSidecar, // mdNewerThanJson, parseError?, sidecarError? } // - parsed: output of parseDesignMd (frontmatter // + six canonical sections) when DESIGN.md exists. - // - sidecar: DESIGN.json contents when present. + // - sidecar: .impeccable/design.json contents when present. // Expected shape: schemaVersion 2, carrying // extensions + components + narrative. // /design-system/raw returns DESIGN.md markdown verbatim @@ -426,7 +431,7 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } const mdPath = path.join(CONTEXT_DIR, 'DESIGN.md'); - const jsonPath = path.join(CONTEXT_DIR, 'DESIGN.json'); + const jsonPath = resolveDesignSidecarPath(process.cwd(), CONTEXT_DIR) || getDesignSidecarPath(process.cwd()); const mdStat = statOrNull(mdPath); const jsonStat = statOrNull(jsonPath); @@ -462,7 +467,7 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { try { response.sidecar = JSON.parse(fs.readFileSync(jsonPath, 'utf-8')); } catch (err) { - response.sidecarError = 'Failed to parse DESIGN.json: ' + err.message; + response.sidecarError = 'Failed to parse .impeccable/design.json: ' + err.message; } } @@ -673,7 +678,7 @@ function handlePollPost(req, res) { let httpServer = null; function shutdown() { - try { fs.unlinkSync(LIVE_PID_FILE); } catch {} + removeLiveServerInfo(process.cwd()); if (state.leaseTimer) clearTimeout(state.leaseTimer); state.leaseTimer = null; if (state.sessionDir) { @@ -725,7 +730,7 @@ Endpoints: if (args.includes('stop')) { const keepInject = args.includes('--keep-inject'); try { - const info = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); + const { info } = readLiveServerInfo(process.cwd()) || {}; const res = await fetch(`http://localhost:${info.port}/stop?token=${info.token}`); if (res.ok) console.log(`Stopped live server on port ${info.port}.`); } catch { @@ -776,7 +781,7 @@ if (args.includes('--background')) { const deadline = Date.now() + 10_000; while (Date.now() < deadline) { try { - const info = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); + const { info } = readLiveServerInfo(process.cwd()) || {}; if (info.pid !== process.pid) { // Output JSON so the agent can read port + token from stdout. console.log(JSON.stringify(info)); @@ -790,14 +795,18 @@ if (args.includes('--background')) { } // Check for existing session -try { - const existing = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); - try { process.kill(existing.pid, 0); +const existingRecord = readLiveServerInfo(process.cwd()); +if (existingRecord?.info) { + const existing = existingRecord.info; + try { + process.kill(existing.pid, 0); console.error(`Live server already running on port ${existing.port} (pid ${existing.pid}).`); console.error('Stop it first with: node ' + path.basename(fileURLToPath(import.meta.url)) + ' stop'); process.exit(1); - } catch { fs.unlinkSync(LIVE_PID_FILE); } -} catch {} + } catch { + try { fs.unlinkSync(existingRecord.path); } catch {} + } +} state.token = randomUUID(); state.sessionStore = createLiveSessionStore({ cwd: process.cwd() }); @@ -807,7 +816,7 @@ state.port = portArg ? parseInt(portArg.split('=')[1], 10) : await findOpenPort( // Annotation screenshots live in the project root so the agent's Read tool // doesn't trip a per-file permission prompt. Sessioned by token so concurrent // projects (or quick restarts) don't collide. -const annotRoot = path.join(process.cwd(), '.impeccable-live', 'annotations'); +const annotRoot = getLiveAnnotationsDir(process.cwd()); fs.mkdirSync(annotRoot, { recursive: true }); state.sessionDir = fs.mkdtempSync(path.join(annotRoot, 'session-')); @@ -815,7 +824,7 @@ const { detectScript, sessionPath, livePath } = loadBrowserScripts(); httpServer = http.createServer(createRequestHandler({ detectScript, sessionPath, livePath })); httpServer.listen(state.port, '127.0.0.1', () => { - fs.writeFileSync(LIVE_PID_FILE, JSON.stringify({ pid: process.pid, port: state.port, token: state.token })); + writeLiveServerInfo(process.cwd(), { pid: process.pid, port: state.port, token: state.token }); const url = `http://localhost:${state.port}`; console.log(`\nImpeccable live server running on ${url}`); console.log(`Token: ${state.token}\n`); diff --git a/source/skills/impeccable/scripts/live-session-store.mjs b/source/skills/impeccable/scripts/live-session-store.mjs index cc7744df..37711168 100644 --- a/source/skills/impeccable/scripts/live-session-store.mjs +++ b/source/skills/impeccable/scripts/live-session-store.mjs @@ -1,30 +1,43 @@ import fs from 'node:fs'; import path from 'node:path'; +import { getLegacyLiveSessionsDir, getLiveSessionsDir } from './impeccable-paths.mjs'; -const LIVE_DIR = '.impeccable-live'; -const SESSIONS_DIR = 'sessions'; const COMPLETED_PHASES = new Set(['completed', 'discarded']); export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {}) { - const rootDir = path.join(cwd, LIVE_DIR, SESSIONS_DIR); + 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 = getJournalPath(rootDir, id); + const journalPath = getReadableJournalPath(id); const rebuilt = rebuildSnapshotFromJournal(journalPath, id); snapshotCache.set(id, rebuilt); return rebuilt; } + function getReadableJournalPath(id) { + const primary = getJournalPath(rootDir, id); + if (fs.existsSync(primary)) return primary; + const legacy = getJournalPath(legacyRootDir, id); + if (fs.existsSync(legacy)) return legacy; + return primary; + } + return { rootDir, + legacyRootDir, appendEvent(event) { const normalized = normalizeEvent(event, sessionId); const journalPath = getJournalPath(rootDir, normalized.id); const snapshotPath = getSnapshotPath(rootDir, normalized.id); + const legacyJournalPath = getJournalPath(legacyRootDir, normalized.id); + if (!fs.existsSync(journalPath) && fs.existsSync(legacyJournalPath)) { + fs.copyFileSync(legacyJournalPath, journalPath); + } const prior = loadCachedOrRebuild(normalized.id); const seq = prior.nextSeq; const entry = { @@ -42,7 +55,7 @@ export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {}) }, getSnapshot(id = sessionId, opts = {}) { if (!id) throw new Error('session id required'); - const journalPath = getJournalPath(rootDir, id); + const journalPath = getReadableJournalPath(id); const snapshotPath = getSnapshotPath(rootDir, id); const rebuilt = rebuildSnapshotFromJournal(journalPath, id); snapshotCache.set(id, rebuilt); @@ -51,10 +64,15 @@ export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {}) return rebuilt.snapshot; }, listActiveSessions() { - if (!fs.existsSync(rootDir)) return []; - return fs.readdirSync(rootDir) - .filter((name) => name.endsWith('.jsonl')) - .map((name) => name.slice(0, -'.jsonl'.length)) + const ids = new Set(); + for (const dir of [legacyRootDir, rootDir]) { + if (!fs.existsSync(dir)) continue; + for (const name of fs.readdirSync(dir)) { + if (name.endsWith('.jsonl')) ids.add(name.slice(0, -'.jsonl'.length)); + } + } + return [...ids] + .sort() .map((id) => this.getSnapshot(id)) .filter(Boolean); }, diff --git a/source/skills/impeccable/scripts/live-status.mjs b/source/skills/impeccable/scripts/live-status.mjs index 1b85357c..dce1fbca 100644 --- a/source/skills/impeccable/scripts/live-status.mjs +++ b/source/skills/impeccable/scripts/live-status.mjs @@ -3,15 +3,11 @@ * Print durable recovery status for Impeccable live sessions. */ -import fs from 'node:fs'; -import path from 'node:path'; import { createLiveSessionStore } from './live-session-store.mjs'; - -const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); +import { readLiveServerInfo } from './impeccable-paths.mjs'; function readServerInfo() { - try { return JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); } - catch { return null; } + return readLiveServerInfo(process.cwd())?.info || null; } async function fetchServerStatus(info) { diff --git a/source/skills/impeccable/scripts/live.mjs b/source/skills/impeccable/scripts/live.mjs index befbdb8e..cafb0eca 100644 --- a/source/skills/impeccable/scripts/live.mjs +++ b/source/skills/impeccable/scripts/live.mjs @@ -2,10 +2,10 @@ * CLI entry point: prepare everything needed to enter the live variant poll loop. * * Does (all in one command): - * 1. Check config.json (returns config_missing if first-ever run) + * 1. Check .impeccable/live/config.json (returns config_missing if first-ever run) * 2. Start the live server in the background (or reuse a running one) * 3. Inject the browser script tag into the project's entry file - * 4. Read .impeccable.md for design context (if present) + * 4. Read PRODUCT.md / DESIGN.md for project context * 5. Print a single JSON blob with everything the agent needs * * After this, the agent's only remaining steps are: @@ -23,9 +23,9 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { loadContext } from './load-context.mjs'; import { resolveFiles } from './live-inject.mjs'; +import { readLiveServerInfo } from './impeccable-paths.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); async function liveCli() { const args = process.argv.slice(2); @@ -34,10 +34,10 @@ async function liveCli() { console.log(`Usage: node live.mjs Prepare everything for live variant mode in a single command: - - Checks scripts/config.json (required, created once per project) + - Checks .impeccable/live/config.json (required, created once per project) - Starts (or reuses) the live server in the background - Injects the browser script tag - - Reads .impeccable.md for design context + - Reads PRODUCT.md / DESIGN.md for project context On success, prints a JSON blob with: { ok, serverPort, serverToken, pageFile, hasContext, context } @@ -223,7 +223,7 @@ function safeParse(out) { function ensureServerRunning() { // Try to reuse an existing server try { - const existing = JSON.parse(fs.readFileSync(PID_FILE, 'utf-8')); + const existing = readLiveServerInfo(process.cwd())?.info; if (existing && existing.pid) { try { process.kill(existing.pid, 0); // throws if dead diff --git a/source/skills/impeccable/scripts/load-context.mjs b/source/skills/impeccable/scripts/load-context.mjs index dca23c1f..dc340bf1 100644 --- a/source/skills/impeccable/scripts/load-context.mjs +++ b/source/skills/impeccable/scripts/load-context.mjs @@ -39,7 +39,7 @@ const LEGACY_NAMES = ['.impeccable.md']; const FALLBACK_DIRS = ['.agents/context', 'docs']; /** - * Resolve the directory that holds PRODUCT.md / DESIGN.md / DESIGN.json for + * Resolve the directory that holds PRODUCT.md / DESIGN.md for * this project. Exported so other scripts (e.g. live-server.mjs) can read the * design files from the same location the loader uses. */ diff --git a/tests/framework-fixtures.test.mjs b/tests/framework-fixtures.test.mjs index 84bb699f..f59fcd2c 100644 --- a/tests/framework-fixtures.test.mjs +++ b/tests/framework-fixtures.test.mjs @@ -12,7 +12,7 @@ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { execFileSync } from 'node:child_process'; -import { cpSync, existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -43,7 +43,8 @@ function stageFixture(name) { const tmp = mkdtempSync(join(tmpdir(), 'impeccable-fixture-')); cpSync(join(fixtureRoot, 'files'), tmp, { recursive: true }); writeFileSync(join(tmp, '.gitignore'), gitignore); - writeFileSync(join(tmp, 'impeccable-live.config.json'), JSON.stringify(fixture.config)); + mkdirSync(join(tmp, '.impeccable', 'live'), { recursive: true }); + writeFileSync(join(tmp, '.impeccable', 'live', 'config.json'), JSON.stringify(fixture.config)); execFileSync('git', ['init', '-q'], { cwd: tmp }); execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: tmp }); @@ -109,11 +110,7 @@ for (const name of listFixtures()) { it('live-inject --port adds the script tag to every config file', () => { const { tmp } = stageFixture(name); try { - const configPath = join(tmp, 'impeccable-live.config.json'); - const out = runScript('live-inject.mjs', ['--port', '9999'], { - cwd: tmp, - env: { IMPECCABLE_LIVE_CONFIG: configPath }, - }); + const out = runScript('live-inject.mjs', ['--port', '9999'], { cwd: tmp }); const result = JSON.parse(typeof out === 'string' ? out : out.error); assert.equal(result.ok, true, 'inject succeeded'); for (const r of result.results) { @@ -130,15 +127,8 @@ for (const name of listFixtures()) { it('live-inject --remove strips the script tag cleanly', () => { const { tmp } = stageFixture(name); try { - const configPath = join(tmp, 'impeccable-live.config.json'); - runScript('live-inject.mjs', ['--port', '9999'], { - cwd: tmp, - env: { IMPECCABLE_LIVE_CONFIG: configPath }, - }); - const out = runScript('live-inject.mjs', ['--remove'], { - cwd: tmp, - env: { IMPECCABLE_LIVE_CONFIG: configPath }, - }); + runScript('live-inject.mjs', ['--port', '9999'], { cwd: tmp }); + const out = runScript('live-inject.mjs', ['--remove'], { cwd: tmp }); const result = JSON.parse(typeof out === 'string' ? out : out.error); assert.equal(result.ok, true, 'remove succeeded'); for (const r of result.results) { diff --git a/tests/framework-fixtures/README.md b/tests/framework-fixtures/README.md index 7f7865fc..6941b1ba 100644 --- a/tests/framework-fixtures/README.md +++ b/tests/framework-fixtures/README.md @@ -18,7 +18,7 @@ Fixtures can also opt into a **runtime E2E** pass that actually installs depende ```json { "name": "human-readable label", - "config": { ...contents for live-inject.mjs config.json ... }, + "config": { ...contents for .impeccable/live/config.json ... }, "sourceFiles": ["paths that is-generated should classify as source (false)"], "generatedFiles": ["paths that is-generated should classify as generated (true)"], "wrapCases": [ diff --git a/tests/impeccable-paths.test.mjs b/tests/impeccable-paths.test.mjs new file mode 100644 index 00000000..933b9937 --- /dev/null +++ b/tests/impeccable-paths.test.mjs @@ -0,0 +1,100 @@ +/** + * Tests for project-local Impeccable path resolution. + * Run with: node --test tests/impeccable-paths.test.mjs + */ + +import { describe, it, beforeEach, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +import { + getDesignSidecarPath, + getLegacyLiveServerPath, + getLiveAnnotationsDir, + getLiveConfigPath, + getLiveServerPath, + getLiveSessionsDir, + readLiveServerInfo, + resolveDesignSidecarPath, + resolveLiveConfigPath, +} from '../source/skills/impeccable/scripts/impeccable-paths.mjs'; + +describe('impeccable project paths', () => { + let tmp; + + beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), 'impeccable-paths-')); + }); + + afterEach(() => { + rmSync(tmp, { recursive: true, force: true }); + }); + + it('resolves the generated design sidecar under .impeccable', () => { + assert.equal(getDesignSidecarPath(tmp), join(tmp, '.impeccable', 'design.json')); + + mkdirSync(join(tmp, '.impeccable'), { recursive: true }); + writeFileSync(join(tmp, 'DESIGN.json'), '{"source":"legacy"}'); + writeFileSync(getDesignSidecarPath(tmp), '{"source":"new"}'); + + assert.equal(resolveDesignSidecarPath(tmp), getDesignSidecarPath(tmp)); + }); + + it('falls back to legacy root DESIGN.json when the new sidecar is missing', () => { + const legacyPath = join(tmp, 'DESIGN.json'); + writeFileSync(legacyPath, '{"source":"legacy"}'); + + assert.equal(resolveDesignSidecarPath(tmp), legacyPath); + }); + + it('uses .impeccable/live/config.json as the default live config path', () => { + assert.equal(resolveLiveConfigPath({ cwd: tmp, scriptsDir: join(tmp, 'scripts'), env: {} }), getLiveConfigPath(tmp)); + }); + + it('falls back to legacy scripts/config.json when no new live config exists', () => { + const scriptsDir = join(tmp, 'skills', 'impeccable', 'scripts'); + mkdirSync(scriptsDir, { recursive: true }); + const legacyConfig = join(scriptsDir, 'config.json'); + writeFileSync(legacyConfig, '{"files":["index.html"]}'); + + assert.equal(resolveLiveConfigPath({ cwd: tmp, scriptsDir, env: {} }), legacyConfig); + }); + + it('lets IMPECCABLE_LIVE_CONFIG override both new and legacy config locations', () => { + const override = join(tmp, 'custom-live-config.json'); + mkdirSync(join(tmp, '.impeccable', 'live'), { recursive: true }); + writeFileSync(getLiveConfigPath(tmp), '{"source":"new"}'); + writeFileSync(override, '{"source":"override"}'); + + assert.equal( + resolveLiveConfigPath({ cwd: tmp, scriptsDir: join(tmp, 'scripts'), env: { IMPECCABLE_LIVE_CONFIG: 'custom-live-config.json' } }), + override, + ); + }); + + it('places live server, session, and annotation state under .impeccable/live', () => { + assert.equal(getLiveServerPath(tmp), join(tmp, '.impeccable', 'live', 'server.json')); + assert.equal(getLiveSessionsDir(tmp), join(tmp, '.impeccable', 'live', 'sessions')); + assert.equal(getLiveAnnotationsDir(tmp), join(tmp, '.impeccable', 'live', 'annotations')); + }); + + it('reads new live server state before legacy recovery state', () => { + mkdirSync(join(tmp, '.impeccable', 'live'), { recursive: true }); + writeFileSync(getLiveServerPath(tmp), JSON.stringify({ port: 8401, token: 'new' })); + writeFileSync(getLegacyLiveServerPath(tmp), JSON.stringify({ port: 8400, token: 'legacy' })); + + const record = readLiveServerInfo(tmp); + assert.equal(record.path, getLiveServerPath(tmp)); + assert.equal(record.info.token, 'new'); + }); + + it('reads legacy live server state when the new state file is absent', () => { + writeFileSync(getLegacyLiveServerPath(tmp), JSON.stringify({ port: 8400, token: 'legacy' })); + + const record = readLiveServerInfo(tmp); + assert.equal(record.path, getLegacyLiveServerPath(tmp)); + assert.equal(record.info.token, 'legacy'); + }); +}); diff --git a/tests/live-e2e/session.mjs b/tests/live-e2e/session.mjs index 743922ae..a7140218 100644 --- a/tests/live-e2e/session.mjs +++ b/tests/live-e2e/session.mjs @@ -14,7 +14,7 @@ */ import { execFileSync, spawn } from 'node:child_process'; -import { cpSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -39,7 +39,8 @@ export function stageFixture(name, fixture) { const tmp = mkdtempSync(join(tmpdir(), 'impeccable-e2e-')); cpSync(join(fixtureRoot, 'files'), tmp, { recursive: true }); writeFileSync(join(tmp, '.gitignore'), gitignore); - writeFileSync(join(tmp, 'impeccable-live.config.json'), JSON.stringify(fixture.config)); + mkdirSync(join(tmp, '.impeccable', 'live'), { recursive: true }); + writeFileSync(join(tmp, '.impeccable', 'live', 'config.json'), JSON.stringify(fixture.config)); execFileSync('git', ['init', '-q'], { cwd: tmp }); execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: tmp }); @@ -90,7 +91,7 @@ export function runInject(tmp, port) { { cwd: tmp, encoding: 'utf-8', - env: { ...process.env, IMPECCABLE_LIVE_CONFIG: join(tmp, 'impeccable-live.config.json') }, + env: { ...process.env }, }, ); const last = out.trim().split('\n').filter(Boolean).pop(); diff --git a/tests/live-inject.test.mjs b/tests/live-inject.test.mjs index b42694df..3b0dfb21 100644 --- a/tests/live-inject.test.mjs +++ b/tests/live-inject.test.mjs @@ -5,7 +5,7 @@ import { describe, it, beforeEach, afterEach } from 'node:test'; import assert from 'node:assert/strict'; -import { mkdtempSync, writeFileSync, readFileSync, rmSync } from 'node:fs'; +import { 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'; @@ -29,11 +29,59 @@ function runInject(cwd, configPath, args) { } } +function runInjectDefault(cwd, args) { + try { + const out = execFileSync('node', [INJECT, ...args], { + cwd, + encoding: 'utf-8', + env: { ...process.env, IMPECCABLE_LIVE_CONFIG: '' }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + return JSON.parse(out.trim()); + } catch (err) { + const body = err.stdout?.toString().trim() || err.stderr?.toString().trim() || ''; + return JSON.parse(body || '{}'); + } +} + describe('live-inject — insert/remove round-trip preserves file bytes', () => { let tmp; beforeEach(() => { tmp = mkdtempSync(join(tmpdir(), 'impeccable-inject-test-')); }); afterEach(() => { rmSync(tmp, { recursive: true, force: true }); }); + it('reports .impeccable/live/config.json as the default missing config path', () => { + const result = runInjectDefault(tmp, ['--check']); + + assert.equal(result.ok, false); + assert.equal(result.error, 'config_missing'); + assert.equal(result.path, join(realpathSync(tmp), '.impeccable', 'live', 'config.json')); + }); + + it('uses .impeccable/live/config.json without an environment override', () => { + const original = ` + +

Content

+ + +`; + writeFileSync(join(tmp, 'index.html'), original); + const configDir = join(tmp, '.impeccable', 'live'); + mkdirSync(configDir, { recursive: true }); + writeFileSync(join(configDir, 'config.json'), JSON.stringify({ + files: ['index.html'], + insertBefore: '', + commentSyntax: 'html', + })); + + const inserted = runInjectDefault(tmp, ['--port', '8400']); + assert.equal(inserted.ok, true); + assert.match(readFileSync(join(tmp, 'index.html'), 'utf-8'), /localhost:8400\/live\.js/); + + const removed = runInjectDefault(tmp, ['--remove']); + assert.equal(removed.ok, true); + assert.equal(readFileSync(join(tmp, 'index.html'), 'utf-8'), original); + }); + it('round-trips an HTML file without mangling indentation', () => { const original = ` diff --git a/tests/live-server.test.mjs b/tests/live-server.test.mjs index db734e5d..da219c52 100644 --- a/tests/live-server.test.mjs +++ b/tests/live-server.test.mjs @@ -9,6 +9,13 @@ import { existsSync, mkdtempSync, readFileSync, writeFileSync, mkdirSync, rmSync import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { execFileSync, execSync, spawn } from 'node:child_process'; +import { + getDesignSidecarPath, + getLegacyLiveServerPath, + getLegacyLiveSessionsDir, + getLiveServerPath, + getLiveSessionsDir, +} from '../source/skills/impeccable/scripts/impeccable-paths.mjs'; const REPO_ROOT = process.cwd(); const SERVER_SCRIPT = join(REPO_ROOT, 'source/skills/impeccable/scripts/live-server.mjs'); @@ -30,7 +37,7 @@ function startServer(port = 8499, { cwd = REPO_ROOT } = {}) { if (output.includes('running on')) { // Read token from PID file try { - const info = JSON.parse(readFileSync(join(cwd, '.impeccable-live.json'), 'utf-8')); + const info = JSON.parse(readFileSync(getLiveServerPath(cwd), 'utf-8')); resolve({ proc, port: info.port, token: info.token, cwd }); } catch { reject(new Error('Server started but PID file not readable')); @@ -72,7 +79,10 @@ describe('live-server integration', () => { let server; before(async () => { - rmSync(join(REPO_ROOT, '.impeccable-live', 'sessions'), { recursive: true, force: true }); + rmSync(getLiveSessionsDir(REPO_ROOT), { recursive: true, force: true }); + rmSync(getLegacyLiveSessionsDir(REPO_ROOT), { recursive: true, force: true }); + rmSync(getLiveServerPath(REPO_ROOT), { force: true }); + rmSync(getLegacyLiveServerPath(REPO_ROOT), { force: true }); server = await startServer(8499); }); @@ -139,6 +149,72 @@ describe('live-server integration', () => { ); }); + it('/design-system.json reads DESIGN.md plus .impeccable/design.json', async () => { + const tmp = mkdtempSync(join(tmpdir(), 'impeccable-design-system-')); + let designServer; + try { + writeFileSync(join(tmp, 'DESIGN.md'), `--- +name: Temp System +description: Temporary design context +colors: {} +--- + +# Temp System +`); + const sidecarPath = getDesignSidecarPath(tmp); + mkdirSync(join(tmp, '.impeccable'), { recursive: true }); + writeFileSync(sidecarPath, JSON.stringify({ version: 2, source: 'new-sidecar' })); + + designServer = await startServer(8520, { cwd: tmp }); + const res = await fetch(`http://localhost:${designServer.port}/design-system.json?token=${designServer.token}`); + const data = await res.json(); + + assert.equal(res.status, 200); + assert.equal(data.hasMd, true); + assert.equal(data.hasSidecar, true); + assert.equal(data.parsed.frontmatter.name, 'Temp System'); + assert.equal(data.sidecar.source, 'new-sidecar'); + } finally { + if (designServer) { + await stopServer(designServer.port, designServer.token); + designServer.proc.kill(); + } + rmSync(tmp, { recursive: true, force: true }); + } + }); + + it('/design-system.json falls back to legacy root DESIGN.json', async () => { + const tmp = mkdtempSync(join(tmpdir(), 'impeccable-design-system-legacy-')); + let designServer; + try { + writeFileSync(join(tmp, 'DESIGN.md'), `--- +name: Legacy System +description: Legacy design context +colors: {} +--- + +# Legacy System +`); + writeFileSync(join(tmp, 'DESIGN.json'), JSON.stringify({ version: 2, source: 'legacy-sidecar' })); + + designServer = await startServer(8521, { cwd: tmp }); + const res = await fetch(`http://localhost:${designServer.port}/design-system.json?token=${designServer.token}`); + const data = await res.json(); + + assert.equal(res.status, 200); + assert.equal(data.hasMd, true); + assert.equal(data.hasSidecar, true); + assert.equal(data.parsed.frontmatter.name, 'Legacy System'); + assert.equal(data.sidecar.source, 'legacy-sidecar'); + } finally { + if (designServer) { + await stopServer(designServer.port, designServer.token); + designServer.proc.kill(); + } + rmSync(tmp, { recursive: true, force: true }); + } + }); + it('/detect.js serves the detection overlay', async () => { const res = await fetch(`http://localhost:${server.port}/detect.js`); // May 404 if detect-antipatterns-browser.js hasn't been built @@ -281,7 +357,7 @@ describe('live-server integration', () => { it('persists browser events to the durable session journal before poll delivery', async () => { await drainPolls(server); - const journalPath = join(REPO_ROOT, '.impeccable-live', 'sessions', 'a1b2c3d6.jsonl'); + const journalPath = join(getLiveSessionsDir(REPO_ROOT), 'a1b2c3d6.jsonl'); rmSync(journalPath, { force: true }); const postRes = await fetch(`http://localhost:${server.port}/events`, { @@ -340,7 +416,7 @@ describe('live-server integration', () => { 'event=live_server.checkpoint_not_polled actor=browser operation=checkpoint risk=checkpoint_starves_agent_queue expected=timeout actual=' + polled.type + ' suggestion=journal checkpoint without enqueueing agent work', ); - const snapshot = JSON.parse(readFileSync(join(REPO_ROOT, '.impeccable-live', 'sessions', 'a1b2c3d7.snapshot.json'), 'utf-8')); + const snapshot = JSON.parse(readFileSync(join(getLiveSessionsDir(REPO_ROOT), 'a1b2c3d7.snapshot.json'), 'utf-8')); assert.equal(snapshot.visibleVariant, 2); assert.deepEqual(snapshot.paramValues, { density: 'packed' }); }); @@ -415,7 +491,7 @@ describe('live-server integration', () => { body: JSON.stringify({ token: server.token, id: 'a1b2c3d9', type: 'complete' }), }); assert.equal(ack.status, 200); - const snapshot = JSON.parse(readFileSync(join(REPO_ROOT, '.impeccable-live', 'sessions', 'a1b2c3d9.snapshot.json'), 'utf-8')); + const snapshot = JSON.parse(readFileSync(join(getLiveSessionsDir(REPO_ROOT), 'a1b2c3d9.snapshot.json'), 'utf-8')); assert.equal(snapshot.phase, 'completed'); }); diff --git a/tests/live-session-store.test.mjs b/tests/live-session-store.test.mjs index 1deebfb7..383eb59f 100644 --- a/tests/live-session-store.test.mjs +++ b/tests/live-session-store.test.mjs @@ -5,11 +5,16 @@ import { describe, it, beforeEach, afterEach } from 'node:test'; import assert from 'node:assert/strict'; -import { mkdtempSync, rmSync, appendFileSync, readFileSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, rmSync, appendFileSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { createLiveSessionStore } from '../source/skills/impeccable/scripts/live-session-store.mjs'; +import { + getLegacyLiveSessionsDir, + getLiveAnnotationsDir, + getLiveSessionsDir, +} from '../source/skills/impeccable/scripts/impeccable-paths.mjs'; describe('live-session-store', () => { let tmp; @@ -31,7 +36,7 @@ describe('live-session-store', () => { count: 3, pageUrl: 'http://localhost:4321/', element: { outerHTML: '
Hero
', tagName: 'section' }, - screenshotPath: join(tmp, '.impeccable-live', 'annotations', 'session-a.png'), + screenshotPath: join(getLiveAnnotationsDir(tmp), 'session-a.png'), }); store.appendEvent({ type: 'variants_ready', id: 'session-a', file: 'src/pages/index.astro', arrivedVariants: 3 }); store.appendEvent({ type: 'accept_intent', id: 'session-a', variantId: 2, paramValues: { density: 'packed' } }); @@ -67,7 +72,7 @@ describe('live-session-store', () => { element: { outerHTML: '
Card
', tagName: 'div' }, }); - appendFileSync(join(tmp, '.impeccable-live', 'sessions', 'corrupt-session.jsonl'), '{not json}\n'); + appendFileSync(join(getLiveSessionsDir(tmp), 'corrupt-session.jsonl'), '{not json}\n'); const restarted = createLiveSessionStore({ cwd: tmp, sessionId: 'corrupt-session' }); const snapshot = restarted.getSnapshot('corrupt-session'); @@ -199,7 +204,7 @@ describe('live-session-store', () => { element: { outerHTML: '
Palette
', tagName: 'div' }, }); - const snapshotPath = join(tmp, '.impeccable-live', 'sessions', 'cache-session.snapshot.json'); + const snapshotPath = join(getLiveSessionsDir(tmp), 'cache-session.snapshot.json'); const cached = JSON.parse(readFileSync(snapshotPath, 'utf-8')); assert.equal(cached.phase, 'generate_requested'); @@ -212,4 +217,36 @@ describe('live-session-store', () => { assert.equal(repaired.phase, 'variants_ready'); assert.equal(repaired.sourceFile, 'src/pages/index.astro'); }); + + it('recovers legacy journals from .impeccable-live/sessions', () => { + const legacyDir = getLegacyLiveSessionsDir(tmp); + mkdirSync(legacyDir, { recursive: true }); + appendFileSync(join(legacyDir, 'legacy-session.jsonl'), JSON.stringify({ + seq: 1, + id: 'legacy-session', + type: 'generate', + ts: new Date().toISOString(), + event: { + type: 'generate', + id: 'legacy-session', + action: 'polish', + count: 2, + element: { outerHTML: '
Legacy
', tagName: 'section' }, + }, + }) + '\n'); + + const store = createLiveSessionStore({ cwd: tmp, sessionId: 'legacy-session' }); + const snapshot = store.getSnapshot('legacy-session'); + + assert.equal(snapshot.phase, 'generate_requested'); + assert.equal(snapshot.expectedVariants, 2); + assert.equal(store.listActiveSessions().some((s) => s.id === 'legacy-session'), true); + + store.appendEvent({ type: 'agent_done', id: 'legacy-session', file: 'src/App.jsx' }); + const restarted = createLiveSessionStore({ cwd: tmp, sessionId: 'legacy-session' }); + const migratedSnapshot = restarted.getSnapshot('legacy-session'); + assert.equal(migratedSnapshot.phase, 'variants_ready'); + assert.equal(migratedSnapshot.expectedVariants, 2); + assert.equal(migratedSnapshot.sourceFile, 'src/App.jsx'); + }); }); From e62a36c98eed1a2c94d4db3e677675137f10f6dd Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Sun, 3 May 2026 12:04:42 -0700 Subject: [PATCH 09/13] Fix live disconnect recovery phase --- .agents/skills/impeccable/scripts/live-browser.js | 4 +++- .claude/skills/impeccable/scripts/live-browser.js | 4 +++- .cursor/skills/impeccable/scripts/live-browser.js | 4 +++- .gemini/skills/impeccable/scripts/live-browser.js | 4 +++- .github/skills/impeccable/scripts/live-browser.js | 4 +++- .kiro/skills/impeccable/scripts/live-browser.js | 4 +++- .opencode/skills/impeccable/scripts/live-browser.js | 4 +++- .pi/skills/impeccable/scripts/live-browser.js | 4 +++- .qoder/skills/impeccable/scripts/live-browser.js | 4 +++- .rovodev/skills/impeccable/scripts/live-browser.js | 4 +++- .trae-cn/skills/impeccable/scripts/live-browser.js | 4 +++- .trae/skills/impeccable/scripts/live-browser.js | 4 +++- plugin/skills/impeccable/scripts/live-browser.js | 4 +++- source/skills/impeccable/scripts/live-browser.js | 4 +++- tests/live-browser-regression.test.mjs | 13 +++++++++++++ 15 files changed, 55 insertions(+), 14 deletions(-) diff --git a/.agents/skills/impeccable/scripts/live-browser.js b/.agents/skills/impeccable/scripts/live-browser.js index bb5332a3..077395c8 100644 --- a/.agents/skills/impeccable/scripts/live-browser.js +++ b/.agents/skills/impeccable/scripts/live-browser.js @@ -2241,6 +2241,7 @@ /** Server died or became unreachable. Reset UI to a clean state. */ function handleServerLost() { + const recoveryState = currentSessionId ? state : 'IDLE'; if (state === 'GENERATING' || state === 'CYCLING' || state === 'SAVING') { showToast('Live server disconnected. Session ended.', 5000); } @@ -2257,7 +2258,8 @@ // transient disconnect as an explicit discard. selectedElement = null; selectedAction = 'impeccable'; - state = currentSessionId ? 'GENERATING' : 'IDLE'; + state = recoveryState; + if (currentSessionId) saveSession(); } function sendEvent(msg, opts) { diff --git a/.claude/skills/impeccable/scripts/live-browser.js b/.claude/skills/impeccable/scripts/live-browser.js index bb5332a3..077395c8 100644 --- a/.claude/skills/impeccable/scripts/live-browser.js +++ b/.claude/skills/impeccable/scripts/live-browser.js @@ -2241,6 +2241,7 @@ /** Server died or became unreachable. Reset UI to a clean state. */ function handleServerLost() { + const recoveryState = currentSessionId ? state : 'IDLE'; if (state === 'GENERATING' || state === 'CYCLING' || state === 'SAVING') { showToast('Live server disconnected. Session ended.', 5000); } @@ -2257,7 +2258,8 @@ // transient disconnect as an explicit discard. selectedElement = null; selectedAction = 'impeccable'; - state = currentSessionId ? 'GENERATING' : 'IDLE'; + state = recoveryState; + if (currentSessionId) saveSession(); } function sendEvent(msg, opts) { diff --git a/.cursor/skills/impeccable/scripts/live-browser.js b/.cursor/skills/impeccable/scripts/live-browser.js index bb5332a3..077395c8 100644 --- a/.cursor/skills/impeccable/scripts/live-browser.js +++ b/.cursor/skills/impeccable/scripts/live-browser.js @@ -2241,6 +2241,7 @@ /** Server died or became unreachable. Reset UI to a clean state. */ function handleServerLost() { + const recoveryState = currentSessionId ? state : 'IDLE'; if (state === 'GENERATING' || state === 'CYCLING' || state === 'SAVING') { showToast('Live server disconnected. Session ended.', 5000); } @@ -2257,7 +2258,8 @@ // transient disconnect as an explicit discard. selectedElement = null; selectedAction = 'impeccable'; - state = currentSessionId ? 'GENERATING' : 'IDLE'; + state = recoveryState; + if (currentSessionId) saveSession(); } function sendEvent(msg, opts) { diff --git a/.gemini/skills/impeccable/scripts/live-browser.js b/.gemini/skills/impeccable/scripts/live-browser.js index bb5332a3..077395c8 100644 --- a/.gemini/skills/impeccable/scripts/live-browser.js +++ b/.gemini/skills/impeccable/scripts/live-browser.js @@ -2241,6 +2241,7 @@ /** Server died or became unreachable. Reset UI to a clean state. */ function handleServerLost() { + const recoveryState = currentSessionId ? state : 'IDLE'; if (state === 'GENERATING' || state === 'CYCLING' || state === 'SAVING') { showToast('Live server disconnected. Session ended.', 5000); } @@ -2257,7 +2258,8 @@ // transient disconnect as an explicit discard. selectedElement = null; selectedAction = 'impeccable'; - state = currentSessionId ? 'GENERATING' : 'IDLE'; + state = recoveryState; + if (currentSessionId) saveSession(); } function sendEvent(msg, opts) { diff --git a/.github/skills/impeccable/scripts/live-browser.js b/.github/skills/impeccable/scripts/live-browser.js index bb5332a3..077395c8 100644 --- a/.github/skills/impeccable/scripts/live-browser.js +++ b/.github/skills/impeccable/scripts/live-browser.js @@ -2241,6 +2241,7 @@ /** Server died or became unreachable. Reset UI to a clean state. */ function handleServerLost() { + const recoveryState = currentSessionId ? state : 'IDLE'; if (state === 'GENERATING' || state === 'CYCLING' || state === 'SAVING') { showToast('Live server disconnected. Session ended.', 5000); } @@ -2257,7 +2258,8 @@ // transient disconnect as an explicit discard. selectedElement = null; selectedAction = 'impeccable'; - state = currentSessionId ? 'GENERATING' : 'IDLE'; + state = recoveryState; + if (currentSessionId) saveSession(); } function sendEvent(msg, opts) { diff --git a/.kiro/skills/impeccable/scripts/live-browser.js b/.kiro/skills/impeccable/scripts/live-browser.js index bb5332a3..077395c8 100644 --- a/.kiro/skills/impeccable/scripts/live-browser.js +++ b/.kiro/skills/impeccable/scripts/live-browser.js @@ -2241,6 +2241,7 @@ /** Server died or became unreachable. Reset UI to a clean state. */ function handleServerLost() { + const recoveryState = currentSessionId ? state : 'IDLE'; if (state === 'GENERATING' || state === 'CYCLING' || state === 'SAVING') { showToast('Live server disconnected. Session ended.', 5000); } @@ -2257,7 +2258,8 @@ // transient disconnect as an explicit discard. selectedElement = null; selectedAction = 'impeccable'; - state = currentSessionId ? 'GENERATING' : 'IDLE'; + state = recoveryState; + if (currentSessionId) saveSession(); } function sendEvent(msg, opts) { diff --git a/.opencode/skills/impeccable/scripts/live-browser.js b/.opencode/skills/impeccable/scripts/live-browser.js index bb5332a3..077395c8 100644 --- a/.opencode/skills/impeccable/scripts/live-browser.js +++ b/.opencode/skills/impeccable/scripts/live-browser.js @@ -2241,6 +2241,7 @@ /** Server died or became unreachable. Reset UI to a clean state. */ function handleServerLost() { + const recoveryState = currentSessionId ? state : 'IDLE'; if (state === 'GENERATING' || state === 'CYCLING' || state === 'SAVING') { showToast('Live server disconnected. Session ended.', 5000); } @@ -2257,7 +2258,8 @@ // transient disconnect as an explicit discard. selectedElement = null; selectedAction = 'impeccable'; - state = currentSessionId ? 'GENERATING' : 'IDLE'; + state = recoveryState; + if (currentSessionId) saveSession(); } function sendEvent(msg, opts) { diff --git a/.pi/skills/impeccable/scripts/live-browser.js b/.pi/skills/impeccable/scripts/live-browser.js index bb5332a3..077395c8 100644 --- a/.pi/skills/impeccable/scripts/live-browser.js +++ b/.pi/skills/impeccable/scripts/live-browser.js @@ -2241,6 +2241,7 @@ /** Server died or became unreachable. Reset UI to a clean state. */ function handleServerLost() { + const recoveryState = currentSessionId ? state : 'IDLE'; if (state === 'GENERATING' || state === 'CYCLING' || state === 'SAVING') { showToast('Live server disconnected. Session ended.', 5000); } @@ -2257,7 +2258,8 @@ // transient disconnect as an explicit discard. selectedElement = null; selectedAction = 'impeccable'; - state = currentSessionId ? 'GENERATING' : 'IDLE'; + state = recoveryState; + if (currentSessionId) saveSession(); } function sendEvent(msg, opts) { diff --git a/.qoder/skills/impeccable/scripts/live-browser.js b/.qoder/skills/impeccable/scripts/live-browser.js index bb5332a3..077395c8 100644 --- a/.qoder/skills/impeccable/scripts/live-browser.js +++ b/.qoder/skills/impeccable/scripts/live-browser.js @@ -2241,6 +2241,7 @@ /** Server died or became unreachable. Reset UI to a clean state. */ function handleServerLost() { + const recoveryState = currentSessionId ? state : 'IDLE'; if (state === 'GENERATING' || state === 'CYCLING' || state === 'SAVING') { showToast('Live server disconnected. Session ended.', 5000); } @@ -2257,7 +2258,8 @@ // transient disconnect as an explicit discard. selectedElement = null; selectedAction = 'impeccable'; - state = currentSessionId ? 'GENERATING' : 'IDLE'; + state = recoveryState; + if (currentSessionId) saveSession(); } function sendEvent(msg, opts) { diff --git a/.rovodev/skills/impeccable/scripts/live-browser.js b/.rovodev/skills/impeccable/scripts/live-browser.js index bb5332a3..077395c8 100644 --- a/.rovodev/skills/impeccable/scripts/live-browser.js +++ b/.rovodev/skills/impeccable/scripts/live-browser.js @@ -2241,6 +2241,7 @@ /** Server died or became unreachable. Reset UI to a clean state. */ function handleServerLost() { + const recoveryState = currentSessionId ? state : 'IDLE'; if (state === 'GENERATING' || state === 'CYCLING' || state === 'SAVING') { showToast('Live server disconnected. Session ended.', 5000); } @@ -2257,7 +2258,8 @@ // transient disconnect as an explicit discard. selectedElement = null; selectedAction = 'impeccable'; - state = currentSessionId ? 'GENERATING' : 'IDLE'; + state = recoveryState; + if (currentSessionId) saveSession(); } function sendEvent(msg, opts) { diff --git a/.trae-cn/skills/impeccable/scripts/live-browser.js b/.trae-cn/skills/impeccable/scripts/live-browser.js index bb5332a3..077395c8 100644 --- a/.trae-cn/skills/impeccable/scripts/live-browser.js +++ b/.trae-cn/skills/impeccable/scripts/live-browser.js @@ -2241,6 +2241,7 @@ /** Server died or became unreachable. Reset UI to a clean state. */ function handleServerLost() { + const recoveryState = currentSessionId ? state : 'IDLE'; if (state === 'GENERATING' || state === 'CYCLING' || state === 'SAVING') { showToast('Live server disconnected. Session ended.', 5000); } @@ -2257,7 +2258,8 @@ // transient disconnect as an explicit discard. selectedElement = null; selectedAction = 'impeccable'; - state = currentSessionId ? 'GENERATING' : 'IDLE'; + state = recoveryState; + if (currentSessionId) saveSession(); } function sendEvent(msg, opts) { diff --git a/.trae/skills/impeccable/scripts/live-browser.js b/.trae/skills/impeccable/scripts/live-browser.js index bb5332a3..077395c8 100644 --- a/.trae/skills/impeccable/scripts/live-browser.js +++ b/.trae/skills/impeccable/scripts/live-browser.js @@ -2241,6 +2241,7 @@ /** Server died or became unreachable. Reset UI to a clean state. */ function handleServerLost() { + const recoveryState = currentSessionId ? state : 'IDLE'; if (state === 'GENERATING' || state === 'CYCLING' || state === 'SAVING') { showToast('Live server disconnected. Session ended.', 5000); } @@ -2257,7 +2258,8 @@ // transient disconnect as an explicit discard. selectedElement = null; selectedAction = 'impeccable'; - state = currentSessionId ? 'GENERATING' : 'IDLE'; + state = recoveryState; + if (currentSessionId) saveSession(); } function sendEvent(msg, opts) { diff --git a/plugin/skills/impeccable/scripts/live-browser.js b/plugin/skills/impeccable/scripts/live-browser.js index bb5332a3..077395c8 100644 --- a/plugin/skills/impeccable/scripts/live-browser.js +++ b/plugin/skills/impeccable/scripts/live-browser.js @@ -2241,6 +2241,7 @@ /** Server died or became unreachable. Reset UI to a clean state. */ function handleServerLost() { + const recoveryState = currentSessionId ? state : 'IDLE'; if (state === 'GENERATING' || state === 'CYCLING' || state === 'SAVING') { showToast('Live server disconnected. Session ended.', 5000); } @@ -2257,7 +2258,8 @@ // transient disconnect as an explicit discard. selectedElement = null; selectedAction = 'impeccable'; - state = currentSessionId ? 'GENERATING' : 'IDLE'; + state = recoveryState; + if (currentSessionId) saveSession(); } function sendEvent(msg, opts) { diff --git a/source/skills/impeccable/scripts/live-browser.js b/source/skills/impeccable/scripts/live-browser.js index bb5332a3..077395c8 100644 --- a/source/skills/impeccable/scripts/live-browser.js +++ b/source/skills/impeccable/scripts/live-browser.js @@ -2241,6 +2241,7 @@ /** Server died or became unreachable. Reset UI to a clean state. */ function handleServerLost() { + const recoveryState = currentSessionId ? state : 'IDLE'; if (state === 'GENERATING' || state === 'CYCLING' || state === 'SAVING') { showToast('Live server disconnected. Session ended.', 5000); } @@ -2257,7 +2258,8 @@ // transient disconnect as an explicit discard. selectedElement = null; selectedAction = 'impeccable'; - state = currentSessionId ? 'GENERATING' : 'IDLE'; + state = recoveryState; + if (currentSessionId) saveSession(); } function sendEvent(msg, opts) { diff --git a/tests/live-browser-regression.test.mjs b/tests/live-browser-regression.test.mjs index b0130d52..ce38f513 100644 --- a/tests/live-browser-regression.test.mjs +++ b/tests/live-browser-regression.test.mjs @@ -55,4 +55,17 @@ describe('live-browser.js regression guards', () => { 'detectPageTheme must keep its readOpaque helper that filters out fully-transparent backgrounds before computing luminance', ); }); + + it('handleServerLost preserves the current recoverable phase', () => { + assert.doesNotMatch( + SOURCE, + /state\s*=\s*currentSessionId\s*\?\s*['"]GENERATING['"]\s*:\s*['"]IDLE['"]/, + 'event=live_browser.server_lost_phase actor=browser operation=sse_disconnect risk=cycling_or_saving_session_saved_as_generating expected=preserve current phase actual=forced generating', + ); + assert.match( + SOURCE, + /function handleServerLost\(\)[\s\S]{0,300}?const recoveryState = currentSessionId \? state : 'IDLE';[\s\S]{0,1200}?state = recoveryState;[\s\S]{0,120}?if \(currentSessionId\) saveSession\(\);/, + 'server-lost cleanup should keep the current session phase in local recovery state instead of rewriting it to GENERATING', + ); + }); }); From d1e7ab0a4e346af82419343a1b041a28e5cbe5ad Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Sun, 3 May 2026 12:14:00 -0700 Subject: [PATCH 10/13] Refine live CSS authoring contract --- .agents/skills/impeccable/reference/live.md | 41 +++++-------------- .../skills/impeccable/scripts/live-wrap.mjs | 38 +++++++++++++++++ .claude/skills/impeccable/reference/live.md | 41 +++++-------------- .../skills/impeccable/scripts/live-wrap.mjs | 38 +++++++++++++++++ .cursor/skills/impeccable/reference/live.md | 41 +++++-------------- .../skills/impeccable/scripts/live-wrap.mjs | 38 +++++++++++++++++ .gemini/skills/impeccable/reference/live.md | 41 +++++-------------- .../skills/impeccable/scripts/live-wrap.mjs | 38 +++++++++++++++++ .github/skills/impeccable/reference/live.md | 41 +++++-------------- .../skills/impeccable/scripts/live-wrap.mjs | 38 +++++++++++++++++ .kiro/skills/impeccable/reference/live.md | 41 +++++-------------- .kiro/skills/impeccable/scripts/live-wrap.mjs | 38 +++++++++++++++++ .opencode/skills/impeccable/reference/live.md | 41 +++++-------------- .../skills/impeccable/scripts/live-wrap.mjs | 38 +++++++++++++++++ .pi/skills/impeccable/reference/live.md | 41 +++++-------------- .pi/skills/impeccable/scripts/live-wrap.mjs | 38 +++++++++++++++++ .qoder/skills/impeccable/reference/live.md | 41 +++++-------------- .../skills/impeccable/scripts/live-wrap.mjs | 38 +++++++++++++++++ .rovodev/skills/impeccable/reference/live.md | 41 +++++-------------- .../skills/impeccable/scripts/live-wrap.mjs | 38 +++++++++++++++++ .trae-cn/skills/impeccable/reference/live.md | 41 +++++-------------- .../skills/impeccable/scripts/live-wrap.mjs | 38 +++++++++++++++++ .trae/skills/impeccable/reference/live.md | 41 +++++-------------- .trae/skills/impeccable/scripts/live-wrap.mjs | 38 +++++++++++++++++ plugin/skills/impeccable/reference/live.md | 41 +++++-------------- .../skills/impeccable/scripts/live-wrap.mjs | 38 +++++++++++++++++ source/skills/impeccable/reference/live.md | 41 +++++-------------- .../skills/impeccable/scripts/live-wrap.mjs | 38 +++++++++++++++++ tests/live-e2e/agents/llm-agent.mjs | 12 ++++-- tests/live-wrap.test.mjs | 30 ++++++++++++++ 30 files changed, 711 insertions(+), 437 deletions(-) diff --git a/.agents/skills/impeccable/reference/live.md b/.agents/skills/impeccable/reference/live.md index 235f7fed..3d081a09 100644 --- a/.agents/skills/impeccable/reference/live.md +++ b/.agents/skills/impeccable/reference/live.md @@ -107,12 +107,14 @@ The helper searches ID first, then classes, then tag + class combo. If `event.pa If `--text` matches multiple candidates equally well, wrap exits with `{ error: "element_ambiguous", candidates: [...] }` and `fallback: "agent-driven"`: read the candidate line ranges, decide which one matches the picked element from page context, and write the wrapper manually per the fallback flow. -Output on success: `{ file, insertLine, commentSyntax, styleMode, styleTag, cssSelectorPrefixExamples }`. +Output on success: `{ file, insertLine, commentSyntax, styleMode, styleTag, cssSelectorPrefixExamples, cssAuthoring }`. -`styleMode` controls how preview CSS must be authored: +`styleMode` controls how preview CSS must be authored. Treat it as a detected capability mode, not a framework guess: -- `scoped`: default for HTML, JSX, Vue, and Svelte. Use a normal ` -
- -
-
- -
-
- -
-``` - -For `styleMode: "astro-global-prefixed"` files: - -```astro - -
@@ -273,15 +254,13 @@ For `styleMode: "astro-global-prefixed"` files:
``` -Astro rule: never use raw `@scope ([data-impeccable-variant="N"])` inside `.astro` live preview styles. Prefix every selector with `[data-impeccable-variant="N"]` and add `is:inline` to the temporary style tag so Astro does not transform the preview CSS. - **Each variant div contains exactly one top-level element: the full replacement for the original.** Use the same tag as the original (e.g. `
` if the user picked a `
`). Loose siblings (heading + paragraph + div as direct children of the variant div) break the outline tracking and the accept flow, which both assume one child. -The first variant has no `display: none` (visible by default). All others do. If variants use only inline styles and no scoped CSS, omit the ` -
- -
-
- -
-
- -
-``` - -For `styleMode: "astro-global-prefixed"` files: - -```astro - -
@@ -273,15 +254,13 @@ For `styleMode: "astro-global-prefixed"` files:
``` -Astro rule: never use raw `@scope ([data-impeccable-variant="N"])` inside `.astro` live preview styles. Prefix every selector with `[data-impeccable-variant="N"]` and add `is:inline` to the temporary style tag so Astro does not transform the preview CSS. - **Each variant div contains exactly one top-level element: the full replacement for the original.** Use the same tag as the original (e.g. `
` if the user picked a `
`). Loose siblings (heading + paragraph + div as direct children of the variant div) break the outline tracking and the accept flow, which both assume one child. -The first variant has no `display: none` (visible by default). All others do. If variants use only inline styles and no scoped CSS, omit the ` -
- -
-
- -
-
- -
-``` - -For `styleMode: "astro-global-prefixed"` files: - -```astro - -
@@ -273,15 +254,13 @@ For `styleMode: "astro-global-prefixed"` files:
``` -Astro rule: never use raw `@scope ([data-impeccable-variant="N"])` inside `.astro` live preview styles. Prefix every selector with `[data-impeccable-variant="N"]` and add `is:inline` to the temporary style tag so Astro does not transform the preview CSS. - **Each variant div contains exactly one top-level element: the full replacement for the original.** Use the same tag as the original (e.g. `
` if the user picked a `
`). Loose siblings (heading + paragraph + div as direct children of the variant div) break the outline tracking and the accept flow, which both assume one child. -The first variant has no `display: none` (visible by default). All others do. If variants use only inline styles and no scoped CSS, omit the ` -
- -
-
- -
-
- -
-``` - -For `styleMode: "astro-global-prefixed"` files: - -```astro - -
@@ -273,15 +254,13 @@ For `styleMode: "astro-global-prefixed"` files:
``` -Astro rule: never use raw `@scope ([data-impeccable-variant="N"])` inside `.astro` live preview styles. Prefix every selector with `[data-impeccable-variant="N"]` and add `is:inline` to the temporary style tag so Astro does not transform the preview CSS. - **Each variant div contains exactly one top-level element: the full replacement for the original.** Use the same tag as the original (e.g. `
` if the user picked a `
`). Loose siblings (heading + paragraph + div as direct children of the variant div) break the outline tracking and the accept flow, which both assume one child. -The first variant has no `display: none` (visible by default). All others do. If variants use only inline styles and no scoped CSS, omit the ` -
- -
-
- -
-
- -
-``` - -For `styleMode: "astro-global-prefixed"` files: - -```astro - -
@@ -273,15 +254,13 @@ For `styleMode: "astro-global-prefixed"` files:
``` -Astro rule: never use raw `@scope ([data-impeccable-variant="N"])` inside `.astro` live preview styles. Prefix every selector with `[data-impeccable-variant="N"]` and add `is:inline` to the temporary style tag so Astro does not transform the preview CSS. - **Each variant div contains exactly one top-level element: the full replacement for the original.** Use the same tag as the original (e.g. `
` if the user picked a `
`). Loose siblings (heading + paragraph + div as direct children of the variant div) break the outline tracking and the accept flow, which both assume one child. -The first variant has no `display: none` (visible by default). All others do. If variants use only inline styles and no scoped CSS, omit the ` -
- -
-
- -
-
- -
-``` - -For `styleMode: "astro-global-prefixed"` files: - -```astro - -
@@ -273,15 +254,13 @@ For `styleMode: "astro-global-prefixed"` files:
``` -Astro rule: never use raw `@scope ([data-impeccable-variant="N"])` inside `.astro` live preview styles. Prefix every selector with `[data-impeccable-variant="N"]` and add `is:inline` to the temporary style tag so Astro does not transform the preview CSS. - **Each variant div contains exactly one top-level element: the full replacement for the original.** Use the same tag as the original (e.g. `
` if the user picked a `
`). Loose siblings (heading + paragraph + div as direct children of the variant div) break the outline tracking and the accept flow, which both assume one child. -The first variant has no `display: none` (visible by default). All others do. If variants use only inline styles and no scoped CSS, omit the ` -
- -
-
- -
-
- -
-``` - -For `styleMode: "astro-global-prefixed"` files: - -```astro - -
@@ -273,15 +254,13 @@ For `styleMode: "astro-global-prefixed"` files:
``` -Astro rule: never use raw `@scope ([data-impeccable-variant="N"])` inside `.astro` live preview styles. Prefix every selector with `[data-impeccable-variant="N"]` and add `is:inline` to the temporary style tag so Astro does not transform the preview CSS. - **Each variant div contains exactly one top-level element: the full replacement for the original.** Use the same tag as the original (e.g. `
` if the user picked a `
`). Loose siblings (heading + paragraph + div as direct children of the variant div) break the outline tracking and the accept flow, which both assume one child. -The first variant has no `display: none` (visible by default). All others do. If variants use only inline styles and no scoped CSS, omit the ` -
- -
-
- -
-
- -
-``` - -For `styleMode: "astro-global-prefixed"` files: - -```astro - -
@@ -273,15 +254,13 @@ For `styleMode: "astro-global-prefixed"` files:
``` -Astro rule: never use raw `@scope ([data-impeccable-variant="N"])` inside `.astro` live preview styles. Prefix every selector with `[data-impeccable-variant="N"]` and add `is:inline` to the temporary style tag so Astro does not transform the preview CSS. - **Each variant div contains exactly one top-level element: the full replacement for the original.** Use the same tag as the original (e.g. `
` if the user picked a `
`). Loose siblings (heading + paragraph + div as direct children of the variant div) break the outline tracking and the accept flow, which both assume one child. -The first variant has no `display: none` (visible by default). All others do. If variants use only inline styles and no scoped CSS, omit the ` -
- -
-
- -
-
- -
-``` - -For `styleMode: "astro-global-prefixed"` files: - -```astro - -
@@ -273,15 +254,13 @@ For `styleMode: "astro-global-prefixed"` files:
``` -Astro rule: never use raw `@scope ([data-impeccable-variant="N"])` inside `.astro` live preview styles. Prefix every selector with `[data-impeccable-variant="N"]` and add `is:inline` to the temporary style tag so Astro does not transform the preview CSS. - **Each variant div contains exactly one top-level element: the full replacement for the original.** Use the same tag as the original (e.g. `
` if the user picked a `
`). Loose siblings (heading + paragraph + div as direct children of the variant div) break the outline tracking and the accept flow, which both assume one child. -The first variant has no `display: none` (visible by default). All others do. If variants use only inline styles and no scoped CSS, omit the ` -
- -
-
- -
-
- -
-``` - -For `styleMode: "astro-global-prefixed"` files: - -```astro - -
@@ -273,15 +254,13 @@ For `styleMode: "astro-global-prefixed"` files:
``` -Astro rule: never use raw `@scope ([data-impeccable-variant="N"])` inside `.astro` live preview styles. Prefix every selector with `[data-impeccable-variant="N"]` and add `is:inline` to the temporary style tag so Astro does not transform the preview CSS. - **Each variant div contains exactly one top-level element: the full replacement for the original.** Use the same tag as the original (e.g. `
` if the user picked a `
`). Loose siblings (heading + paragraph + div as direct children of the variant div) break the outline tracking and the accept flow, which both assume one child. -The first variant has no `display: none` (visible by default). All others do. If variants use only inline styles and no scoped CSS, omit the ` -
- -
-
- -
-
- -
-``` - -For `styleMode: "astro-global-prefixed"` files: - -```astro - -
@@ -273,15 +254,13 @@ For `styleMode: "astro-global-prefixed"` files:
``` -Astro rule: never use raw `@scope ([data-impeccable-variant="N"])` inside `.astro` live preview styles. Prefix every selector with `[data-impeccable-variant="N"]` and add `is:inline` to the temporary style tag so Astro does not transform the preview CSS. - **Each variant div contains exactly one top-level element: the full replacement for the original.** Use the same tag as the original (e.g. `
` if the user picked a `
`). Loose siblings (heading + paragraph + div as direct children of the variant div) break the outline tracking and the accept flow, which both assume one child. -The first variant has no `display: none` (visible by default). All others do. If variants use only inline styles and no scoped CSS, omit the ` -
- -
-
- -
-
- -
-``` - -For `styleMode: "astro-global-prefixed"` files: - -```astro - -
@@ -273,15 +254,13 @@ For `styleMode: "astro-global-prefixed"` files:
``` -Astro rule: never use raw `@scope ([data-impeccable-variant="N"])` inside `.astro` live preview styles. Prefix every selector with `[data-impeccable-variant="N"]` and add `is:inline` to the temporary style tag so Astro does not transform the preview CSS. - **Each variant div contains exactly one top-level element: the full replacement for the original.** Use the same tag as the original (e.g. `
` if the user picked a `
`). Loose siblings (heading + paragraph + div as direct children of the variant div) break the outline tracking and the accept flow, which both assume one child. -The first variant has no `display: none` (visible by default). All others do. If variants use only inline styles and no scoped CSS, omit the ` -
- -
-
- -
-
- -
-``` - -For `styleMode: "astro-global-prefixed"` files: - -```astro - -
@@ -273,15 +254,13 @@ For `styleMode: "astro-global-prefixed"` files:
``` -Astro rule: never use raw `@scope ([data-impeccable-variant="N"])` inside `.astro` live preview styles. Prefix every selector with `[data-impeccable-variant="N"]` and add `is:inline` to the temporary style tag so Astro does not transform the preview CSS. - **Each variant div contains exactly one top-level element: the full replacement for the original.** Use the same tag as the original (e.g. `
` if the user picked a `
`). Loose siblings (heading + paragraph + div as direct children of the variant div) break the outline tracking and the accept flow, which both assume one child. -The first variant has no `display: none` (visible by default). All others do. If variants use only inline styles and no scoped CSS, omit the ` -
- -
-
- -
-
- -
-``` - -For `styleMode: "astro-global-prefixed"` files: - -```astro - -
@@ -273,15 +254,13 @@ For `styleMode: "astro-global-prefixed"` files:
``` -Astro rule: never use raw `@scope ([data-impeccable-variant="N"])` inside `.astro` live preview styles. Prefix every selector with `[data-impeccable-variant="N"]` and add `is:inline` to the temporary style tag so Astro does not transform the preview CSS. - **Each variant div contains exactly one top-level element: the full replacement for the original.** Use the same tag as the original (e.g. `
` if the user picked a `
`). Loose siblings (heading + paragraph + div as direct children of the variant div) break the outline tracking and the accept flow, which both assume one child. -The first variant has no `display: none` (visible by default). All others do. If variants use only inline styles and no scoped CSS, omit the `