From 950d48192c93a193ffa866adc59d3ff337aa3338 Mon Sep 17 00:00:00 2001 From: Zeeshan Date: Fri, 9 Oct 2026 23:17:39 +0530 Subject: [PATCH 01/11] feat(v2): remote suites, use-case detail and a History tab Remote suites: a suite sent to the grid reports its tests only when the job ends, so the band sat on "planning". It now reads remote_start, remote_dispatched and remote_error: the band shows how many tests are on the grid and since when, the pane names the grid, the job and its link, and a job that fails before any test reports gives every test its reason. Use-case detail: a use case on the Assurance tab opens to its title, risk and what it still owes to design and to run, each with kane-cli's reason and next command. "Close these gaps with Claude" hands that to Claude; the mod still runs nothing itself. History: a third tab lists the finished runs kept for the project (30, up from 10), with when, how long, and where each failed or a suite's counts. /kane history opens it. Older stored entries still read. Co-Authored-By: Claude Opus 5.5 --- README.md | 12 +-- docs/index.html | 5 +- guide/kane-qe-guide.html | 5 +- guide/v2/design-coverage.md | 18 +++- kane-qe/README.md | 13 +-- kane-qe/hooks/adapter.ts | 9 ++ kane-qe/hooks/model.ts | 82 +++++++++++++++++- kane-qe/hooks/register.tsx | 150 ++++++++++++++++++++++++++++++--- kane-qe/tests/adapter.test.ts | 4 + kane-qe/tests/engine.test.tsx | 151 +++++++++++++++++++++++++++++++++- kane-qe/tests/model.test.ts | 142 ++++++++++++++++++++++++++++++-- kane-qe/types/index.d.ts | 17 +++- 12 files changed, 566 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index 2b4072c..383244b 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ **kane-qe** is a [Claude Code](https://claude.com/claude-code) plugin (a mod) that watches [kane-cli](https://github.com/LambdaTest/kane-cli) ([KaneAI](https://www.lambdatest.com/kane-ai)) and shows what it is doing, without being asked. -Claude runs kane-cli; the mod reads what kane-cli leaves on disk and draws it: a band of three rows above the prompt with the kane mascot, and a pane for runs and assurance. When Claude changes code and nothing has tested it, the band says so and offers a test. +Claude runs kane-cli; the mod reads what kane-cli leaves on disk and draws it: a band of three rows above the prompt with the kane mascot, and a pane for runs, assurance and history. When Claude changes code and nothing has tested it, the band says so and offers a test. ![The band while a test runs](guide/v2/band-running.png) @@ -52,14 +52,16 @@ Nothing to start. Open Claude Code in a project and the band is there. 1. **Ask Claude to test something**: "run `kane-cli run "Search for iPod and assert 4 products are listed" --url https://ecommerce-playground.lambdatest.io --agent --headless`", or "run the login test with kane-cli". Within two seconds the band shows the run, its time and the step it is on. A run started in a terminal in the same folder shows up the same way. 2. **Open the pane** with `/kane` or `[ Open ]`. Press a run for its steps; the current or failing step opens to what the agent thinks, does and checks. 3. **When a test fails**, the band names it and the step; the run's detail gives kane's own verdict (why, what kind, how sure, where) and *Open evidence*. -4. **Several runs or a suite**: one coloured cell per run or test, with counts; a failure takes row 2 while the rest keep going. +4. **Several runs or a suite**: one coloured cell per run or test, with counts; a failure takes row 2 while the rest keep going. A suite on the remote grid (`--remote`) shows how many tests are on the grid and since when; the pane gives the job and its link, and the tests are counted when the job ends. 5. **After Claude changes code**, the band warns *⚠ 1 file changed, untested* with *Test this change*: saved tests that cover the change (with their last result), or an objective Claude drafts from the diff. `/kane auto` makes Claude test before it finishes; `/kane off` turns it off. -6. **Assurance**: row 3 is requirement coverage from `kane-cli cover gaps --json`; the Assurance tab breaks it down per use case, or offers to set it up. +6. **Assurance**: row 3 is requirement coverage from `kane-cli cover gaps --json`; the Assurance tab breaks it down per use case, or offers to set it up. Press a use case to see what it still owes and kane-cli's next command for each; *Close these gaps with Claude* hands it over. +7. **History**: the History tab lists the last 30 finished runs in this project, with where each failed. | Command | | |---|---| | `/kane` | Open the pane | | `/kane assurance` | Open the Assurance tab | +| `/kane history` | Open the History tab | | `/kane auto` · `/kane ask` · `/kane off` | What happens after Claude changes code, for this session | `/config` → kane-qe: **After Claude changes code** (`offer` by default, `auto`, `off`) and **kane-cli command** (default `kane-cli`, used only for the read-only `cover gaps`). Colours follow Claude Code's theme. @@ -73,7 +75,7 @@ What it never does: it never starts a kane-cli run, never reads kane-cli's human ``` claude plugin validate kane-qe --strict # the plugin: manifest, hooks, state contract claude plugin validate . --strict # the marketplace manifest -claude plugin test kane-qe # 81 tests +claude plugin test kane-qe # 103 tests ``` CI (`.github/workflows/ci.yml`) runs the same on every push to `main` and on pull requests. @@ -111,7 +113,7 @@ References: [install plugins](https://code.claude.com/docs/en/plugins/install), | `kane-qe/hooks/sources.ts` | The live-run pointer and byte offsets into event files | | `kane-qe/hooks/register.tsx` | Polling, hooks (edits, end of turn, `/kane`, theme) and the drawing | | `kane-qe/hooks/mascot.ts` | The mascot: a cell grid in terminals, an image elsewhere | -| `kane-qe/tests/` | Adapter table, band states at 60/80/120 columns, recorded kane-cli streams replayed, band and pane on terminal and desktop, change tracking, stale pointers, assurance, theme | +| `kane-qe/tests/` | Adapter table, band states at 60/80/120 columns, recorded kane-cli streams replayed, band and pane on terminal and desktop, change tracking, stale pointers, assurance and use-case detail, remote suites, history, theme | | `.claude-plugin/marketplace.json` | The `lambdatest` marketplace: this repository installs with `/plugin marketplace add` | | `docs/` | The Field Guide and the explainer as standalone HTML, with the walkthrough video and screenshots (`guide/build_docs.sh` rebuilds them) | | `guide/` | The page sources, screenshots, design coverage, and the script that records the walkthrough (`KANE_DEMO_DIR= guide/run_tour_v2.sh`) | diff --git a/docs/index.html b/docs/index.html index f49b321..95d32da 100644 --- a/docs/index.html +++ b/docs/index.html @@ -166,7 +166,7 @@

v2 in a real session

What it is

above the prompt

The band

Three rows, always there: what kane-cli is doing, the step or the failure, and assurance coverage. The mascot's eyes move while a test runs.

-
/kane · [ Open ]

The pane

Runs: a card per run or a row per suite test; press one for its steps. Assurance: coverage bars and a card per use case.

+
/kane · [ Open ]

The pane

Runs: a card per run or a row per suite test; press one for its steps. Assurance: coverage bars and a card per use case; press one for what it still owes. History: the last 30 finished runs in this project.

nothing to start

It watches

Any kane-cli run in this project shows up by itself within two seconds: started by Claude, a terminal or a script.

after an edit

Test what changed

When Claude changes code and nothing has tested it, the band says so and offers the saved test that covers it, or a drafted one.

kane's own verdict

Why it failed

Why, what kind, how sure, and the step it failed on, straight from kane-cli's investigation. Never guessed.

@@ -310,6 +310,7 @@

Commands

/kaneOpen the pane /kane assuranceOpen the Assurance tab + /kane historyOpen the History tab /kane autoClaude tests a change before it finishes (this session) /kane askThe band offers a test (this session) /kane offNothing after a change (this session) @@ -333,7 +334,7 @@

Settings

Design coverage

Built to the design, and checked against it

-

v2 was built from the kane-cli mod design: 17 features, 13 screens and a build spec. Every feature and screen was compared in a real terminal with the design's screenshots, and 81 tests cover the rest. The full list, with how each item was checked, is in design-coverage.md.

+

v2 was built from the kane-cli mod design: 17 features, 13 screens and a build spec. Every feature and screen was compared in a real terminal with the design's screenshots, and 103 tests cover the rest. The full list, with how each item was checked, is in design-coverage.md.

Two deliberate differences

The design lists a test file's coming steps; kane-cli reports no step total, and the spec says never to show one. The design's cards say “Chrome 154”; kane-cli reports no browser name for desktop runs.

Not checked yet

The desktop app: the band and pane are tested on its surface, with the mascot as an image, but were not opened in the app itself.

diff --git a/guide/kane-qe-guide.html b/guide/kane-qe-guide.html index d2905f2..e47609e 100644 --- a/guide/kane-qe-guide.html +++ b/guide/kane-qe-guide.html @@ -162,7 +162,7 @@

v2 in a real session

What it is

above the prompt

The band

Three rows, always there: what kane-cli is doing, the step or the failure, and assurance coverage. The mascot's eyes move while a test runs.

-
/kane · [ Open ]

The pane

Runs: a card per run or a row per suite test; press one for its steps. Assurance: coverage bars and a card per use case.

+
/kane · [ Open ]

The pane

Runs: a card per run or a row per suite test; press one for its steps. Assurance: coverage bars and a card per use case; press one for what it still owes. History: the last 30 finished runs in this project.

nothing to start

It watches

Any kane-cli run in this project shows up by itself within two seconds: started by Claude, a terminal or a script.

after an edit

Test what changed

When Claude changes code and nothing has tested it, the band says so and offers the saved test that covers it, or a drafted one.

kane's own verdict

Why it failed

Why, what kind, how sure, and the step it failed on, straight from kane-cli's investigation. Never guessed.

@@ -306,6 +306,7 @@

Commands

/kaneOpen the pane /kane assuranceOpen the Assurance tab + /kane historyOpen the History tab /kane autoClaude tests a change before it finishes (this session) /kane askThe band offers a test (this session) /kane offNothing after a change (this session) @@ -329,7 +330,7 @@

Settings

Design coverage

Built to the design, and checked against it

-

v2 was built from the kane-cli mod design: 17 features, 13 screens and a build spec. Every feature and screen was compared in a real terminal with the design's screenshots, and 81 tests cover the rest. The full list, with how each item was checked, is in design-coverage.md.

+

v2 was built from the kane-cli mod design: 17 features, 13 screens and a build spec. Every feature and screen was compared in a real terminal with the design's screenshots, and 103 tests cover the rest. The full list, with how each item was checked, is in design-coverage.md.

Two deliberate differences

The design lists a test file's coming steps; kane-cli reports no step total, and the spec says never to show one. The design's cards say “Chrome 154”; kane-cli reports no browser name for desktop runs.

Not checked yet

The desktop app: the band and pane are tested on its surface, with the mascot as an image, but were not opened in the app itself.

diff --git a/guide/v2/design-coverage.md b/guide/v2/design-coverage.md index 04a9c39..06b7829 100644 --- a/guide/v2/design-coverage.md +++ b/guide/v2/design-coverage.md @@ -5,7 +5,7 @@ The design ([kane-cli mod: terminal build guide](https://claude.ai/artifact/5Dkz How each item was checked: - **live**: in a real Claude Code session in a terminal, with a real kane-cli run; - **staged**: a real Claude Code session, with kane-cli runs written to disk in kane-cli 0.8.20's own format, by `sleep` processes standing in for kane-cli; -- **test**: `claude plugin test kane-qe` (81 tests). +- **test**: `claude plugin test kane-qe` (103 tests). ## Features (design part 1, "What to build") @@ -19,7 +19,7 @@ How each item was checked: | 6 | Suite progress | One cell per test, `5 of 12 tests done · 3 running · 4 left`, running tests on row 2 | live (3-test suite), staged (12), test | | 7 | Failures shown at once | The newest failure takes row 2 the moment it happens, in a suite too | live, staged | | 8 | Results when idle | `✓ 4 passed · ✗ 1 failed`, the failed test and its step; `last run ✓ login · 2h ago` from earlier sessions | live, test | -| 9 | Details pane | `/kane` or `[ Open ]`: Runs and Assurance tabs | live | +| 9 | Details pane | `/kane` or `[ Open ]`: Runs and Assurance tabs (a History tab was added later, see below) | live | | 10 | Step-by-step view | Steps with ticks and times; the current or failing step opens to think, act and checks | staged, test | | 11 | Failure explanation | why, kind (category · severity · confidence), where, the failing check, *Open evidence* | staged, live (kane's own verdict) | | 12 | Assurance coverage | Row 3 `47% proven · 4 use cases`; the tab's bars, failing/blocked, last run, use-case cards | live (real requirement store) | @@ -45,7 +45,7 @@ Two differences, both deliberate: | Structured data only | Pointer JSON and `events.ndjson` lines; kane-cli's human output is never read | | No guesses on screen | What kane-cli does not report is left out (step totals, credits while running, balance, browser) | | Ignore what you do not know | `adapter.ts` dispatches on known types and skips the rest | -| Every behaviour has a test; README changes with it | 79 tests; both READMEs describe v2 | +| Every behaviour has a test; README changes with it | 103 tests; both READMEs describe v2 | | Code shape: sources → adapter → model → views | `sources.ts`, `adapter.ts` (only place raw field names appear), `model.ts` (pure), `register.tsx` | | Faults 1–5 in the prototype | All fixed: wrapped text keeps its column, waiting markers in the dim colour, card borders in the dim colour, cuts sized from the pane width, no background on band or pane | @@ -64,8 +64,20 @@ Two differences, both deliberate: | A light terminal theme, a narrow terminal | A darker palette on light themes; every band row fits its width | test, live (80 columns) | | kane-cli is upgraded | The display keeps working; a newer wire format says so instead of breaking | test | +## Added after the design + +Three things the design did not ask for, each checked by **test** only (not yet in a live session): + +| Addition | What it does | +|---|---| +| Remote suites | `kane-cli testrun run --remote`: the grid reports its tests only when the job ends, so until then the band reads `12 tests on the grid · sent 2m ago` and the pane gives the grid, the job and its link. A job that fails before any test reports gives every test that reason | +| Use-case detail | Press a use case on the Assurance tab: its title and risk, what it owes to design and to run, kane-cli's reason and next command for each, and *Close these gaps with Claude* | +| History tab | The last 30 finished runs in this project (it was 10, shown only as `last run …`), with when, how long, and where each failed or a suite's counts. `/kane history` opens it | + ## Not checked +- **The three additions above in a live session.** The remote events are written from kane-cli's contract (`docs/remote-execution.md`); no real grid job has been watched with them. + - **The desktop app.** The band and pane are mounted on the desktop surface in tests (the mascot as an image there), but not opened in the app itself. - **The light palette in a live session**: switching a session's theme changes the person's global setting, so it is covered by a test only. diff --git a/kane-qe/README.md b/kane-qe/README.md index 873385e..62ae2d2 100644 --- a/kane-qe/README.md +++ b/kane-qe/README.md @@ -6,8 +6,8 @@ A Claude Code mod that shows what kane-cli (KaneAI) is doing, without being aske | Surface | What it shows | |---|---| -| **Band** (three rows above the prompt, the kane mascot on the left) | Row 1: `idle` with this session's passed/failed counts, `running` with the test's name and time, `N runs` with one coloured cell per run, or `suite` with one cell per test and `5 of 12 tests done · 3 running · 4 left`. Row 2: the step a single run is on; `now` and each running test with its time; a failure the moment it happens (`✗ checkout_guest failed on Fill the shipping address`); `last run ✓ login · 2h ago` from earlier sessions; or `⚠ 2 files changed, untested [ Test this change ]`. Row 3: assurance, `47% proven · 4 use cases`, or `not set up [ Set up now ]`. The mascot's eyes move while a test runs. | -| **Pane** (`/kane` or `[ Open ]`) | **Runs**: a card per run (failed first, red border), or one row per test for a suite. Press a name for its steps: done steps with a tick and time, the current or failing step expanded to *think*, *act* and each *check*, the failure's *why*, *kind* (kane's category, severity, confidence) and *where*, then *Open evidence*. **Assurance**: designed and proven bars, failing/blocked counts and when it last ran, a card per use case with what it still owes, or a card that offers to set it up with Claude. | +| **Band** (three rows above the prompt, the kane mascot on the left) | Row 1: `idle` with this session's passed/failed counts, `running` with the test's name and time, `N runs` with one coloured cell per run, or `suite` with one cell per test and `5 of 12 tests done · 3 running · 4 left`. A suite sent to the remote grid (`testrun run --remote`) reads `12 tests on the grid · sent 2m 05s ago` until the job ends, because the grid reports its tests only then. Row 2: the step a single run is on; `now` and each running test with its time; a failure the moment it happens (`✗ checkout_guest failed on Fill the shipping address`); `last run ✓ login · 2h ago` from earlier sessions; or `⚠ 2 files changed, untested [ Test this change ]`. Row 3: assurance, `47% proven · 4 use cases`, or `not set up [ Set up now ]`. The mascot's eyes move while a test runs. | +| **Pane** (`/kane` or `[ Open ]`) | **Runs**: a card per run (failed first, red border), or one row per test for a suite. Press a name for its steps: done steps with a tick and time, the current or failing step expanded to *think*, *act* and each *check*, the failure's *why*, *kind* (kane's category, severity, confidence) and *where*, then *Open evidence*. For a remote suite the pane names the grid, the job and its link. **Assurance**: designed and proven bars, failing/blocked counts and when it last ran, a card per use case with what it still owes, or a card that offers to set it up with Claude. Press a use case for its detail: its title and risk, then each thing it owes under *To design* and *To run*, with kane-cli's reason and its own next command; *Close these gaps with Claude* hands that to Claude. **History**: the finished runs kept for this project, newest first, each with when it ran, how long it took, and where it failed or a suite's passed/failed counts. | | **After Claude changes code** | When a turn ends with edited files (by the edit tools or a shell command; not docs, hidden tool folders or git-ignored files) and no kane-cli run since, the band warns and offers *Test this change*. The card lists the changed files and saved tests that mention the same feature, with their last result and *Run it*. Only when none does, Claude drafts an objective from the diff (also on *Draft a new objective*); *Run the objective* hands it to Claude with the app's start URL. A kane-cli run that starts after the last edit clears the warning. | ## Settings @@ -19,7 +19,7 @@ A Claude Code mod that shows what kane-cli (KaneAI) is doing, without being aske | After Claude changes code | `offer` | `offer`: the band offers a test. `auto`: Claude is held once at the end of its turn to test the change. `off`: nothing | | kane-cli command | `kane-cli` | Used only for `kane-cli cover gaps --json` (read-only, no credits) | -`/kane auto`, `/kane ask`, `/kane off` change it for this session. Colours follow Claude Code's theme (the design's palette on dark, a darker one on light), and every band row fits its width: names are cut first, then items drop from the right, `[ Open ]` stays. `/kane assurance` opens the Assurance tab. +`/kane auto`, `/kane ask`, `/kane off` change it for this session. Colours follow Claude Code's theme (the design's palette on dark, a darker one on light), and every band row fits its width: names are cut first, then items drop from the right, `[ Open ]` stays. `/kane assurance` opens the Assurance tab and `/kane history` the History tab. ## How it reads kane-cli @@ -27,14 +27,15 @@ A Claude Code mod that shows what kane-cli (KaneAI) is doing, without being aske - Each run's `events.ndjson` (and each suite member's own stream, read once more when it ends), from where it last stopped. Structured lines only, never kane-cli's human output. Unknown events and fields are skipped; lines that are not JSON are skipped and counted; a stream with a newer wire version shows its name and time and says so. - Names come from the command Claude ran (`kane-cli testmd run login_test.md` → `login_test.md`), then kane-cli's own `recording_state`, then the objective. - Failure facts come from `run_end.verdict` (root cause, category, severity, confidence), never guessed. -- Assurance: `kane-cli cover gaps --json` at session start and after each run, when the project has a `.context` folder. -- The last ten results per project are kept in the mod's store (`recentByProject`, apart from v1's `history`) for `last run …`. +- A remote suite: `remote_start` (the grid) and `remote_dispatched` (the job and its link) while the job runs; its tests arrive after the fact, stamped with the grid's own time. A job that fails before any test reports (`remote_error`) gives every test that reason. +- Assurance: `kane-cli cover gaps --json` at session start and after each run, when the project has a `.context` folder. Each use case keeps up to 20 of its `pending` rows (title, why, risk, stage, `ready_command`) for its detail view; its counts cover them all. +- The last 30 results per project are kept in the mod's store (`recentByProject`, apart from v1's `history`) for `last run …` and the History tab. Entries kept by earlier versions (label, status and time only) still read. ## Develop ``` claude plugin validate kane-qe -claude plugin test kane-qe # adapter table, band states at 60/80/120 columns, recorded streams, pane and band on terminal and desktop, change tracking, stale pointers, assurance +claude plugin test kane-qe # adapter table, band states at 60/80/120 columns, recorded streams, pane and band on terminal and desktop, change tracking, stale pointers, assurance and use-case detail, remote suites, history node kane-qe/tests/fixtures/build.mjs # after adding a recorded stream to tests/fixtures/*.ndjson ``` diff --git a/kane-qe/hooks/adapter.ts b/kane-qe/hooks/adapter.ts index e4ba221..b15ccce 100644 --- a/kane-qe/hooks/adapter.ts +++ b/kane-qe/hooks/adapter.ts @@ -21,6 +21,7 @@ export type Ev = | { k: 'progress'; running: string[]; pending: number; done: number; total: number; at: number } | { k: 'memberEnd'; path: string; status: string; durationS?: number; logPath?: string; why?: string; at: number } | { k: 'suiteDone'; status: string; at: number } + | { k: 'remote'; backend?: string; jobId?: string; jobUrl?: string; dispatched: boolean; at: number } | { k: 'postHoc' } /** Label sources, weakest first: a stronger one replaces a weaker one. */ @@ -164,6 +165,14 @@ function one(o: Json, at: number): Ev[] { } case 'testrun_done': return [{ k: 'suiteDone', status: str(o.overall_status) ?? 'failed', at }] + case 'remote_start': + return [{ k: 'remote', backend: str(o.backend), dispatched: false, at }] + case 'remote_dispatched': + return [{ k: 'remote', jobId: str(o.job_id), jobUrl: str(o.job_url), dispatched: true, at }] + case 'remote_error': { + const m = str(o.detail) ?? str(o.code) + return m ? [{ k: 'error', message: m, at }] : [] + } default: return [] } diff --git a/kane-qe/hooks/model.ts b/kane-qe/hooks/model.ts index 6de98d8..de1c920 100644 --- a/kane-qe/hooks/model.ts +++ b/kane-qe/hooks/model.ts @@ -3,7 +3,7 @@ import type { Ev } from './adapter' import { RANK } from './adapter' -import type { Assurance, Check, HistoryEntry, Kind, Run, Status, Step, UseCase } from '../types' +import type { Assurance, Check, HistoryEntry, Kind, Pending, Run, Status, Step, UseCase } from '../types' /** The design's palette, made for dark terminals. */ const DARK = { @@ -197,9 +197,23 @@ export function fold(run: Run, ev: Ev): Run { }) case 'suiteDone': { const status: Status = ev.status === 'passed' ? 'passed' : 'failed' - const members = (run.members ?? []).map(m => (m.status === 'running' ? { ...m, status: 'failed' as const, endedAt: ev.at, failure: { why: 'the suite ended before this test finished' } } : m)) + // A grid job that failed before any test reported: every test shares the job's reason. + const lost = status === 'failed' && !!run.remote && !(run.members ?? []).some(m => m.status === 'passed' || m.status === 'failed') + const members = (run.members ?? []).map(m => { + if (m.status === 'running') return { ...m, status: 'failed' as const, endedAt: ev.at, failure: { why: 'the suite ended before this test finished' } } + if (lost && m.status === 'pending') return { ...m, status: 'failed' as const, startedAt: ev.at, endedAt: ev.at, failure: { why: run.failure?.why || 'the grid job ended with no result for this test' } } + return m + }) return { ...run, status, endedAt: ev.at, members } } + case 'remote': { + const remote = { ...run.remote } + if (ev.backend) remote.backend = ev.backend + if (ev.jobId) remote.jobId = ev.jobId + if (ev.jobUrl) remote.jobUrl = ev.jobUrl + if (ev.dispatched) remote.dispatchedAt = ev.at + return { ...run, kind: 'testrun', remote } + } } } @@ -263,6 +277,15 @@ export function nowText(r: Run): string { return 'starting the browser' } +/** True while a suite's tests are on the remote grid and none has reported: the grid reports them when the job ends. */ +export function onGrid(r: Run): boolean { + if (!r.remote || !isLive(r.status)) return false + return !(r.members ?? []).some(m => m.status !== 'pending') +} + +/** "sent 2m 05s ago", or that the job is still being sent. */ +export const gridSince = (r: Run, now: number): string => (r.remote?.dispatchedAt ? `sent ${fmt((now - r.remote.dispatchedAt) / 1000)} ago` : 'sending the job') + // ── the band ──────────────────────────────────────────────────────────── export type Span = { t: string; c?: string; bg?: string; b?: boolean; d?: boolean } @@ -401,6 +424,17 @@ function pickBand(runs: readonly Run[], o: BandOptions): Band { const running = only.members.filter(m => m.status === 'running') const failed = newestFailed(only.members) const counts = c.total > 0 ? [{ t: `${c.passed + c.failed} of ${c.total} tests done`, b: true }, dim(`· ${c.running} running · ${c.pending} left`)] : [dim('planning')] + if (onGrid(only)) { + const tests = c.total > 0 ? [{ t: `${c.total} test${c.total === 1 ? '' : 's'} on the grid`, b: true }, dim(`· ${gridSince(only, o.now)}`)] : [dim(gridSince(only, o.now))] + return { + think, + rows: [ + { items: [chip('suite', true), cells(only.members.map(m => m.status)), ...tests], button: open }, + { items: [dim('grid'), { t: only.remote?.backend ?? 'remote' }, dim('· each test is reported when the job ends')] }, + assuranceRow(o.assurance), + ], + } + } return { think, rows: [ @@ -487,6 +521,9 @@ export function kindLine(r: Run): string | undefined { // ── assurance ──────────────────────────────────────────────────────────── +/** The most a use case's detail lists; its counts still cover every row. */ +const MAX_PENDING = 20 + /** `kane-cli cover gaps --json` → what the band and the pane show. */ export function parseCoverGaps(text: string): Assurance { const start = text.indexOf('{') @@ -502,13 +539,21 @@ export function parseCoverGaps(text: string): Assurance { const started = Date.parse(String(proven?.latest_run?.started_at ?? '')) const useCases: UseCase[] = (Array.isArray(o.usecases) ? o.usecases : []).map((u: Record) => { const pending: Record[] = Array.isArray(u.pending) ? u.pending : [] + const text = (v: unknown): string | undefined => (typeof v === 'string' && v !== '' ? v : undefined) + const owed: Pending[] = pending + .filter(p => p && (p.stage === 'design' || p.stage === 'cover')) + .slice(0, MAX_PENDING) + .map(p => ({ title: text(p.title) ?? text(p.id) ?? text(p.ref) ?? text(p.kind) ?? 'gap', why: text(p.why), risk: text(p.risk), stage: p.stage, command: text(p.ready_command) })) return { id: String(u.id ?? u.title ?? 'use case'), + title: text(u.id) ? text(u.title) : undefined, + risk: text(u.risk), designed: pct(u.design_completeness?.pct) ?? 0, proven: pct(u.proven?.pct), stale: typeof u.stale_acs === 'number' ? u.stale_acs : 0, toDesign: pending.filter(p => p.stage === 'design').length, toCover: pending.filter(p => p.stage === 'cover').length, + pending: owed, } }) return { @@ -532,6 +577,39 @@ export function debt(u: UseCase): Span[] { return out } +/** What Claude is asked when the person wants a use case's gaps closed. */ +export function gapsPrompt(u: UseCase): string { + const first = u.pending.find(p => p.command) + const start = first?.command ? ` Start with \`${first.command}\` (${first.title}).` : '' + return `Close the assurance gaps of kane-cli use case ${u.id}: \`kane-cli cover gaps ${u.id}\` lists what it still owes.${start} Use --mode agent on kane-cli's design and maintain commands, and tell me what changed.` +} + +// ── history ────────────────────────────────────────────────────────────── + +/** How many finished runs the mod keeps per project. */ +export const HISTORY_MAX = 30 + +/** What the mod remembers of a finished run. */ +export function historyEntry(r: Run): HistoryEntry | undefined { + if (r.status !== 'passed' && r.status !== 'failed') return undefined + const at = r.endedAt ?? r.startedAt + const e: HistoryEntry = { label: r.label, status: r.status, at, kind: r.kind, seconds: Math.max(0, Math.round((at - r.startedAt) / 1000)) } + if (r.failure?.where) e.where = r.failure.where + if (r.kind === 'testrun' && r.members) { + const c = tally(r.members) + e.tests = { passed: c.passed, failed: c.failed } + } + return e +} + +/** A history row's second line: a suite's counts, where a run failed, or that it passed. */ +export function historyDetail(h: HistoryEntry): string { + const took = h.seconds ? ` · ${fmt(h.seconds)}` : '' + if (h.tests) return `${h.tests.passed} passed · ${h.tests.failed} failed${took}` + if (h.status === 'failed') return `${h.where ? `failed on ${h.where}` : 'failed'}${took}` + return `passed${took}` +} + // ── what Claude ran ────────────────────────────────────────────────────── /** The run a Bash command starts, named from its own words: the best label kane-cli's runs can get. */ diff --git a/kane-qe/hooks/register.tsx b/kane-qe/hooks/register.tsx index 2c7bb0b..b8abb26 100644 --- a/kane-qe/hooks/register.tsx +++ b/kane-qe/hooks/register.tsx @@ -3,13 +3,14 @@ // The mod never starts a test: Claude runs kane-cli, the mod reads what kane-cli leaves on disk. import { atom, read, update } from 'claude-code' import type { Elements, EngineInterface, Register, RenderSurface, TextProps } from 'claude-code' -import type { AfterChange, Assurance, Change, HistoryEntry, Kind, Offer, Run, Status, Step, View } from '../types' +import type { AfterChange, Assurance, Change, HistoryEntry, Kind, Offer, Run, Status, Step, UseCase, View } from '../types' import { adapt, splitLines } from './adapter' import type { Ev } from './adapter' import { RANK } from './adapter' import { PICTURE, RASTER, STEP_MS, mascotSvg, pixelFrame, poseAt, rasterCells } from './mascot' import { C, + HISTORY_MAX, ORDER, band, bar, @@ -21,6 +22,10 @@ import { fmt, fold, foldMember, + gapsPrompt, + gridSince, + historyDetail, + historyEntry, isTrackedEdit, kindLine, labelFromCommand, @@ -29,6 +34,7 @@ import { nm, nowText, objectiveFrom, + onGrid, parseCoverGaps, ago, savedTestsFor, @@ -51,6 +57,7 @@ const changeAtom = atom({ plugin: 'kane-qe', key: 'change' } as const, null as C const offerAtom = atom({ plugin: 'kane-qe', key: 'offer' } as const, null as Offer | null) const modeAtom = atom({ plugin: 'kane-qe', key: 'mode' } as const, '' as '' | AfterChange) const lastAtom = atom({ plugin: 'kane-qe', key: 'last' } as const, null as HistoryEntry | null) +const historyAtom = atom({ plugin: 'kane-qe', key: 'history' } as const, [] as HistoryEntry[]) type Kit = Pick type Table = Elements[RenderSurface] @@ -133,7 +140,7 @@ const meta = (r: Run) => [r.kind, r.target].filter(Boolean).join(' · ') function runCard(ui: Kit, press: Press, r: Run, now: number, width: number) { const isSuite = r.kind === 'testrun' && r.members const c = isSuite ? tally(r.members ?? []) : undefined - const second = c ? `${c.passed + c.failed} of ${c.total} tests done${c.running ? ` · ${c.running} running` : ''}${c.failed ? ` · ${c.failed} failed` : ''}` : summary(r) + const second = c ? (onGrid(r) ? `${c.total} test${c.total === 1 ? '' : 's'} on the grid · ${gridSince(r, now)}` : `${c.passed + c.failed} of ${c.total} tests done${c.running ? ` · ${c.running} running` : ''}${c.failed ? ` · ${c.failed} failed` : ''}`) : summary(r) return ( @@ -164,6 +171,18 @@ const sortRuns = (list: readonly Run[]) => [...list].sort((a, b) => ORDER[a.stat /** A suite's tests by state, each state in the plan's order. */ const sortMembers = (list: readonly Run[]) => list.map((m, i) => ({ m, i })).sort((a, b) => ORDER[a.m.status] - ORDER[b.m.status] || a.i - b.i).map(x => x.m) +/** Where a remote suite is running: the grid, its job, and why nothing moves until it ends. */ +function gridFacts(ui: Kit, s: Run) { + const r = s.remote ?? {} + return ( + + {fact(ui, 'grid', [r.backend ?? 'remote', r.jobId ? `job ${r.jobId}` : ''].filter(Boolean).join(' · '))} + {r.jobUrl ? fact(ui, 'link', r.jobUrl) : null} + {onGrid(s) ? The grid reports each test when the job ends. Open the link to watch it there. : null} + + ) +} + function suiteView(ui: Kit, press: Press, s: Run, now: number, width: number, back: boolean) { const c = tally(s.members ?? []) return ( @@ -172,7 +191,8 @@ function suiteView(ui: Kit, press: Press, s: Run, now: number, width: number, ba {back ? press('back')} /> : null} {cut(s.label, width - 10)} - {c.total ? `${c.passed + c.failed} of ${c.total} done · ${c.running} running · ${c.pending} waiting` : 'planning the suite'} + {onGrid(s) ? `${c.total ? `${c.total} test${c.total === 1 ? '' : 's'} on the grid · ` : ''}${gridSince(s, now)}` : c.total ? `${c.passed + c.failed} of ${c.total} done · ${c.running} running · ${c.pending} waiting` : 'planning the suite'} + {s.remote ? gridFacts(ui, s) : null} {sortMembers(s.members ?? []).map(m => memberRow(ui, press, m, now, width))} @@ -303,8 +323,96 @@ function offerView(ui: Kit, press: Press, o: Offer | null, change: Change | null ) } -function assureView(ui: Kit, press: Press, a: Assurance, now: number) { +/** One use case: its bars, then what it still owes, to design first and to run after, each with kane-cli's own next command. */ +function useCaseView(ui: Kit, press: Press, u: UseCase) { + const pct = (n: number | undefined) => (n === undefined ? ' –' : `${String(n).padStart(3)}%`) + const listed = u.pending.length + const owed = u.toDesign + u.toCover + const group = (stage: 'design' | 'cover', title: string) => { + const rows = u.pending.filter(p => p.stage === stage) + if (!rows.length) return null + return ( + + {title} + {rows.map((p, i) => ( + + + {p.risk === 'high' ? '▲' : '·'} + + {p.title} + + + + {p.why ? fact(ui, 'why', p.why) : null} + {p.command ? fact(ui, 'next', p.command, C.purple) : null} + + + ))} + + + ) + } + return ( + + + press('uc-back')} /> + {u.id} + + {u.title ? {u.title} : null} + + {u.risk ? {`${u.risk} risk`} : null} + {debt(u).map(s => text(ui, s))} + + + designed + {item(ui, bar(12, u.designed / 100, C.yellow))} + {pct(u.designed)} + + + proven + {item(ui, bar(12, (u.proven ?? 0) / 100, C.yellow))} + {pct(u.proven)} + + + {owed === 0 ? Nothing owed: every criterion has a test{u.proven === 100 ? ' and a passing run' : ''}. : null} + {group('design', 'To design')} + {group('cover', 'To run')} + {owed > listed ? {`+${owed - listed} more: kane-cli cover gaps ${u.id}`} : null} + {owed > 0 ? press(`uc-close:${u.id}`)} /> : null} + + ) +} + +/** Finished runs the mod has seen in this project, newest first. */ +function historyView(ui: Kit, list: readonly HistoryEntry[], now: number, width: number) { + if (list.length === 0) return No finished kane-cli run seen in this project yet. Each run is kept here when it ends. + const c = { passed: list.filter(x => x.status === 'passed').length, failed: list.filter(x => x.status === 'failed').length } + return ( + + {`last ${list.length} run${list.length === 1 ? '' : 's'} here · ${c.passed} passed · ${c.failed} failed`} + + {list.map((entry, i) => ( + + + {glyph(ui, entry.status)} + + {cut(nm(entry), Math.max(16, width - 14))} + + {ago(now - entry.at)} + + + {cut([entry.kind, historyDetail(entry)].filter(Boolean).join(' · '), Math.max(20, width * 2 - 4))} + + + ))} + + ) +} + +function assureView(ui: Kit, press: Press, a: Assurance, now: number, open?: string) { if (a.state === 'unknown') return Reading the requirement store… + const opened = a.state === 'ready' && open ? a.useCases.find(u => u.id === open) : undefined + if (opened) return useCaseView(ui, press, opened) if (a.state === 'none') { return ( @@ -340,7 +448,7 @@ function assureView(ui: Kit, press: Press, a: Assurance, now: number) { {a.useCases.map(u => ( - {u.id} + press(`uc:${u.id}`)} /> {debt(u).map(s => text(ui, s))} @@ -509,10 +617,13 @@ function startWatch(runs: Run[], p: Pointer, pointer: string): Run[] { /** A run reached its end: remember it, refresh assurance, and clear a change it covered. */ async function finished($: EngineInterface, r: Run) { if (r.status !== 'passed' && r.status !== 'failed') return - const entry: HistoryEntry = { label: r.label, status: r.status, at: r.endedAt ?? r.startedAt, where: r.failure?.where } + const entry = historyEntry(r) + if (!entry) return await update($, lastAtom, () => entry) const all = await recent($) - all[projectDir] = [entry, ...(all[projectDir] ?? [])].slice(0, 10) + const list = [entry, ...(all[projectDir] ?? [])].slice(0, HISTORY_MAX) + all[projectDir] = list + await update($, historyAtom, () => list) await $.store.set(RECENT, all).catch(() => undefined) const change = await read($, changeAtom) if (change && r.startedAt > change.lastAt) { @@ -554,8 +665,9 @@ async function readTheme($: EngineInterface) { } async function loadLast($: EngineInterface) { - const all = await recent($) - const last = all[projectDir]?.[0] + const list = (await recent($))[projectDir] ?? [] + if (list.length) await update($, historyAtom, () => list) + const last = list[0] if (last) await update($, lastAtom, () => last) } @@ -684,8 +796,14 @@ async function act($: EngineInterface, a: string) { const arg = at < 0 ? '' : a.slice(at + 1) if (k === 'open') await openPane($) else if (k === 'tab') { - await update($, viewAtom, v => ({ ...v, tab: arg === 'assure' ? ('assure' as const) : ('runs' as const), open: '' })) + await update($, viewAtom, v => ({ ...v, tab: arg === 'assure' ? ('assure' as const) : arg === 'history' ? ('history' as const) : ('runs' as const), open: '', uc: '' })) await openPane($) + } else if (k === 'uc') await update($, viewAtom, v => ({ ...v, tab: 'assure' as const, uc: arg })) + else if (k === 'uc-back') await update($, viewAtom, v => ({ ...v, uc: '' })) + else if (k === 'uc-close') { + const a = await read($, assuranceAtom) + const u = a.state === 'ready' ? a.useCases.find(x => x.id === arg) : undefined + if (u) void $.prompt.submit({ text: gapsPrompt(u) }).catch(() => undefined) } else if (k === 'run') await update($, viewAtom, v => ({ ...v, open: arg, tab: 'runs' as const })) else if (k === 'back') await update($, viewAtom, v => ({ ...v, open: '' })) else if (k === 'offer') await openOffer($) @@ -748,7 +866,7 @@ export const register: Register = (on, options) => { projectDir = String((e as { cwd?: unknown }).cwd ?? '').replace(/\/$/, '') const home = await $.env.get('HOME').catch(() => undefined) if (home) activeDir = `${home}/.testmuai/kaneai/sessions/active` - await $.command.register({ name: 'kane', description: 'Kane: open the runs pane; /kane assurance; /kane auto | ask | off for changes', argumentHint: '[assurance | auto | ask | off]' }) + await $.command.register({ name: 'kane', description: 'Kane: open the runs pane; /kane assurance; /kane history; /kane auto | ask | off for changes', argumentHint: '[assurance | history | auto | ask | off]' }) $.clock.every(1000, () => void poll($).catch(() => undefined)) $.clock.every(STEP_MS, () => paint($)) $.clock.every(STEP_MS, () => void pulse($).catch(() => undefined)) @@ -776,12 +894,16 @@ export const register: Register = (on, options) => { await act($, 'tab:assure') return { text: 'Kane: assurance for this project.' } } + if (arg === 'history') { + await act($, 'tab:history') + return { text: 'Kane: finished runs in this project.' } + } if (arg === 'auto' || arg === 'ask' || arg === 'off') { const mode: AfterChange = arg === 'ask' ? 'offer' : arg await update($, modeAtom, () => mode) return { text: `Kane: after Claude changes code, ${mode === 'auto' ? 'Claude tests it before it finishes' : mode === 'offer' ? 'the band offers to test it' : 'nothing happens'} (this session).` } } - return { text: 'Kane: /kane opens the pane · /kane assurance · /kane auto | ask | off sets what happens after Claude changes code.' } + return { text: 'Kane: /kane opens the pane · /kane assurance · /kane history · /kane auto | ask | off sets what happens after Claude changes code.' } }) // These three sit in the path of what the person and Claude do: the mod's own work is caught, the action always goes on. @@ -839,7 +961,8 @@ export const register: Register = (on, options) => { const press: Press = a => void act($, a).catch(() => undefined) const opened = all.find(r => r.id === v.open) ?? runs.find(r => r.id === v.open) let body - if (v.tab === 'assure') body = assureView(ui, press, await read($, assuranceAtom), now) + if (v.tab === 'assure') body = assureView(ui, press, await read($, assuranceAtom), now, v.uc) + else if (v.tab === 'history') body = historyView(ui, await read($, historyAtom), now, width) else if (v.open === 'offer') body = offerView(ui, press, await read($, offerAtom), await read($, changeAtom), now) else if (opened?.kind === 'testrun' && opened.members) body = suiteView(ui, press, opened, now, width, true) else if (opened) body = runDetail(ui, press, opened, now, width) @@ -856,6 +979,7 @@ export const register: Register = (on, options) => { press('tab:runs')} /> press('tab:assure')} /> + press('tab:history')} /> diff --git a/kane-qe/tests/adapter.test.ts b/kane-qe/tests/adapter.test.ts index c416870..7ca9b2e 100644 --- a/kane-qe/tests/adapter.test.ts +++ b/kane-qe/tests/adapter.test.ts @@ -34,6 +34,10 @@ describe('the adapter: one wire line → internal events', () => { { name: 'testrun_member_end', line: L({ type: 'testrun_member_end', path: '/p/a_test.md', status: 'broken', duration_s: 5, failure: { message: 'boom' } }), want: [{ k: 'memberEnd', path: '/p/a_test.md', status: 'broken', durationS: 5, logPath: undefined, why: 'boom', at: AT }] }, { name: 'testrun_done', line: L({ type: 'testrun_done', overall_status: 'cancelled' }), want: [{ k: 'suiteDone', status: 'cancelled', at: AT }] }, { name: 'post_hoc lines are marked', line: L({ type: 'testrun_done', overall_status: 'passed', post_hoc: true }), want: [{ k: 'postHoc' }, { k: 'suiteDone', status: 'passed', at: AT }] }, + { name: 'remote_start names the grid', line: L({ type: 'remote_start', backend: 'hyperexecute', env: 'prod', log_path: '/j/hyper.log' }), want: [{ k: 'remote', backend: 'hyperexecute', dispatched: false, at: AT }] }, + { name: 'remote_dispatched gives the job and its link', line: L({ type: 'remote_dispatched', job_id: 'j-42', job_url: 'https://grid.example/jobs/j-42' }), want: [{ k: 'remote', jobId: 'j-42', jobUrl: 'https://grid.example/jobs/j-42', dispatched: true, at: AT }] }, + { name: 'remote_error is the failure reason, its detail first', line: L({ type: 'remote_error', code: 'unknown_backend', detail: 'No such grid: foo' }), want: [{ k: 'error', message: 'No such grid: foo', at: AT }] }, + { name: 'remote_error with no detail falls back to its code', line: L({ type: 'remote_error', code: 'internal_error' }), want: [{ k: 'error', message: 'internal_error', at: AT }] }, { name: 'an unknown type is skipped', line: L({ type: 'brand_new_event', x: 1 }), want: [] }, { name: 'a newer wire version is flagged, not parsed', line: JSON.stringify({ type: 'run_end', status: 'passed', v: 2, ts: '2026-10-08T10:00:00.000Z' }), want: [{ k: 'newer', at: AT }] }, ] diff --git a/kane-qe/tests/engine.test.tsx b/kane-qe/tests/engine.test.tsx index 031eecb..0a4047e 100644 --- a/kane-qe/tests/engine.test.tsx +++ b/kane-qe/tests/engine.test.tsx @@ -226,6 +226,98 @@ describe('watching kane-cli', () => { await band.unmount() }) + test('a suite sent to the remote grid says where it is until the job reports, then counts its tests', async ($, on) => { + const w = world(on) + await $.session.start({ cwd: PROJECT, surface: 'terminal', isInteractive: true } as never) + const sdir = `${S}/testrun-remote-j42` + const a = `${PROJECT}/.testmuai/tests/login_test.md` + const b = `${PROJECT}/.testmuai/tests/search_test.md` + startRun( + w, + 50, + sdir, + 'testrun', + line({ type: 'stream_start', surface: 'testrun', pid: 50 }) + + line({ type: 'testrun_plan', members: [{ path: a }, { path: b }], parallel: 2 }) + + line({ type: 'remote_start', backend: 'hyperexecute', env: 'prod', log_path: `${sdir}/hyper.log` }, 1) + + line({ type: 'remote_dispatched', job_id: 'j-42', job_url: 'https://grid.example/jobs/j-42' }, 3), + ) + await w.clock.advance(1500) + await w.clock.settle() + const band = await $.ui.mount({ ...BAND, surface: 'terminal' } as never) + expect(await band.find({ type: 'Text', text: /2 tests on the grid/ })).toBeDefined() + expect(await band.find({ type: 'Text', text: /each test is reported when the job ends/ })).toBeDefined() + expect(await band.find({ type: 'Text', text: /tests done/ })).toBeUndefined() + const pane = await $.ui.mount({ ...PANE, surface: 'terminal' } as never) + expect(await pane.find({ type: 'Text', text: /hyperexecute · job j-42/ })).toBeDefined() + expect(await pane.find({ type: 'Text', text: /https:\/\/grid\.example\/jobs\/j-42/ })).toBeDefined() + expect(await pane.find({ type: 'Text', text: /The grid reports each test when the job ends/ })).toBeDefined() + + // The job ends: the grid's results arrive after the fact, stamped with the grid's own time. + w.files[`${sdir}/events.ndjson`] += + line({ type: 'testrun_member_start', path: a, post_hoc: true }, 10) + + line({ type: 'testrun_member_end', path: a, status: 'passed', duration_s: 20, post_hoc: true }, 30) + + line({ type: 'testrun_member_start', path: b, post_hoc: true }, 10) + + line({ type: 'testrun_member_end', path: b, status: 'failed', duration_s: 25, failure: { message: 'No results for the query' }, post_hoc: true }, 35) + + line({ type: 'testrun_done', overall_status: 'failed' }, 120) + + line({ type: 'remote_done', status: 'failed', exit: 1, job_id: 'j-42' }, 120) + endRun(w, 50) + await w.clock.advance(1500) + await w.clock.settle() + expect(await band.find({ type: 'Text', text: /✓ 1 passed/ })).toBeDefined() + expect(await band.find({ type: 'Text', text: /✗ 1 failed/ })).toBeDefined() + expect(await pane.find({ type: 'Text', text: /The grid reports each test when the job ends/ })).toBeUndefined() + expect(await pane.find({ type: 'Text', text: /hyperexecute · job j-42/ })).toBeDefined() + await pane.unmount() + await band.unmount() + }) + + test('History lists the finished runs kept for this project, and a run that ends joins them', async ($, on) => { + const w = world(on, { + store: { + recentByProject: { + [PROJECT]: [ + { label: 'checkout_guest_test.md', status: 'failed', at: T0 - 3_600_000, where: 'Fill the shipping address', kind: 'testmd', seconds: 66 }, + { label: 'login_test.md', status: 'passed', at: T0 - 7_200_000 }, + ], + '/work/other-project': [{ label: 'theirs_test.md', status: 'passed', at: T0 - 60_000 }], + }, + }, + }) + await $.session.start({ cwd: PROJECT, surface: 'terminal', isInteractive: true } as never) + await w.clock.settle() + for (const surface of ['terminal', 'desktop'] as const) { + const pane = await $.ui.mount({ ...PANE, surface } as never) + await pane.press({ key: 'tab-history' }) + expect(await pane.find({ type: 'Text', text: /last 2 runs here · 1 passed · 1 failed/ })).toBeDefined() + expect(await pane.find({ type: 'Text', text: /^checkout_guest$/ })).toBeDefined() + expect(await pane.find({ type: 'Text', text: /testmd · failed on Fill the shipping address · 1m 06s/ })).toBeDefined() + expect(await pane.find({ type: 'Text', text: /^login$/ })).toBeDefined() + expect(await pane.find({ type: 'Text', text: /theirs/ })).toBeUndefined() + await pane.press({ key: 'tab-runs' }) + await pane.unmount() + } + startRun(w, 61, `${S}/h1`, 'run', line({ type: 'stream_start', surface: 'run', pid: 61 }) + line({ step: 1, status: 'done', remark: 'Search for iPod' }, 4) + line({ type: 'run_end', status: 'passed', credits_consumed: 5 }, 9)) + await w.clock.advance(1500) + await w.clock.settle() + endRun(w, 61) + const pane = await $.ui.mount({ ...PANE, surface: 'terminal' } as never) + expect((await $.command.run({ command: 'kane', args: 'history' } as never) as { text?: string }).text).toContain('finished runs') + expect(await pane.find({ type: 'Text', text: /last 3 runs here · 2 passed · 1 failed/ })).toBeDefined() + expect(await pane.find({ type: 'Text', text: /^Search for iPod$/ })).toBeDefined() + await pane.unmount() + }) + + test('History with nothing kept says so', async ($, on) => { + const w = world(on) + await $.session.start({ cwd: PROJECT, surface: 'terminal', isInteractive: true } as never) + await w.clock.settle() + const pane = await $.ui.mount({ ...PANE, surface: 'terminal' } as never) + await pane.press({ key: 'tab-history' }) + expect(await pane.find({ type: 'Text', text: /No finished kane-cli run seen in this project yet/ })).toBeDefined() + await pane.unmount() + }) + test('a stale pointer and another project’s run are not shown', async ($, on) => { const w = world(on) await $.session.start({ cwd: PROJECT, surface: 'terminal', isInteractive: true } as never) @@ -376,10 +468,67 @@ describe('assurance', () => { expect(await band.find({ type: 'Text', text: /proven · 1 use case/ })).toBeDefined() const pane = await $.ui.mount({ ...PANE, surface: 'terminal' } as never) await pane.press({ key: 'tab-assure' }) - expect(await pane.find({ type: 'Text', text: /uc-buy-as-a-guest/ })).toBeDefined() + expect(await pane.find({ key: 'uc-uc-buy-as-a-guest' })).toBeDefined() expect(await pane.find({ type: 'Text', text: /2 failing · 1 blocked · run 2h ago/ })).toBeDefined() expect(await pane.find({ type: 'Text', text: /1 stale/ })).toBeDefined() await pane.unmount() await band.unmount() }) + + test('a use case opens to what it still owes; its gaps are handed to Claude, never run by the mod', async ($, on) => { + const w = world(on, { + cover: JSON.stringify({ + design_completeness: { pct: 82 }, + proven: { pct: 47 }, + usecases: [ + { + id: 'uc-buy-as-a-guest', + title: 'Buy as a guest', + risk: 'high', + design_completeness: { pct: 75 }, + proven: { pct: 38 }, + stale_acs: 0, + pending: [ + { stage: 'design', title: 'Declined card shows a message', why: 'no test verifies this AC', risk: 'high', ready_command: 'kane-cli design tests --use-case uc-buy-as-a-guest' }, + { stage: 'cover', title: 'Guest email is required', why: 'covered, never run', ready_command: 'kane-cli testrun run' }, + ], + }, + { id: 'uc-mobile-sign-in', design_completeness: { pct: 100 }, proven: { pct: 100 }, stale_acs: 0, pending: [] }, + ], + }), + }) + w.files[`${PROJECT}/.context/commits/1`] = 'x' + await $.session.start({ cwd: PROJECT, surface: 'terminal', isInteractive: true } as never) + await w.clock.settle() + for (const surface of ['terminal', 'desktop'] as const) { + const pane = await $.ui.mount({ ...PANE, surface } as never) + await pane.press({ key: 'tab-assure' }) + await pane.press({ key: 'uc-uc-buy-as-a-guest' }) + expect(await pane.find({ type: 'Text', text: /^Buy as a guest$/ })).toBeDefined() + expect(await pane.find({ type: 'Text', text: /^high risk$/ })).toBeDefined() + expect(await pane.find({ type: 'Text', text: /^To design$/ })).toBeDefined() + expect(await pane.find({ type: 'Text', text: /Declined card shows a message/ })).toBeDefined() + expect(await pane.find({ type: 'Text', text: /no test verifies this AC/ })).toBeDefined() + expect(await pane.find({ type: 'Text', text: /kane-cli design tests --use-case uc-buy-as-a-guest/ })).toBeDefined() + expect(await pane.find({ type: 'Text', text: /^To run$/ })).toBeDefined() + expect(await pane.find({ key: 'uc-uc-mobile-sign-in' })).toBeUndefined() + await pane.press({ key: 'uc-back' }) + await pane.press({ key: 'uc-uc-mobile-sign-in' }) + expect(await pane.find({ type: 'Text', text: /Nothing owed/ })).toBeDefined() + expect(await pane.find({ key: 'uc-close' })).toBeUndefined() + // Leaving the tab closes the use case. + await pane.press({ key: 'tab-runs' }) + await pane.press({ key: 'tab-assure' }) + expect(await pane.find({ key: 'uc-uc-buy-as-a-guest' })).toBeDefined() + await pane.unmount() + } + expect(w.prompts).toEqual([]) + const pane = await $.ui.mount({ ...PANE, surface: 'terminal' } as never) + await pane.press({ key: 'uc-uc-buy-as-a-guest' }) + await pane.press({ key: 'uc-close' }) + expect(w.prompts.length).toBe(1) + expect(w.prompts[0]).toContain('kane-cli cover gaps uc-buy-as-a-guest') + expect(w.prompts[0]).toContain('kane-cli design tests --use-case uc-buy-as-a-guest') + await pane.unmount() + }) }) diff --git a/kane-qe/tests/model.test.ts b/kane-qe/tests/model.test.ts index ca309a9..d091359 100644 --- a/kane-qe/tests/model.test.ts +++ b/kane-qe/tests/model.test.ts @@ -7,6 +7,10 @@ import { featureWords, fold, foldMember, + gapsPrompt, + historyDetail, + historyEntry, + onGrid, labelFromCommand, newRun, objectiveFrom, @@ -18,11 +22,11 @@ import { isTrackedEdit, } from '../hooks/model' import type { BandOptions } from '../hooks/model' -import type { Assurance, Kind, Run } from '../types' +import type { Assurance, HistoryEntry, Kind, Run } from '../types' import { runFailed, runPassed, testmdPassed, testrunFailed, testrunPassed, testrunPassedMember } from './fixtures/fixtures' const T0 = Date.parse('2026-10-08T10:00:00.000Z') -const READY: Assurance = { state: 'ready', designedPct: 82, provenPct: 47, useCases: [1, 2, 3, 4].map(i => ({ id: `uc-${i}`, designed: 80, proven: 40, stale: 0, toDesign: 0, toCover: 1 })) } +const READY: Assurance = { state: 'ready', designedPct: 82, provenPct: 47, useCases: [1, 2, 3, 4].map(i => ({ id: `uc-${i}`, designed: 80, proven: 40, stale: 0, toDesign: 0, toCover: 1, pending: [] })) } const opts = (o: Partial = {}): BandOptions => ({ assurance: READY, now: T0 + 30_000, columns: 80, ...o }) const rows = (runs: Run[], o: Partial = {}) => band(runs, opts(o)).rows.map(rowText) @@ -165,6 +169,84 @@ describe('the band, state by state, at 60, 80 and 120 columns', () => { }) }) +describe('a suite on the remote grid', () => { + const remote = (evs: Ev[]): Run => evs.reduce(fold, suite(['pending', 'pending', 'pending'])) + const start: Ev = { k: 'remote', backend: 'hyperexecute', dispatched: false, at: T0 } + const sent: Ev = { k: 'remote', jobId: 'j-42', jobUrl: 'https://grid.example/jobs/j-42', dispatched: true, at: T0 + 5_000 } + + for (const columns of [60, 80, 120]) { + test(`before the job reports, the band says where the tests are (${columns})`, () => { + const sending = rows([remote([start])], { columns }) + expect(sending[0]).toContain(' suite □□□ 3 tests on the grid') + const r = rows([remote([start, sent])], { columns }) + expect(r[0]).toContain(' suite □□□ 3 tests on the grid') + if (columns >= 80) { + expect(sending[0]).toContain('· sending the job') + expect(r[0]).toContain('· sent 25s ago [Open]') + expect(r[1]).toBe('grid hyperexecute · each test is reported when the job ends') + } + for (const row of band([remote([start, sent])], opts({ columns })).rows) expect(rowWidth(row) <= columns).toBe(true) + }) + } + + test('it keeps the job and its link, and the mascot keeps thinking', () => { + const s = remote([start, sent]) + expect(s.remote).toEqual({ backend: 'hyperexecute', jobId: 'j-42', jobUrl: 'https://grid.example/jobs/j-42', dispatchedAt: T0 + 5_000 }) + expect(onGrid(s)).toBe(true) + expect(band([s], opts()).think).toBe(true) + }) + + test('once the grid reports its tests, the band counts them as any suite', () => { + const paths = (remote([]).members ?? []).map(m => m.id) + const s = remote([start, sent, { k: 'postHoc' }, { k: 'memberStart', path: paths[0]!, at: T0 + 1000 }, { k: 'memberEnd', path: paths[0]!, status: 'passed', at: T0 + 9000 }]) + expect(onGrid(s)).toBe(false) + expect(rows([s])[0]).toContain('1 of 3 tests done') + }) + + test('a job that fails before any test reports gives every test the job’s reason', () => { + const s = remote([start, { k: 'error', message: 'No such grid: foo', at: T0 + 1000 }, { k: 'suiteDone', status: 'failed', at: T0 + 2000 }]) + expect((s.members ?? []).map(m => m.status)).toEqual(['failed', 'failed', 'failed']) + expect(s.members?.[0]?.failure?.why).toBe('No such grid: foo') + expect(rows([s], { now: T0 + 3000 })[0]).toContain('✗ 3 failed') + }) + + test('a local suite that ends early leaves its unstarted tests waiting', () => { + const s = fold(suite(['passed', 'pending']), { k: 'suiteDone', status: 'failed', at: T0 + 2000 }) + expect((s.members ?? []).map(m => m.status)).toEqual(['passed', 'pending']) + }) +}) + +describe('history', () => { + const cases: { name: string; run: Run; want: HistoryEntry | undefined; detail?: string }[] = [ + { name: 'a run still going is not kept', run: run('a', 'run', [label('Search'), step(1, 'running', 'Open the page')]), want: undefined }, + { name: 'a passed run', run: run('b', 'run', [label('Search'), step(1, 'done', 'Open the page', 3), passed(41)]), want: { label: 'Search', status: 'passed', at: T0 + 41_000, kind: 'run', seconds: 41 }, detail: 'passed · 41s' }, + { + name: 'a failed run keeps where it failed', + run: run('c', 'run', [label('Checkout'), step(1, 'failed', 'Fill the shipping address', 30), failedEnd(66)]), + want: { label: 'Checkout', status: 'failed', at: T0 + 66_000, kind: 'run', seconds: 66, where: 'Fill the shipping address' }, + detail: 'failed on Fill the shipping address · 1m 06s', + }, + { + name: 'a suite keeps its counts', + run: fold(suite(['passed', 'passed', 'failed']), { k: 'suiteDone', status: 'failed', at: T0 + 90_000 }), + want: { label: 'suite · --tags smoke', status: 'failed', at: T0 + 90_000, kind: 'testrun', seconds: 90, tests: { passed: 2, failed: 1 } }, + detail: '2 passed · 1 failed · 1m 30s', + }, + ] + for (const c of cases) { + test(c.name, () => { + const e = historyEntry(c.run) + expect(e).toEqual(c.want) + if (e && c.detail) expect(historyDetail(e)).toBe(c.detail) + }) + } + + test('an entry kept by an earlier version still reads', () => { + expect(historyDetail({ label: 'login_test.md', status: 'passed', at: T0 })).toBe('passed') + expect(historyDetail({ label: 'x', status: 'failed', at: T0 })).toBe('failed') + }) +}) + describe('recorded streams replayed', () => { test('surface run, failed: kane’s own root cause, where, and credits', () => { const r = replay(runFailed, 'run') @@ -265,7 +347,23 @@ describe('assurance from cover gaps', () => { stage: 'all', design_completeness: { pct: 82, acs_designed: '13/16' }, proven: { pct: 47, failing: 2, blocked: 1, not_run: 6, latest_run: { started_at: '2026-10-08T08:00:00Z' } }, - usecases: [{ id: 'uc-buy-as-a-guest', design_completeness: { pct: 75 }, proven: { pct: 38 }, stale_acs: 1, pending: [{ stage: 'design' }, { stage: 'cover' }, { stage: 'cover' }] }, { id: null, title: 'Saved addresses', design_completeness: { pct: 100 }, stale_acs: 0, pending: [] }], + usecases: [ + { + id: 'uc-buy-as-a-guest', + title: 'Buy as a guest', + risk: 'high', + design_completeness: { pct: 75 }, + proven: { pct: 38 }, + stale_acs: 1, + pending: [ + { stage: 'design', ref: 'ac-payment-declined', title: 'Declined card shows a message', why: 'no test verifies this AC', risk: 'high', kind: 'no-verifies', tag: 'create', ready_command: 'kane-cli design tests --use-case uc-buy-as-a-guest' }, + { stage: 'cover', ref: 'ac-guest-email', kind: 'covered-not-run', ready_command: 'kane-cli testrun run' }, + { stage: 'cover' }, + { stage: 'other', title: 'not a stage the mod knows' }, + ], + }, + { id: null, title: 'Saved addresses', design_completeness: { pct: 100 }, stale_acs: 0, pending: [] }, + ], })}`, ) expect(a).toEqual({ @@ -276,12 +374,46 @@ describe('assurance from cover gaps', () => { blocked: 1, lastRunAt: Date.parse('2026-10-08T08:00:00Z'), useCases: [ - { id: 'uc-buy-as-a-guest', designed: 75, proven: 38, stale: 1, toDesign: 1, toCover: 2 }, - { id: 'Saved addresses', designed: 100, proven: undefined, stale: 0, toDesign: 0, toCover: 0 }, + { + id: 'uc-buy-as-a-guest', + title: 'Buy as a guest', + risk: 'high', + designed: 75, + proven: 38, + stale: 1, + toDesign: 1, + toCover: 2, + pending: [ + { title: 'Declined card shows a message', why: 'no test verifies this AC', risk: 'high', stage: 'design', command: 'kane-cli design tests --use-case uc-buy-as-a-guest' }, + { title: 'ac-guest-email', why: undefined, risk: undefined, stage: 'cover', command: 'kane-cli testrun run' }, + { title: 'gap', why: undefined, risk: undefined, stage: 'cover', command: undefined }, + ], + }, + { id: 'Saved addresses', title: undefined, risk: undefined, designed: 100, proven: undefined, stale: 0, toDesign: 0, toCover: 0, pending: [] }, ], }) }) + test('a use case lists at most 20 things it owes; its counts still cover them all', () => { + const pending = Array.from({ length: 25 }, (_, i) => ({ stage: 'cover', title: `ac-${i}` })) + const a = parseCoverGaps(JSON.stringify({ design_completeness: { pct: 50 }, usecases: [{ id: 'uc-big', design_completeness: { pct: 50 }, pending }] })) + const u = a.state === 'ready' ? a.useCases[0]! : undefined + expect(u?.pending.length).toBe(20) + expect(u?.toCover).toBe(25) + }) + + test('closing a use case’s gaps hands Claude its first next command', () => { + const cases = [ + { name: 'with a command', pending: [{ title: 'Declined card', stage: 'design' as const, command: 'kane-cli design tests --use-case uc-a' }], has: ['kane-cli cover gaps uc-a', 'Start with `kane-cli design tests --use-case uc-a` (Declined card)'], lacks: [] as string[] }, + { name: 'with none', pending: [{ title: 'x', stage: 'cover' as const }], has: ['kane-cli cover gaps uc-a'], lacks: ['Start with'] }, + ] + for (const c of cases) { + const p = gapsPrompt({ id: 'uc-a', designed: 50, stale: 0, toDesign: 1, toCover: 0, pending: c.pending }) + for (const h of c.has) expect(p).toContain(h) + for (const l of c.lacks) expect(p).not.toContain(l) + } + }) + test('nothing run yet has no proven', () => { const a = parseCoverGaps(JSON.stringify({ design_completeness: { pct: 60 }, usecases: [] })) expect(a.state === 'ready' && a.provenPct).toBe(undefined) diff --git a/kane-qe/types/index.d.ts b/kane-qe/types/index.d.ts index e9cea7d..3529048 100644 --- a/kane-qe/types/index.d.ts +++ b/kane-qe/types/index.d.ts @@ -42,9 +42,16 @@ export type Run = { skipped?: number /** Where the label came from; a better source replaces a weaker one. */ labelRank?: number + /** A suite sent to the remote grid: the grid reports its tests only when the job ends. */ + remote?: Remote } -export type UseCase = { id: string; designed: number; proven?: number; stale: number; toDesign: number; toCover: number } +export type Remote = { backend?: string; jobId?: string; jobUrl?: string; dispatchedAt?: number } + +/** One thing a use case still owes, as `cover gaps` lists it. */ +export type Pending = { title: string; why?: string; risk?: string; stage: 'design' | 'cover'; command?: string } + +export type UseCase = { id: string; title?: string; risk?: string; designed: number; proven?: number; stale: number; toDesign: number; toCover: number; pending: Pending[] } export type Assurance = | { state: 'unknown' } @@ -69,12 +76,15 @@ export type Change = { files: string[]; firstAt: number; lastAt: number; shown: export type Offer = { files: string[]; objective?: string; drafting: boolean; note?: string; saved: string[]; url?: string; last?: Record } export type View = { - tab: 'runs' | 'assure' + tab: 'runs' | 'assure' | 'history' /** The run shown in detail, `offer` for the offer card, or empty for the list. */ open: string + /** The use case shown in detail on the Assurance tab, or empty for the list. */ + uc?: string } -export type HistoryEntry = { label: string; status: 'passed' | 'failed'; at: number; where?: string } +/** One finished run, as the mod keeps it per project. `tests` is set for a suite. */ +export type HistoryEntry = { label: string; status: 'passed' | 'failed'; at: number; where?: string; kind?: Kind; seconds?: number; tests?: { passed: number; failed: number } } declare module 'claude-code' { interface PluginState { @@ -88,6 +98,7 @@ declare module 'claude-code' { /** `/kane auto|ask|off` for this session; empty uses the setting. */ mode: '' | AfterChange last: HistoryEntry | null + history: HistoryEntry[] } } } From 01d39c84a9d26da077fc0b16c8e2d259c66a2225 Mon Sep 17 00:00:00 2001 From: Zeeshan Date: Fri, 9 Oct 2026 23:46:40 +0530 Subject: [PATCH 02/11] feat(v2): the mascot's eyes move faster while a test runs One step of the loop every 150 ms, down from 260. Co-Authored-By: Claude Opus 5.5 --- kane-qe/hooks/mascot.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kane-qe/hooks/mascot.ts b/kane-qe/hooks/mascot.ts index 5151814..a11eef1 100644 --- a/kane-qe/hooks/mascot.ts +++ b/kane-qe/hooks/mascot.ts @@ -8,7 +8,7 @@ export type Pose = 'c' | 'lm' | 'l' | 'rm' | 'r' | 'xh' | 'x' export type Frame = readonly string[] /** The length of one step of the loop, in ms. */ -export const STEP_MS = 260 +export const STEP_MS = 150 /** The loop follows the GIF: rest, glance left, glance right, rest, blink, rest. */ const LOOP: readonly Pose[] = ['c', 'c', 'c', 'c', 'lm', 'l', 'l', 'l', 'lm', 'rm', 'r', 'r', 'r', 'r', 'rm', 'c', 'c', 'c', 'x', 'c', 'c', 'c'] From 333357c0d7a05269524c9ed7b2c9efcd4b3de8ed Mon Sep 17 00:00:00 2001 From: Zeeshan Date: Fri, 9 Oct 2026 23:46:40 +0530 Subject: [PATCH 03/11] feat(v2): a kane-cli run Claude started is a card in the chat A test Claude runs (run, testmd run, testrun run) is drawn in the conversation in place of its shell row: one line while it runs, then a bordered card with the mascot, the result, a failure's on/why/kind or a suite's cells, counts and up to three failures. View steps opens the pane on that run. View evidence opens the run's sealed pack in kane-cli's local viewer in one click: the mod starts `kane-cli evidence serve `, reads the viewer link it prints and opens it. This is the one kane-cli command the mod starts besides `cover gaps`; it still never starts a test. With no pack or no viewer, Claude is asked instead. The pane's Open evidence button does the same. Where the chat is printed text (the terminal outside fullscreen) the card ends with the evidence path instead of buttons. A command that never became a run keeps its normal row. A folded group of shell calls is unfolded when it holds a run, so the card shows. Co-Authored-By: Claude Opus 5.5 --- README.md | 19 +-- docs/index.html | 2 +- guide/kane-qe-guide.html | 2 +- guide/v2/design-coverage.md | 11 +- kane-qe/README.md | 9 +- kane-qe/hooks/adapter.ts | 4 +- kane-qe/hooks/model.ts | 40 ++++++- kane-qe/hooks/register.tsx | 206 ++++++++++++++++++++++++++++++-- kane-qe/tests/engine.test.tsx | 216 +++++++++++++++++++++++++++++++++- kane-qe/tests/model.test.ts | 47 ++++++++ kane-qe/types/index.d.ts | 6 + 11 files changed, 526 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index 383244b..0d88893 100644 --- a/README.md +++ b/README.md @@ -50,12 +50,13 @@ The same block works in `~/.claude/settings.json` (every project on one machine) Nothing to start. Open Claude Code in a project and the band is there. 1. **Ask Claude to test something**: "run `kane-cli run "Search for iPod and assert 4 products are listed" --url https://ecommerce-playground.lambdatest.io --agent --headless`", or "run the login test with kane-cli". Within two seconds the band shows the run, its time and the step it is on. A run started in a terminal in the same folder shows up the same way. -2. **Open the pane** with `/kane` or `[ Open ]`. Press a run for its steps; the current or failing step opens to what the agent thinks, does and checks. -3. **When a test fails**, the band names it and the step; the run's detail gives kane's own verdict (why, what kind, how sure, where) and *Open evidence*. -4. **Several runs or a suite**: one coloured cell per run or test, with counts; a failure takes row 2 while the rest keep going. A suite on the remote grid (`--remote`) shows how many tests are on the grid and since when; the pane gives the job and its link, and the tests are counted when the job ends. -5. **After Claude changes code**, the band warns *⚠ 1 file changed, untested* with *Test this change*: saved tests that cover the change (with their last result), or an objective Claude drafts from the diff. `/kane auto` makes Claude test before it finishes; `/kane off` turns it off. -6. **Assurance**: row 3 is requirement coverage from `kane-cli cover gaps --json`; the Assurance tab breaks it down per use case, or offers to set it up. Press a use case to see what it still owes and kane-cli's next command for each; *Close these gaps with Claude* hands it over. -7. **History**: the History tab lists the last 30 finished runs in this project, with where each failed. +2. **Read the card**: each test Claude runs leaves a card in the conversation with its result, and *View evidence* opens the evidence in the browser. +3. **Open the pane** with `/kane` or `[ Open ]`. Press a run for its steps; the current or failing step opens to what the agent thinks, does and checks. +4. **When a test fails**, the band names it and the step; the run's detail gives kane's own verdict (why, what kind, how sure, where) and *Open evidence*. +5. **Several runs or a suite**: one coloured cell per run or test, with counts; a failure takes row 2 while the rest keep going. A suite on the remote grid (`--remote`) shows how many tests are on the grid and since when; the pane gives the job and its link, and the tests are counted when the job ends. +6. **After Claude changes code**, the band warns *⚠ 1 file changed, untested* with *Test this change*: saved tests that cover the change (with their last result), or an objective Claude drafts from the diff. `/kane auto` makes Claude test before it finishes; `/kane off` turns it off. +7. **Assurance**: row 3 is requirement coverage from `kane-cli cover gaps --json`; the Assurance tab breaks it down per use case, or offers to set it up. Press a use case to see what it still owes and kane-cli's next command for each; *Close these gaps with Claude* hands it over. +8. **History**: the History tab lists the last 30 finished runs in this project, with where each failed. | Command | | |---|---| @@ -66,7 +67,7 @@ Nothing to start. Open Claude Code in a project and the band is there. `/config` → kane-qe: **After Claude changes code** (`offer` by default, `auto`, `off`) and **kane-cli command** (default `kane-cli`, used only for the read-only `cover gaps`). Colours follow Claude Code's theme. -What it never does: it never starts a kane-cli run, never reads kane-cli's human output, and never shows what kane-cli did not report. The only commands it runs are `kane-cli cover gaps --json`, `kill -0`, `git check-ignore` and `git diff`. +What it never does: it never starts a kane-cli test, and never shows what kane-cli did not report. The commands it runs are `kane-cli cover gaps --json`, `kill -0`, `git check-ignore` and `git diff`, and, only when you press *View evidence*, `kane-cli evidence serve ` and `open`. It reads kane-cli's structured output only, with one exception: the viewer link that `evidence serve` prints. ## How to publish it on Claude @@ -75,7 +76,7 @@ What it never does: it never starts a kane-cli run, never reads kane-cli's human ``` claude plugin validate kane-qe --strict # the plugin: manifest, hooks, state contract claude plugin validate . --strict # the marketplace manifest -claude plugin test kane-qe # 103 tests +claude plugin test kane-qe # 116 tests ``` CI (`.github/workflows/ci.yml`) runs the same on every push to `main` and on pull requests. @@ -113,7 +114,7 @@ References: [install plugins](https://code.claude.com/docs/en/plugins/install), | `kane-qe/hooks/sources.ts` | The live-run pointer and byte offsets into event files | | `kane-qe/hooks/register.tsx` | Polling, hooks (edits, end of turn, `/kane`, theme) and the drawing | | `kane-qe/hooks/mascot.ts` | The mascot: a cell grid in terminals, an image elsewhere | -| `kane-qe/tests/` | Adapter table, band states at 60/80/120 columns, recorded kane-cli streams replayed, band and pane on terminal and desktop, change tracking, stale pointers, assurance and use-case detail, remote suites, history, theme | +| `kane-qe/tests/` | Adapter table, band states at 60/80/120 columns, recorded kane-cli streams replayed, band and pane on terminal and desktop, change tracking, stale pointers, assurance and use-case detail, remote suites, history, the card in the chat, theme | | `.claude-plugin/marketplace.json` | The `lambdatest` marketplace: this repository installs with `/plugin marketplace add` | | `docs/` | The Field Guide and the explainer as standalone HTML, with the walkthrough video and screenshots (`guide/build_docs.sh` rebuilds them) | | `guide/` | The page sources, screenshots, design coverage, and the script that records the walkthrough (`KANE_DEMO_DIR= guide/run_tour_v2.sh`) | diff --git a/docs/index.html b/docs/index.html index 95d32da..bb0c604 100644 --- a/docs/index.html +++ b/docs/index.html @@ -334,7 +334,7 @@

Settings

Design coverage

Built to the design, and checked against it

-

v2 was built from the kane-cli mod design: 17 features, 13 screens and a build spec. Every feature and screen was compared in a real terminal with the design's screenshots, and 103 tests cover the rest. The full list, with how each item was checked, is in design-coverage.md.

+

v2 was built from the kane-cli mod design: 17 features, 13 screens and a build spec. Every feature and screen was compared in a real terminal with the design's screenshots, and 116 tests cover the rest. The full list, with how each item was checked, is in design-coverage.md.

Two deliberate differences

The design lists a test file's coming steps; kane-cli reports no step total, and the spec says never to show one. The design's cards say “Chrome 154”; kane-cli reports no browser name for desktop runs.

Not checked yet

The desktop app: the band and pane are tested on its surface, with the mascot as an image, but were not opened in the app itself.

diff --git a/guide/kane-qe-guide.html b/guide/kane-qe-guide.html index e47609e..a6de5da 100644 --- a/guide/kane-qe-guide.html +++ b/guide/kane-qe-guide.html @@ -330,7 +330,7 @@

Settings

Design coverage

Built to the design, and checked against it

-

v2 was built from the kane-cli mod design: 17 features, 13 screens and a build spec. Every feature and screen was compared in a real terminal with the design's screenshots, and 103 tests cover the rest. The full list, with how each item was checked, is in design-coverage.md.

+

v2 was built from the kane-cli mod design: 17 features, 13 screens and a build spec. Every feature and screen was compared in a real terminal with the design's screenshots, and 116 tests cover the rest. The full list, with how each item was checked, is in design-coverage.md.

Two deliberate differences

The design lists a test file's coming steps; kane-cli reports no step total, and the spec says never to show one. The design's cards say “Chrome 154”; kane-cli reports no browser name for desktop runs.

Not checked yet

The desktop app: the band and pane are tested on its surface, with the mascot as an image, but were not opened in the app itself.

diff --git a/guide/v2/design-coverage.md b/guide/v2/design-coverage.md index 06b7829..039771d 100644 --- a/guide/v2/design-coverage.md +++ b/guide/v2/design-coverage.md @@ -5,7 +5,7 @@ The design ([kane-cli mod: terminal build guide](https://claude.ai/artifact/5Dkz How each item was checked: - **live**: in a real Claude Code session in a terminal, with a real kane-cli run; - **staged**: a real Claude Code session, with kane-cli runs written to disk in kane-cli 0.8.20's own format, by `sleep` processes standing in for kane-cli; -- **test**: `claude plugin test kane-qe` (103 tests). +- **test**: `claude plugin test kane-qe` (116 tests). ## Features (design part 1, "What to build") @@ -41,11 +41,11 @@ Two differences, both deliberate: | Rule | v2 | |---|---| -| The mod watches; it never starts a test | It runs only `kane-cli cover gaps --json`, `kill -0`, `git check-ignore` and `git diff`; tests are run by Claude, through prompts the person's buttons send | +| The mod watches; it never starts a test | It runs `kane-cli cover gaps --json`, `kill -0`, `git check-ignore` and `git diff`, and on *View evidence* `kane-cli evidence serve` and `open`; tests are run by Claude, through prompts the person's buttons send | | Structured data only | Pointer JSON and `events.ndjson` lines; kane-cli's human output is never read | | No guesses on screen | What kane-cli does not report is left out (step totals, credits while running, balance, browser) | | Ignore what you do not know | `adapter.ts` dispatches on known types and skips the rest | -| Every behaviour has a test; README changes with it | 103 tests; both READMEs describe v2 | +| Every behaviour has a test; README changes with it | 116 tests; both READMEs describe v2 | | Code shape: sources → adapter → model → views | `sources.ts`, `adapter.ts` (only place raw field names appear), `model.ts` (pure), `register.tsx` | | Faults 1–5 in the prototype | All fixed: wrapped text keeps its column, waiting markers in the dim colour, card borders in the dim colour, cuts sized from the pane width, no background on band or pane | @@ -66,17 +66,18 @@ Two differences, both deliberate: ## Added after the design -Three things the design did not ask for, each checked by **test** only (not yet in a live session): +Four things the design did not ask for, each checked by **test** only (not yet in a live session, except that a trial card with fake data was drawn and clicked in a fullscreen terminal): | Addition | What it does | |---|---| | Remote suites | `kane-cli testrun run --remote`: the grid reports its tests only when the job ends, so until then the band reads `12 tests on the grid · sent 2m ago` and the pane gives the grid, the job and its link. A job that fails before any test reports gives every test that reason | | Use-case detail | Press a use case on the Assurance tab: its title and risk, what it owes to design and to run, kane-cli's reason and next command for each, and *Close these gaps with Claude* | +| Card in the chat | A kane-cli test Claude runs is drawn in the conversation as a card in place of its shell row: one line while it runs, then the result, a failure's on/why/kind or a suite's counts, *View steps* and *View evidence*. *View evidence* opens the pack in kane-cli's viewer in one click, which is the one time the mod starts a kane-cli command other than `cover gaps`. Printed chats (terminal outside fullscreen) get the evidence path instead of buttons | | History tab | The last 30 finished runs in this project (it was 10, shown only as `last run …`), with when, how long, and where each failed or a suite's counts. `/kane history` opens it | ## Not checked -- **The three additions above in a live session.** The remote events are written from kane-cli's contract (`docs/remote-execution.md`); no real grid job has been watched with them. +- **The four additions above in a live session.** The card's evidence button has never opened a real pack. The remote events are written from kane-cli's contract (`docs/remote-execution.md`); no real grid job has been watched with them. - **The desktop app.** The band and pane are mounted on the desktop surface in tests (the mascot as an image there), but not opened in the app itself. - **The light palette in a live session**: switching a session's theme changes the person's global setting, so it is covered by a test only. diff --git a/kane-qe/README.md b/kane-qe/README.md index 62ae2d2..5ada59d 100644 --- a/kane-qe/README.md +++ b/kane-qe/README.md @@ -6,8 +6,9 @@ A Claude Code mod that shows what kane-cli (KaneAI) is doing, without being aske | Surface | What it shows | |---|---| -| **Band** (three rows above the prompt, the kane mascot on the left) | Row 1: `idle` with this session's passed/failed counts, `running` with the test's name and time, `N runs` with one coloured cell per run, or `suite` with one cell per test and `5 of 12 tests done · 3 running · 4 left`. A suite sent to the remote grid (`testrun run --remote`) reads `12 tests on the grid · sent 2m 05s ago` until the job ends, because the grid reports its tests only then. Row 2: the step a single run is on; `now` and each running test with its time; a failure the moment it happens (`✗ checkout_guest failed on Fill the shipping address`); `last run ✓ login · 2h ago` from earlier sessions; or `⚠ 2 files changed, untested [ Test this change ]`. Row 3: assurance, `47% proven · 4 use cases`, or `not set up [ Set up now ]`. The mascot's eyes move while a test runs. | +| **Band** (three rows above the prompt, the kane mascot on the left) | Row 1: `idle` with this session's passed/failed counts, `running` with the test's name and time, `N runs` with one coloured cell per run, or `suite` with one cell per test and `5 of 12 tests done · 3 running · 4 left`. A suite sent to the remote grid (`testrun run --remote`) reads `12 tests on the grid · sent 2m 05s ago` until the job ends, because the grid reports its tests only then. Row 2: the step a single run is on; `now` and each running test with its time; a failure the moment it happens (`✗ checkout_guest failed on Fill the shipping address`); `last run ✓ login · 2h ago` from earlier sessions; or `⚠ 2 files changed, untested [ Test this change ]`. Row 3: assurance, `47% proven · 4 use cases`, or `not set up [ Set up now ]`. The mascot's eyes move while a test runs (one step every 150 ms). | | **Pane** (`/kane` or `[ Open ]`) | **Runs**: a card per run (failed first, red border), or one row per test for a suite. Press a name for its steps: done steps with a tick and time, the current or failing step expanded to *think*, *act* and each *check*, the failure's *why*, *kind* (kane's category, severity, confidence) and *where*, then *Open evidence*. For a remote suite the pane names the grid, the job and its link. **Assurance**: designed and proven bars, failing/blocked counts and when it last ran, a card per use case with what it still owes, or a card that offers to set it up with Claude. Press a use case for its detail: its title and risk, then each thing it owes under *To design* and *To run*, with kane-cli's reason and its own next command; *Close these gaps with Claude* hands that to Claude. **History**: the finished runs kept for this project, newest first, each with when it ran, how long it took, and where it failed or a suite's passed/failed counts. | +| **Card in the chat** | A kane-cli test Claude runs (`run`, `testmd run`, `testrun run`) is drawn in the conversation as a card, in place of its shell row: one line while it runs (name, time, step), then a bordered card with the mascot, the result (`✓ passed · 41s · 12.4 credits`), a failure's *on*, *why* and *kind*, or a suite's cells, counts and up to three failures. **View steps** / **View tests** opens the pane on that run; **View evidence** opens the run's evidence pack in kane-cli's viewer in the browser, in one click. Where the chat is printed text (the terminal outside fullscreen) the card ends with the evidence path instead of buttons. A command that never became a run (kane-cli refused or failed to start) keeps its normal row, and so does every other command. The chat folds shell calls into one count line; a group that holds a run is unfolded so its card shows. | | **After Claude changes code** | When a turn ends with edited files (by the edit tools or a shell command; not docs, hidden tool folders or git-ignored files) and no kane-cli run since, the band warns and offers *Test this change*. The card lists the changed files and saved tests that mention the same feature, with their last result and *Run it*. Only when none does, Claude drafts an objective from the diff (also on *Draft a new objective*); *Run the objective* hands it to Claude with the app's start URL. A kane-cli run that starts after the last edit clears the warning. | ## Settings @@ -27,6 +28,8 @@ A Claude Code mod that shows what kane-cli (KaneAI) is doing, without being aske - Each run's `events.ndjson` (and each suite member's own stream, read once more when it ends), from where it last stopped. Structured lines only, never kane-cli's human output. Unknown events and fields are skipped; lines that are not JSON are skipped and counted; a stream with a newer wire version shows its name and time and says so. - Names come from the command Claude ran (`kane-cli testmd run login_test.md` → `login_test.md`), then kane-cli's own `recording_state`, then the objective. - Failure facts come from `run_end.verdict` (root cause, category, severity, confidence), never guessed. +- A run's card is its shell call's row: the mod notes the call's id when Claude runs a kane-cli test and keeps it on the run that starts. +- Evidence: kane-cli seals a run's pack as it exits, at `/evidence/.evidence`; a suite's is `/.testmuai/evidence/.evidence`. The mod looks for it for half a minute after the run ends. *View evidence* starts `kane-cli evidence serve `, reads the viewer link it prints and opens it (`open`, else `xdg-open`); the viewer serves until the session ends, and a second press reuses it. With no pack or no viewer, Claude is asked to open it instead. - A remote suite: `remote_start` (the grid) and `remote_dispatched` (the job and its link) while the job runs; its tests arrive after the fact, stamped with the grid's own time. A job that fails before any test reports (`remote_error`) gives every test that reason. - Assurance: `kane-cli cover gaps --json` at session start and after each run, when the project has a `.context` folder. Each use case keeps up to 20 of its `pending` rows (title, why, risk, stage, `ready_command`) for its detail view; its counts cover them all. - The last 30 results per project are kept in the mod's store (`recentByProject`, apart from v1's `history`) for `last run …` and the History tab. Entries kept by earlier versions (label, status and time only) still read. @@ -35,10 +38,10 @@ A Claude Code mod that shows what kane-cli (KaneAI) is doing, without being aske ``` claude plugin validate kane-qe -claude plugin test kane-qe # adapter table, band states at 60/80/120 columns, recorded streams, pane and band on terminal and desktop, change tracking, stale pointers, assurance and use-case detail, remote suites, history +claude plugin test kane-qe # adapter table, band states at 60/80/120 columns, recorded streams, pane and band on terminal and desktop, change tracking, stale pointers, assurance and use-case detail, remote suites, history, the card in the chat node kane-qe/tests/fixtures/build.mjs # after adding a recorded stream to tests/fixtures/*.ndjson ``` -Layout: `hooks/adapter.ts` (wire line → internal event, the only place raw field names appear), `hooks/model.ts` (pure: events → runs, runs → band rows and pane text), `hooks/sources.ts` (pointer and byte-offset helpers), `hooks/register.tsx` (polling, hooks and drawing), `hooks/mascot.ts` (the mascot, unchanged from the design). +Layout: `hooks/adapter.ts` (wire line → internal event, the only place raw field names appear), `hooks/model.ts` (pure: events → runs, runs → band rows and pane text), `hooks/sources.ts` (pointer and byte-offset helpers), `hooks/register.tsx` (polling, hooks and drawing), `hooks/mascot.ts` (the mascot, as designed but for its speed). v1 (the cockpit that starts runs, with Insights, Tests, History and Setup tabs and model tools) is on the `main` branch. diff --git a/kane-qe/hooks/adapter.ts b/kane-qe/hooks/adapter.ts index b15ccce..d31a74f 100644 --- a/kane-qe/hooks/adapter.ts +++ b/kane-qe/hooks/adapter.ts @@ -20,7 +20,7 @@ export type Ev = | { k: 'memberStart'; path: string; logPath?: string; at: number } | { k: 'progress'; running: string[]; pending: number; done: number; total: number; at: number } | { k: 'memberEnd'; path: string; status: string; durationS?: number; logPath?: string; why?: string; at: number } - | { k: 'suiteDone'; status: string; at: number } + | { k: 'suiteDone'; status: string; executionId?: string; at: number } | { k: 'remote'; backend?: string; jobId?: string; jobUrl?: string; dispatched: boolean; at: number } | { k: 'postHoc' } @@ -164,7 +164,7 @@ function one(o: Json, at: number): Ev[] { return [{ k: 'memberEnd', path, status: str(o.status) ?? 'failed', durationS: num(o.duration_s), logPath: str(o.log_path), why: str(failure.message), at }] } case 'testrun_done': - return [{ k: 'suiteDone', status: str(o.overall_status) ?? 'failed', at }] + return [{ k: 'suiteDone', status: str(o.overall_status) ?? 'failed', executionId: str(o.execution_id), at }] case 'remote_start': return [{ k: 'remote', backend: str(o.backend), dispatched: false, at }] case 'remote_dispatched': diff --git a/kane-qe/hooks/model.ts b/kane-qe/hooks/model.ts index de1c920..4953716 100644 --- a/kane-qe/hooks/model.ts +++ b/kane-qe/hooks/model.ts @@ -204,7 +204,7 @@ export function fold(run: Run, ev: Ev): Run { if (lost && m.status === 'pending') return { ...m, status: 'failed' as const, startedAt: ev.at, endedAt: ev.at, failure: { why: run.failure?.why || 'the grid job ended with no result for this test' } } return m }) - return { ...run, status, endedAt: ev.at, members } + return { ...run, status, endedAt: ev.at, members, ...(ev.executionId ? { executionId: ev.executionId } : {}) } } case 'remote': { const remote = { ...run.remote } @@ -519,6 +519,44 @@ export function kindLine(r: Run): string | undefined { return parts.length ? parts.join(' · ') : undefined } +// ── the card in the chat ───────────────────────────────────────────────── + +/** A chat card's right-hand result: "✓ passed · 41s · 12.4 credits", or a suite's counts. Each part only if kane-cli reported it. */ +export function cardResult(r: Run, now: number): Span[] { + const time = fmt(elapsed(r, now)) + if (r.kind === 'testrun' && r.members) { + const c = tally(r.members) + const out: Span[] = [] + if (c.passed) out.push({ t: `${c.passed} passed`, c: C.mint }) + if (c.failed) out.push({ t: `${c.failed} failed`, c: C.coral }) + if (c.running) out.push({ t: `${c.running} running`, c: C.cyan }) + if (c.pending && isLive(r.status)) out.push({ t: `${c.pending} left`, d: true }) + out.push({ t: time, d: true }) + return out + } + const out: Span[] = [{ t: r.status === 'passed' ? '✓ passed' : '✗ failed', c: tone(r.status), b: true }, { t: time, d: true }] + if (r.credits !== undefined) out.push({ t: `${Math.round(r.credits * 10) / 10} credits`, d: true }) + return out +} + +/** The header fitted to `room` cells: the name is cut first, then credits drop, then the time; the status stays. */ +export function cardHeader(r: Run, now: number, room: number): { name: string; result: Span[] } { + let result = cardResult(r, now) + const width = (list: Span[]) => list.reduce((n, s) => n + [...s.t].length + 3, 0) + const min = 12 + while (result.length > 1 && width(result) + min + 8 > room) result = result.slice(0, -1) + return { name: cut(r.label, Math.max(min, room - width(result) - 8)), result } +} + +/** A suite card's failures: three at most, then how many more. */ +export function cardFailures(r: Run): { rows: Run[]; more: number } { + const failed = (r.members ?? []).filter(m => m.status === 'failed').sort((a, b) => (b.endedAt ?? 0) - (a.endedAt ?? 0)) + return { rows: failed.slice(0, 3), more: Math.max(0, failed.length - 3) } +} + +/** True when a shell command is a kane-cli test run: the only commands that get a card. */ +export const isRunCommand = (command: string): boolean => labelFromCommand(command) !== undefined + // ── assurance ──────────────────────────────────────────────────────────── /** The most a use case's detail lists; its counts still cover every row. */ diff --git a/kane-qe/hooks/register.tsx b/kane-qe/hooks/register.tsx index b8abb26..ba296aa 100644 --- a/kane-qe/hooks/register.tsx +++ b/kane-qe/hooks/register.tsx @@ -14,7 +14,10 @@ import { ORDER, band, bar, + cardFailures, + cardHeader, currentStep, + cells, cut, debt, draftPrompt, @@ -26,6 +29,7 @@ import { gridSince, historyDetail, historyEntry, + isRunCommand, isTrackedEdit, kindLine, labelFromCommand, @@ -79,7 +83,11 @@ type Watch = { id: string; path: string; pointer?: string; pid?: number; bytes: const watches = new Map() const seenPointers = new Set() /** kane-cli commands Claude ran, to name the runs they start. */ -const commands: { label: string; surface: Kind; at: number; used: boolean }[] = [] +const commands: { label: string; surface: Kind; at: number; used: boolean; toolUseId?: string }[] = [] +/** Evidence viewers this session started, by pack: the link each one printed. */ +const viewers = new Map() +/** Ended runs whose evidence pack is still being looked for, by run id: when to stop looking. */ +const seeking = new Map() let polling = false const sites = new Set() let frame = 0 @@ -465,6 +473,81 @@ function assureView(ui: Kit, press: Press, a: Assurance, now: number, open?: str ) } +/** A run Claude started, as a card in the chat in place of its shell row: one line while it runs, then its result. */ +function chatCard(table: Table, surface: RenderSurface, press: Press, r: Run, now: number, columns: number, clickable: boolean) { + const ui: Kit = table + if (r.status === 'running' || r.status === 'pending') { + const suite = r.kind === 'testrun' && r.members ? cardHeader(r, now, columns).result : undefined + return ( + + ◉ + + kane + + · + {cut(nm(r), Math.max(12, Math.floor(columns / 3)))} + {suite ? suite.map(s => text(ui, s)) : {fmt(elapsed(r, now))}} + {suite ? null : ·} + {suite ? null : ( + + {cut(nowText(r), Math.max(10, columns - Math.floor(columns / 3) - 22))} + + )} + + ) + } + const width = Math.max(48, Math.min(88, columns - 2)) + const inner = width - RASTER.columns - 6 + const head = cardHeader(r, now, inner) + const isSuite = r.kind === 'testrun' && !!r.members + const failures = isSuite ? cardFailures(r) : undefined + const kind = kindLine(r) + const steps = r.steps.filter(s => s.text || s.status !== 'pending').length + return ( + + {mascot(table, surface, false, 0)} + + + + + kane + + · + {head.name} + + + {head.result.map((s, i) => (i === 0 ? text(ui, s) : {[·, text(ui, s)]}))} + + + {isSuite ? item(ui, cells((r.members ?? []).map(m => m.status))) : null} + {failures?.rows.map(m => ( + + {`✗ ${cut(nm(m), 24)}`} + {m.failure?.where ? 'failed on' : 'failed'} + + {cut(m.failure?.where ?? m.failure?.why ?? '', Math.max(10, inner - 38))} + + + ))} + {failures && failures.more ? {`+${failures.more} more`} : null} + {!isSuite && r.status === 'failed' && r.failure?.where ? fact(ui, 'on', r.failure.where) : null} + {!isSuite && r.status === 'failed' && r.failure?.why ? fact(ui, 'why', r.failure.why) : null} + {!isSuite && r.status === 'failed' && kind ? fact(ui, 'kind', kind) : null} + {!isSuite && r.status === 'passed' && steps ? {`${steps} step${steps === 1 ? '' : 's'}`} : null} + {clickable ? : null} + {clickable ? ( + + {isSuite || r.status === 'failed' ? press(`steps:${r.id}`)} /> : null} + {r.evidence ? press(`evidence:${r.id}`)} /> : null} + + ) : r.evidence ? ( + fact(ui, 'evidence', r.evidence) + ) : null} + + + ) +} + // ── reading kane-cli ───────────────────────────────────────────────────── /** Opened only by something the person did, and brought to the front: Claude Code opens its Diff pane in the @@ -505,11 +588,11 @@ async function readNew($: EngineInterface, w: Watch): Promise { } /** The label Claude's own command gives a run that started just now in this project. */ -function claimCommand(surface: Kind, started: number): string | undefined { +function claimCommand(surface: Kind, started: number): { label: string; toolUseId?: string } | undefined { const c = [...commands].reverse().find(x => !x.used && x.surface === surface && started >= x.at - 5_000 && started - x.at < 120_000) if (!c) return undefined c.used = true - return c.label + return { label: c.label, toolUseId: c.toolUseId } } /** Applies one file's new lines to the runs; reports the runs that just ended. */ @@ -597,20 +680,91 @@ async function poll($: EngineInterface) { for (const [k, w] of watches) if (w.done) watches.delete(k) if (changed) await update($, runsAtom, () => runs) for (const r of ended) await finished($, r) + for (const r of ended) seeking.set(r.id, now + 30_000) + if (seeking.size) await seekEvidence($, now) if (runs.some(r => r.status === 'running' || r.status === 'pending')) await update($, tickAtom, n => n + 1) } finally { polling = false } } +/** kane-cli seals a run's evidence pack as it exits: `/evidence/.evidence`, and a suite's in the project's store under its execution id. */ +async function findEvidence($: EngineInterface, r: Run): Promise { + if (r.executionId && r.cwd) { + const stored = `${r.cwd}/.testmuai/evidence/${r.executionId}.evidence` + if (await $.fs.exists(stored).catch(() => false)) return stored + } + const dir = `${r.sessionDir}/evidence` + const packs = (await $.fs.list(dir).catch(() => [])).filter(e => e.kind === 'file' && e.name.endsWith('.evidence')) + return packs.length === 1 ? `${dir}/${packs[0]!.name}` : undefined +} + +/** Looks for the pack of each run that just ended, for half a minute at most. */ +async function seekEvidence($: EngineInterface, now: number) { + const runs = await read($, runsAtom) + const found = new Map() + for (const [id, until] of seeking) { + const r = runs.find(x => x.id === id) + const pack = r && !r.evidence ? await findEvidence($, r) : undefined + if (pack) found.set(id, pack) + if (pack || !r || r.evidence || now > until) seeking.delete(id) + } + if (found.size) await update($, runsAtom, list => list.map(r => (found.has(r.id) ? { ...r, evidence: found.get(r.id) } : r))) +} + +/** Starts kane-cli's local evidence viewer for a pack and answers the link it prints; the viewer lives until the session ends. */ +function startViewer($: EngineInterface, pack: string): Promise { + return new Promise(resolve => { + let said = false + const say = (url: string | undefined) => { + if (said) return + said = true + resolve(url) + } + void $.clock.sleep(15_000).then(() => say(undefined), () => say(undefined)) + void (async () => { + let text = '' + try { + for await (const piece of $.process.spawn({ argv: [...kane, 'evidence', 'serve', pack], cwd: projectDir })) { + text = (text + piece.text).slice(-4000) + const link = text.match(/viewer\s+(https?:\/\/\S+)/) + if (link && !viewers.has(pack)) { + viewers.set(pack, link[1]!) + say(link[1]) + } + } + } catch { + // the viewer could not start: the caller falls back + } + viewers.delete(pack) + say(undefined) + })() + }) +} + +/** One click to a run's evidence: its pack in kane-cli's viewer, in the browser. Without a pack or a viewer, Claude is asked instead. */ +async function openEvidence($: EngineInterface, id: string) { + const all = await read($, runsAtom) + const r = all.find(x => x.id === id) ?? leaves(all).find(x => x.id === id) + const pack = r?.evidence + const url = pack ? (viewers.get(pack) ?? (await startViewer($, pack))) : undefined + if (url) { + const opened = await $.process.run(['open', url], { timeoutMs: 5000 }).catch(() => undefined) + if (opened?.exitCode !== 0) await $.process.run(['xdg-open', url], { timeoutMs: 5000 }).catch(() => undefined) + $.ui.toast('Evidence opened in the browser') + return + } + void $.prompt.submit({ text: `Open the evidence for the kane-cli run in ${pack ?? r?.sessionDir ?? id}.` }).catch(() => undefined) +} + function startWatch(runs: Run[], p: Pointer, pointer: string): Run[] { const id = p.sessionDir - const label = claimCommand(p.surface, p.started) - const run = newRun(id, p.surface, p.sessionDir, p.started, { pid: p.pid, cwd: p.cwd, ...(label ? { label, labelRank: RANK.command } : {}) }) + const claimed = claimCommand(p.surface, p.started) + const run = newRun(id, p.surface, p.sessionDir, p.started, { pid: p.pid, cwd: p.cwd, ...(claimed ? { label: claimed.label, labelRank: RANK.command } : {}), ...(claimed?.toolUseId ? { toolUseId: claimed.toolUseId } : {}) }) // A reload follows the file again from its start, replacing what the last module folded. watches.set(id, { id, path: `${p.sessionDir}/events.ndjson`, pointer, pid: p.pid, bytes: 0, rest: '', done: false }) const keep = runs.find(r => r.id === id) - const fresh = keep ? { ...run, label: keep.labelRank && keep.labelRank >= RANK.command ? keep.label : run.label, labelRank: Math.max(keep.labelRank ?? 0, run.labelRank ?? 0) } : run + const fresh = keep ? { ...run, toolUseId: run.toolUseId ?? keep.toolUseId, label: keep.labelRank && keep.labelRank >= RANK.command ? keep.label : run.label, labelRank: Math.max(keep.labelRank ?? 0, run.labelRank ?? 0) } : run return [...runs.filter(r => r.id !== id), fresh] } @@ -742,11 +896,11 @@ function autoInstruction(files: readonly string[], saved: readonly string[], url } /** A kane-cli command Claude is about to run names the run it starts. */ -async function beforeTool($: EngineInterface, e: { tool: string; command?: unknown }) { +async function beforeTool($: EngineInterface, e: { tool: string; command?: unknown; tool_use_id?: string }) { if (e.tool !== 'Bash') return const named = labelFromCommand(String(e.command ?? '')) if (!named) return - commands.push({ ...named, at: await $.clock.now(), used: false }) + commands.push({ ...named, at: await $.clock.now(), used: false, toolUseId: e.tool_use_id }) if (commands.length > 20) commands.shift() } @@ -826,10 +980,10 @@ async function act($: EngineInterface, a: string) { await update($, offerAtom, () => null) await update($, changeAtom, c => (c ? { ...c, shown: false } : c)) await update($, viewAtom, v => ({ ...v, open: '' })) - } else if (k === 'evidence') { - const r = leaves(await read($, runsAtom)).concat(await read($, runsAtom)).find(x => x.id === arg) - const dir = r?.sessionDir || arg - void $.prompt.submit({ text: `Open the evidence for the kane-cli run in ${dir}.` }).catch(() => undefined) + } else if (k === 'evidence') await openEvidence($, arg) + else if (k === 'steps') { + await update($, viewAtom, v => ({ ...v, tab: 'runs' as const, open: arg, uc: '' })) + await openPane($) } else if (k === 'setup') { void $.prompt.submit({ text: 'Set up kane-cli assurance for this project: ask me for the requirement (a PRD file, a Jira, Confluence or Linear link, or a web page), ingest it with kane-cli, extract the use cases and design tests (use --mode agent), then show me the coverage with kane-cli cover gaps.', @@ -922,6 +1076,34 @@ export const register: Register = (on, options) => { return block ? { ...result, block } : result }) + // A kane-cli run Claude started is drawn as a card in the chat, in place of its shell row. + // The chat folds shell calls into one count line: a group holding a run is unfolded, so its row is drawn. + on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => { + if (e.props.isExpanded || !e.props.calls.some(c => c.tool === 'Bash' && isRunCommand(String((c.input as { command?: unknown } | undefined)?.command ?? '')))) return next(e) + return next({ ...e, props: { ...e.props, isExpanded: true } }) + }) + + on('ui.render', { component: 'ToolUse' }, async ($, e, next) => { + if (e.props.tool !== 'Bash' || !isRunCommand(String((e.props.input as { command?: unknown } | undefined)?.command ?? ''))) return next(e) + const r = (await read($, runsAtom)).find(x => x.toolUseId === e.props.tool_use_id) + // No run came of the command (kane-cli refused or failed to start): its own row says why. + if (!r) return next(e) + await read($, tickAtom) + const press: Press = a => void act($, a).catch(() => undefined) + // Printed text takes no clicks: only the desktop and the fullscreen terminal draw buttons. + const clickable = e.surface !== 'terminal' || e.viewport?.isFullscreen === true + return chatCard($.ui.resolve(e), e.surface, press, r, await $.clock.now(), e.viewport?.columns ?? 90, clickable) + }) + + // The card stands for the whole call: once the run has a result, kane-cli's raw output is not drawn beneath it. + on('ui.render', { component: 'ToolResult' }, async ($, e, next) => { + if (e.props.tool !== 'Bash') return next(e) + const r = (await read($, runsAtom)).find(x => x.toolUseId === e.props.tool_use_id) + if (!r || (r.status !== 'passed' && r.status !== 'failed')) return next(e) + const ui: Kit = $.ui.resolve(e) + return + }) + on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => { if (e.props.hasSurvey) return next(e) const runs = await read($, runsAtom) diff --git a/kane-qe/tests/engine.test.tsx b/kane-qe/tests/engine.test.tsx index 0a4047e..210dee8 100644 --- a/kane-qe/tests/engine.test.tsx +++ b/kane-qe/tests/engine.test.tsx @@ -23,11 +23,11 @@ const PANE = { props: { title: 'Kane', isFocused: false, bodyColumns: 56, placement: 'dock', scroll: { offset: 0, bodyRows: 40 }, view: {} }, } as const -type World = { files: Record; alive: Set; prompts: string[]; cover?: string; forkText?: string; ignored: Set; bashResult?: unknown; store?: Record } +type World = { files: Record; alive: Set; prompts: string[]; ran: string[][]; rows: { component: string; isExpanded?: boolean }[]; viewer?: string; cover?: string; forkText?: string; ignored: Set; bashResult?: unknown; store?: Record } /** The engine beneath the plugin: a fake disk, processes, and the calls the mod makes. */ function world(on: On, w: Partial = {}): World & { clock: ReturnType } { - const state: World = { files: {}, alive: new Set(), prompts: [], ignored: new Set(), ...w } + const state: World = { files: {}, alive: new Set(), prompts: [], ran: [], rows: [], ignored: new Set(), ...w } const clock = mock.clock(on, { now: T0 }) mock.store(on, state.store) mock.env(on, { HOME }) @@ -50,6 +50,8 @@ function world(on: On, w: Partial = {}): World & { clock: ReturnType (e.path in state.files ? { value: { kind: 'file', size: state.files[e.path]!.length, mtimeMs: 0, isLink: false } } : { deny: 'ENOENT' }) as never) on('process.run', ($, e) => { const argv = e.argv + state.ran.push([...argv]) + if (argv[0] === 'open') return OK('') as never if (argv[0] === 'kill') return OK('', state.alive.has(Number(argv[2])) ? 0 : 1) as never if (argv[0] === 'git' && argv[1] === 'check-ignore') return OK('', state.ignored.has(String(argv[3])) ? 0 : 1) as never if (argv[0] === 'git' && argv[1] === 'diff') return OK('+ postcode.trim()') as never @@ -58,6 +60,21 @@ function world(on: On, w: Partial = {}): World & { clock: ReturnType ({ result: (e as { tool?: string }).tool === 'Bash' && state.bashResult ? state.bashResult : { ok: true } }) as never) on('classic.Stop', () => ({}) as never) + // kane-cli's evidence viewer: it prints its link and keeps serving. + on('process.spawn', async function* ($: unknown, e: { argv: readonly string[] }) { + state.ran.push([...e.argv]) + if (state.viewer) yield { stream: 'stdout', text: `serving 1 pack on http://127.0.0.1:5173\npack.evidence\n pack http://127.0.0.1:5173/t/p\n viewer ${state.viewer}\npress Ctrl-C to stop\n` } as never + // A viewer that started keeps serving; one that could not start exits. + if (state.viewer) await new Promise(() => undefined) + return { value: { code: 2, signal: null } } as never + } as never) + on('ui.toast', () => ({ value: {} }) as never) + // The engine's own row, where the mod leaves a row alone. + on('ui.render', ($, e) => { + state.rows.push({ component: e.component, isExpanded: (e.props as { isExpanded?: boolean }).isExpanded }) + const { Text } = $.ui.resolve(e) + return ({`engine ${e.component}`}) as never + }) return Object.assign(state, { clock }) } @@ -532,3 +549,198 @@ describe('assurance', () => { await pane.unmount() }) }) + +describe('the card in the chat', () => { + const CMD = 'kane-cli testmd run .testmuai/tests/checkout_guest_test.md --agent --headless' + const row = (component: 'ToolUse' | 'ToolResult', id: string, command: string, more: object = {}) => ({ + plugin: 'kane-qe', + component, + requestId: id, + props: component === 'ToolUse' ? { tool_use_id: id, tool: 'Bash', input: { command }, isRunning: false, isErrored: false, isInterrupted: false } : { tool_use_id: id, tool: 'Bash', output: { stdout: 'raw kane-cli output', stderr: '' }, isErrored: false }, + ...more, + }) + const FULL = { viewport: { columns: 100, rows: 40, isFullscreen: true } } + + /** Claude runs a test file; kane-cli starts it. */ + async function begin($: { session: { start: (a: never) => Promise }; tool: { call: (a: never) => Promise } }, w: World, id = 'toolu_1') { + await $.session.start({ cwd: PROJECT, surface: 'terminal', isInteractive: true } as never) + await $.tool.call({ tool: 'Bash', command: CMD, tool_use_id: id } as never) + const dir = `${S}/card-1` + startRun(w, 71, dir, 'testmd', line({ type: 'stream_start', surface: 'testmd', pid: 71 }) + line({ type: 'test_md_step_start', step_index: 1, heading: 'Fill the shipping address' }, 2)) + return dir + } + const fail = (w: World, dir: string) => { + w.files[`${dir}/events.ndjson`] += + line({ type: 'run_end', status: 'failed', credits_consumed: 31.2, verdict: { root_cause: 'Postcode field rejects valid input', category: 'functional_defect', severity: 'high', confidence: 0.86 } }, 60) + + line({ type: 'test_md_step_end', step_index: 1, status: 'failed', duration_s: 58 }, 60) + + line({ type: 'test_md_done', overall_status: 'failed', duration_s: 66 }, 66) + w.files[`${dir}/evidence/8f0e.evidence`] = 'pack' + endRun(w, 71) + } + + test('a run Claude started is one line while it runs, then a card with its result, its steps and its evidence', async ($, on) => { + const w = world(on, { viewer: 'https://viewer.example/?pack=p' }) + const dir = await begin($, w) + await w.clock.advance(1500) + await w.clock.settle() + for (const surface of ['terminal', 'desktop'] as const) { + const live = await $.ui.mount({ ...row('ToolUse', 'toolu_1', CMD, FULL), surface } as never) + expect(await live.find({ type: 'Text', text: /^checkout_guest$/ })).toBeDefined() + expect(await live.find({ type: 'Text', text: /Fill the shipping address/ })).toBeDefined() + expect(await live.find({ key: 'card-evidence' })).toBeUndefined() + await live.unmount() + } + // While it runs, kane-cli's own output still shows. + const before = await $.ui.mount({ ...row('ToolResult', 'toolu_1', CMD, FULL), surface: 'terminal' } as never) + expect(await before.find({ type: 'Text', text: /engine ToolResult/ })).toBeDefined() + await before.unmount() + + fail(w, dir) + await w.clock.advance(1500) + await w.clock.settle() + for (const surface of ['terminal', 'desktop'] as const) { + const card = await $.ui.mount({ ...row('ToolUse', 'toolu_1', CMD, FULL), surface } as never) + expect(await card.find({ type: 'Text', text: /^✗ failed$/ })).toBeDefined() + expect(await card.find({ type: 'Text', text: /^31\.2 credits$/ })).toBeDefined() + expect(await card.find({ type: 'Text', text: /^Postcode field rejects valid input$/ })).toBeDefined() + expect(await card.find({ type: 'Text', text: /^functional defect · high · 86% sure$/ })).toBeDefined() + expect(await card.find({ type: surface === 'terminal' ? 'Raster' : 'Svg' })).toBeDefined() + expect(await card.find({ key: 'card-steps' })).toBeDefined() + expect(await card.find({ key: 'card-evidence' })).toBeDefined() + await card.unmount() + } + // The card stands for the call: its raw output is gone. + const after = await $.ui.mount({ ...row('ToolResult', 'toolu_1', CMD, FULL), surface: 'terminal' } as never) + expect(await after.find({ type: 'Text', text: /engine ToolResult/ })).toBeUndefined() + await after.unmount() + + const card = await $.ui.mount({ ...row('ToolUse', 'toolu_1', CMD, FULL), surface: 'terminal' } as never) + await card.press({ key: 'card-evidence' }) + await w.clock.settle() + expect(w.ran.some(a => a.join(' ') === `kane-cli evidence serve ${dir}/evidence/8f0e.evidence`)).toBe(true) + expect(w.ran.some(a => a[0] === 'open' && a[1] === 'https://viewer.example/?pack=p')).toBe(true) + expect(w.prompts).toEqual([]) + // A second press reuses the viewer that is already serving. + await card.press({ key: 'card-evidence' }) + await w.clock.settle() + expect(w.ran.filter(a => a.includes('serve')).length).toBe(1) + expect(w.ran.filter(a => a[0] === 'open').length).toBe(2) + await card.press({ key: 'card-steps' }) + const pane = await $.ui.mount({ ...PANE, surface: 'terminal' } as never) + expect(await pane.find({ type: 'Text', text: /^where$/ })).toBeDefined() + await pane.unmount() + await card.unmount() + }) + + test('where the chat is printed text, the card ends with the evidence path, not buttons', async ($, on) => { + const w = world(on) + const dir = await begin($, w) + await w.clock.advance(1500) + await w.clock.settle() + fail(w, dir) + await w.clock.advance(1500) + await w.clock.settle() + const card = await $.ui.mount({ ...row('ToolUse', 'toolu_1', CMD, { viewport: { columns: 100, rows: 40, isFullscreen: false } }), surface: 'terminal' } as never) + expect(await card.find({ type: 'Text', text: /^✗ failed$/ })).toBeDefined() + expect(await card.find({ key: 'card-evidence' })).toBeUndefined() + expect(await card.find({ key: 'card-steps' })).toBeUndefined() + expect(await card.find({ type: 'Text', text: new RegExp(`^${dir}/evidence/8f0e\\.evidence$`) })).toBeDefined() + await card.unmount() + }) + + test('with no viewer to start, View evidence asks Claude instead', async ($, on) => { + const w = world(on) + const dir = await begin($, w) + await w.clock.advance(1500) + await w.clock.settle() + fail(w, dir) + await w.clock.advance(1500) + await w.clock.settle() + const card = await $.ui.mount({ ...row('ToolUse', 'toolu_1', CMD, FULL), surface: 'terminal' } as never) + await card.press({ key: 'card-evidence' }) + await w.clock.settle() + expect(w.ran.some(a => a[0] === 'open')).toBe(false) + expect(w.prompts[0]).toContain(`${dir}/evidence/8f0e.evidence`) + await card.unmount() + }) + + test('other rows keep the engine’s drawing: another command, a kane-cli command that is no run, a run that never started', async ($, on) => { + const w = world(on) + await $.session.start({ cwd: PROJECT, surface: 'terminal', isInteractive: true } as never) + await $.tool.call({ tool: 'Bash', command: CMD, tool_use_id: 'toolu_9' } as never) + await w.clock.advance(1500) + await w.clock.settle() + const cases = [ + { name: 'another command', id: 'toolu_2', command: 'npm test' }, + { name: 'a read-only kane-cli command', id: 'toolu_3', command: 'kane-cli cover gaps --json' }, + { name: 'a run kane-cli never started', id: 'toolu_9', command: CMD }, + ] + for (const c of cases) { + for (const component of ['ToolUse', 'ToolResult'] as const) { + const r = await $.ui.mount({ ...row(component, c.id, c.command, FULL), surface: 'terminal' } as never) + expect(await r.find({ type: 'Text', text: new RegExp(`engine ${component}`) })).toBeDefined() + await r.unmount() + } + } + }) + + test('a folded group of shell calls is unfolded only when it holds a kane-cli run', async ($, on) => { + const w = world(on) + await $.session.start({ cwd: PROJECT, surface: 'terminal', isInteractive: true } as never) + const group = (commands: string[]) => ({ + plugin: 'kane-qe', + component: 'ToolGroup', + requestId: 'g1', + surface: 'terminal', + props: { calls: commands.map((command, i) => ({ tool_use_id: `t${i}`, tool: 'Bash', input: { command }, isRunning: false, isErrored: false })), isActive: false, isExpanded: false }, + }) + const cases = [ + { commands: ['ls', 'git status'], want: false }, + { commands: ['kane-cli whoami'], want: false }, + { commands: ['ls', CMD], want: true }, + { commands: ['kane-cli run "Search for iPod" --agent'], want: true }, + ] + for (const c of cases) { + w.rows.length = 0 + const g = await $.ui.mount(group(c.commands) as never) + expect(w.rows.find(r => r.component === 'ToolGroup')?.isExpanded).toBe(c.want) + await g.unmount() + } + }) + + test('a suite’s card counts its tests and names its failures; its pack is the one its execution id names', async ($, on) => { + const w = world(on) + await $.session.start({ cwd: PROJECT, surface: 'terminal', isInteractive: true } as never) + const cmd = 'kane-cli testrun run --tags smoke --agent' + await $.tool.call({ tool: 'Bash', command: cmd, tool_use_id: 'toolu_s' } as never) + const a = `${PROJECT}/.testmuai/tests/login_test.md` + const b = `${PROJECT}/.testmuai/tests/checkout_guest_test.md` + startRun( + w, + 81, + `${S}/testrun-ex1`, + 'testrun', + line({ type: 'stream_start', surface: 'testrun', pid: 81 }) + + line({ type: 'testrun_plan', members: [{ path: a }, { path: b }], parallel: 2 }) + + line({ type: 'testrun_member_start', path: a }, 1) + + line({ type: 'testrun_member_end', path: a, status: 'passed', duration_s: 20 }, 21) + + line({ type: 'testrun_member_start', path: b }, 1) + + line({ type: 'testrun_member_end', path: b, status: 'failed', duration_s: 30, failure: { message: 'Postcode field rejects valid input' } }, 31) + + line({ type: 'testrun_done', execution_id: 'ex1', overall_status: 'failed' }, 40), + ) + w.files[`${PROJECT}/.testmuai/evidence/ex1.evidence`] = 'pack' + await w.clock.advance(1500) + await w.clock.settle() + endRun(w, 81) + await w.clock.advance(1500) + await w.clock.settle() + const card = await $.ui.mount({ ...row('ToolUse', 'toolu_s', cmd, FULL), surface: 'terminal' } as never) + expect(await card.find({ type: 'Text', text: /^suite · --tags smoke$/ })).toBeDefined() + expect(await card.find({ type: 'Text', text: /^1 passed$/ })).toBeDefined() + expect(await card.find({ type: 'Text', text: /^1 failed$/ })).toBeDefined() + expect(await card.find({ type: 'Text', text: /^✗ checkout_guest$/ })).toBeDefined() + expect(await card.find({ type: 'Button', text: /View tests/ }) ?? (await card.find({ key: 'card-steps' }))).toBeDefined() + expect(await card.find({ key: 'card-evidence' })).toBeDefined() + await card.unmount() + }) +}) diff --git a/kane-qe/tests/model.test.ts b/kane-qe/tests/model.test.ts index d091359..82f72e6 100644 --- a/kane-qe/tests/model.test.ts +++ b/kane-qe/tests/model.test.ts @@ -4,6 +4,10 @@ import { adapt, splitLines } from '../hooks/adapter' import type { Ev } from '../hooks/adapter' import { band, + cardFailures, + cardHeader, + cardResult, + isRunCommand, featureWords, fold, foldMember, @@ -216,6 +220,49 @@ describe('a suite on the remote grid', () => { }) }) +describe('the card in the chat', () => { + const texts = (r: Run, now = T0 + 70_000) => cardResult(r, now).map(s => s.t) + const cases: { name: string; run: Run; want: string[] }[] = [ + { name: 'a passed run: status, time, credits', run: run('a', 'run', [label('login_test.md'), step(1, 'done', 'Sign in', 3), passed(41)]), want: ['✓ passed', '41s', '12.4 credits'] }, + { name: 'a failed run with no credits reported shows none', run: run('b', 'run', [label('x'), step(1, 'failed', 'Open', 3), { k: 'runEnd', status: 'failed', why: 'boom', at: T0 + 66_000 }]), want: ['✗ failed', '1m 06s'] }, + { name: 'a finished suite: its counts and time', run: fold(suite(['passed', 'passed', 'failed']), { k: 'suiteDone', status: 'failed', at: T0 + 90_000 }), want: ['2 passed', '1 failed', '1m 30s'] }, + { name: 'a suite still going says what is left', run: suite(['passed', 'running', 'pending']), want: ['1 passed', '1 running', '1 left', '1m 10s'] }, + ] + for (const c of cases) test(c.name, () => expect(texts(c.run)).toEqual(c.want)) + + test('the header fits its room: the name is cut, then credits and time drop, the status stays', () => { + const r = run('a', 'run', [label('a-very-long-test-name-that-will-not-fit-anywhere_test.md'), step(1, 'done', 'Sign in', 3), passed(41)]) + const wide = cardHeader(r, T0 + 50_000, 120) + expect(wide.name).toBe('a-very-long-test-name-that-will-not-fit-anywhere_test.md') + expect(wide.result.map(s => s.t)).toEqual(['✓ passed', '41s', '12.4 credits']) + const mid = cardHeader(r, T0 + 50_000, 50) + expect(mid.name.endsWith('…')).toBe(true) + expect(mid.result[0]!.t).toBe('✓ passed') + const narrow = cardHeader(r, T0 + 50_000, 30) + expect(narrow.result.map(s => s.t)).toEqual(['✓ passed']) + }) + + test('a suite’s card names three failures at most, newest first, then how many more', () => { + const s = fold(suite(['failed', 'failed', 'failed', 'failed', 'failed', 'passed']), { k: 'suiteDone', status: 'failed', at: T0 + 90_000 }) + const f = cardFailures(s) + expect(f.rows.length).toBe(3) + expect(f.more).toBe(2) + expect(cardFailures(suite(['passed'])).rows).toEqual([]) + }) + + test('only kane-cli test runs get a card', () => { + const cases: [string, boolean][] = [ + ['kane-cli run "Search for iPod" --agent', true], + ['cd app && kane-cli testmd run .testmuai/tests/login_test.md --agent', true], + ['kane-cli testrun run --tags smoke', true], + ['kane-cli cover gaps --json', false], + ['kane-cli whoami', false], + ['npm test', false], + ] + for (const [command, want] of cases) expect(isRunCommand(command)).toBe(want) + }) +}) + describe('history', () => { const cases: { name: string; run: Run; want: HistoryEntry | undefined; detail?: string }[] = [ { name: 'a run still going is not kept', run: run('a', 'run', [label('Search'), step(1, 'running', 'Open the page')]), want: undefined }, diff --git a/kane-qe/types/index.d.ts b/kane-qe/types/index.d.ts index 3529048..e9df046 100644 --- a/kane-qe/types/index.d.ts +++ b/kane-qe/types/index.d.ts @@ -44,6 +44,12 @@ export type Run = { labelRank?: number /** A suite sent to the remote grid: the grid reports its tests only when the job ends. */ remote?: Remote + /** The chat row of the shell call that started it, when Claude ran it: where its card is drawn. */ + toolUseId?: string + /** A suite's execution id, which names its evidence pack. */ + executionId?: string + /** Its sealed evidence pack on disk, once kane-cli has written it. */ + evidence?: string } export type Remote = { backend?: string; jobId?: string; jobUrl?: string; dispatchedAt?: number } From ec7be50bc7eb3d9998409346d7eb2fcb62b64d62 Mon Sep 17 00:00:00 2001 From: Zeeshan Date: Fri, 9 Oct 2026 23:47:57 +0530 Subject: [PATCH 04/11] feat(v2): the mascot's eyes step every 130 ms Co-Authored-By: Claude Opus 5.5 --- kane-qe/README.md | 2 +- kane-qe/hooks/mascot.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/kane-qe/README.md b/kane-qe/README.md index 5ada59d..4104b63 100644 --- a/kane-qe/README.md +++ b/kane-qe/README.md @@ -6,7 +6,7 @@ A Claude Code mod that shows what kane-cli (KaneAI) is doing, without being aske | Surface | What it shows | |---|---| -| **Band** (three rows above the prompt, the kane mascot on the left) | Row 1: `idle` with this session's passed/failed counts, `running` with the test's name and time, `N runs` with one coloured cell per run, or `suite` with one cell per test and `5 of 12 tests done · 3 running · 4 left`. A suite sent to the remote grid (`testrun run --remote`) reads `12 tests on the grid · sent 2m 05s ago` until the job ends, because the grid reports its tests only then. Row 2: the step a single run is on; `now` and each running test with its time; a failure the moment it happens (`✗ checkout_guest failed on Fill the shipping address`); `last run ✓ login · 2h ago` from earlier sessions; or `⚠ 2 files changed, untested [ Test this change ]`. Row 3: assurance, `47% proven · 4 use cases`, or `not set up [ Set up now ]`. The mascot's eyes move while a test runs (one step every 150 ms). | +| **Band** (three rows above the prompt, the kane mascot on the left) | Row 1: `idle` with this session's passed/failed counts, `running` with the test's name and time, `N runs` with one coloured cell per run, or `suite` with one cell per test and `5 of 12 tests done · 3 running · 4 left`. A suite sent to the remote grid (`testrun run --remote`) reads `12 tests on the grid · sent 2m 05s ago` until the job ends, because the grid reports its tests only then. Row 2: the step a single run is on; `now` and each running test with its time; a failure the moment it happens (`✗ checkout_guest failed on Fill the shipping address`); `last run ✓ login · 2h ago` from earlier sessions; or `⚠ 2 files changed, untested [ Test this change ]`. Row 3: assurance, `47% proven · 4 use cases`, or `not set up [ Set up now ]`. The mascot's eyes move while a test runs (one step every 130 ms). | | **Pane** (`/kane` or `[ Open ]`) | **Runs**: a card per run (failed first, red border), or one row per test for a suite. Press a name for its steps: done steps with a tick and time, the current or failing step expanded to *think*, *act* and each *check*, the failure's *why*, *kind* (kane's category, severity, confidence) and *where*, then *Open evidence*. For a remote suite the pane names the grid, the job and its link. **Assurance**: designed and proven bars, failing/blocked counts and when it last ran, a card per use case with what it still owes, or a card that offers to set it up with Claude. Press a use case for its detail: its title and risk, then each thing it owes under *To design* and *To run*, with kane-cli's reason and its own next command; *Close these gaps with Claude* hands that to Claude. **History**: the finished runs kept for this project, newest first, each with when it ran, how long it took, and where it failed or a suite's passed/failed counts. | | **Card in the chat** | A kane-cli test Claude runs (`run`, `testmd run`, `testrun run`) is drawn in the conversation as a card, in place of its shell row: one line while it runs (name, time, step), then a bordered card with the mascot, the result (`✓ passed · 41s · 12.4 credits`), a failure's *on*, *why* and *kind*, or a suite's cells, counts and up to three failures. **View steps** / **View tests** opens the pane on that run; **View evidence** opens the run's evidence pack in kane-cli's viewer in the browser, in one click. Where the chat is printed text (the terminal outside fullscreen) the card ends with the evidence path instead of buttons. A command that never became a run (kane-cli refused or failed to start) keeps its normal row, and so does every other command. The chat folds shell calls into one count line; a group that holds a run is unfolded so its card shows. | | **After Claude changes code** | When a turn ends with edited files (by the edit tools or a shell command; not docs, hidden tool folders or git-ignored files) and no kane-cli run since, the band warns and offers *Test this change*. The card lists the changed files and saved tests that mention the same feature, with their last result and *Run it*. Only when none does, Claude drafts an objective from the diff (also on *Draft a new objective*); *Run the objective* hands it to Claude with the app's start URL. A kane-cli run that starts after the last edit clears the warning. | diff --git a/kane-qe/hooks/mascot.ts b/kane-qe/hooks/mascot.ts index a11eef1..46c5570 100644 --- a/kane-qe/hooks/mascot.ts +++ b/kane-qe/hooks/mascot.ts @@ -8,7 +8,7 @@ export type Pose = 'c' | 'lm' | 'l' | 'rm' | 'r' | 'xh' | 'x' export type Frame = readonly string[] /** The length of one step of the loop, in ms. */ -export const STEP_MS = 150 +export const STEP_MS = 130 /** The loop follows the GIF: rest, glance left, glance right, rest, blink, rest. */ const LOOP: readonly Pose[] = ['c', 'c', 'c', 'c', 'lm', 'l', 'l', 'l', 'lm', 'rm', 'r', 'r', 'r', 'r', 'rm', 'c', 'c', 'c', 'x', 'c', 'c', 'c'] From ed4fc6025a943037766382783994126c0abc3ffe Mon Sep 17 00:00:00 2001 From: Zeeshan Date: Fri, 9 Oct 2026 23:49:03 +0530 Subject: [PATCH 05/11] feat(v2): the mascot's eyes step every 120 ms Co-Authored-By: Claude Opus 5.5 --- kane-qe/README.md | 2 +- kane-qe/hooks/mascot.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/kane-qe/README.md b/kane-qe/README.md index 4104b63..78e8b69 100644 --- a/kane-qe/README.md +++ b/kane-qe/README.md @@ -6,7 +6,7 @@ A Claude Code mod that shows what kane-cli (KaneAI) is doing, without being aske | Surface | What it shows | |---|---| -| **Band** (three rows above the prompt, the kane mascot on the left) | Row 1: `idle` with this session's passed/failed counts, `running` with the test's name and time, `N runs` with one coloured cell per run, or `suite` with one cell per test and `5 of 12 tests done · 3 running · 4 left`. A suite sent to the remote grid (`testrun run --remote`) reads `12 tests on the grid · sent 2m 05s ago` until the job ends, because the grid reports its tests only then. Row 2: the step a single run is on; `now` and each running test with its time; a failure the moment it happens (`✗ checkout_guest failed on Fill the shipping address`); `last run ✓ login · 2h ago` from earlier sessions; or `⚠ 2 files changed, untested [ Test this change ]`. Row 3: assurance, `47% proven · 4 use cases`, or `not set up [ Set up now ]`. The mascot's eyes move while a test runs (one step every 130 ms). | +| **Band** (three rows above the prompt, the kane mascot on the left) | Row 1: `idle` with this session's passed/failed counts, `running` with the test's name and time, `N runs` with one coloured cell per run, or `suite` with one cell per test and `5 of 12 tests done · 3 running · 4 left`. A suite sent to the remote grid (`testrun run --remote`) reads `12 tests on the grid · sent 2m 05s ago` until the job ends, because the grid reports its tests only then. Row 2: the step a single run is on; `now` and each running test with its time; a failure the moment it happens (`✗ checkout_guest failed on Fill the shipping address`); `last run ✓ login · 2h ago` from earlier sessions; or `⚠ 2 files changed, untested [ Test this change ]`. Row 3: assurance, `47% proven · 4 use cases`, or `not set up [ Set up now ]`. The mascot's eyes move while a test runs (one step every 120 ms). | | **Pane** (`/kane` or `[ Open ]`) | **Runs**: a card per run (failed first, red border), or one row per test for a suite. Press a name for its steps: done steps with a tick and time, the current or failing step expanded to *think*, *act* and each *check*, the failure's *why*, *kind* (kane's category, severity, confidence) and *where*, then *Open evidence*. For a remote suite the pane names the grid, the job and its link. **Assurance**: designed and proven bars, failing/blocked counts and when it last ran, a card per use case with what it still owes, or a card that offers to set it up with Claude. Press a use case for its detail: its title and risk, then each thing it owes under *To design* and *To run*, with kane-cli's reason and its own next command; *Close these gaps with Claude* hands that to Claude. **History**: the finished runs kept for this project, newest first, each with when it ran, how long it took, and where it failed or a suite's passed/failed counts. | | **Card in the chat** | A kane-cli test Claude runs (`run`, `testmd run`, `testrun run`) is drawn in the conversation as a card, in place of its shell row: one line while it runs (name, time, step), then a bordered card with the mascot, the result (`✓ passed · 41s · 12.4 credits`), a failure's *on*, *why* and *kind*, or a suite's cells, counts and up to three failures. **View steps** / **View tests** opens the pane on that run; **View evidence** opens the run's evidence pack in kane-cli's viewer in the browser, in one click. Where the chat is printed text (the terminal outside fullscreen) the card ends with the evidence path instead of buttons. A command that never became a run (kane-cli refused or failed to start) keeps its normal row, and so does every other command. The chat folds shell calls into one count line; a group that holds a run is unfolded so its card shows. | | **After Claude changes code** | When a turn ends with edited files (by the edit tools or a shell command; not docs, hidden tool folders or git-ignored files) and no kane-cli run since, the band warns and offers *Test this change*. The card lists the changed files and saved tests that mention the same feature, with their last result and *Run it*. Only when none does, Claude drafts an objective from the diff (also on *Draft a new objective*); *Run the objective* hands it to Claude with the app's start URL. A kane-cli run that starts after the last edit clears the warning. | diff --git a/kane-qe/hooks/mascot.ts b/kane-qe/hooks/mascot.ts index 46c5570..8d53d23 100644 --- a/kane-qe/hooks/mascot.ts +++ b/kane-qe/hooks/mascot.ts @@ -8,7 +8,7 @@ export type Pose = 'c' | 'lm' | 'l' | 'rm' | 'r' | 'xh' | 'x' export type Frame = readonly string[] /** The length of one step of the loop, in ms. */ -export const STEP_MS = 130 +export const STEP_MS = 120 /** The loop follows the GIF: rest, glance left, glance right, rest, blink, rest. */ const LOOP: readonly Pose[] = ['c', 'c', 'c', 'c', 'lm', 'l', 'l', 'l', 'lm', 'rm', 'r', 'r', 'r', 'r', 'rm', 'c', 'c', 'c', 'x', 'c', 'c', 'c'] From 8def15a46c7315c5fe6209b74c8ec36d7d307916 Mon Sep 17 00:00:00 2001 From: Zeeshan Date: Sat, 10 Oct 2026 00:26:41 +0530 Subject: [PATCH 06/11] feat(v2): use-case cards show their title; a shared reason is said once Found in a live session against a real requirement store: the list showed only uc-1, and the detail repeated one reason and one command per row. Co-Authored-By: Claude Opus 5.5 --- kane-qe/hooks/register.tsx | 19 +++++++++++++++---- kane-qe/tests/engine.test.tsx | 21 +++++++++++++++++++++ 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/kane-qe/hooks/register.tsx b/kane-qe/hooks/register.tsx index ba296aa..1066753 100644 --- a/kane-qe/hooks/register.tsx +++ b/kane-qe/hooks/register.tsx @@ -339,6 +339,8 @@ function useCaseView(ui: Kit, press: Press, u: UseCase) { const group = (stage: 'design' | 'cover', title: string) => { const rows = u.pending.filter(p => p.stage === stage) if (!rows.length) return null + // Rows that share one reason and one next command say them once, under the list. + const shared = rows.length > 1 && rows.every(p => p.why === rows[0]!.why && p.command === rows[0]!.command) return ( {title} @@ -350,12 +352,16 @@ function useCaseView(ui: Kit, press: Press, u: UseCase) { {p.title} - - {p.why ? fact(ui, 'why', p.why) : null} - {p.command ? fact(ui, 'next', p.command, C.purple) : null} - + {shared ? null : ( + + {p.why ? fact(ui, 'why', p.why) : null} + {p.command ? fact(ui, 'next', p.command, C.purple) : null} + + )} ))} + {shared && rows[0]!.why ? fact(ui, 'why', rows[0]!.why) : null} + {shared && rows[0]!.command ? fact(ui, 'next', rows[0]!.command, C.purple) : null} ) @@ -457,6 +463,11 @@ function assureView(ui: Kit, press: Press, a: Assurance, now: number, open?: str press(`uc:${u.id}`)} /> + {u.title ? ( + + {u.title} + + ) : null} {debt(u).map(s => text(ui, s))} diff --git a/kane-qe/tests/engine.test.tsx b/kane-qe/tests/engine.test.tsx index 210dee8..d5179d3 100644 --- a/kane-qe/tests/engine.test.tsx +++ b/kane-qe/tests/engine.test.tsx @@ -520,6 +520,8 @@ describe('assurance', () => { for (const surface of ['terminal', 'desktop'] as const) { const pane = await $.ui.mount({ ...PANE, surface } as never) await pane.press({ key: 'tab-assure' }) + // The list names each use case, not only its id. + expect(await pane.find({ type: 'Text', text: /^Buy as a guest$/ })).toBeDefined() await pane.press({ key: 'uc-uc-buy-as-a-guest' }) expect(await pane.find({ type: 'Text', text: /^Buy as a guest$/ })).toBeDefined() expect(await pane.find({ type: 'Text', text: /^high risk$/ })).toBeDefined() @@ -550,6 +552,25 @@ describe('assurance', () => { }) }) +describe('a use case whose gaps share one reason', () => { + test('says the reason and the next command once, under the list', async ($, on) => { + const row = (title: string) => ({ stage: 'cover', title, why: 'a covering test exists but has not run yet', ready_command: 'kane-cli testrun run' }) + const w = world(on, { + cover: JSON.stringify({ design_completeness: { pct: 100 }, proven: { pct: 50 }, usecases: [{ id: 'uc-1', title: 'Buy as a guest', risk: 'high', design_completeness: { pct: 100 }, proven: { pct: 50 }, stale_acs: 0, pending: [row('A placed order displays an order number.'), row('A placed order empties the cart.')] }] }), + }) + w.files[`${PROJECT}/.context/commits/1`] = 'x' + await $.session.start({ cwd: PROJECT, surface: 'terminal', isInteractive: true } as never) + await w.clock.settle() + const pane = await $.ui.mount({ ...PANE, surface: 'terminal' } as never) + await pane.press({ key: 'tab-assure' }) + await pane.press({ key: 'uc-uc-1' }) + expect(await pane.find({ type: 'Text', text: /A placed order empties the cart/ })).toBeDefined() + expect((await pane.findAll({ type: 'Text', text: /^a covering test exists but has not run yet$/ })).length).toBe(1) + expect((await pane.findAll({ type: 'Text', text: /^kane-cli testrun run$/ })).length).toBe(1) + await pane.unmount() + }) +}) + describe('the card in the chat', () => { const CMD = 'kane-cli testmd run .testmuai/tests/checkout_guest_test.md --agent --headless' const row = (component: 'ToolUse' | 'ToolResult', id: string, command: string, more: object = {}) => ({ From 0c6dc8d72c28dcf57f075af60f7fa41aa552faa0 Mon Sep 17 00:00:00 2001 From: Zeeshan Date: Sat, 10 Oct 2026 00:40:14 +0530 Subject: [PATCH 07/11] fix(v2): what a first live take showed - Saved tests that match a change equally: the one that last failed comes first, then the one run most recently, then by name (it was the order the folder listed them in). - The offer card cuts a long test name to its width instead of wrapping it under the button. - A run nobody started in this chat is named from its own process's command line, so a test file started in a terminal shows its file name. - The chat card keeps the failing step to one line: a designed test's step can run to a paragraph. Co-Authored-By: Claude Opus 5.5 --- kane-qe/hooks/model.ts | 17 ++++++++++++++--- kane-qe/hooks/register.tsx | 32 +++++++++++++++++++++----------- kane-qe/tests/engine.test.tsx | 20 ++++++++++++++++++-- kane-qe/tests/model.test.ts | 11 +++++++++++ 4 files changed, 64 insertions(+), 16 deletions(-) diff --git a/kane-qe/hooks/model.ts b/kane-qe/hooks/model.ts index 4953716..23481dd 100644 --- a/kane-qe/hooks/model.ts +++ b/kane-qe/hooks/model.ts @@ -548,6 +548,9 @@ export function cardHeader(r: Run, now: number, room: number): { name: string; r return { name: cut(r.label, Math.max(min, room - width(result) - 8)), result } } +/** The first line of a step's text: a designed test's step can run to a paragraph. */ +export const firstLine = (t: string): string => t.split('\n')[0]!.trim() + /** A suite card's failures: three at most, then how many more. */ export function cardFailures(r: Run): { rows: Run[]; more: number } { const failed = (r.members ?? []).filter(m => m.status === 'failed').sort((a, b) => (b.endedAt ?? 0) - (a.endedAt ?? 0)) @@ -690,8 +693,9 @@ export function featureWords(path: string): string[] { return [...new Set(words)] } -/** Saved tests that mention a changed file's feature, best match first. */ -export function savedTestsFor(changed: readonly string[], saved: readonly { name: string; text: string }[]): string[] { +/** Saved tests that mention a changed file's feature, best match first. Equal matches: one that last failed, then the + * one run most recently, then by name. */ +export function savedTestsFor(changed: readonly string[], saved: readonly { name: string; text: string }[], last: Readonly>> = {}): string[] { const words = [...new Set(changed.flatMap(featureWords))] if (!words.length) return [] return saved @@ -703,7 +707,14 @@ export function savedTestsFor(changed: readonly string[], saved: readonly { name return { name: t.name, hits } }) .filter(t => t.hits > 0) - .sort((a, b) => b.hits - a.hits) + .sort((a, b) => { + if (b.hits !== a.hits) return b.hits - a.hits + const [x, y] = [last[a.name], last[b.name]] + const failed = Number(y?.status === 'failed') - Number(x?.status === 'failed') + if (failed) return failed + if ((y?.at ?? 0) !== (x?.at ?? 0)) return (y?.at ?? 0) - (x?.at ?? 0) + return a.name < b.name ? -1 : a.name > b.name ? 1 : 0 + }) .map(t => t.name) } diff --git a/kane-qe/hooks/register.tsx b/kane-qe/hooks/register.tsx index 1066753..bdb6432 100644 --- a/kane-qe/hooks/register.tsx +++ b/kane-qe/hooks/register.tsx @@ -22,6 +22,7 @@ import { debt, draftPrompt, elapsed, + firstLine, fmt, fold, foldMember, @@ -283,7 +284,7 @@ function runDetail(ui: Kit, press: Press, r: Run, now: number, width: number) { ) } -function offerView(ui: Kit, press: Press, o: Offer | null, change: Change | null, now: number) { +function offerView(ui: Kit, press: Press, o: Offer | null, change: Change | null, now: number, width: number) { const files = o?.files ?? change?.files ?? [] const saved = o?.saved ?? [] const hasDraft = !!(o && (o.drafting || o.objective || o.note)) @@ -313,7 +314,10 @@ function offerView(ui: Kit, press: Press, o: Offer | null, change: Change | null return ( - {last ? `${t} · last ${last.status === 'passed' ? '✓' : '✗'} ${ago(now - last.at)}` : t} + {(() => { + const tail = last ? ` · last ${last.status === 'passed' ? '✓' : '✗'} ${ago(now - last.at)}` : '' + return `${cut(t.replace(/_test\.md$/, ''), Math.max(12, width - tail.length - 12))}${tail}` + })()} press(`saved:${t}`)} /> @@ -541,7 +545,7 @@ function chatCard(table: Table, surface: RenderSurface, press: Press, r: Run, no ))} {failures && failures.more ? {`+${failures.more} more`} : null} - {!isSuite && r.status === 'failed' && r.failure?.where ? fact(ui, 'on', r.failure.where) : null} + {!isSuite && r.status === 'failed' && r.failure?.where ? fact(ui, 'on', cut(firstLine(r.failure.where), Math.max(20, inner * 2 - 8))) : null} {!isSuite && r.status === 'failed' && r.failure?.why ? fact(ui, 'why', r.failure.why) : null} {!isSuite && r.status === 'failed' && kind ? fact(ui, 'kind', kind) : null} {!isSuite && r.status === 'passed' && steps ? {`${steps} step${steps === 1 ? '' : 's'}`} : null} @@ -663,7 +667,7 @@ async function poll($: EngineInterface) { seenPointers.add(e.name) const p = parsePointer((await $.fs.read(`${activeDir}/${e.name}`).catch(() => '')) ?? '', now) if (!p || !inside(p.cwd, projectDir) || !(await alive($, p.pid))) continue - runs = startWatch(runs, p, e.name) + runs = startWatch(runs, p, e.name, await processLabel($, p.pid)) changed = true } // Members first, so a suite's end reads its members' last lines; a member a suite names now is read now too. @@ -768,9 +772,15 @@ async function openEvidence($: EngineInterface, id: string) { void $.prompt.submit({ text: `Open the evidence for the kane-cli run in ${pack ?? r?.sessionDir ?? id}.` }).catch(() => undefined) } -function startWatch(runs: Run[], p: Pointer, pointer: string): Run[] { +/** A run nobody started in this chat is named from its own process: the command line kane-cli was started with. */ +async function processLabel($: EngineInterface, pid: number): Promise { + const r = await $.process.run(['ps', '-o', 'args=', '-p', String(pid)], { timeoutMs: 3000 }).catch(() => undefined) + return r?.exitCode === 0 ? labelFromCommand(r.stdout)?.label : undefined +} + +function startWatch(runs: Run[], p: Pointer, pointer: string, fromProcess?: string): Run[] { const id = p.sessionDir - const claimed = claimCommand(p.surface, p.started) + const claimed = claimCommand(p.surface, p.started) ?? (fromProcess ? { label: fromProcess } : undefined) const run = newRun(id, p.surface, p.sessionDir, p.started, { pid: p.pid, cwd: p.cwd, ...(claimed ? { label: claimed.label, labelRank: RANK.command } : {}), ...(claimed?.toolUseId ? { toolUseId: claimed.toolUseId } : {}) }) // A reload follows the file again from its start, replacing what the last module folded. watches.set(id, { id, path: `${p.sessionDir}/events.ndjson`, pointer, pid: p.pid, bytes: 0, rest: '', done: false }) @@ -874,13 +884,13 @@ async function openOffer($: EngineInterface) { await update($, viewAtom, v => ({ ...v, tab: 'runs' as const, open: 'offer' })) await openPane($) const tests = await savedTests($) - const saved = savedTestsFor(files, tests) const history = (await recent($))[projectDir] ?? [] const last: Record = {} - for (const name of saved) { - const hit = history.find(h => h.label === name || h.label.endsWith(`/${name}`)) - if (hit) last[name] = hit + for (const t of tests) { + const hit = history.find(h => h.label === t.name || h.label.endsWith(`/${t.name}`)) + if (hit) last[t.name] = hit } + const saved = savedTestsFor(files, tests, last) await update($, offerAtom, () => ({ files, drafting: false, saved, url: startUrl(tests), last })) // A saved test that covers the change comes first; Claude drafts a new objective only when none does. if (saved.length === 0) await draftObjective($) @@ -1156,7 +1166,7 @@ export const register: Register = (on, options) => { let body if (v.tab === 'assure') body = assureView(ui, press, await read($, assuranceAtom), now, v.uc) else if (v.tab === 'history') body = historyView(ui, await read($, historyAtom), now, width) - else if (v.open === 'offer') body = offerView(ui, press, await read($, offerAtom), await read($, changeAtom), now) + else if (v.open === 'offer') body = offerView(ui, press, await read($, offerAtom), await read($, changeAtom), now, width) else if (opened?.kind === 'testrun' && opened.members) body = suiteView(ui, press, opened, now, width, true) else if (opened) body = runDetail(ui, press, opened, now, width) else body = runsView(ui, press, runs, now, width, await read($, lastAtom)) diff --git a/kane-qe/tests/engine.test.tsx b/kane-qe/tests/engine.test.tsx index d5179d3..8829b4d 100644 --- a/kane-qe/tests/engine.test.tsx +++ b/kane-qe/tests/engine.test.tsx @@ -23,7 +23,7 @@ const PANE = { props: { title: 'Kane', isFocused: false, bodyColumns: 56, placement: 'dock', scroll: { offset: 0, bodyRows: 40 }, view: {} }, } as const -type World = { files: Record; alive: Set; prompts: string[]; ran: string[][]; rows: { component: string; isExpanded?: boolean }[]; viewer?: string; cover?: string; forkText?: string; ignored: Set; bashResult?: unknown; store?: Record } +type World = { files: Record; alive: Set; prompts: string[]; ran: string[][]; rows: { component: string; isExpanded?: boolean }[]; viewer?: string; cover?: string; forkText?: string; ignored: Set; bashResult?: unknown; store?: Record; ps?: Record } /** The engine beneath the plugin: a fake disk, processes, and the calls the mod makes. */ function world(on: On, w: Partial = {}): World & { clock: ReturnType } { @@ -52,6 +52,7 @@ function world(on: On, w: Partial = {}): World & { clock: ReturnType { await pane.unmount() }) + test('a test file started outside the chat is named from its own process', async ($, on) => { + const w = world(on, { ps: { 91: '/opt/kane/bin/node /opt/kane/kane-cli/dist/index.js testmd run .testmuai/tests/empty_cart_test.md --agent --headless' } }) + await $.session.start({ cwd: PROJECT, surface: 'terminal', isInteractive: true } as never) + startRun(w, 91, `${S}/o1`, 'testmd', line({ type: 'stream_start', surface: 'testmd', pid: 91 }) + line({ type: 'test_md_step_start', step_index: 1, heading: 'Step 1' }, 1) + line({ type: 'run_start', objective: 'At the shop, open the cart page and assert it is empty' }, 1)) + startRun(w, 92, `${S}/o2`, 'testmd', line({ type: 'stream_start', surface: 'testmd', pid: 92 }) + line({ type: 'run_start', objective: 'Sign in as the test user' }, 1)) + await w.clock.advance(1500) + await w.clock.settle() + const pane = await $.ui.mount({ ...PANE, surface: 'terminal' } as never) + expect(await pane.find({ key: `run-${S}/o1`, type: 'Button' })).toBeDefined() + expect((await pane.find({ key: `run-${S}/o1` }))?.props.label).toBe('empty_cart_test.md') + // With no process to read, the objective names it, as before. + expect((await pane.find({ key: `run-${S}/o2` }))?.props.label).toBe('Sign in as the test user') + await pane.unmount() + }) + test('a stale pointer and another project’s run are not shown', async ($, on) => { const w = world(on) await $.session.start({ cwd: PROJECT, surface: 'terminal', isInteractive: true } as never) @@ -391,7 +407,7 @@ describe('after Claude changes code', () => { await w.clock.settle() const pane = await $.ui.mount({ ...PANE, surface: 'terminal' } as never) // A saved test covers it: offered first, no draft until asked. - expect(await pane.find({ type: 'Text', text: /checkout_guest_test.md/ })).toBeDefined() + expect(await pane.find({ type: 'Text', text: /^checkout_guest$/ })).toBeDefined() expect(await pane.find({ key: 'saved-run-checkout_guest_test.md' })).toBeDefined() expect(await pane.find({ type: 'Text', text: /Suggested objective/ })).toBeUndefined() await pane.press({ key: 'offer-draft' }) diff --git a/kane-qe/tests/model.test.ts b/kane-qe/tests/model.test.ts index 82f72e6..1c412a3 100644 --- a/kane-qe/tests/model.test.ts +++ b/kane-qe/tests/model.test.ts @@ -380,6 +380,17 @@ describe('labels, edits and drafts', () => { expect(savedTestsFor(['/p/src/search/searchQuery.ts'], both)).toEqual(['search_ipod_test.md', 'cart_add_test.md']) }) + test('equal matches: the test that last failed comes first, then the one run most recently, then by name', () => { + const saved = ['guest-checkout-places-order_test.md', 'guest-checkout-refuses-email_test.md', 'checkout_guest_test.md', 'checkout_express_test.md'].map(name => ({ name, text: 'Checkout as guest.' })) + const changed = ['src/checkout/validate.js'] + expect(savedTestsFor(changed, saved)).toEqual(['checkout_express_test.md', 'checkout_guest_test.md', 'guest-checkout-places-order_test.md', 'guest-checkout-refuses-email_test.md']) + const last = { 'checkout_guest_test.md': { status: 'failed' as const, at: T0 }, 'guest-checkout-refuses-email_test.md': { status: 'passed' as const, at: T0 + 5000 } } + expect(savedTestsFor(changed, saved, last)).toEqual(['checkout_guest_test.md', 'guest-checkout-refuses-email_test.md', 'checkout_express_test.md', 'guest-checkout-places-order_test.md']) + // A better match still outranks a failure. + const better = [...saved, { name: 'validate_postcode_test.md', text: 'checkout validate' }] + expect(savedTestsFor(changed, better, last)[0]).toBe('validate_postcode_test.md') + }) + test('the drafted objective is the reply, without labels or quotes', () => { expect(objectiveFrom('Objective: "Type a postcode with a space and check the order is placed."')).toBe('Type a postcode with a space and check the order is placed.') // Real reply: the fork added a remark about the conversation after the objective. From a0f4d9407112b61d3c59ac71dd7be7e85964ea2a Mon Sep 17 00:00:00 2001 From: Zeeshan Date: Sat, 10 Oct 2026 00:52:59 +0530 Subject: [PATCH 08/11] fix(v2): the idle band counts each test by its latest result A test that failed and then passed counted as one of each, and its old failure stayed on row 2. Found in a live take: after Claude fixed the bug and the test passed, the band still named the failure. Co-Authored-By: Claude Opus 5.5 --- README.md | 4 ++-- docs/index.html | 2 +- guide/kane-qe-guide.html | 2 +- guide/v2/design-coverage.md | 6 +++--- kane-qe/README.md | 6 +++--- kane-qe/hooks/model.ts | 14 ++++++++++++-- kane-qe/hooks/register.tsx | 4 +++- kane-qe/tests/model.test.ts | 23 +++++++++++++++++++++++ 8 files changed, 48 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 0d88893..6d46d6e 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ Nothing to start. Open Claude Code in a project and the band is there. `/config` → kane-qe: **After Claude changes code** (`offer` by default, `auto`, `off`) and **kane-cli command** (default `kane-cli`, used only for the read-only `cover gaps`). Colours follow Claude Code's theme. -What it never does: it never starts a kane-cli test, and never shows what kane-cli did not report. The commands it runs are `kane-cli cover gaps --json`, `kill -0`, `git check-ignore` and `git diff`, and, only when you press *View evidence*, `kane-cli evidence serve ` and `open`. It reads kane-cli's structured output only, with one exception: the viewer link that `evidence serve` prints. +What it never does: it never starts a kane-cli test, and never shows what kane-cli did not report. The commands it runs are `kane-cli cover gaps --json`, `kill -0`, `ps`, `git check-ignore` and `git diff`, and, only when you press *View evidence*, `kane-cli evidence serve ` and `open`. It reads kane-cli's structured output only, with one exception: the viewer link that `evidence serve` prints. ## How to publish it on Claude @@ -76,7 +76,7 @@ What it never does: it never starts a kane-cli test, and never shows what kane-c ``` claude plugin validate kane-qe --strict # the plugin: manifest, hooks, state contract claude plugin validate . --strict # the marketplace manifest -claude plugin test kane-qe # 116 tests +claude plugin test kane-qe # 122 tests ``` CI (`.github/workflows/ci.yml`) runs the same on every push to `main` and on pull requests. diff --git a/docs/index.html b/docs/index.html index bb0c604..6d04e96 100644 --- a/docs/index.html +++ b/docs/index.html @@ -334,7 +334,7 @@

Settings

Design coverage

Built to the design, and checked against it

-

v2 was built from the kane-cli mod design: 17 features, 13 screens and a build spec. Every feature and screen was compared in a real terminal with the design's screenshots, and 116 tests cover the rest. The full list, with how each item was checked, is in design-coverage.md.

+

v2 was built from the kane-cli mod design: 17 features, 13 screens and a build spec. Every feature and screen was compared in a real terminal with the design's screenshots, and 122 tests cover the rest. The full list, with how each item was checked, is in design-coverage.md.

Two deliberate differences

The design lists a test file's coming steps; kane-cli reports no step total, and the spec says never to show one. The design's cards say “Chrome 154”; kane-cli reports no browser name for desktop runs.

Not checked yet

The desktop app: the band and pane are tested on its surface, with the mascot as an image, but were not opened in the app itself.

diff --git a/guide/kane-qe-guide.html b/guide/kane-qe-guide.html index a6de5da..7bce8ec 100644 --- a/guide/kane-qe-guide.html +++ b/guide/kane-qe-guide.html @@ -330,7 +330,7 @@

Settings

Design coverage

Built to the design, and checked against it

-

v2 was built from the kane-cli mod design: 17 features, 13 screens and a build spec. Every feature and screen was compared in a real terminal with the design's screenshots, and 116 tests cover the rest. The full list, with how each item was checked, is in design-coverage.md.

+

v2 was built from the kane-cli mod design: 17 features, 13 screens and a build spec. Every feature and screen was compared in a real terminal with the design's screenshots, and 122 tests cover the rest. The full list, with how each item was checked, is in design-coverage.md.

Two deliberate differences

The design lists a test file's coming steps; kane-cli reports no step total, and the spec says never to show one. The design's cards say “Chrome 154”; kane-cli reports no browser name for desktop runs.

Not checked yet

The desktop app: the band and pane are tested on its surface, with the mascot as an image, but were not opened in the app itself.

diff --git a/guide/v2/design-coverage.md b/guide/v2/design-coverage.md index 039771d..8e55e57 100644 --- a/guide/v2/design-coverage.md +++ b/guide/v2/design-coverage.md @@ -5,7 +5,7 @@ The design ([kane-cli mod: terminal build guide](https://claude.ai/artifact/5Dkz How each item was checked: - **live**: in a real Claude Code session in a terminal, with a real kane-cli run; - **staged**: a real Claude Code session, with kane-cli runs written to disk in kane-cli 0.8.20's own format, by `sleep` processes standing in for kane-cli; -- **test**: `claude plugin test kane-qe` (116 tests). +- **test**: `claude plugin test kane-qe` (122 tests). ## Features (design part 1, "What to build") @@ -41,11 +41,11 @@ Two differences, both deliberate: | Rule | v2 | |---|---| -| The mod watches; it never starts a test | It runs `kane-cli cover gaps --json`, `kill -0`, `git check-ignore` and `git diff`, and on *View evidence* `kane-cli evidence serve` and `open`; tests are run by Claude, through prompts the person's buttons send | +| The mod watches; it never starts a test | It runs `kane-cli cover gaps --json`, `kill -0`, `ps`, `git check-ignore` and `git diff`, and on *View evidence* `kane-cli evidence serve` and `open`; tests are run by Claude, through prompts the person's buttons send | | Structured data only | Pointer JSON and `events.ndjson` lines; kane-cli's human output is never read | | No guesses on screen | What kane-cli does not report is left out (step totals, credits while running, balance, browser) | | Ignore what you do not know | `adapter.ts` dispatches on known types and skips the rest | -| Every behaviour has a test; README changes with it | 116 tests; both READMEs describe v2 | +| Every behaviour has a test; README changes with it | 122 tests; both READMEs describe v2 | | Code shape: sources → adapter → model → views | `sources.ts`, `adapter.ts` (only place raw field names appear), `model.ts` (pure), `register.tsx` | | Faults 1–5 in the prototype | All fixed: wrapped text keeps its column, waiting markers in the dim colour, card borders in the dim colour, cuts sized from the pane width, no background on band or pane | diff --git a/kane-qe/README.md b/kane-qe/README.md index 78e8b69..d65e974 100644 --- a/kane-qe/README.md +++ b/kane-qe/README.md @@ -6,10 +6,10 @@ A Claude Code mod that shows what kane-cli (KaneAI) is doing, without being aske | Surface | What it shows | |---|---| -| **Band** (three rows above the prompt, the kane mascot on the left) | Row 1: `idle` with this session's passed/failed counts, `running` with the test's name and time, `N runs` with one coloured cell per run, or `suite` with one cell per test and `5 of 12 tests done · 3 running · 4 left`. A suite sent to the remote grid (`testrun run --remote`) reads `12 tests on the grid · sent 2m 05s ago` until the job ends, because the grid reports its tests only then. Row 2: the step a single run is on; `now` and each running test with its time; a failure the moment it happens (`✗ checkout_guest failed on Fill the shipping address`); `last run ✓ login · 2h ago` from earlier sessions; or `⚠ 2 files changed, untested [ Test this change ]`. Row 3: assurance, `47% proven · 4 use cases`, or `not set up [ Set up now ]`. The mascot's eyes move while a test runs (one step every 120 ms). | +| **Band** (three rows above the prompt, the kane mascot on the left) | Row 1: `idle` with this session's passed/failed counts (each test once, by its latest result, so a failure that has since passed is gone), `running` with the test's name and time, `N runs` with one coloured cell per run, or `suite` with one cell per test and `5 of 12 tests done · 3 running · 4 left`. A suite sent to the remote grid (`testrun run --remote`) reads `12 tests on the grid · sent 2m 05s ago` until the job ends, because the grid reports its tests only then. Row 2: the step a single run is on; `now` and each running test with its time; a failure the moment it happens (`✗ checkout_guest failed on Fill the shipping address`); `last run ✓ login · 2h ago` from earlier sessions; or `⚠ 2 files changed, untested [ Test this change ]`. Row 3: assurance, `47% proven · 4 use cases`, or `not set up [ Set up now ]`. The mascot's eyes move while a test runs (one step every 120 ms). | | **Pane** (`/kane` or `[ Open ]`) | **Runs**: a card per run (failed first, red border), or one row per test for a suite. Press a name for its steps: done steps with a tick and time, the current or failing step expanded to *think*, *act* and each *check*, the failure's *why*, *kind* (kane's category, severity, confidence) and *where*, then *Open evidence*. For a remote suite the pane names the grid, the job and its link. **Assurance**: designed and proven bars, failing/blocked counts and when it last ran, a card per use case with what it still owes, or a card that offers to set it up with Claude. Press a use case for its detail: its title and risk, then each thing it owes under *To design* and *To run*, with kane-cli's reason and its own next command; *Close these gaps with Claude* hands that to Claude. **History**: the finished runs kept for this project, newest first, each with when it ran, how long it took, and where it failed or a suite's passed/failed counts. | | **Card in the chat** | A kane-cli test Claude runs (`run`, `testmd run`, `testrun run`) is drawn in the conversation as a card, in place of its shell row: one line while it runs (name, time, step), then a bordered card with the mascot, the result (`✓ passed · 41s · 12.4 credits`), a failure's *on*, *why* and *kind*, or a suite's cells, counts and up to three failures. **View steps** / **View tests** opens the pane on that run; **View evidence** opens the run's evidence pack in kane-cli's viewer in the browser, in one click. Where the chat is printed text (the terminal outside fullscreen) the card ends with the evidence path instead of buttons. A command that never became a run (kane-cli refused or failed to start) keeps its normal row, and so does every other command. The chat folds shell calls into one count line; a group that holds a run is unfolded so its card shows. | -| **After Claude changes code** | When a turn ends with edited files (by the edit tools or a shell command; not docs, hidden tool folders or git-ignored files) and no kane-cli run since, the band warns and offers *Test this change*. The card lists the changed files and saved tests that mention the same feature, with their last result and *Run it*. Only when none does, Claude drafts an objective from the diff (also on *Draft a new objective*); *Run the objective* hands it to Claude with the app's start URL. A kane-cli run that starts after the last edit clears the warning. | +| **After Claude changes code** | When a turn ends with edited files (by the edit tools or a shell command; not docs, hidden tool folders or git-ignored files) and no kane-cli run since, the band warns and offers *Test this change*. The card lists the changed files and saved tests that mention the same feature, with their last result and *Run it*; equal matches put the test that last failed first, then the one run most recently. Only when none does, Claude drafts an objective from the diff (also on *Draft a new objective*); *Run the objective* hands it to Claude with the app's start URL. A kane-cli run that starts after the last edit clears the warning. | ## Settings @@ -26,7 +26,7 @@ A Claude Code mod that shows what kane-cli (KaneAI) is doing, without being aske - Live runs: `~/.testmuai/kaneai/sessions/active/.json`, once a second; only runs whose `cwd` is inside this project, and only while the pid is alive. - Each run's `events.ndjson` (and each suite member's own stream, read once more when it ends), from where it last stopped. Structured lines only, never kane-cli's human output. Unknown events and fields are skipped; lines that are not JSON are skipped and counted; a stream with a newer wire version shows its name and time and says so. -- Names come from the command Claude ran (`kane-cli testmd run login_test.md` → `login_test.md`), then kane-cli's own `recording_state`, then the objective. +- Names come from the command Claude ran (`kane-cli testmd run login_test.md` → `login_test.md`); for a run nobody started in this chat, from its own process's command line (`ps -o args= -p `); then kane-cli's own `recording_state`, then the objective. - Failure facts come from `run_end.verdict` (root cause, category, severity, confidence), never guessed. - A run's card is its shell call's row: the mod notes the call's id when Claude runs a kane-cli test and keeps it on the run that starts. - Evidence: kane-cli seals a run's pack as it exits, at `/evidence/.evidence`; a suite's is `/.testmuai/evidence/.evidence`. The mod looks for it for half a minute after the run ends. *View evidence* starts `kane-cli evidence serve `, reads the viewer link it prints and opens it (`open`, else `xdg-open`); the viewer serves until the session ends, and a second press reuses it. With no pack or no viewer, Claude is asked to open it instead. diff --git a/kane-qe/hooks/model.ts b/kane-qe/hooks/model.ts index 23481dd..ce79606 100644 --- a/kane-qe/hooks/model.ts +++ b/kane-qe/hooks/model.ts @@ -257,6 +257,16 @@ export function tally(list: readonly Run[]): Tally { return out } +/** Each test by its latest result: a test that failed and then passed counts once, as passed. */ +export function latest(list: readonly Run[]): Run[] { + const newest = new Map() + for (const r of list) { + const seen = newest.get(r.label) + if (!seen || (r.endedAt ?? r.startedAt) >= (seen.endedAt ?? seen.startedAt)) newest.set(r.label, r) + } + return list.filter(r => newest.get(r.label) === r) +} + /** Seconds a run has taken, or has been going. */ export const elapsed = (r: Run, now: number): number => ((r.endedAt ?? now) - r.startedAt) / 1000 @@ -470,8 +480,8 @@ function pickBand(runs: readonly Run[], o: BandOptions): Band { } } - // idle: counts cover runs seen in this session - const all = leaves(runs) + // idle: counts cover the tests seen in this session, each by its latest result + const all = latest(leaves(runs)) const c = tally(all) const first: Item[] = [chip('idle', false)] if (c.passed) first.push({ t: `✓ ${c.passed} passed`, c: C.mint }) diff --git a/kane-qe/hooks/register.tsx b/kane-qe/hooks/register.tsx index bdb6432..d9d637b 100644 --- a/kane-qe/hooks/register.tsx +++ b/kane-qe/hooks/register.tsx @@ -33,6 +33,7 @@ import { isRunCommand, isTrackedEdit, kindLine, + latest, labelFromCommand, leaves, newRun, @@ -1155,7 +1156,8 @@ export const register: Register = (on, options) => { const runs = await read($, runsAtom) const now = await $.clock.now() const all = leaves(runs) - const c = tally(all) + // The header counts each test once, by its latest result, as the band does; the list below keeps every run. + const c = tally(all.some(r => r.status === 'running' || r.status === 'pending') ? all : latest(all)) const width = Math.max(30, e.props.bodyColumns - 2) const table = $.ui.resolve(e) const ui: Kit = table diff --git a/kane-qe/tests/model.test.ts b/kane-qe/tests/model.test.ts index 1c412a3..68aab98 100644 --- a/kane-qe/tests/model.test.ts +++ b/kane-qe/tests/model.test.ts @@ -4,6 +4,7 @@ import { adapt, splitLines } from '../hooks/adapter' import type { Ev } from '../hooks/adapter' import { band, + latest, cardFailures, cardHeader, cardResult, @@ -263,6 +264,28 @@ describe('the card in the chat', () => { }) }) +describe('a test run more than once', () => { + const failedThen = (later: Ev[]) => [checkoutFailed(), run('co2', 'run', [label('checkout_guest_test.md'), step(1, 'done', 'Open the cart', 5), ...later], T0 - 300_000)] + + test('counts once, by its latest result: a failure it has since passed is gone from the band', () => { + const r = rows(failedThen([passed(40)]), { now: T0 + 60_000 }) + expect(r[0]).toContain('✓ 1 passed') + expect(r[0]).not.toContain('failed') + expect(r[1]).toBe('') + }) + + test('a pass it has since failed shows the failure', () => { + const r = rows([loginPassed(), run('l2', 'run', [label('login_test.md'), step(1, 'failed', 'Sign in', 25), failedEnd(30)], T0 - 100_000)], { now: T0 + 60_000 }) + expect(r[0]).toContain('✗ 1 failed') + expect(r[0]).not.toContain('passed') + expect(r[1]).toContain('✗ login failed on Sign in') + }) + + test('different tests are all kept', () => { + expect(latest([loginPassed(), checkoutFailed()]).length).toBe(2) + }) +}) + describe('history', () => { const cases: { name: string; run: Run; want: HistoryEntry | undefined; detail?: string }[] = [ { name: 'a run still going is not kept', run: run('a', 'run', [label('Search'), step(1, 'running', 'Open the page')]), want: undefined }, From 9838cf65c2e1e3f72266f1009c32ab67b4c19d4b Mon Sep 17 00:00:00 2001 From: Zeeshan Date: Sat, 10 Oct 2026 00:57:48 +0530 Subject: [PATCH 09/11] feat(v2): a spinner leads the running step On the band's row 2 and on the chat card's running line the step read as a plain sentence. A one-cell spinner now leads it, stepping with the mascot: repainted in place on a terminal, a glyph elsewhere. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- docs/index.html | 2 +- guide/kane-qe-guide.html | 2 +- guide/v2/design-coverage.md | 4 ++-- kane-qe/README.md | 4 ++-- kane-qe/hooks/mascot.ts | 13 +++++++++++++ kane-qe/hooks/model.ts | 9 +++++---- kane-qe/hooks/register.tsx | 24 ++++++++++++++++++++---- kane-qe/tests/engine.test.tsx | 12 ++++++++++++ kane-qe/tests/model.test.ts | 21 +++++++++++++++++++++ 10 files changed, 78 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 6d46d6e..f99914c 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ What it never does: it never starts a kane-cli test, and never shows what kane-c ``` claude plugin validate kane-qe --strict # the plugin: manifest, hooks, state contract claude plugin validate . --strict # the marketplace manifest -claude plugin test kane-qe # 122 tests +claude plugin test kane-qe # 127 tests ``` CI (`.github/workflows/ci.yml`) runs the same on every push to `main` and on pull requests. diff --git a/docs/index.html b/docs/index.html index 6d04e96..c2fa4cd 100644 --- a/docs/index.html +++ b/docs/index.html @@ -334,7 +334,7 @@

Settings

Design coverage

Built to the design, and checked against it

-

v2 was built from the kane-cli mod design: 17 features, 13 screens and a build spec. Every feature and screen was compared in a real terminal with the design's screenshots, and 122 tests cover the rest. The full list, with how each item was checked, is in design-coverage.md.

+

v2 was built from the kane-cli mod design: 17 features, 13 screens and a build spec. Every feature and screen was compared in a real terminal with the design's screenshots, and 127 tests cover the rest. The full list, with how each item was checked, is in design-coverage.md.

Two deliberate differences

The design lists a test file's coming steps; kane-cli reports no step total, and the spec says never to show one. The design's cards say “Chrome 154”; kane-cli reports no browser name for desktop runs.

Not checked yet

The desktop app: the band and pane are tested on its surface, with the mascot as an image, but were not opened in the app itself.

diff --git a/guide/kane-qe-guide.html b/guide/kane-qe-guide.html index 7bce8ec..87f37b8 100644 --- a/guide/kane-qe-guide.html +++ b/guide/kane-qe-guide.html @@ -330,7 +330,7 @@

Settings

Design coverage

Built to the design, and checked against it

-

v2 was built from the kane-cli mod design: 17 features, 13 screens and a build spec. Every feature and screen was compared in a real terminal with the design's screenshots, and 122 tests cover the rest. The full list, with how each item was checked, is in design-coverage.md.

+

v2 was built from the kane-cli mod design: 17 features, 13 screens and a build spec. Every feature and screen was compared in a real terminal with the design's screenshots, and 127 tests cover the rest. The full list, with how each item was checked, is in design-coverage.md.

Two deliberate differences

The design lists a test file's coming steps; kane-cli reports no step total, and the spec says never to show one. The design's cards say “Chrome 154”; kane-cli reports no browser name for desktop runs.

Not checked yet

The desktop app: the band and pane are tested on its surface, with the mascot as an image, but were not opened in the app itself.

diff --git a/guide/v2/design-coverage.md b/guide/v2/design-coverage.md index 8e55e57..d246fa6 100644 --- a/guide/v2/design-coverage.md +++ b/guide/v2/design-coverage.md @@ -5,7 +5,7 @@ The design ([kane-cli mod: terminal build guide](https://claude.ai/artifact/5Dkz How each item was checked: - **live**: in a real Claude Code session in a terminal, with a real kane-cli run; - **staged**: a real Claude Code session, with kane-cli runs written to disk in kane-cli 0.8.20's own format, by `sleep` processes standing in for kane-cli; -- **test**: `claude plugin test kane-qe` (122 tests). +- **test**: `claude plugin test kane-qe` (127 tests). ## Features (design part 1, "What to build") @@ -45,7 +45,7 @@ Two differences, both deliberate: | Structured data only | Pointer JSON and `events.ndjson` lines; kane-cli's human output is never read | | No guesses on screen | What kane-cli does not report is left out (step totals, credits while running, balance, browser) | | Ignore what you do not know | `adapter.ts` dispatches on known types and skips the rest | -| Every behaviour has a test; README changes with it | 122 tests; both READMEs describe v2 | +| Every behaviour has a test; README changes with it | 127 tests; both READMEs describe v2 | | Code shape: sources → adapter → model → views | `sources.ts`, `adapter.ts` (only place raw field names appear), `model.ts` (pure), `register.tsx` | | Faults 1–5 in the prototype | All fixed: wrapped text keeps its column, waiting markers in the dim colour, card borders in the dim colour, cuts sized from the pane width, no background on band or pane | diff --git a/kane-qe/README.md b/kane-qe/README.md index d65e974..13b987f 100644 --- a/kane-qe/README.md +++ b/kane-qe/README.md @@ -6,9 +6,9 @@ A Claude Code mod that shows what kane-cli (KaneAI) is doing, without being aske | Surface | What it shows | |---|---| -| **Band** (three rows above the prompt, the kane mascot on the left) | Row 1: `idle` with this session's passed/failed counts (each test once, by its latest result, so a failure that has since passed is gone), `running` with the test's name and time, `N runs` with one coloured cell per run, or `suite` with one cell per test and `5 of 12 tests done · 3 running · 4 left`. A suite sent to the remote grid (`testrun run --remote`) reads `12 tests on the grid · sent 2m 05s ago` until the job ends, because the grid reports its tests only then. Row 2: the step a single run is on; `now` and each running test with its time; a failure the moment it happens (`✗ checkout_guest failed on Fill the shipping address`); `last run ✓ login · 2h ago` from earlier sessions; or `⚠ 2 files changed, untested [ Test this change ]`. Row 3: assurance, `47% proven · 4 use cases`, or `not set up [ Set up now ]`. The mascot's eyes move while a test runs (one step every 120 ms). | +| **Band** (three rows above the prompt, the kane mascot on the left) | Row 1: `idle` with this session's passed/failed counts (each test once, by its latest result, so a failure that has since passed is gone), `running` with the test's name and time, `N runs` with one coloured cell per run, or `suite` with one cell per test and `5 of 12 tests done · 3 running · 4 left`. A suite sent to the remote grid (`testrun run --remote`) reads `12 tests on the grid · sent 2m 05s ago` until the job ends, because the grid reports its tests only then. Row 2: the step a single run is on, behind a spinner; `now` and each running test with its time, behind the same spinner; a failure the moment it happens (`✗ checkout_guest failed on Fill the shipping address`); `last run ✓ login · 2h ago` from earlier sessions; or `⚠ 2 files changed, untested [ Test this change ]`. Row 3: assurance, `47% proven · 4 use cases`, or `not set up [ Set up now ]`. The mascot's eyes move while a test runs (one step every 120 ms), and the spinner steps with them. | | **Pane** (`/kane` or `[ Open ]`) | **Runs**: a card per run (failed first, red border), or one row per test for a suite. Press a name for its steps: done steps with a tick and time, the current or failing step expanded to *think*, *act* and each *check*, the failure's *why*, *kind* (kane's category, severity, confidence) and *where*, then *Open evidence*. For a remote suite the pane names the grid, the job and its link. **Assurance**: designed and proven bars, failing/blocked counts and when it last ran, a card per use case with what it still owes, or a card that offers to set it up with Claude. Press a use case for its detail: its title and risk, then each thing it owes under *To design* and *To run*, with kane-cli's reason and its own next command; *Close these gaps with Claude* hands that to Claude. **History**: the finished runs kept for this project, newest first, each with when it ran, how long it took, and where it failed or a suite's passed/failed counts. | -| **Card in the chat** | A kane-cli test Claude runs (`run`, `testmd run`, `testrun run`) is drawn in the conversation as a card, in place of its shell row: one line while it runs (name, time, step), then a bordered card with the mascot, the result (`✓ passed · 41s · 12.4 credits`), a failure's *on*, *why* and *kind*, or a suite's cells, counts and up to three failures. **View steps** / **View tests** opens the pane on that run; **View evidence** opens the run's evidence pack in kane-cli's viewer in the browser, in one click. Where the chat is printed text (the terminal outside fullscreen) the card ends with the evidence path instead of buttons. A command that never became a run (kane-cli refused or failed to start) keeps its normal row, and so does every other command. The chat folds shell calls into one count line; a group that holds a run is unfolded so its card shows. | +| **Card in the chat** | A kane-cli test Claude runs (`run`, `testmd run`, `testrun run`) is drawn in the conversation as a card, in place of its shell row: one line while it runs (a spinner, name, time, step), then a bordered card with the mascot, the result (`✓ passed · 41s · 12.4 credits`), a failure's *on*, *why* and *kind*, or a suite's cells, counts and up to three failures. **View steps** / **View tests** opens the pane on that run; **View evidence** opens the run's evidence pack in kane-cli's viewer in the browser, in one click. Where the chat is printed text (the terminal outside fullscreen) the card ends with the evidence path instead of buttons. A command that never became a run (kane-cli refused or failed to start) keeps its normal row, and so does every other command. The chat folds shell calls into one count line; a group that holds a run is unfolded so its card shows. | | **After Claude changes code** | When a turn ends with edited files (by the edit tools or a shell command; not docs, hidden tool folders or git-ignored files) and no kane-cli run since, the band warns and offers *Test this change*. The card lists the changed files and saved tests that mention the same feature, with their last result and *Run it*; equal matches put the test that last failed first, then the one run most recently. Only when none does, Claude drafts an objective from the diff (also on *Draft a new objective*); *Run the objective* hands it to Claude with the app's start URL. A kane-cli run that starts after the last edit clears the warning. | ## Settings diff --git a/kane-qe/hooks/mascot.ts b/kane-qe/hooks/mascot.ts index 8d53d23..6aaded1 100644 --- a/kane-qe/hooks/mascot.ts +++ b/kane-qe/hooks/mascot.ts @@ -101,6 +101,11 @@ export function rasterCells(frame: Frame): string { else words.push(SPACE, DEFAULT, DEFAULT) } } + return pack(words) +} + +/** Cell words (code point, foreground, background) as a Raster takes them: little-endian u32s, base64. */ +function pack(words: readonly number[]): string { const bytes = new Uint8Array(words.length * 4) words.forEach((w, i) => { bytes[i * 4] = w & 255 @@ -111,6 +116,14 @@ export function rasterCells(frame: Frame): string { return base64(bytes) } +/** The spinner drawn before a step while its test runs: one cell, stepping with the mascot. */ +const SPIN = [...'⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'] +export const spinnerGlyph = (n: number): string => SPIN[((n % SPIN.length) + SPIN.length) % SPIN.length]! +/** That spinner as a one-cell terminal Raster, in `color` (`#rrggbb`). */ +export function spinnerCells(n: number, color: string): string { + return pack([spinnerGlyph(n).codePointAt(0)!, Number.parseInt(color.slice(1), 16), DEFAULT]) +} + export const RASTER = { columns: MASCOT.width, rows: MASCOT.height / 2 } as const export const PICTURE = { width: 50, height: 44 } as const diff --git a/kane-qe/hooks/model.ts b/kane-qe/hooks/model.ts index ce79606..26ab5ff 100644 --- a/kane-qe/hooks/model.ts +++ b/kane-qe/hooks/model.ts @@ -302,7 +302,8 @@ export type Span = { t: string; c?: string; bg?: string; b?: boolean; d?: boolea /** One piece of a row; an array is drawn with no gap between its spans. */ export type Item = Span | Span[] export type RowButton = { key: string; label: string; act: string } -export type Row = { items: Item[]; button?: RowButton } +/** `spin`: the row is what a running test is doing now, drawn behind a spinner. */ +export type Row = { items: Item[]; button?: RowButton; spin?: boolean } export type Band = { think: boolean; rows: [Row, Row, Row] } export type BandOptions = { assurance: Assurance @@ -376,7 +377,7 @@ function nowRow(live: readonly Run[], now: number, room: number): Row { shown += 1 } if (shown < live.length) items.push(dim(`· +${live.length - shown} more`)) - return { items } + return { items, spin: live.length > 0 } } function failRow(r: Run, room: number): Row { @@ -404,7 +405,7 @@ const itemWidth = (i: Item): number => (Array.isArray(i) ? i.reduce((n, s) => n /** Cells a row takes as drawn: items one cell apart, a button as `[ label ]`. */ export function rowWidth(r: Row): number { - const parts = r.items.map(itemWidth).concat(r.button ? [r.button.label.length + 4] : []) + const parts = (r.spin ? [1] : []).concat(r.items.map(itemWidth), r.button ? [r.button.label.length + 4] : []) return parts.reduce((a, b) => a + b, 0) + Math.max(0, parts.length - 1) } @@ -462,7 +463,7 @@ function pickBand(runs: readonly Run[], o: BandOptions): Band { think, rows: [ { items: [chip('running', true), { t: cut(nm(only), Math.max(12, room - 24)), b: true }, dim(fmt(elapsed(only, o.now)))], button: open }, - { items: [only.waiting ? { t: cut(step, room), c: C.orange } : { t: cut(step, room) }] }, + only.waiting ? { items: [{ t: cut(step, room), c: C.orange }] } : { items: [{ t: cut(step, room - 2) }], spin: true }, assuranceRow(o.assurance), ], } diff --git a/kane-qe/hooks/register.tsx b/kane-qe/hooks/register.tsx index d9d637b..0ee2546 100644 --- a/kane-qe/hooks/register.tsx +++ b/kane-qe/hooks/register.tsx @@ -7,7 +7,7 @@ import type { AfterChange, Assurance, Change, HistoryEntry, Kind, Offer, Run, St import { adapt, splitLines } from './adapter' import type { Ev } from './adapter' import { RANK } from './adapter' -import { PICTURE, RASTER, STEP_MS, mascotSvg, pixelFrame, poseAt, rasterCells } from './mascot' +import { PICTURE, RASTER, STEP_MS, mascotSvg, pixelFrame, poseAt, rasterCells, spinnerCells, spinnerGlyph } from './mascot' import { C, HISTORY_MAX, @@ -92,6 +92,8 @@ const viewers = new Map() const seeking = new Map() let polling = false const sites = new Set() +/** Sites that draw the running-step spinner: repainted in place with the mascot. */ +const spinSites = new Set() let frame = 0 let thinking = false let remote = false @@ -111,10 +113,18 @@ function text(ui: Kit, s: Span) { const item = (ui: Kit, i: Item) => (Array.isArray(i) ? {i.filter(s => s.t !== '').map(s => text(ui, s))} : text(ui, i)) -function line(ui: Kit, press: Press, r: Row) { +// A step while its test runs: a spinner in front, so it reads as work in progress and not as a sentence. +// One cell repainted in place on a terminal; a glyph that steps with each redraw elsewhere. +function spinner(table: Table, surface: RenderSurface, n: number) { + if (surface === 'terminal' && 'Raster' in table) return + return {spinnerGlyph(n)} +} + +function line(ui: Kit, press: Press, r: Row, lead?: ReturnType) { if (r.items.length === 0 && !r.button) return return ( + {r.spin && lead ? lead : null} {r.items.map(i => item(ui, i))} {r.button ? press(r.button?.act ?? '')} /> : null} @@ -496,7 +506,7 @@ function chatCard(table: Table, surface: RenderSurface, press: Press, r: Run, no const suite = r.kind === 'testrun' && r.members ? cardHeader(r, now, columns).result : undefined return ( - ◉ + {spinner(table, surface, frame)} kane @@ -1027,6 +1037,8 @@ function paint($: EngineInterface) { if (!remote) frame += 1 const cells = rasterCells(pixelFrame(poseAt(true, frame))) for (const requestId of sites) void $.ui.blit({ requestId, key: 'mascot', cells }).catch(() => sites.delete(requestId)) + const spin = spinnerCells(frame, C.cyan) + for (const requestId of spinSites) void $.ui.blit({ requestId, key: 'spin', cells: spin }).catch(() => spinSites.delete(requestId)) } // ── the mod ────────────────────────────────────────────────────────────── @@ -1114,6 +1126,8 @@ export const register: Register = (on, options) => { const press: Press = a => void act($, a).catch(() => undefined) // Printed text takes no clicks: only the desktop and the fullscreen terminal draw buttons. const clickable = e.surface !== 'terminal' || e.viewport?.isFullscreen === true + if (e.surface === 'terminal' && (r.status === 'running' || r.status === 'pending')) spinSites.add(e.requestId) + else spinSites.delete(e.requestId) return chatCard($.ui.resolve(e), e.surface, press, r, await $.clock.now(), e.viewport?.columns ?? 90, clickable) }) @@ -1141,11 +1155,13 @@ export const register: Register = (on, options) => { const table = $.ui.resolve(e) if (e.surface === 'terminal') sites.add(e.requestId) else remote = true + if (e.surface === 'terminal' && model.rows.some(r => r.spin)) spinSites.add(e.requestId) + else spinSites.delete(e.requestId) const press: Press = a => void act($, a).catch(() => undefined) return ( {mascot(table, e.surface, model.think, frame)} - {model.rows.map(r => line(table, press, r))} + {model.rows.map(r => line(table, press, r, spinner(table, e.surface, frame)))} ) }) diff --git a/kane-qe/tests/engine.test.tsx b/kane-qe/tests/engine.test.tsx index 8829b4d..8626825 100644 --- a/kane-qe/tests/engine.test.tsx +++ b/kane-qe/tests/engine.test.tsx @@ -622,6 +622,13 @@ describe('the card in the chat', () => { await w.clock.settle() for (const surface of ['terminal', 'desktop'] as const) { const live = await $.ui.mount({ ...row('ToolUse', 'toolu_1', CMD, FULL), surface } as never) + // A spinner leads the line: a cell repainted in place on a terminal, a glyph elsewhere. + if (surface === 'terminal') expect(await live.find({ type: 'Raster', key: 'spin' })).toBeDefined() + else expect(await live.find({ type: 'Text', text: /^[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]$/ })).toBeDefined() + const band = await $.ui.mount({ ...BAND, surface } as never) + if (surface === 'terminal') expect(await band.find({ type: 'Raster', key: 'spin' })).toBeDefined() + else expect(await band.find({ type: 'Text', text: /^[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]$/ })).toBeDefined() + await band.unmount() expect(await live.find({ type: 'Text', text: /^checkout_guest$/ })).toBeDefined() expect(await live.find({ type: 'Text', text: /Fill the shipping address/ })).toBeDefined() expect(await live.find({ key: 'card-evidence' })).toBeUndefined() @@ -651,7 +658,12 @@ describe('the card in the chat', () => { expect(await after.find({ type: 'Text', text: /engine ToolResult/ })).toBeUndefined() await after.unmount() + // Once it has ended nothing spins: not the card, not the band. + const idle = await $.ui.mount({ ...BAND, surface: 'terminal' } as never) + expect(await idle.find({ key: 'spin' })).toBeUndefined() + await idle.unmount() const card = await $.ui.mount({ ...row('ToolUse', 'toolu_1', CMD, FULL), surface: 'terminal' } as never) + expect(await card.find({ key: 'spin' })).toBeUndefined() await card.press({ key: 'card-evidence' }) await w.clock.settle() expect(w.ran.some(a => a.join(' ') === `kane-cli evidence serve ${dir}/evidence/8f0e.evidence`)).toBe(true) diff --git a/kane-qe/tests/model.test.ts b/kane-qe/tests/model.test.ts index 68aab98..985223f 100644 --- a/kane-qe/tests/model.test.ts +++ b/kane-qe/tests/model.test.ts @@ -264,6 +264,27 @@ describe('the card in the chat', () => { }) }) +describe('the running step', () => { + const spins = (runs: Run[], o: Partial = {}) => band(runs, opts(o)).rows.map(r => r.spin === true) + + test('one run: its step is drawn behind a spinner; nothing else is', () => { + expect(spins([one()])).toEqual([false, true, false]) + }) + test('a run waiting on a question is not spinning', () => { + expect(spins([fold(one(), { k: 'ask', question: 'Which account?', at: T0 + 7000 })])).toEqual([false, false, false]) + }) + test('a suite: the now row spins, a failure row does not', () => { + expect(spins([suite(['passed', 'running', 'pending'])])).toEqual([false, true, false]) + expect(spins([suite(['failed', 'running', 'pending'])])).toEqual([false, false, false]) + }) + test('idle: nothing spins', () => { + expect(spins([loginPassed()])).toEqual([false, false, false]) + }) + test('the spinner is part of the row’s width', () => { + for (const columns of [60, 80, 120]) for (const row of band([one()], opts({ columns })).rows) expect(rowWidth(row) <= columns).toBe(true) + }) +}) + describe('a test run more than once', () => { const failedThen = (later: Ev[]) => [checkoutFailed(), run('co2', 'run', [label('checkout_guest_test.md'), step(1, 'done', 'Open the cart', 5), ...later], T0 - 300_000)] From 9a3db16c1eecd50c6d55556a0f832ded06ad7ce8 Mon Sep 17 00:00:00 2001 From: Zeeshan Date: Sat, 10 Oct 2026 01:24:19 +0530 Subject: [PATCH 10/11] docs(v2): scripts to record a walkthrough in a demo shop and cut a promo run_tour_v3.sh drives a real Claude Code session with the plugin through the chat card, the fix and its test, auto mode, a suite, outside runs, assurance with a use case, and History. make_promo.py cuts captioned clips from a take, with per-clip crops and masks. Co-Authored-By: Claude Opus 5.5 --- .gitignore | 5 + README.md | 2 +- guide/cut_tour_v3.py | 38 +++++ guide/make_promo.py | 107 ++++++++++++++ guide/promo.json | 304 ++++++++++++++++++++++++++++++++++++++++ guide/run_tour_v3.sh | 35 +++++ guide/tour_driver_v3.py | 220 +++++++++++++++++++++++++++++ guide/tour_v3.tape | 15 ++ 8 files changed, 725 insertions(+), 1 deletion(-) create mode 100644 guide/cut_tour_v3.py create mode 100644 guide/make_promo.py create mode 100644 guide/promo.json create mode 100755 guide/run_tour_v3.sh create mode 100644 guide/tour_driver_v3.py create mode 100644 guide/tour_v3.tape diff --git a/.gitignore b/.gitignore index a3dfc16..71456ba 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,8 @@ guide/*.png guide/probe.* guide/tour-v2-raw.mp4 guide/tour-v2.json +guide/take*.mp4 +guide/tour-v3-raw.mp4 +guide/*.log +guide/tour-v3.json +guide/chapters-take*.json diff --git a/README.md b/README.md index f99914c..e0944fd 100644 --- a/README.md +++ b/README.md @@ -117,7 +117,7 @@ References: [install plugins](https://code.claude.com/docs/en/plugins/install), | `kane-qe/tests/` | Adapter table, band states at 60/80/120 columns, recorded kane-cli streams replayed, band and pane on terminal and desktop, change tracking, stale pointers, assurance and use-case detail, remote suites, history, the card in the chat, theme | | `.claude-plugin/marketplace.json` | The `lambdatest` marketplace: this repository installs with `/plugin marketplace add` | | `docs/` | The Field Guide and the explainer as standalone HTML, with the walkthrough video and screenshots (`guide/build_docs.sh` rebuilds them) | -| `guide/` | The page sources, screenshots, design coverage, and the script that records the walkthrough (`KANE_DEMO_DIR= guide/run_tour_v2.sh`) | +| `guide/` | The page sources, screenshots, design coverage, and the scripts that record a walkthrough with real runs (`KANE_DEMO_DIR= guide/run_tour_v2.sh`; `run_tour_v3.sh` for a demo shop project, covering the chat card, auto mode, use-case detail and History) and cut a short captioned promo from one (`python3 guide/make_promo.py guide/promo.json`) | Edit `kane-qe/` and a session that loads it with `--plugin-dir` or `CLAUDE_CODE_PLUGIN_DIRS` reloads it on save. After recording a new kane-cli stream into `kane-qe/tests/fixtures/`, run `node kane-qe/tests/fixtures/build.mjs`. diff --git a/guide/cut_tour_v3.py b/guide/cut_tour_v3.py new file mode 100644 index 0000000..8cea17c --- /dev/null +++ b/guide/cut_tour_v3.py @@ -0,0 +1,38 @@ +"""Cuts tour-v3-raw.mp4 into kane-qe-v3-tour.mp4: stops at the end of the tour, plays the waiting +stretches the driver marked 6x faster, and writes chapters-v3.json with the chapter times moved to match.""" +import json +import subprocess + +FAST = 6.0 +tour = json.load(open('tour-v3.json')) +end = tour['chapters'][-1]['t'] + 4 +waits = sorted([a, b] for a, b in tour['waits'] if b - a > 2 and b <= end) + +# Segments of the raw video: (start, end, speed). +segs, t = [], 0.0 +for a, b in waits: + if a > t: + segs.append((t, a, 1.0)) + segs.append((a, b, FAST)) + t = b +segs.append((t, end, 1.0)) + + +def moved(x: float) -> float: + out = 0.0 + for a, b, k in segs: + if x <= a: + break + out += (min(x, b) - a) / k + return round(out, 1) + + +parts = [] +for i, (a, b, k) in enumerate(segs): + parts.append(f'[0:v]trim=start={a}:end={b},setpts=(PTS-STARTPTS)/{k}[v{i}]') +filt = ';'.join(parts) + ';' + ''.join(f'[v{i}]' for i in range(len(segs))) + f'concat=n={len(segs)}:v=1:a=0[out]' +subprocess.run(['ffmpeg', '-loglevel', 'error', '-y', '-i', 'tour-v3-raw.mp4', '-filter_complex', filt, '-map', '[out]', + '-c:v', 'libx264', '-crf', '23', '-preset', 'slow', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', 'kane-qe-v3-tour.mp4'], check=True) +chapters = [{**c, 't': moved(c['t'])} for c in tour['chapters']] +json.dump(chapters, open('chapters-v3.json', 'w'), indent=2) +print('cut', len(segs), 'segments; chapters at', [c['t'] for c in chapters]) diff --git a/guide/make_promo.py b/guide/make_promo.py new file mode 100644 index 0000000..a941d07 --- /dev/null +++ b/guide/make_promo.py @@ -0,0 +1,107 @@ +"""Cuts a short captioned promo from a recorded walkthrough. + + python3 make_promo.py promo.json + +promo.json: { "source": "take.mp4", "output": "promo.mp4", "title": [..lines], "end": [..lines], + "segments": [ { "from": 12.0, "to": 17.5, "speed": 1, "caption": "...", "sub": "...", + "crop": [x, y, w, h], "mask": [[x, y, w, h], ...] }, ... ] } +The recording plays in a frame above a caption bar. Captions and cards are drawn with Pillow (this ffmpeg has no drawtext).""" +import json +import os +import subprocess +import sys +import tempfile + +from PIL import Image, ImageDraw, ImageFont + +W, H = 1920, 1080 +BAR = 132 # caption bar height +VH = H - BAR # the recording's height in the frame +VW = round(VH * 16 / 9) +BG = (13, 11, 22) +INK = (233, 230, 245) +MUTED = (160, 155, 187) +PURPLE = (179, 136, 255) +FONT = '/System/Library/Fonts/Avenir Next.ttc' +FPS = 30 + + +def font(size: int, weight: str = 'demi'): + return ImageFont.truetype(FONT, size, index={'bold': 0, 'demi': 2, 'medium': 5, 'regular': 7}[weight]) + + +def caption_png(path: str, caption: str, sub: str): + """The full frame, transparent where the recording shows: only the bar is painted.""" + im = Image.new('RGBA', (W, H), (0, 0, 0, 0)) + d = ImageDraw.Draw(im) + d.rectangle([0, VH, W, H], fill=BG + (255,)) + d.rectangle([0, VH, W, VH + 2], fill=(50, 45, 72, 255)) + x = (W - VW) // 2 + 6 + d.rectangle([x, VH + 34, x + 6, H - 34], fill=PURPLE + (255,)) + d.text((x + 26, VH + 26), caption, font=font(40, 'demi'), fill=INK + (255,)) + if sub: + d.text((x + 26, VH + 80), sub, font=font(26, 'regular'), fill=MUTED + (255,)) + im.save(path) + + +def card_png(path: str, lines: list[str]): + """A title or end card: the first line large, the rest smaller; a line starting with `$ ` is a command.""" + im = Image.new('RGB', (W, H), BG) + d = ImageDraw.Draw(im) + sizes = [(l, font(34, 'medium') if l.startswith('$ ') else font(84 if i == 0 else 40, 'bold' if i == 0 else 'regular')) for i, l in enumerate(lines)] + heights = [f.getbbox('Ag')[3] + (44 if i == 0 else 22) for i, (_, f) in enumerate(sizes)] + y = (H - sum(heights)) // 2 + for i, (l, f) in enumerate(sizes): + text = l[2:] if l.startswith('$ ') else l + w = d.textlength(text, font=f) + colour = PURPLE if l.startswith('$ ') else INK if i == 0 else MUTED + d.text(((W - w) / 2, y), text, font=f, fill=colour) + y += heights[i] + im.save(path) + + +def run(args: list[str]): + subprocess.run(['ffmpeg', '-loglevel', 'error', '-y', *args], check=True) + + +def main(): + cfg = json.load(open(sys.argv[1])) + tmp = tempfile.mkdtemp(prefix='promo-') + parts: list[str] = [] + enc = ['-c:v', 'libx264', '-crf', '18', '-preset', 'medium', '-pix_fmt', 'yuv420p', '-r', str(FPS)] + + def card(name: str, lines: list[str], seconds: float): + png, mp4 = f'{tmp}/{name}.png', f'{tmp}/{name}.mp4' + card_png(png, lines) + run(['-loop', '1', '-t', str(seconds), '-i', png, '-vf', f'fade=in:0:10,fade=out:{int(seconds * FPS) - 10}:10', *enc, mp4]) + parts.append(mp4) + + if cfg.get('title'): + card('title', cfg['title'], cfg.get('title_seconds', 2.6)) + for i, s in enumerate(cfg['segments']): + png, mp4 = f'{tmp}/c{i}.png', f'{tmp}/s{i}.mp4' + caption_png(png, s['caption'], s.get('sub', '')) + speed = s.get('speed', 1) + length = (s['to'] - s['from']) / speed + frames = int(length * FPS) + # An optional crop (x, y, w, h in source pixels) zooms into one part of the screen. + crop = f"crop={s['crop'][2]}:{s['crop'][3]}:{s['crop'][0]}:{s['crop'][1]}," if s.get('crop') else '' + # Optional masks ([x, y, w, h] in source pixels) paint over part of the chat in the terminal's own background. + mask = ''.join(f"drawbox=x={m[0]}:y={m[1]}:w={m[2]}:h={m[3]}:color=0x171717:t=fill," for m in s.get('mask', [])) + filt = ( + f"[0:v]trim=start={s['from']}:end={s['to']},setpts=(PTS-STARTPTS)/{speed},fps={FPS},{mask}{crop}scale={VW}:{VH}:flags=lanczos," + f"pad={W}:{H}:{(W - VW) // 2}:0:color=0x0d0b16[v];[v][1:v]overlay=0:0,fade=in:0:6,fade=out:{max(0, frames - 6)}:6[out]" + ) + run(['-i', cfg['source'], '-i', png, '-filter_complex', filt, '-map', '[out]', *enc, mp4]) + parts.append(mp4) + if cfg.get('end'): + card('end', cfg['end'], cfg.get('end_seconds', 4)) + listing = f'{tmp}/list.txt' + open(listing, 'w').write(''.join(f"file '{p}'\n" for p in parts)) + run(['-f', 'concat', '-safe', '0', '-i', listing, '-c', 'copy', '-movflags', '+faststart', cfg['output']]) + total = sum(float(subprocess.run(['ffprobe', '-v', 'error', '-show_entries', 'format=duration', '-of', 'csv=p=0', p], capture_output=True, text=True).stdout) for p in parts) + print(f"{cfg['output']}: {len(parts)} parts, {total:.1f}s") + + +if __name__ == '__main__': + main() diff --git a/guide/promo.json b/guide/promo.json new file mode 100644 index 0000000..e44f306 --- /dev/null +++ b/guide/promo.json @@ -0,0 +1,304 @@ +{ + "source": "take3.mp4", + "output": "kane-qe-promo.mp4", + "title": [ + "kane-qe", + "See every kane-cli test, right inside Claude Code" + ], + "title_seconds": 2.8, + "end": [ + "kane-qe", + "A Claude Code plugin for kane-cli", + "$ /plugin marketplace add LambdaTest/kane-claude-plugin", + "$ /plugin install kane-qe@lambdatest" + ], + "end_seconds": 4.5, + "segments": [ + { + "from": 1.0, + "to": 4.5, + "caption": "Always above the prompt", + "sub": "What kane-cli is doing, your last run, and how much of your requirements is proven.", + "crop": [ + 0, + 630, + 800, + 450 + ] + }, + { + "from": 27.5, + "to": 32.5, + "caption": "Ask Claude to test", + "sub": "The run shows up in the chat as it happens: the test, its time, the step it is on.", + "crop": [ + 0, + 150, + 960, + 540 + ] + }, + { + "from": 33, + "to": 39, + "caption": "Watch it live", + "sub": "The band follows the same step, and the mascot's eyes move while kane-cli works.", + "crop": [ + 0, + 630, + 800, + 450 + ] + }, + { + "from": 49, + "to": 55, + "caption": "Every run leaves a card", + "sub": "Pass or fail, kane-cli's own reason for a failure, and how sure it is.", + "crop": [ + 0, + 170, + 1120, + 630 + ] + }, + { + "from": 61, + "to": 68.5, + "caption": "One click to the steps", + "sub": "What the agent thought, did and checked, with kane-cli's verdict on top.", + "mask": [ + [ + 0, + 110, + 640, + 34 + ] + ] + }, + { + "from": 69.5, + "to": 74, + "caption": "One click to the evidence", + "sub": "View evidence opens that run's evidence pack in your browser.", + "mask": [ + [ + 0, + 110, + 640, + 34 + ] + ] + }, + { + "from": 91, + "to": 97, + "caption": "Claude changes your code", + "sub": "The band notices at once: 1 file changed, untested.", + "crop": [ + 0, + 480, + 1066, + 600 + ] + }, + { + "from": 100, + "to": 106, + "caption": "Test this change", + "sub": "It offers the saved tests that cover the change, with their last result.", + "crop": [ + 960, + 0, + 960, + 540 + ], + "mask": [ + [ + 960, + 0, + 294, + 1080 + ] + ] + }, + { + "from": 139, + "to": 144.5, + "caption": "Proven", + "sub": "The saved test runs, the card is green, and the warning clears.", + "crop": [ + 0, + 480, + 1066, + 600 + ] + }, + { + "from": 173.6, + "to": 176.6, + "caption": "Or let Claude test by itself", + "sub": "In auto mode Claude is held at the end of its turn until the change is tested.", + "crop": [ + 0, + 540, + 960, + 540 + ], + "mask": [ + [ + 0, + 0, + 1250, + 880 + ] + ] + }, + { + "from": 179, + "to": 185, + "caption": "Claude runs the test", + "sub": "No button, no prompt: it picks the saved test and runs it.", + "crop": [ + 0, + 600, + 853, + 480 + ], + "mask": [ + [ + 0, + 0, + 1250, + 812 + ] + ] + }, + { + "from": 187.6, + "to": 190.8, + "caption": "And reports back", + "sub": "Another green card in the conversation.", + "crop": [ + 0, + 520, + 996, + 560 + ], + "mask": [ + [ + 0, + 0, + 1250, + 730 + ] + ] + }, + { + "from": 214, + "to": 220, + "caption": "Whole suites at a glance", + "sub": "One cell per test, how many are done, and what is running now.", + "crop": [ + 0, + 440, + 1137, + 640 + ] + }, + { + "from": 226, + "to": 232, + "caption": "One card for the suite", + "sub": "Its counts, a cell per test, and the suite's evidence.", + "crop": [ + 0, + 330, + 1280, + 720 + ], + "mask": [ + [ + 0, + 0, + 1250, + 350 + ] + ] + }, + { + "from": 247, + "to": 255, + "caption": "Runs started anywhere", + "sub": "Tests started in another terminal show up too, by name, with no setup.", + "mask": [ + [ + 0, + 0, + 1250, + 322 + ] + ] + }, + { + "from": 282, + "to": 287, + "caption": "Requirement coverage", + "sub": "How much is designed, how much is proven, and what each use case still owes.", + "crop": [ + 960, + 0, + 960, + 540 + ], + "mask": [ + [ + 960, + 0, + 294, + 1080 + ] + ] + }, + { + "from": 289, + "to": 297, + "caption": "Open a use case", + "sub": "Every gap, kane-cli's next command for it, and one button to hand it to Claude.", + "crop": [ + 960, + 0, + 960, + 540 + ], + "mask": [ + [ + 960, + 0, + 294, + 1080 + ] + ] + }, + { + "from": 302, + "to": 309, + "caption": "History", + "sub": "Every finished run in this project: when, how long, and where it failed.", + "crop": [ + 960, + 0, + 960, + 540 + ], + "mask": [ + [ + 960, + 0, + 294, + 1080 + ] + ] + } + ] +} \ No newline at end of file diff --git a/guide/run_tour_v3.sh b/guide/run_tour_v3.sh new file mode 100755 index 0000000..4f4bf1b --- /dev/null +++ b/guide/run_tour_v3.sh @@ -0,0 +1,35 @@ +#!/bin/zsh +# Records the v3 walkthrough: a real Claude Code session in a demo shop project, every kane-cli run real. +# Needs vhs, tmux, ffmpeg, a kane-cli login, and KANE_DEMO_DIR: a git project served at KANE_DEMO_URL with +# - src/checkout/validate.js refusing a postcode that has a space, and src/search/search.js not trimming its query; +# - saved tests tagged smoke in .testmuai/tests: checkout_guest (fails on that postcode), search, cart_add; +# - a requirement store (.context) with designed tests, some of them run. +set -u +export LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 +cd "$(dirname "$0")" +DEMO_DIR="${KANE_DEMO_DIR:?set KANE_DEMO_DIR to the demo project}" +export KANE_DEMO_DIR +export KANE_DEMO_URL="${KANE_DEMO_URL:-http://localhost:4173}" +PLUGIN="$(cd ../kane-qe && pwd)" +tmux -L kqvid kill-server 2>/dev/null +# A session of its own: the plugin under test, no personal settings, hooks or MCP servers. +tmux -L kqvid -f /dev/null new-session -d -s kq -x 176 -y 49 -c "$DEMO_DIR" "LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 KANE_CLI_USER_AGENT=claude-code CLAUDE_CODE_NO_FLICKER=1 claude --plugin-dir $PLUGIN --setting-sources project,local --strict-mcp-config --model sonnet --append-system-prompt 'This session is a recorded product demo. Answer in one or two short plain sentences. Do exactly what is asked and nothing more. Run each kane-cli command exactly once and never rerun it, whatever its exit code. The only shell commands you may run are kane-cli commands: never ls, grep, cat, git or anything else, and never read the saved output of a command. When you are told to test a change, run the saved test that covers it straight away: kane-cli testmd run .testmuai/tests/_test.md --agent --headless. Never ask follow-up questions, and never mention unit tests, READMEs or documentation.' --allowedTools 'Bash(kane-cli:*)' Edit Read" +tmux -L kqvid set -g status off +tmux -L kqvid set -g focus-events on +tmux -L kqvid set -as terminal-features ',xterm*:RGB'; tmux -L kqvid set -g window-size latest +sleep 10 +# A folder Claude Code has not seen asks whether to trust it: the demo project is ours. +if tmux -L kqvid capture-pane -t kq -p | grep -q 'trust this folder'; then tmux -L kqvid send-keys -t kq Down; sleep 0.3; tmux -L kqvid send-keys -t kq Enter; sleep 6; fi +until tmux -L kqvid capture-pane -t kq -p | grep -q ' idle '; do sleep 1; done +vhs tour_v3.tape > vhs-v3.log 2>&1 & +VHS=$! +until tmux -L kqvid list-clients | grep -q .; do sleep 0.2; done +sleep 1.1 +python3 tour_driver_v3.py tour-v3.json 2> driver-v3.log +echo "driver exit $?" >> driver-v3.log +# The tape waits for the session to be detached: that ends the recording. +sleep 2 +tmux -L kqvid detach-client -s kq 2>/dev/null +wait $VHS 2>/dev/null +echo "vhs exit $?" >> driver-v3.log +python3 cut_tour_v3.py diff --git a/guide/tour_driver_v3.py b/guide/tour_driver_v3.py new file mode 100644 index 0000000..4f3281d --- /dev/null +++ b/guide/tour_driver_v3.py @@ -0,0 +1,220 @@ +"""Drives a real Claude Code session (tmux socket kqvid, session kq) through kane-qe in a demo shop project while VHS +records the attached terminal. Every run in it is a real kane-cli run. Writes chapter times and the +waiting stretches (to speed up afterwards) to the JSON file named on the command line. +Clicks are SGR mouse events at the on-screen position of a label.""" +import json +import os +import re +import subprocess +import sys +import time + +T = ['tmux', '-L', 'kqvid'] +START = time.time() +CHAPTERS: list[dict] = [] +WAITS: list[list[float]] = [] +DEMO = os.environ['KANE_DEMO_DIR'] +URL = os.environ.get('KANE_DEMO_URL', 'http://localhost:4173') + + +def now() -> float: + return round(time.time() - START, 1) + + +def sh(args): + return subprocess.run(T + args, capture_output=True, text=True).stdout + + +def screen() -> str: + return sh(['capture-pane', '-t', 'kq', '-p']) + + +def band() -> str: + """The band's three rows: from the mascot at column 0, left of the pane if one is open.""" + lines = screen().split('\n') + for i, line in enumerate(lines): + if line.startswith(' ▄▀▄▄▀▄'): + return '\n'.join(l.split('│')[0] for l in lines[i:i + 3]) + return '' + + +def find(label: str, last: bool = False): + lines = screen().split('\n') + hits = [(y, line.index(label)) for y, line in enumerate(lines) if label in line] + if not hits: + return None + y, x = hits[-1] if last else hits[0] + return x + 1 + min(2, len(label) // 2), y + 1 + + +def click(label: str, last: bool = False, wait: float = 1.5) -> bool: + pos = find(label, last) + if not pos: + print(f' ! not on screen: {label}', file=sys.stderr) + return False + x, y = pos + for seq in (f'\x1b[<0;{x};{y}M', f'\x1b[<0;{x};{y}m'): + sh(['send-keys', '-t', 'kq', '-H', *[f'{b:02x}' for b in seq.encode()]]) + time.sleep(0.08) + time.sleep(wait) + return True + + +def click_on_line(anchor: str, label: str, wait: float = 1.5) -> bool: + """Clicks `label` on the first screen line that also holds `anchor`: one button out of several alike.""" + for y, line in enumerate(screen().split('\n')): + if anchor in line and label in line: + x = line.rindex(label) + 1 + min(2, len(label) // 2) + for seq in (f'\x1b[<0;{x};{y + 1}M', f'\x1b[<0;{x};{y + 1}m'): + sh(['send-keys', '-t', 'kq', '-H', *[f'{b:02x}' for b in seq.encode()]]) + time.sleep(0.08) + time.sleep(wait) + return True + print(f' ! not on one line: {anchor} / {label}', file=sys.stderr) + return False + + +def keys(*k: str, wait: float = 1.0): + sh(['send-keys', '-t', 'kq', *k]) + time.sleep(wait) + + +def type_text(text: str, per_char: float = 0.03, wait: float = 0.6): + for ch in text: + sh(['send-keys', '-t', 'kq', '-l', ch]) + time.sleep(per_char) + time.sleep(wait) + + +def prompt(text: str, wait: float = 2.0): + type_text(text) + keys('Enter', wait=wait) + + +def wait_for(pattern: str, timeout: float = 300, every: float = 1.0, fast: bool = True, on=None) -> bool: + """Waits until the band (or `on`) matches; the stretch is marked to be sped up in the cut.""" + on = on or band + t0 = now() + end = time.time() + timeout + ok = False + while time.time() < end: + if re.search(pattern, on()): + ok = True + break + time.sleep(every) + if fast and now() - t0 > 6: + WAITS.append([t0 + 3, now() - 2]) + if not ok: + print(f' ! timed out waiting for {pattern}', file=sys.stderr) + return ok + + +def wait_gone(pattern: str, timeout: float = 400, on=None): + on = on or band + t0 = now() + end = time.time() + timeout + while time.time() < end and re.search(pattern, on()): + time.sleep(1.5) + if now() - t0 > 6: + WAITS.append([t0 + 3, now() - 2]) + + +def chapter(title: str, detail: str): + CHAPTERS.append({'t': now(), 'title': title, 'detail': detail}) + print(f'[{now():6.1f}s] {title}', file=sys.stderr) + + +def kane(*args: str): + """A kane-cli run started outside Claude, in the project: as from another terminal. + `testrun run` takes no --agent (it streams NDJSON when not on a terminal).""" + flags = ['--headless'] if args[0] == 'testrun' else ['--agent', '--headless'] + env = {**os.environ, 'LANG': 'en_US.UTF-8', 'LC_ALL': 'en_US.UTF-8', 'KANE_CLI_USER_AGENT': 'claude-code'} + subprocess.Popen(['kane-cli', *args, *flags], cwd=DEMO, env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + +def turn_done(timeout: float = 240): + """Waits for Claude's turn to end: its spinner line gone.""" + time.sleep(2) + wait_gone(r'\(\d+m? ?\d*s? · |esc to interrupt', timeout=timeout, on=screen) + time.sleep(1.5) + + +# ── the tour ───────────────────────────────────────────────────────────── + +wait_for(r' idle ', timeout=60, fast=False) +chapter('The band', 'Always above the prompt: kane-cli is idle, the last run in this project, and requirement coverage on row 3.') +time.sleep(6) + +chapter('Claude runs a test: a card in the chat', 'One line while it runs, with the band following the same step. Then the result, as a card in place of the shell output.') +prompt('Run the guest checkout test and tell me the result in one line: kane-cli testmd run .testmuai/tests/checkout_guest_test.md --agent --headless', wait=3) +wait_for(r' running ', timeout=90, fast=False) +time.sleep(8) +wait_for(r' idle ', timeout=400) +turn_done() +time.sleep(6) + +chapter('Why it failed', 'View steps opens the pane on that run: kane’s own verdict, the steps, the failing check. View evidence opens the evidence in the browser.') +click('[ View steps ]', wait=4) +time.sleep(6) +click('[ View evidence ]', wait=5) +time.sleep(2) + +chapter('Claude fixes it; the change is untested', 'An edit and no test since: the band warns, and offers the saved test that covers the change.') +prompt('Postcodes with a space, like "SW1A 1AA", must be accepted at checkout. Fix validatePostcode in src/checkout/validate.js with one Edit and change nothing else.', wait=3) +wait_for(r'changed, untested', timeout=240) +turn_done() +time.sleep(4) +click('[ Test this change ]', wait=4) +time.sleep(5) + +chapter('Test the change', 'Run it hands the saved test to Claude. This time the card is green, and the warning clears.') +click_on_line('checkout_guest', '[ Run it ]', wait=3) +wait_for(r' running ', timeout=120, fast=False) +time.sleep(8) +wait_for(r' idle ', timeout=500) +turn_done() +time.sleep(6) + +chapter('Auto: Claude tests before it finishes', '/kane auto: after an edit Claude is held once at the end of its turn, tests the change itself, and reports.') +prompt('/kane auto', wait=3) +prompt('Search must ignore spaces around the query, so " mug " finds the Stoneware Mug. Fix searchProducts in src/search/search.js with one Edit and change nothing else.', wait=3) +wait_for(r'Claude will test', timeout=240, fast=False) +wait_for(r' running ', timeout=240) +time.sleep(8) +wait_for(r' idle ', timeout=500) +turn_done(timeout=300) +time.sleep(6) +prompt('/kane ask', wait=2) + +chapter('A suite', 'kane-cli testrun: one cell per test and done · running · left on the band; one card for the whole suite.') +prompt('Run the smoke suite and give me one line: kane-cli testrun run --tags smoke --parallel 3 --headless', wait=3) +wait_for(r' suite ', timeout=120, fast=False) +time.sleep(8) +wait_for(r' idle ', timeout=600) +turn_done(timeout=300) +time.sleep(6) + +chapter('Runs started anywhere', 'Two runs from another terminal in this project: one cell per run, and what is running now. They have no card: nobody ran them in this chat.') +click('[ Runs ]', wait=1) +kane('testmd', 'run', '.testmuai/tests/cart_add_test.md') +kane('testmd', 'run', '.testmuai/tests/search_test.md') +wait_for(r'2 runs', timeout=60, fast=False) +time.sleep(8) +wait_for(r' idle ', timeout=400) +time.sleep(4) + +chapter('Assurance', 'Designed and proven coverage from kane-cli cover gaps. A use case opens to what it still owes, with kane-cli’s next command.') +prompt('/kane assurance', wait=4) +time.sleep(5) +click('uc-1', wait=3) +time.sleep(7) +click('‹ Assurance', wait=2) + +chapter('History', 'Every finished run in this project, with when, how long, and where it failed.') +click('[ History ]', wait=3) +time.sleep(7) + +chapter('End', 'kane-qe: a band that watches kane-cli, a card for every test Claude runs, a pane for runs, assurance and history, and a nudge to test what Claude changed.') +time.sleep(4) +json.dump({'chapters': CHAPTERS, 'waits': WAITS}, open(sys.argv[1] if len(sys.argv) > 1 else 'tour-v3.json', 'w'), indent=2) +print('DONE', now(), file=sys.stderr) diff --git a/guide/tour_v3.tape b/guide/tour_v3.tape new file mode 100644 index 0000000..c098257 --- /dev/null +++ b/guide/tour_v3.tape @@ -0,0 +1,15 @@ +Output tour-v3-raw.mp4 +Set Shell zsh +Set FontSize 18 +Set Width 1920 +Set Height 1080 +Set Padding 8 +Set Framerate 24 +Hide +Type "LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 tmux -u -L kqvid attach -t kq" +Enter +Sleep 1s +Show +# The driver detaches the session when the tour is over; the recording ends there. +Wait+Screen@1800s /detached/ +Sleep 1s From e8e4beb8a8ed8898cf56a52095e9dbbef6e4f05f Mon Sep 17 00:00:00 2001 From: Zeeshan Date: Sat, 10 Oct 2026 01:27:25 +0530 Subject: [PATCH 11/11] docs(v2): how the additions were checked, after the live takes Co-Authored-By: Claude Opus 5.5 --- guide/v2/design-coverage.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/guide/v2/design-coverage.md b/guide/v2/design-coverage.md index d246fa6..fdade3a 100644 --- a/guide/v2/design-coverage.md +++ b/guide/v2/design-coverage.md @@ -66,7 +66,7 @@ Two differences, both deliberate: ## Added after the design -Four things the design did not ask for, each checked by **test** only (not yet in a live session, except that a trial card with fake data was drawn and clicked in a fullscreen terminal): +Things the design did not ask for. Use-case detail, the card, the spinner and History were checked **live**: a real Claude Code session in a fullscreen terminal, a demo shop project with a real requirement store, and real kane-cli runs (`guide/run_tour_v3.sh` records it). Remote suites are checked by **test** only. | Addition | What it does | |---|---| @@ -74,10 +74,14 @@ Four things the design did not ask for, each checked by **test** only (not yet i | Use-case detail | Press a use case on the Assurance tab: its title and risk, what it owes to design and to run, kane-cli's reason and next command for each, and *Close these gaps with Claude* | | Card in the chat | A kane-cli test Claude runs is drawn in the conversation as a card in place of its shell row: one line while it runs, then the result, a failure's on/why/kind or a suite's counts, *View steps* and *View evidence*. *View evidence* opens the pack in kane-cli's viewer in one click, which is the one time the mod starts a kane-cli command other than `cover gaps`. Printed chats (terminal outside fullscreen) get the evidence path instead of buttons | | History tab | The last 30 finished runs in this project (it was 10, shown only as `last run …`), with when, how long, and where each failed or a suite's counts. `/kane history` opens it | +| Spinner | A one-cell spinner leads the running step on the band and on the card's running line, stepping with the mascot | +| Found live, and fixed | Saved tests that match a change equally are ordered by last result; a run started outside the chat is named from its process; the idle band counts each test by its latest result; use-case cards show their title; a reason shared by every row is said once | ## Not checked -- **The four additions above in a live session.** The card's evidence button has never opened a real pack. The remote events are written from kane-cli's contract (`docs/remote-execution.md`); no real grid job has been watched with them. +- **Remote suites in a live session.** The remote events are written from kane-cli's contract (`docs/remote-execution.md`); no real grid job has been watched with them. +- **View evidence opening the browser.** Live, the button started `kane-cli evidence serve` for the run's real pack and reported the evidence opened; the browser tab itself was not observed by the recording. +- **The card outside a fullscreen terminal**: its evidence-path form is covered by a test only. - **The desktop app.** The band and pane are mounted on the desktop surface in tests (the mascot as an image there), but not opened in the app itself. - **The light palette in a live session**: switching a session's theme changes the person's global setting, so it is covered by a test only.