flowviant 0.28.9 → 0.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/cli.mjs CHANGED
@@ -101,6 +101,16 @@ if (process.argv[2] === 'clean') {
101
101
  process.exit(0);
102
102
  }
103
103
 
104
+ // `flowviant shot <url>` — capture a headless-browser screenshot of a running
105
+ // page. Build agents shell out to this to attach REAL visual evidence to the
106
+ // delivery card. Self-contained + graceful (no browser → exit 1, agent falls
107
+ // back to text evidence); needs no credential, so it runs before the auth gate.
108
+ if (process.argv[2] === 'shot') {
109
+ const { runShot } = await import('./lib/shot.mjs');
110
+ await runShot(process.argv.slice(3));
111
+ process.exit(0);
112
+ }
113
+
104
114
  // `flowviant env <import|set|show>` — the CLI half of team env sync. Values
105
115
  // are sealed to the project pubkey ON THIS MACHINE (same write-only crypto as
106
116
  // the browser); `show` decrypts locally — it only works on an ENROLLED machine.
@@ -147,32 +147,44 @@ its frontmatter and title are part of the chapter and must comply):
147
147
  Open every existing docs/ chapter and FIX any that violate these two rules on
148
148
  EVERY run. The sidebar grouping + clean titles depend on it; it is not skippable.
149
149
 
150
- Every chapter declares its sidebar GROUP with a "category:" line in its
151
- frontmatter — the bold group header it sits under, exactly like the grouped left
152
- nav in Stripe / HuggingFace docs. Group RELATED chapters under a shared category
153
- (aim for 2-5 categories total that mirror the codebase's real divisions e.g.
154
- "Getting started", "Core runtime", "Workspaces", "Reference"; a category holding a
155
- single chapter is a smell merge or regroup). Number the chapters so same-category
156
- chapters are CONTIGUOUS and the categories flow in reading order. The "# Title" is
157
- a clean human name NO number prefix (ordering comes from the filename prefix).
150
+ Every page declares its sidebar GROUP with a "category:" line in its frontmatter
151
+ — the group header it sits under, like the grouped left nav in HuggingFace docs.
152
+ The category may be TWO levels, "Top group / Sub-group", to add HuggingFace's
153
+ second nav tier: use the sub-level to break a LARGE top group into coherent
154
+ sub-groups (e.g. "Workspaces / Fundraising", "Workspaces / Finance & budget"); a
155
+ single level ("Reference") is fine for small groups. Aim for 3-6 top groups that
156
+ mirror the codebase's real divisions; a group OR sub-group holding a single page
157
+ is a smell merge or regroup. Keep same-group pages CONTIGUOUS by filename number
158
+ so reading order also orders the nav. The "# Title" is a clean human name — NO
159
+ number prefix (ordering comes from the filename prefix).
160
+
161
+ Prefer MANY FOCUSED pages over a few giant chapters — HuggingFace granularity:
162
+ ONE page per coherent topic, not one page per whole subsystem. If a subsystem is
163
+ large, SPLIT it into several pages (its overview, its data model, its API, its
164
+ key flows), each its own docs/NN-page.md with its own category, so the left nav
165
+ is a fine-grained tree of pages and each page is focused enough to read in one
166
+ sitting. The in-page "## " sections are the right-hand on-this-page rail — the
167
+ left nav is pages, so when a chapter grows more than a handful of "## " sections,
168
+ that is the signal to split it into separate pages.
158
169
 
159
170
  Fixed spine (flat docs/ files; numeric prefix = reading order):
160
171
  - docs/00-start-here.md (category: "Getting started") — the landing page + MASTER
161
172
  TABLE OF CONTENTS: what the product is (2-3 sentences); how to run it locally
162
173
  (prerequisites, install, required env, dev server, tests); then a linked table
163
- of contents of EVERY chapter GROUPED BY CATEGORY, each with a one-line
164
- description; then 2-3 role-based reading paths (e.g. "New to the backend: read
165
- Architecture, then Agent fleet, then Data model").
174
+ of contents of EVERY page GROUPED BY CATEGORY, each with a one-line description;
175
+ then 2-3 role-based reading paths (e.g. "New to the backend: read Architecture,
176
+ then Agent fleet, then Data model").
166
177
  - docs/01-architecture.md (category: "Getting started") — the system at a glance:
167
178
  a Mermaid diagram (a fenced code block whose language is mermaid) of the major
168
179
  components and how they connect, a component-responsibility table, the primary
169
- request/data flows, and a link into the chapter for each component.
170
- - docs/1N-<chapter>.md — ONE chapter per major subsystem, EACH with its own
171
- "category:" grouping it with its siblings. Cover every significant subsystem.
180
+ request/data flows, and a link into the page for each component.
181
+ - docs/NN-<page>.md — the subsystem PAGES: many focused pages (split large
182
+ subsystems into several), EACH with its own 1- or 2-level "category:" placing it
183
+ in the nav. Cover every significant part of the system.
172
184
  - docs/90-decisions.md (category: "Reference") — notable design decisions, each as
173
185
  context, decision, why, and consequences.
174
186
  - docs/91-glossary.md (category: "Reference") — the project's terms of art,
175
- alphabetized, each linking to the chapter or vault page that defines it.
187
+ alphabetized, each linking to the page that defines it.
176
188
 
177
189
  EVERY chapter follows this exact anatomy, in order:
178
190
  1. YAML frontmatter: a "category:" group header (see the spine) AND a "files:"
package/bin/lib/live.mjs CHANGED
@@ -112,6 +112,7 @@ const SAFE_TOOLS = [
112
112
  'Bash(gh:*)',
113
113
  'Bash(npm:*)',
114
114
  'Bash(bun:*)',
115
+ 'Bash(flowviant:*)', // `flowviant shot` — capture screenshot evidence
115
116
  'mcp__flowviant',
116
117
  ];
117
118
 
@@ -131,14 +132,21 @@ tools. When you hit a decision only a human can make, call report_blocker (with
131
132
  options when you can) and then STOP your turn — do not spin or guess; you will be
132
133
  resumed with the answer. As you satisfy each "done when" criterion, call
133
134
  attach_evidence for it — proof the reviewer can SEE without running anything.
134
- Match the evidence to what you built: backend/API work → a request/response
135
- capture or a data sample showing the write; a single screen a screenshot.
136
- CRITICAL for a multi-step FLOW (login, signup, checkout): a screenshot of one
137
- page does NOT prove the flow works — you MUST prove the whole path end to end.
138
- Best: write an e2e/integration test that DRIVES the flow (fill form → submit →
139
- assert the post-login/success state) and attach its test_output; if you have a
140
- browser tool (e.g. Playwright), also attach a screen recording of it running.
141
- Never let a static screenshot stand in for a flow. When the work is done: open ONE draft PR (git push +
135
+ This IS your handover, so make it tangible; match the evidence to what you built:
136
+ UI / any visible screen attach a real SCREENSHOT. Start the app's dev server
137
+ in your worktree, then capture it headlessly with
138
+ \`flowviant shot http://localhost:<PORT>/<route> --out shot.png\` (it finds a
139
+ browser for you and never needs a display), and attach_evidence with kind
140
+ "screenshot" and the file's base64 (\`base64 -w0 shot.png\`). Shoot EVERY key
141
+ screen you changed. If \`flowviant shot\` reports that no browser is available,
142
+ do NOT block fall back to the text evidence below.
143
+ • backend / API work → a request/response capture or a data sample showing the
144
+ write (kind "request_response" or "sample").
145
+ • a multi-step FLOW (login, signup, checkout): one screenshot does NOT prove it
146
+ works — write an e2e/integration test that DRIVES the flow (fill form → submit
147
+ → assert the post-success state), attach its test_output, AND screenshot the
148
+ end state. Never let a single static screenshot stand in for a flow.
149
+ When the work is done: open ONE draft PR (git push +
142
150
  gh pr create --draft), call attach_pr, then call complete with a plain-language
143
151
  summary of what you built AND a criteria self-report (index into the brief's
144
152
  "done when" list + met true/false + a short note per item). That summary +
@@ -168,7 +176,7 @@ function seedPrompt(runId, brief, transcript, resumedInPlace) {
168
176
  ? [``, `Conversation so far (you may be resuming — pick up where this left off):`, transcript]
169
177
  : []),
170
178
  ``,
171
- `${transcript ? 'Continue' : 'Begin'}. Post a short plan first as a Markdown list (one numbered line per step), then: report_progress as you go; attach_evidence for each "done when" criterion as you satisfy it (test output, a request/response, a data sample, or a screenshot — so it's reviewable without running anything); report_blocker + stop if you hit a human decision; open a draft PR, attach_pr, then complete (summary + criteria self-report — your delivery card) when done.`,
179
+ `${transcript ? 'Continue' : 'Begin'}. Post a short plan first as a Markdown list (one numbered line per step), then: report_progress as you go; attach_evidence for each "done when" criterion as you satisfy it — a real screenshot for UI (run the dev server, then \`flowviant shot <url> --out shot.png\`), or test output / a request-response / a data sample for backend, so it's reviewable without running anything; report_blocker + stop if you hit a human decision; open a draft PR, attach_pr, then complete (summary + criteria self-report — your delivery card) when done.`,
172
180
  ].join('\n');
173
181
  }
174
182
 
@@ -0,0 +1,218 @@
1
+ /**
2
+ * `flowviant shot <url>` — capture a headless-browser screenshot of a running
3
+ * page, so a build agent can attach REAL visual evidence to its delivery card
4
+ * (not just the ephemeral live preview). This is the primitive the daemon agent
5
+ * shells out to; it wraps the two fiddly parts — finding a browser across the
6
+ * user's environment, and driving Chrome's headless `--screenshot` — so the
7
+ * agent doesn't have to guess.
8
+ *
9
+ * Design mirrors the preview/cloudflared path: zero-config where possible, and
10
+ * NEVER a hard failure. No browser found / render crashes → a clear one-line
11
+ * hint on stderr + a non-zero exit, and the agent falls back to text evidence
12
+ * (test output, request/response, sample). It must never block a delivery.
13
+ *
14
+ * Environment coverage (the "what about WSL / a Linux VM?" cases):
15
+ * - Linux: PATH + conventional install paths for chrome/chromium/edge/brave.
16
+ * - macOS / Windows: the standard app locations.
17
+ * - WSL with no Linux browser: falls back to Windows Chrome/Edge via /mnt/c
18
+ * interop, translating paths with `wslpath` (WSL2 forwards localhost, so a
19
+ * Windows browser can still reach the dev server running in WSL).
20
+ * - A bare VM with no browser AND no system libs: probing/render fails →
21
+ * graceful text-evidence fallback with an `apt install chromium` hint.
22
+ * - FLOWVIANT_CHROME / CHROME_PATH override wins over all discovery.
23
+ */
24
+
25
+ import { spawn, execFileSync } from 'node:child_process';
26
+ import { existsSync, statSync, readFileSync, mkdtempSync, rmSync } from 'node:fs';
27
+ import { join } from 'node:path';
28
+ import { tmpdir, platform } from 'node:os';
29
+
30
+ // ── Browser discovery ───────────────────────────────────────────────────────
31
+
32
+ const LINUX_BINS = [
33
+ 'google-chrome-stable', 'google-chrome', 'chromium', 'chromium-browser',
34
+ 'chrome', 'brave-browser', 'microsoft-edge', 'microsoft-edge-stable',
35
+ ];
36
+ const LINUX_PATHS = [
37
+ '/usr/bin/google-chrome-stable', '/usr/bin/google-chrome', '/usr/bin/chromium',
38
+ '/usr/bin/chromium-browser', '/snap/bin/chromium', '/usr/bin/brave-browser',
39
+ '/usr/bin/microsoft-edge', '/usr/bin/microsoft-edge-stable',
40
+ ];
41
+ const MAC_PATHS = [
42
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
43
+ '/Applications/Chromium.app/Contents/MacOS/Chromium',
44
+ '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
45
+ '/Applications/Brave Browser.app/Contents/MacOS/Brave Browser',
46
+ ];
47
+ const WIN_PATHS = [
48
+ 'C:/Program Files/Google/Chrome/Application/chrome.exe',
49
+ 'C:/Program Files (x86)/Google/Chrome/Application/chrome.exe',
50
+ 'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe',
51
+ 'C:/Program Files/Microsoft/Edge/Application/msedge.exe',
52
+ ];
53
+ // WSL interop: the same Windows installs, seen through the /mnt/c mount.
54
+ const WSL_WIN_PATHS = WIN_PATHS.map((p) => `/mnt/c/${p.slice(3)}`);
55
+
56
+ function which(name) {
57
+ try {
58
+ const p = execFileSync('which', [name], { encoding: 'utf8' }).trim();
59
+ return p || null;
60
+ } catch {
61
+ return null;
62
+ }
63
+ }
64
+
65
+ function isWSL() {
66
+ if (process.env.WSL_DISTRO_NAME) return true;
67
+ try {
68
+ return /microsoft|wsl/i.test(readFileSync('/proc/version', 'utf8'));
69
+ } catch {
70
+ return false;
71
+ }
72
+ }
73
+
74
+ /** Locate a usable browser, or null. `viaWindows` means a Windows .exe reached
75
+ * through WSL interop — its file-path args must be translated with wslpath. */
76
+ export function resolveBrowser() {
77
+ const override = process.env.FLOWVIANT_CHROME || process.env.CHROME_PATH;
78
+ if (override && existsSync(override)) return { bin: override, viaWindows: false };
79
+
80
+ const os = platform();
81
+ if (os === 'darwin') {
82
+ for (const p of MAC_PATHS) if (existsSync(p)) return { bin: p, viaWindows: false };
83
+ return null;
84
+ }
85
+ if (os === 'win32') {
86
+ for (const p of WIN_PATHS) if (existsSync(p)) return { bin: p, viaWindows: false };
87
+ return null;
88
+ }
89
+ // linux
90
+ for (const name of LINUX_BINS) {
91
+ const p = which(name);
92
+ if (p) return { bin: p, viaWindows: false };
93
+ }
94
+ for (const p of LINUX_PATHS) if (existsSync(p)) return { bin: p, viaWindows: false };
95
+ // WSL with no Linux browser — reach the Windows one.
96
+ if (isWSL()) {
97
+ for (const p of WSL_WIN_PATHS) if (existsSync(p)) return { bin: p, viaWindows: true };
98
+ }
99
+ return null;
100
+ }
101
+
102
+ // ── Capture ─────────────────────────────────────────────────────────────────
103
+
104
+ function toWinPath(p) {
105
+ return execFileSync('wslpath', ['-w', p], { encoding: 'utf8' }).trim();
106
+ }
107
+
108
+ function runChrome(browser, { url, out, width, height, headlessFlag, timeoutMs }) {
109
+ return new Promise((resolve) => {
110
+ const userDataDir = mkdtempSync(join(tmpdir(), 'flowviant-shot-'));
111
+ const cleanup = () => { try { rmSync(userDataDir, { recursive: true, force: true }); } catch { /* best effort */ } };
112
+
113
+ // A Windows .exe can't read a WSL path — translate the file args it touches.
114
+ let outArg = out;
115
+ let udArg = userDataDir;
116
+ if (browser.viaWindows) {
117
+ try {
118
+ outArg = toWinPath(out);
119
+ udArg = toWinPath(userDataDir);
120
+ } catch {
121
+ cleanup();
122
+ return resolve({ ok: false, reason: 'wslpath', message: 'wslpath unavailable — cannot use Windows Chrome from WSL.' });
123
+ }
124
+ }
125
+
126
+ const args = [
127
+ headlessFlag,
128
+ '--disable-gpu',
129
+ '--no-sandbox', // required as root / in many VMs + containers
130
+ '--disable-dev-shm-usage', // small /dev/shm in containers/VMs crashes Chrome otherwise
131
+ '--hide-scrollbars',
132
+ '--force-color-profile=srgb',
133
+ `--user-data-dir=${udArg}`,
134
+ `--window-size=${width},${height}`,
135
+ '--virtual-time-budget=5000', // let fonts/JS settle before capture (SPAs)
136
+ `--screenshot=${outArg}`,
137
+ url,
138
+ ];
139
+
140
+ let err = '';
141
+ let child;
142
+ try {
143
+ child = spawn(browser.bin, args, { stdio: ['ignore', 'ignore', 'pipe'] });
144
+ } catch (e) {
145
+ cleanup();
146
+ return resolve({ ok: false, reason: 'spawn', message: e.message });
147
+ }
148
+ child.stderr?.on('data', (d) => { err += d.toString(); });
149
+ const timer = setTimeout(() => { try { child.kill('SIGKILL'); } catch { /* gone */ } }, timeoutMs);
150
+ child.on('error', (e) => {
151
+ clearTimeout(timer);
152
+ cleanup();
153
+ resolve({ ok: false, reason: 'spawn', message: e.message });
154
+ });
155
+ child.on('close', (code) => {
156
+ clearTimeout(timer);
157
+ cleanup();
158
+ if (existsSync(out) && statSync(out).size > 0) return resolve({ ok: true, path: out });
159
+ const tail = err.trim().split('\n').slice(-2).join(' ');
160
+ resolve({ ok: false, reason: 'render', message: `Chrome exited (code ${code}) without an image.${tail ? ' ' + tail : ''}` });
161
+ });
162
+ });
163
+ }
164
+
165
+ /** Capture `url` to a PNG at `out`. Tries new headless, then classic headless
166
+ * (older Chromium). Resolves { ok, path } or { ok:false, reason, message }. */
167
+ export async function captureScreenshot({ url, out, width = 1440, height = 900, timeoutMs = 60_000 }) {
168
+ const browser = resolveBrowser();
169
+ if (!browser) {
170
+ return {
171
+ ok: false,
172
+ reason: 'no-browser',
173
+ message: 'No Chrome/Chromium/Edge found. Install one (Linux: `sudo apt install chromium`) to capture screenshot evidence.',
174
+ };
175
+ }
176
+ let r = await runChrome(browser, { url, out, width, height, headlessFlag: '--headless=new', timeoutMs });
177
+ if (!r.ok && r.reason === 'render') {
178
+ // Older Chromium rejects `--headless=new` — retry with classic headless.
179
+ r = await runChrome(browser, { url, out, width, height, headlessFlag: '--headless', timeoutMs });
180
+ }
181
+ return r;
182
+ }
183
+
184
+ // ── CLI ───────────────────────────────────────────────────────────────────
185
+
186
+ function getOpt(argv, name) {
187
+ const i = argv.indexOf(name);
188
+ return i >= 0 ? argv[i + 1] : undefined;
189
+ }
190
+
191
+ /** `flowviant shot <url> [--out file.png] [--width N] [--height N] [--base64]`
192
+ * On success prints the PNG path (or its base64 with --base64) to stdout and
193
+ * exits 0. On any failure: a hint on stderr, exit 1 (graceful — the agent then
194
+ * attaches text evidence instead). Usage error exits 2. */
195
+ export async function runShot(argv) {
196
+ const url = argv.find((a) => !a.startsWith('--') && /^(https?|file|data):/i.test(a));
197
+ if (!url) {
198
+ console.error('usage: flowviant shot <url> [--out file.png] [--width 1440] [--height 900] [--base64]');
199
+ console.error(' url: a running page (http://localhost:5173/…), a built file (file://…), or a data: URL');
200
+ process.exit(2);
201
+ }
202
+ const out = getOpt(argv, '--out') || join(process.cwd(), `flowviant-shot-${Date.now()}.png`);
203
+ const width = Number(getOpt(argv, '--width')) || 1440;
204
+ const height = Number(getOpt(argv, '--height')) || 900;
205
+ const wantBase64 = argv.includes('--base64');
206
+
207
+ const r = await captureScreenshot({ url, out, width, height });
208
+ if (!r.ok) {
209
+ console.error(`flowviant shot: ${r.message}`);
210
+ process.exit(1);
211
+ }
212
+ if (wantBase64) {
213
+ process.stdout.write(readFileSync(out).toString('base64'));
214
+ } else {
215
+ console.log(r.path);
216
+ }
217
+ process.exit(0);
218
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowviant",
3
- "version": "0.28.9",
3
+ "version": "0.29.0",
4
4
  "description": "Run your own Claude Code as headless build agents for Flowviant — on your own credentials. Claims dispatched work, opens PRs, captures review evidence, and routes questions back to you.",
5
5
  "type": "module",
6
6
  "bin": {